Source code for pycsamt.iot.edge_csem

"""CSEM controlled-source electromagnetic edge quality control.

CSEM shares the controlled-source primitives in
:mod:`~pycsamt.iot.edge_csamt` (skin-depth field zones, transmitter
frequency comb, source stability), but its defining measurement is
different: a dipole source is recorded by a *receiver array* and the
signature data product is the response as a function of source-receiver
**offset** at each frequency -- magnitude-versus-offset (MVO) and
phase-versus-offset (PVO).

This module provides the offset-domain QC that is specific to CSEM:

* :func:`field_vs_offset` builds an MVO/PVO curve, flags where the signal
  drops below the noise floor (the detectability limit), and checks that
  the amplitude decays monotonically with offset -- a bump usually means a
  bad receiver, a geometry error, or genuine 3-D structure worth a second
  look;
* :func:`csem_edge_report` / :func:`csem_edge_table` collate one or more
  frequency responses.

Everything is numpy-only, consistent with the rest of the edge modules.
"""

from __future__ import annotations

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

import numpy as np
import pandas as pd

from ..api.property import PyCSAMTObject
from ..api.view import maybe_wrap_frame
from . import _common as _c

__all__ = [
    "OffsetResponse",
    "field_vs_offset",
    "csem_edge_report",
    "csem_edge_table",
]


[docs] @dataclass class OffsetResponse(PyCSAMTObject): """Result of :func:`field_vs_offset` -- one MVO/PVO curve.""" frequency_hz: float | None offsets_m: list[float] = field(default_factory=list) amplitudes: list[float] = field(default_factory=list) phases_deg: list[float] | None = None above_noise: list[bool] = field(default_factory=list) max_detectable_offset_m: float | None = None monotonic_decay: bool = False dynamic_range_db: float = float("nan")
[docs] @property def n_offsets(self) -> int: return len(self.offsets_m)
[docs] @property def n_detectable(self) -> int: return int(sum(1 for ok in self.above_noise if ok))
[docs] def as_dict(self) -> dict[str, Any]: return dict( frequency_hz=self.frequency_hz, n_offsets=self.n_offsets, n_detectable=self.n_detectable, max_detectable_offset_m=self.max_detectable_offset_m, monotonic_decay=self.monotonic_decay, dynamic_range_db=self.dynamic_range_db, offsets_m=list(self.offsets_m), amplitudes=list(self.amplitudes), phases_deg=( list(self.phases_deg) if self.phases_deg is not None else None ), above_noise=list(self.above_noise), )
[docs] def field_vs_offset( offsets_m: Any, amplitudes: Any, *, phases_deg: Any = None, noise_floor: float | None = None, frequency_hz: float | None = None, monotonic_tol: float = 0.05, ) -> OffsetResponse: """Build a magnitude/phase-versus-offset curve and QC it. Parameters ---------- offsets_m : array-like of float Source-receiver offsets in metres (need not be sorted). amplitudes : array-like of float Field amplitude at each offset (e.g. normalised E-field). Must be non-negative and the same length as *offsets_m*. phases_deg : array-like of float, optional Phase at each offset in degrees, carried through for PVO. noise_floor : float, optional Amplitude below which a reading is not detectable. When given, the largest offset above the floor is reported as the detectability limit and only detectable points drive the decay/dynamic-range QC. frequency_hz : float, optional Frequency this curve was measured at, recorded for reference. monotonic_tol : float Fractional tolerance when checking that amplitude never increases with offset (``a[i+1] <= a[i] * (1 + tol)``). Returns ------- OffsetResponse """ off = np.asarray(offsets_m, dtype=float).ravel() amp = np.asarray(amplitudes, dtype=float).ravel() if off.shape != amp.shape: raise ValueError("offsets_m and amplitudes must have equal length.") ph = None if phases_deg is not None: ph = np.asarray(phases_deg, dtype=float).ravel() if ph.shape != off.shape: raise ValueError("phases_deg must match the length of offsets_m.") monotonic_tol = _c.as_nonnegative(monotonic_tol, "monotonic_tol") if frequency_hz is not None: frequency_hz = _c.as_positive(frequency_hz, "frequency_hz") result = OffsetResponse(frequency_hz=frequency_hz) if off.size == 0: return result finite = np.isfinite(off) & np.isfinite(amp) if ph is not None: finite &= np.isfinite(ph) off, amp = off[finite], amp[finite] if ph is not None: ph = ph[finite] if off.size == 0: return result if np.any(amp < 0): raise ValueError("amplitudes must be non-negative.") order = np.argsort(off) off, amp = off[order], amp[order] if ph is not None: ph = ph[order] if noise_floor is not None: floor = _c.as_positive(noise_floor, "noise_floor") above = amp > floor else: above = np.ones(off.shape, dtype=bool) result.offsets_m = [float(v) for v in off] result.amplitudes = [float(v) for v in amp] result.phases_deg = None if ph is None else [float(v) for v in ph] result.above_noise = [bool(v) for v in above] detectable_off = off[above] result.max_detectable_offset_m = ( float(detectable_off.max()) if detectable_off.size else None ) det_amp = amp[above] if det_amp.size >= 2: # Monotonic decay: amplitude should not grow as offset increases. increases = det_amp[1:] > det_amp[:-1] * (1.0 + monotonic_tol) result.monotonic_decay = not bool(np.any(increases)) lo = float(np.min(det_amp)) hi = float(np.max(det_amp)) result.dynamic_range_db = ( float(20.0 * np.log10(hi / lo)) if lo > 0 else float("inf") ) elif det_amp.size == 1: result.monotonic_decay = True result.dynamic_range_db = 0.0 return result
[docs] def csem_edge_report( offsets_m: Any, amplitudes: Any, *, phases_deg: Any = None, noise_floor: float | None = None, frequency_hz: float | None = None, ) -> dict[str, Any]: """Run the CSEM offset-domain diagnostics for one frequency.""" response = field_vs_offset( offsets_m, amplitudes, phases_deg=phases_deg, noise_floor=noise_floor, frequency_hz=frequency_hz, ) return dict(offset_response=response.as_dict())
[docs] def csem_edge_table( reports: dict[str, dict[str, Any]] | Iterable[tuple[str, dict[str, Any]]], *, api: bool | None = None, ) -> Any: """Flatten one or more :func:`csem_edge_report` results into a table. Accepts a ``{label: report}`` mapping (or ``(label, report)`` pairs); the label is typically a frequency or receiver-line identifier. """ items = ( list(reports.items()) if isinstance(reports, dict) else list(reports) ) rows: list[dict[str, Any]] = [] for label, report in items: resp = report.get("offset_response", {}) rows.append( dict( label=str(label), frequency_hz=resp.get("frequency_hz"), n_offsets=resp.get("n_offsets"), n_detectable=resp.get("n_detectable"), max_detectable_offset_m=resp.get("max_detectable_offset_m"), monotonic_decay=resp.get("monotonic_decay"), dynamic_range_db=resp.get("dynamic_range_db"), ) ) df = pd.DataFrame.from_records(rows) return maybe_wrap_frame( df, api=api, name="iot_csem_edge_table", kind="iot.edge.csem", source=items, description="CSEM magnitude/phase-versus-offset edge QC by frequency.", )