# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""
pycsamt.metadata.quality
========================
Data-quality assessment for MT impedance sites.
:class:`DataQuality` is a first-class object that captures the
coverage, validity, and signal-to-noise ratio of every impedance
component at a single station. :func:`assess_collection` computes
quality for an entire :class:`~pycsamt.site.base.Sites` collection
and returns a DataFrame suitable for reporting, filtering, or
upstream ML pipelines.
Quality thresholds
------------------
+----------+----------------------+---------------------------+
| Flag | Coverage | Meaning |
+==========+======================+===========================+
| GOOD | ≥ 0.90 | suitable for inversion |
+----------+----------------------+---------------------------+
| PARTIAL | 0.50 – 0.90 | use with care |
+----------+----------------------+---------------------------+
| POOR | 0.01 – 0.50 | likely noisy / incomplete |
+----------+----------------------+---------------------------+
| MISSING | 0.00 | component absent |
+----------+----------------------+---------------------------+
Quick start
-----------
::
from pycsamt.metadata.quality import DataQuality, assess_collection
# single site
dq = DataQuality.from_site(site)
print(dq.overall) # QualityFlag.GOOD / PARTIAL / POOR / MISSING
print(dq.summary())
# collection
df = assess_collection(sites) # pandas DataFrame
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
import numpy as np
from ..api.view import maybe_wrap_frame
__all__ = [
"QualityFlag",
"ComponentQuality",
"DataQuality",
"assess_collection",
"quality_dataframe",
]
# Component ordering
_Z_COMPONENTS = ("Zxx", "Zxy", "Zyx", "Zyy")
_ALL_COMPONENTS = _Z_COMPONENTS + ("Tipper",)
# Coverage thresholds
_THRESH_GOOD = 0.90
_THRESH_PARTIAL = 0.50
# ---------------------------------------------------------------------------
# QualityFlag
# ---------------------------------------------------------------------------
[docs]
class QualityFlag(str, Enum):
"""Data-quality classification for one component or a whole station.
Attributes
----------
GOOD : "good"
Coverage ≥ 90 %; component is reliable for inversion.
PARTIAL : "partial"
50 % ≤ coverage < 90 %; component has significant gaps.
POOR : "poor"
0 % < coverage < 50 %; component is largely missing or noisy.
MISSING : "missing"
Zero finite values; component is absent.
"""
GOOD = "good"
PARTIAL = "partial"
POOR = "poor"
MISSING = "missing"
[docs]
@classmethod
def from_coverage(cls, coverage: float) -> QualityFlag:
"""Return the flag that matches *coverage* (0–1)."""
if coverage <= 0.0:
return cls.MISSING
if coverage < _THRESH_PARTIAL:
return cls.POOR
if coverage < _THRESH_GOOD:
return cls.PARTIAL
return cls.GOOD
[docs]
@property
def rank(self) -> int:
"""Numeric severity rank (0 = worst, 3 = best)."""
return {"missing": 0, "poor": 1, "partial": 2, "good": 3}[self.value]
[docs]
@classmethod
def worst(cls, flags: list[QualityFlag]) -> QualityFlag:
"""Return the worst (lowest-rank) flag in *flags*."""
if not flags:
return cls.MISSING
return min(flags, key=lambda f: f.rank)
[docs]
@classmethod
def best(cls, flags: list[QualityFlag]) -> QualityFlag:
"""Return the best (highest-rank) flag in *flags*."""
if not flags:
return cls.MISSING
return max(flags, key=lambda f: f.rank)
# ---------------------------------------------------------------------------
# ComponentQuality
# ---------------------------------------------------------------------------
[docs]
@dataclass
class ComponentQuality:
"""Quality metrics for a single impedance component.
Parameters
----------
name : str
Component label: ``"Zxx"``, ``"Zxy"``, ``"Zyx"``, ``"Zyy"``,
or ``"Tipper"``.
coverage : float
Fraction of finite values in the frequency range (0–1).
n_valid : int
Number of frequencies with finite values.
n_total : int
Total number of frequencies.
flag : QualityFlag
Auto-computed classification from *coverage*.
snr_mean : float, optional
Mean signal-to-noise ratio (dB) when available.
snr_std : float, optional
Standard deviation of the SNR (dB) when available.
Examples
--------
::
cq = ComponentQuality.from_array("Zxy", z_arr)
print(cq.flag) # QualityFlag.GOOD
print(cq.pct_str) # "100%"
"""
name: str
coverage: float
n_valid: int
n_total: int
flag: QualityFlag = field(init=False)
snr_mean: float | None = None
snr_std: float | None = None
def __post_init__(self) -> None:
self.flag = QualityFlag.from_coverage(self.coverage)
[docs]
@property
def pct_str(self) -> str:
"""Coverage as a formatted percentage string."""
return f"{self.coverage * 100:.0f}%"
[docs]
@classmethod
def from_array(
cls,
name: str,
arr: Any,
snr: Any | None = None,
) -> ComponentQuality:
"""Compute quality from a raw array.
Parameters
----------
name : str
Component label.
arr : array-like or None
Raw complex or real impedance values; shape ``(n,)`` or
``(n, 2, 2)`` (only the relevant component is expected).
snr : array-like, optional
Per-frequency SNR values in dB (same length as *arr*).
"""
if arr is None:
return cls(name=name, coverage=0.0, n_valid=0, n_total=0)
a = np.asarray(arr)
if a.size == 0:
return cls(name=name, coverage=0.0, n_valid=0, n_total=0)
# For complex arrays check both real and imaginary parts
if np.iscomplexobj(a):
finite = np.isfinite(a.real) & np.isfinite(a.imag)
else:
finite = np.isfinite(a.astype(float, copy=False))
n_total = int(finite.size)
n_valid = int(finite.sum())
coverage = n_valid / n_total if n_total > 0 else 0.0
snr_mean = snr_std = None
if snr is not None:
s = np.asarray(snr, dtype=float).ravel()
fs = s[np.isfinite(s)]
if fs.size:
snr_mean = float(fs.mean())
snr_std = float(fs.std())
return cls(
name=name,
coverage=coverage,
n_valid=n_valid,
n_total=n_total,
snr_mean=snr_mean,
snr_std=snr_std,
)
[docs]
def to_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"coverage": round(self.coverage, 4),
"n_valid": self.n_valid,
"n_total": self.n_total,
"flag": self.flag.value,
"snr_mean": round(self.snr_mean, 2)
if self.snr_mean is not None
else None,
"snr_std": round(self.snr_std, 2)
if self.snr_std is not None
else None,
}
def __repr__(self) -> str:
snr = (
f" SNR={self.snr_mean:.1f} dB"
if self.snr_mean is not None
else ""
)
return (
f"ComponentQuality({self.name!r} {self.pct_str}"
f" [{self.flag.value}]{snr})"
)
# ---------------------------------------------------------------------------
# DataQuality
# ---------------------------------------------------------------------------
[docs]
@dataclass
class DataQuality:
"""Data-quality summary for a single MT station.
Parameters
----------
station : str
Station identifier.
n_freq : int
Total number of frequencies.
freq_min : float, optional
Minimum frequency (Hz).
freq_max : float, optional
Maximum frequency (Hz).
components : list of ComponentQuality
Per-component quality records.
overall : QualityFlag
Worst flag across all present components (auto-computed).
Examples
--------
::
dq = DataQuality.from_site(site)
print(dq.overall)
print(dq.get("Zxy").coverage)
df_row = dq.to_dict()
"""
station: str
n_freq: int
freq_min: float | None = None
freq_max: float | None = None
components: list[ComponentQuality] = field(default_factory=list)
overall: QualityFlag = field(init=False)
def __post_init__(self) -> None:
self.overall = (
QualityFlag.worst(
[c.flag for c in self.components if c.n_total > 0]
)
if self.components
else QualityFlag.MISSING
)
# ------------------------------------------------------------------
# Constructors
# ------------------------------------------------------------------
[docs]
@classmethod
def from_site(cls, site: Any) -> DataQuality:
"""Build a :class:`DataQuality` from a Site-like object.
Accepts any object exposing ``.name``, ``.freq``, ``.z``,
and ``.tipper`` (as per :class:`~pycsamt.site.base.SiteMixin`).
"""
name = getattr(site, "name", "?")
freq = _safe_array(getattr(site, "freq", None))
z = getattr(site, "z", None)
tip = getattr(site, "tipper", None)
n_freq = int(freq.size) if freq is not None else 0
freq_min = (
float(freq.min()) if freq is not None and freq.size else None
)
freq_max = (
float(freq.max()) if freq is not None and freq.size else None
)
comps: list[ComponentQuality] = []
if z is not None:
z_arr = np.asarray(z)
for idx, cname in enumerate(_Z_COMPONENTS):
col = _extract_component(z_arr, idx)
comps.append(ComponentQuality.from_array(cname, col))
if tip is not None:
t_arr = np.asarray(tip)
comps.append(ComponentQuality.from_array("Tipper", t_arr))
return cls(
station=name,
n_freq=n_freq,
freq_min=freq_min,
freq_max=freq_max,
components=comps,
)
[docs]
@classmethod
def from_edi(cls, edi_path: Any) -> DataQuality:
"""Build from a spectra/impedance EDI file path."""
from pycsamt.seg.edi import EDIFile # noqa: PLC0415
from pycsamt.site.base import Site # noqa: PLC0415
ed = EDIFile(str(edi_path))
site = Site(ed)
return cls.from_site(site)
# ------------------------------------------------------------------
# Accessors
# ------------------------------------------------------------------
[docs]
def get(self, name: str) -> ComponentQuality | None:
"""Return :class:`ComponentQuality` for *name*, or None."""
for c in self.components:
if c.name.lower() == name.lower():
return c
return None
[docs]
@property
def z_components(self) -> list[ComponentQuality]:
"""Return only the Z-tensor component records."""
return [c for c in self.components if c.name in _Z_COMPONENTS]
[docs]
@property
def has_tipper(self) -> bool:
t = self.get("Tipper")
return t is not None and t.n_valid > 0
[docs]
@property
def mean_coverage(self) -> float:
"""Mean coverage across all components that have data."""
vals = [c.coverage for c in self.components if c.n_total > 0]
return float(np.mean(vals)) if vals else 0.0
# ------------------------------------------------------------------
# Output
# ------------------------------------------------------------------
[docs]
def summary(self) -> str:
"""Return a compact multi-line summary."""
lines = [
f"Station : {self.station}",
f" Frequencies : {self.n_freq}"
+ (
f" [{self.freq_min:.3g} – {self.freq_max:.3g} Hz]"
if self.freq_min is not None
else ""
),
f" Overall : {self.overall.value.upper()}",
]
for c in self.components:
bar = _bar(c.coverage, width=8)
lines.append(
f" {c.name:<8} {bar} {c.pct_str:>5} [{c.flag.value}]"
)
return "\n".join(lines)
[docs]
def to_dict(self) -> dict[str, Any]:
return {
"station": self.station,
"n_freq": self.n_freq,
"freq_min": self.freq_min,
"freq_max": self.freq_max,
"overall": self.overall.value,
"mean_coverage": round(self.mean_coverage, 4),
"components": [c.to_dict() for c in self.components],
}
def __repr__(self) -> str:
return (
f"DataQuality({self.station!r} {self.n_freq} freq"
f" overall={self.overall.value})"
)
# ---------------------------------------------------------------------------
# Collection utilities
# ---------------------------------------------------------------------------
[docs]
def assess_collection(sites: Any) -> list[DataQuality]:
"""Compute :class:`DataQuality` for every site in *sites*.
Parameters
----------
sites :
Iterable of Site-like objects (e.g.
:class:`~pycsamt.site.base.Sites`).
Returns
-------
list of DataQuality
"""
return [DataQuality.from_site(s) for s in sites]
[docs]
def quality_dataframe(sites: Any, *, api: bool | None = None) -> Any:
"""Return a :class:`pandas.DataFrame` with one quality row per station.
Columns: ``station``, ``n_freq``, ``freq_min``, ``freq_max``,
``overall``, ``mean_coverage``, and one ``cov_<component>`` column
per impedance component.
"""
try:
import pandas as pd # noqa: PLC0415
except ImportError as exc:
raise ImportError(
"pandas is required for quality_dataframe()"
) from exc
rows = []
for dq in assess_collection(sites):
row: dict[str, Any] = {
"station": dq.station,
"n_freq": dq.n_freq,
"freq_min": dq.freq_min,
"freq_max": dq.freq_max,
"overall": dq.overall.value,
"mean_coverage": round(dq.mean_coverage, 4),
}
for c in dq.components:
row[f"cov_{c.name}"] = round(c.coverage, 4)
row[f"flag_{c.name}"] = c.flag.value
rows.append(row)
df = pd.DataFrame(rows)
return maybe_wrap_frame(
df,
api=api,
name="quality_dataframe",
kind="metadata.quality",
source=sites,
description="Per-station data quality assessment table.",
)
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
def _safe_array(arr: Any) -> np.ndarray | None:
if arr is None:
return None
a = np.asarray(arr)
return a if a.size > 0 else None
def _extract_component(z: np.ndarray, idx: int) -> np.ndarray:
"""Extract component *idx* (0–3) from a Z array of any shape."""
if z.ndim == 3 and z.shape[1:] == (2, 2):
row, col = divmod(idx, 2)
return z[:, row, col]
if z.ndim == 2 and z.shape[1] == 4:
return z[:, idx]
return z.ravel()
def _bar(fraction: float, width: int = 8) -> str:
"""Unicode block-char progress bar."""
fraction = max(0.0, min(1.0, float(fraction)))
filled = round(fraction * width)
return "█" * filled + "░" * (width - filled)