Source code for pycsamt.ai.domain_gap.audit

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Survey-level audit reports for the M1 data-contract gate.

The M1 milestone requires that "an audit report accounts for every
included/excluded datum" before a survey is trusted as a training or
validation source. :func:`audit_survey` is that report: it runs a raw
``sites`` input (anything :func:`~pycsamt.emtools._core.ensure_sites`
accepts) through the same canonical bridge used everywhere else in
this package (:func:`~pycsamt.ai.domain_gap.survey_fit.survey_data_from_sites`)
*without* letting a single bad station abort the whole audit the way
that bridge's own fail-fast frequency-grid check does. Every station
dropped for missing data, and every station whose frequency grid does
not match the rest, is named and given a reason; dimensionality,
strike, static-shift, and distortion indicators come from the
existing, heavier pandas-based diagnostics in
:mod:`pycsamt.emtools.dimensionality`, :mod:`pycsamt.emtools.gb`, and
:mod:`pycsamt.emtools.ss` rather than being re-implemented here.

This module depends on those pandas-based diagnostics and therefore
lives in :mod:`pycsamt.ai.domain_gap`, not in the NumPy-only
:mod:`pycsamt.ai.data`, matching the layering
:mod:`pycsamt.ai.data.contracts` documents for itself.
"""

from __future__ import annotations

import json
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

import numpy as np

from ...emtools._core import ensure_sites
from ...emtools.dimensionality import pre2d_inversion_assessment
from ..data.contracts import SurveyCoverage
from .survey_fit import (
    fit_distortion_priors_from_sites,
    survey_data_from_sites,
)

__all__ = [
    "StationExclusion",
    "FrequencyGridReport",
    "DimensionalitySummary",
    "SurveyAuditReport",
    "audit_survey",
]

_DEG_THRESHOLD_M = 1e-5  # ~1 m of latitude/longitude spread


def _station_coordinates_m(
    stations: list[Any], *, station_spacing: float
) -> tuple[np.ndarray, np.ndarray]:
    """Project station (lat, lon) to local metres; return (xy, elevation_m)."""
    n = len(stations)
    lats = np.full(n, np.nan)
    lons = np.full(n, np.nan)
    elevation = np.full(n, np.nan)
    for row, site in enumerate(stations):
        coords = getattr(site, "coords", None)
        if coords is None:
            continue
        try:
            if len(coords) >= 2:
                lats[row] = float(coords[0])
                lons[row] = float(coords[1])
            if len(coords) >= 3:
                elevation[row] = float(coords[2])
        except (TypeError, ValueError):
            continue
    ok = np.isfinite(lats) & np.isfinite(lons)
    lat_range = float(lats[ok].max() - lats[ok].min()) if ok.any() else 0.0
    lon_range = float(lons[ok].max() - lons[ok].min()) if ok.any() else 0.0
    if ok.sum() >= 2 and (
        lat_range > _DEG_THRESHOLD_M or lon_range > _DEG_THRESHOLD_M
    ):
        lat_ref = float(np.nanmean(lats[ok]))
        lon_ref = float(np.nanmean(lons[ok]))
        cos_lat = float(np.cos(np.radians(lat_ref)))
        x = np.where(ok, (lons - lon_ref) * cos_lat * 111320.0, np.nan)
        y = np.where(ok, (lats - lat_ref) * 111320.0, np.nan)
        xy = np.column_stack([x, y])
    else:
        side = int(np.ceil(np.sqrt(max(n, 1))))
        xs = np.array([(i % side) * station_spacing for i in range(n)])
        ys = np.array([(i // side) * station_spacing for i in range(n)])
        xy = np.column_stack([xs, ys])
    return xy, elevation


[docs] @dataclass(frozen=True) class StationExclusion: """One station dropped before the canonical survey bridge. Parameters ---------- station : str Station identifier as reported by the raw site collection. reason : str Human-readable, non-empty exclusion reason. Examples -------- >>> StationExclusion("18-099Z", "missing freq or z array").reason 'missing freq or z array' """ station: str reason: str def __post_init__(self) -> None: station = str(self.station).strip() reason = str(self.reason).strip() if not station: raise ValueError("station cannot be empty.") if not reason: raise ValueError("reason cannot be empty.") object.__setattr__(self, "station", station) object.__setattr__(self, "reason", reason)
[docs] def to_dict(self) -> dict[str, str]: """Return a JSON-serializable representation. Returns ------- dict ``station`` and ``reason`` fields. Examples -------- >>> StationExclusion("A", "missing freq or z array").to_dict() {'station': 'A', 'reason': 'missing freq or z array'} """ return {"station": self.station, "reason": self.reason}
[docs] @dataclass(frozen=True) class FrequencyGridReport: """Whether every included station shares one frequency grid. Parameters ---------- matched : bool ``True`` only when every included station's frequency grid equals the reference station's, within tolerance. reference_station : str or None Station whose grid every other station was compared against. n_frequencies_by_station : mapping Station name to its own frequency count, for every included station, regardless of whether it matched. mismatched_stations : tuple of str Included stations whose frequency grid differs from the reference. Empty when ``matched`` is ``True``. Examples -------- >>> report = FrequencyGridReport(True, "A", {"A": 10, "B": 10}, ()) >>> report.matched True """ matched: bool reference_station: str | None n_frequencies_by_station: Mapping[str, int] mismatched_stations: tuple[str, ...] def __post_init__(self) -> None: counts = { str(name): int(value) for name, value in self.n_frequencies_by_station.items() } mismatched = tuple(str(name) for name in self.mismatched_stations) if bool(self.matched) == bool(mismatched): raise ValueError( "matched must be true exactly when mismatched_stations is empty." ) object.__setattr__(self, "matched", bool(self.matched)) object.__setattr__( self, "reference_station", None if self.reference_station is None else str(self.reference_station), ) object.__setattr__(self, "n_frequencies_by_station", counts) object.__setattr__(self, "mismatched_stations", mismatched)
[docs] def to_dict(self) -> dict[str, Any]: """Return a JSON-serializable representation. Returns ------- dict All fields as plain JSON-compatible values. Examples -------- >>> FrequencyGridReport(True, "A", {"A": 1}, ()).to_dict()["matched"] True """ return { "matched": self.matched, "reference_station": self.reference_station, "n_frequencies_by_station": dict(self.n_frequencies_by_station), "mismatched_stations": list(self.mismatched_stations), }
[docs] @dataclass(frozen=True) class DimensionalitySummary: """Survey-wide aggregate of :func:`~pycsamt.emtools.dimensionality.pre2d_inversion_assessment`. Parameters ---------- n_samples : int Total station-period samples the per-station table was built from. frac_1d, frac_2d, frac_3d : float Sample-weighted fraction classified 1-D, 2-D, and 3-D. They sum to one when ``n_samples`` is positive. strike_consensus_deg, strike_consensus_iqr_deg : float or None Median across stations of each station's consensus strike angle and its interquartile spread; ``None`` when no station produced a finite value. stations_recommending_3d_review : tuple of str Stations :func:`~pycsamt.emtools.dimensionality.pre2d_inversion_assessment` flagged ``"review_3d_effects_before_2d"``. Examples -------- >>> summary = DimensionalitySummary(10, 0.7, 0.2, 0.1, 12.0, 5.0, ()) >>> round(summary.frac_1d + summary.frac_2d + summary.frac_3d, 6) 1.0 """ n_samples: int frac_1d: float frac_2d: float frac_3d: float strike_consensus_deg: float | None strike_consensus_iqr_deg: float | None stations_recommending_3d_review: tuple[str, ...] = () def __post_init__(self) -> None: if self.n_samples < 0: raise ValueError("n_samples cannot be negative.") object.__setattr__( self, "stations_recommending_3d_review", tuple(str(name) for name in self.stations_recommending_3d_review), )
[docs] def to_dict(self) -> dict[str, Any]: """Return a JSON-serializable representation. Returns ------- dict All fields as plain JSON-compatible values. Examples -------- >>> DimensionalitySummary(1, 1.0, 0.0, 0.0, None, None).to_dict()[ ... "n_samples" ... ] 1 """ return { "n_samples": self.n_samples, "frac_1d": self.frac_1d, "frac_2d": self.frac_2d, "frac_3d": self.frac_3d, "strike_consensus_deg": self.strike_consensus_deg, "strike_consensus_iqr_deg": self.strike_consensus_iqr_deg, "stations_recommending_3d_review": list( self.stations_recommending_3d_review ), }
[docs] @dataclass(frozen=True) class SurveyAuditReport: """Complete M1 accounting of one survey's data quality. Parameters ---------- n_stations_input : int Stations found by :func:`~pycsamt.emtools._core.ensure_sites` before any exclusion. excluded_stations : tuple of StationExclusion Every station dropped before the canonical bridge, with a reason. frequency_grid : FrequencyGridReport Whether every included station shares one frequency grid. coverage : SurveyCoverage or None Impedance coverage from :meth:`~pycsamt.ai.data.contracts.SurveyData.coverage`, or ``None`` when :attr:`frequency_grid` did not match (the canonical bridge cannot run without a shared grid). frequency_range_hz : (float, float) or None Minimum and maximum frequency, when available. error_ratio_p05, error_ratio_p50, error_ratio_p95 : float or None 5th/50th/95th percentile of declared ``impedance_error / |Z|`` over valid observations, when available. station_spacing_m : mapping or None ``min``, ``median``, and ``max`` consecutive station spacing in the survey's stored order. elevation_coverage : float Fraction of included stations with a finite elevation. crs_declared : bool Whether a coordinate reference system was supplied explicitly. Station coordinates for MT/AMT sites are ordinarily projected locally from latitude/longitude, which by itself declares no formal :term:`CRS`. dimensionality : DimensionalitySummary Aggregate dimensionality and strike indicators. static_shift_log10_sigma, distortion_gain_log10_sigma, distortion_twist_deg_sigma, distortion_shear_sigma, distortion_anisotropy_sigma : float Empirical spreads from :func:`~pycsamt.ai.domain_gap.survey_fit.fit_distortion_priors_from_sites`. generated_utc : str Timezone-aware ISO-8601 timestamp of when the audit ran. Examples -------- Reports are normally produced by :func:`audit_survey`, not built directly: >>> report = SurveyAuditReport( ... n_stations_input=1, ... excluded_stations=(), ... frequency_grid=FrequencyGridReport(True, "A", {"A": 1}, ()), ... coverage=None, ... frequency_range_hz=None, ... error_ratio_p05=None, ... error_ratio_p50=None, ... error_ratio_p95=None, ... station_spacing_m=None, ... elevation_coverage=0.0, ... crs_declared=False, ... dimensionality=DimensionalitySummary(0, 0.0, 0.0, 0.0, None, None), ... static_shift_log10_sigma=0.0, ... distortion_gain_log10_sigma=0.0, ... distortion_twist_deg_sigma=0.0, ... distortion_shear_sigma=0.0, ... distortion_anisotropy_sigma=0.0, ... generated_utc="2026-01-01T00:00:00Z", ... ) >>> report.n_stations_included 1 """ n_stations_input: int excluded_stations: tuple[StationExclusion, ...] frequency_grid: FrequencyGridReport coverage: SurveyCoverage | None frequency_range_hz: tuple[float, float] | None error_ratio_p05: float | None error_ratio_p50: float | None error_ratio_p95: float | None station_spacing_m: Mapping[str, float] | None elevation_coverage: float crs_declared: bool dimensionality: DimensionalitySummary static_shift_log10_sigma: float distortion_gain_log10_sigma: float distortion_twist_deg_sigma: float distortion_shear_sigma: float distortion_anisotropy_sigma: float generated_utc: str metadata: Mapping[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: if self.n_stations_input < 0: raise ValueError("n_stations_input cannot be negative.") excluded = tuple(self.excluded_stations) if any(not isinstance(item, StationExclusion) for item in excluded): raise TypeError( "excluded_stations entries must be StationExclusion." ) if not isinstance(self.frequency_grid, FrequencyGridReport): raise TypeError("frequency_grid must be a FrequencyGridReport.") if not isinstance(self.dimensionality, DimensionalitySummary): raise TypeError("dimensionality must be a DimensionalitySummary.") n_included = self.n_stations_input - len(excluded) if n_included < 0: raise ValueError( "excluded_stations cannot exceed n_stations_input." ) coverage = self.coverage if coverage is not None and not isinstance(coverage, SurveyCoverage): raise TypeError("coverage must be a SurveyCoverage or None.") elevation_coverage = float(self.elevation_coverage) if not (0.0 <= elevation_coverage <= 1.0): raise ValueError("elevation_coverage must be in [0, 1].") object.__setattr__(self, "excluded_stations", excluded) object.__setattr__(self, "elevation_coverage", elevation_coverage) object.__setattr__(self, "generated_utc", str(self.generated_utc)) object.__setattr__(self, "metadata", dict(self.metadata))
[docs] @property def n_stations_included(self) -> int: """Return the number of stations that reached the canonical bridge. Returns ------- int ``n_stations_input`` minus the number of exclusions. Examples -------- Constructed directly for illustration; see :func:`audit_survey` for the normal way to obtain a report. >>> report = SurveyAuditReport( ... 2, ... (StationExclusion("B", "missing freq or z array"),), ... FrequencyGridReport(True, "A", {"A": 1}, ()), ... None, ... None, ... None, ... None, ... None, ... None, ... 0.0, ... False, ... DimensionalitySummary(0, 0.0, 0.0, 0.0, None, None), ... 0.0, ... 0.0, ... 0.0, ... 0.0, ... 0.0, ... "2026-01-01T00:00:00Z", ... ) >>> report.n_stations_included 1 """ return self.n_stations_input - len(self.excluded_stations)
[docs] def to_dict(self) -> dict[str, Any]: """Return a complete JSON-serializable representation. Returns ------- dict Every field, with nested records converted to plain dicts. Examples -------- >>> report = SurveyAuditReport( ... 1, ... (), ... FrequencyGridReport(True, "A", {"A": 1}, ()), ... None, ... None, ... None, ... None, ... None, ... None, ... 0.0, ... False, ... DimensionalitySummary(0, 0.0, 0.0, 0.0, None, None), ... 0.0, ... 0.0, ... 0.0, ... 0.0, ... 0.0, ... "2026-01-01T00:00:00Z", ... ) >>> report.to_dict()["schema_version"] 1 """ return { "schema_version": 1, "n_stations_input": self.n_stations_input, "n_stations_included": self.n_stations_included, "excluded_stations": [ item.to_dict() for item in self.excluded_stations ], "frequency_grid": self.frequency_grid.to_dict(), "coverage": None if self.coverage is None else { "overall": self.coverage.overall, "by_station": self.coverage.by_station.tolist(), "by_frequency": self.coverage.by_frequency.tolist(), "by_component": self.coverage.by_component.tolist(), }, "frequency_range_hz": None if self.frequency_range_hz is None else list(self.frequency_range_hz), "error_ratio_p05": self.error_ratio_p05, "error_ratio_p50": self.error_ratio_p50, "error_ratio_p95": self.error_ratio_p95, "station_spacing_m": None if self.station_spacing_m is None else dict(self.station_spacing_m), "elevation_coverage": self.elevation_coverage, "crs_declared": self.crs_declared, "dimensionality": self.dimensionality.to_dict(), "static_shift_log10_sigma": self.static_shift_log10_sigma, "distortion_gain_log10_sigma": self.distortion_gain_log10_sigma, "distortion_twist_deg_sigma": self.distortion_twist_deg_sigma, "distortion_shear_sigma": self.distortion_shear_sigma, "distortion_anisotropy_sigma": self.distortion_anisotropy_sigma, "generated_utc": self.generated_utc, "metadata": dict(self.metadata), }
[docs] def write_json(self, path: str | Path, *, overwrite: bool = True) -> Path: """Write a deterministic, human-readable audit report file. Parameters ---------- path : str or pathlib.Path Destination JSON file. overwrite : bool, default=True Permit replacement of an existing file. Returns ------- pathlib.Path Destination path. Raises ------ FileExistsError If the destination exists and ``overwrite`` is false. Examples -------- >>> from tempfile import TemporaryDirectory >>> report = SurveyAuditReport( ... 1, ... (), ... FrequencyGridReport(True, "A", {"A": 1}, ()), ... None, ... None, ... None, ... None, ... None, ... None, ... 0.0, ... False, ... DimensionalitySummary(0, 0.0, 0.0, 0.0, None, None), ... 0.0, ... 0.0, ... 0.0, ... 0.0, ... 0.0, ... "2026-01-01T00:00:00Z", ... ) >>> with TemporaryDirectory() as directory: ... path = report.write_json(Path(directory) / "audit.json") ... loaded = SurveyAuditReport.read_json(path) >>> loaded.n_stations_input 1 """ target = Path(path) if target.exists() and not overwrite: raise FileExistsError(f"audit report already exists: {target}") target.write_text( json.dumps( self.to_dict(), indent=2, sort_keys=True, ensure_ascii=False ) + "\n", encoding="utf-8", ) return target
[docs] @classmethod def from_dict(cls, data: Mapping[str, Any]) -> SurveyAuditReport: """Restore a report from its JSON representation. Parameters ---------- data : mapping State previously returned by :meth:`to_dict`. Returns ------- SurveyAuditReport Validated immutable report. Raises ------ ValueError If the schema version is unsupported. Examples -------- >>> report = SurveyAuditReport( ... 1, ... (), ... FrequencyGridReport(True, "A", {"A": 1}, ()), ... None, ... None, ... None, ... None, ... None, ... None, ... 0.0, ... False, ... DimensionalitySummary(0, 0.0, 0.0, 0.0, None, None), ... 0.0, ... 0.0, ... 0.0, ... 0.0, ... 0.0, ... "2026-01-01T00:00:00Z", ... ) >>> SurveyAuditReport.from_dict(report.to_dict()) == report True """ if data.get("schema_version") != 1: raise ValueError("unsupported SurveyAuditReport schema version.") coverage_data = data.get("coverage") coverage = ( None if coverage_data is None else SurveyCoverage( overall=coverage_data["overall"], by_station=coverage_data["by_station"], by_frequency=coverage_data["by_frequency"], by_component=coverage_data["by_component"], ) ) frequency_range = data.get("frequency_range_hz") return cls( n_stations_input=data["n_stations_input"], excluded_stations=tuple( StationExclusion(item["station"], item["reason"]) for item in data.get("excluded_stations", []) ), frequency_grid=FrequencyGridReport(**data["frequency_grid"]), coverage=coverage, frequency_range_hz=None if frequency_range is None else tuple(frequency_range), error_ratio_p05=data.get("error_ratio_p05"), error_ratio_p50=data.get("error_ratio_p50"), error_ratio_p95=data.get("error_ratio_p95"), station_spacing_m=data.get("station_spacing_m"), elevation_coverage=data["elevation_coverage"], crs_declared=data["crs_declared"], dimensionality=DimensionalitySummary( **{ **data["dimensionality"], "stations_recommending_3d_review": tuple( data["dimensionality"][ "stations_recommending_3d_review" ] ), } ), static_shift_log10_sigma=data["static_shift_log10_sigma"], distortion_gain_log10_sigma=data["distortion_gain_log10_sigma"], distortion_twist_deg_sigma=data["distortion_twist_deg_sigma"], distortion_shear_sigma=data["distortion_shear_sigma"], distortion_anisotropy_sigma=data["distortion_anisotropy_sigma"], generated_utc=data["generated_utc"], metadata=data.get("metadata", {}), )
[docs] @classmethod def read_json(cls, path: str | Path) -> SurveyAuditReport: """Read and validate a UTF-8 JSON audit report. Parameters ---------- path : str or pathlib.Path Existing report file written by :meth:`write_json`. Returns ------- SurveyAuditReport Validated immutable report. Examples -------- See :meth:`write_json` for a complete round trip. """ return cls.from_dict( json.loads(Path(path).read_text(encoding="utf-8")) )
[docs] def summary(self) -> str: """Return a compact, human-readable multi-line report. Returns ------- str Plain-text accounting of inclusion, coverage, geometry, dimensionality, and distortion indicators. Examples -------- >>> report = SurveyAuditReport( ... 1, ... (), ... FrequencyGridReport(True, "A", {"A": 1}, ()), ... None, ... None, ... None, ... None, ... None, ... None, ... 0.0, ... False, ... DimensionalitySummary(0, 0.0, 0.0, 0.0, None, None), ... 0.0, ... 0.0, ... 0.0, ... 0.0, ... 0.0, ... "2026-01-01T00:00:00Z", ... ) >>> print(report.summary()) Survey audit (generated 2026-01-01T00:00:00Z) Stations: 1 input, 1 included, 0 excluded Frequency grid: matched CRS declared: False Elevation coverage: 0.0% Dimensionality: n=0, 1D=0.0%, 2D=0.0%, 3D=0.0% Static shift log10 sigma: 0.0000 Distortion sigma: gain(log10)=0.0000, twist_deg=0.00, shear=0.0000, anisotropy=0.0000 """ lines = [f"Survey audit (generated {self.generated_utc})"] lines.append( f" Stations: {self.n_stations_input} input, " f"{self.n_stations_included} included, " f"{len(self.excluded_stations)} excluded" ) for item in self.excluded_stations: lines.append(f" - {item.station}: {item.reason}") grid = self.frequency_grid lines.append( " Frequency grid: " + ( "matched" if grid.matched else f"MISMATCHED ({len(grid.mismatched_stations)} stations)" ) ) if self.coverage is not None: lines.append( f" Impedance coverage: {self.coverage.overall * 100:.1f}%" ) if self.frequency_range_hz is not None: lo, hi = self.frequency_range_hz lines.append(f" Frequency range: {lo:g}-{hi:g} Hz") if self.error_ratio_p50 is not None: lines.append( " Declared error / |Z|: p05=" f"{self.error_ratio_p05:.4f}, p50={self.error_ratio_p50:.4f}, " f"p95={self.error_ratio_p95:.4f}" ) if self.station_spacing_m is not None: lines.append( " Station spacing (m): min=" f"{self.station_spacing_m['min']:.1f}, " f"median={self.station_spacing_m['median']:.1f}, " f"max={self.station_spacing_m['max']:.1f}" ) lines.append(f" CRS declared: {self.crs_declared}") lines.append( f" Elevation coverage: {self.elevation_coverage * 100:.1f}%" ) dim = self.dimensionality lines.append( f" Dimensionality: n={dim.n_samples}, " f"1D={dim.frac_1d * 100:.1f}%, 2D={dim.frac_2d * 100:.1f}%, " f"3D={dim.frac_3d * 100:.1f}%" ) if dim.strike_consensus_deg is not None: lines.append( f" Strike (consensus): {dim.strike_consensus_deg:.1f} deg " f"(IQR {dim.strike_consensus_iqr_deg:.1f} deg)" ) if dim.stations_recommending_3d_review: lines.append( " Stations flagged for 3-D review: " + ", ".join(dim.stations_recommending_3d_review) ) lines.append( f" Static shift log10 sigma: {self.static_shift_log10_sigma:.4f}" ) lines.append( " Distortion sigma: gain(log10)=" f"{self.distortion_gain_log10_sigma:.4f}, " f"twist_deg={self.distortion_twist_deg_sigma:.2f}, " f"shear={self.distortion_shear_sigma:.4f}, " f"anisotropy={self.distortion_anisotropy_sigma:.4f}" ) return "\n".join(lines)
[docs] def audit_survey( sites: Any, *, recursive: bool = True, on_dup: str = "replace", verbose: int = 0, freq_rtol: float = 1e-6, band: tuple[float, float] | None = None, skew_th: float = 3.0, ellipt_th: float = 0.2, station_spacing_fallback: float = 500.0, metadata: Mapping[str, Any] | None = None, ) -> SurveyAuditReport: """Audit a raw survey and account for every included/excluded station. Unlike :func:`~pycsamt.ai.domain_gap.survey_fit.survey_data_from_sites`, which deliberately raises on a mismatched frequency grid so training can never proceed silently on inconsistent data, this function never raises for that reason: it is meant to be run *before* deciding whether a survey is fit for that stricter bridge, and reports a mismatch as a finding rather than an exception. Parameters ---------- sites : Any Anything accepted by :func:`pycsamt.emtools._core.ensure_sites`. recursive, on_dup, verbose Forwarded to ``ensure_sites`` and the underlying diagnostics. freq_rtol : float, default=1e-6 Relative tolerance used when comparing each station's frequency grid against the reference station's. band : (float, float), optional Period band in seconds forwarded to :func:`~pycsamt.emtools.dimensionality.pre2d_inversion_assessment`. skew_th, ellipt_th : float, optional Phase-tensor skew and ellipticity thresholds forwarded to the same dimensionality assessment. station_spacing_fallback : float, default=500.0 Uniform-grid spacing in metres used only when no station reports usable latitude/longitude. metadata : mapping, optional Extra provenance recorded on the returned report. Returns ------- SurveyAuditReport Complete accounting of inclusion, coverage, geometry, dimensionality, and distortion indicators. Raises ------ ValueError If no station in ``sites`` has usable impedance data at all. Examples -------- >>> report = audit_survey( ... "data/AMT/WILLY_data/L18PLT", recursive=True, verbose=0 ... ) # doctest: +SKIP >>> report.frequency_grid.matched # doctest: +SKIP True """ collection = ensure_sites( sites, recursive=recursive, on_dup=on_dup, verbose=verbose ) stations = list(collection) n_input = len(stations) excluded: list[StationExclusion] = [] kept: list[Any] = [] for site in stations: freq = getattr(site, "freq", None) z = getattr(site, "z", None) name = str( getattr(site, "name", f"station-{len(kept) + len(excluded)}") ) if freq is None or z is None: excluded.append(StationExclusion(name, "missing freq or z array")) continue kept.append(site) if not kept: raise ValueError("no station in sites has usable impedance data.") n_freq_by_station: dict[str, int] = {} reference = np.asarray(kept[0].freq, dtype=float) reference_name = str(getattr(kept[0], "name", "station-0")) mismatched: list[str] = [] for site in kept: name = str(getattr(site, "name", "?")) freq = np.asarray(site.freq, dtype=float) n_freq_by_station[name] = int(freq.size) if freq.shape != reference.shape or not np.allclose( freq, reference, rtol=freq_rtol, atol=0.0 ): mismatched.append(name) grid_report = FrequencyGridReport( matched=not mismatched, reference_station=reference_name, n_frequencies_by_station=n_freq_by_station, mismatched_stations=tuple(mismatched), ) coverage: SurveyCoverage | None = None frequency_range: tuple[float, float] | None = None error_p05 = error_p50 = error_p95 = None if grid_report.matched: survey = survey_data_from_sites( collection, recursive=False, on_dup=on_dup, verbose=verbose ) coverage = survey.coverage() frequency_range = ( float(np.min(survey.frequencies_hz)), float(np.max(survey.frequencies_hz)), ) if survey.impedance_error is not None: valid = survey.valid ratio = np.abs(survey.impedance_error[valid]) / np.maximum( np.abs(survey.impedance[valid]), 1e-24 ) ratio = ratio[np.isfinite(ratio)] if ratio.size: error_p05, error_p50, error_p95 = ( float(np.percentile(ratio, 5)), float(np.percentile(ratio, 50)), float(np.percentile(ratio, 95)), ) xy, elevation = _station_coordinates_m( kept, station_spacing=station_spacing_fallback ) if len(kept) >= 2: steps = np.linalg.norm(np.diff(xy, axis=0), axis=1) steps = steps[np.isfinite(steps)] else: steps = np.empty(0) spacing = ( None if steps.size == 0 else { "min": float(np.min(steps)), "median": float(np.median(steps)), "max": float(np.max(steps)), } ) elevation_coverage = ( float(np.mean(np.isfinite(elevation))) if len(kept) else 0.0 ) dim_table = pre2d_inversion_assessment( collection, band=band, skew_th=skew_th, ellipt_th=ellipt_th, recursive=False, on_dup=on_dup, verbose=verbose, api=False, ) if len(dim_table): n_samples = dim_table["n_samples"].to_numpy(dtype=float) total = float(n_samples.sum()) if total > 0: frac_1d = float( (dim_table["frac_1d"].to_numpy() * n_samples).sum() / total ) frac_2d = float( (dim_table["frac_2d"].to_numpy() * n_samples).sum() / total ) frac_3d = float( (dim_table["frac_3d"].to_numpy() * n_samples).sum() / total ) else: frac_1d = frac_2d = frac_3d = 0.0 cons = dim_table["strike_consensus_deg"].to_numpy(dtype=float) cons_iqr = dim_table["strike_consensus_iqr_deg"].to_numpy(dtype=float) strike_deg = ( float(np.nanmedian(cons)) if np.isfinite(cons).any() else None ) strike_iqr = ( float(np.nanmedian(cons_iqr)) if np.isfinite(cons_iqr).any() else None ) flagged = tuple( str(name) for name, recommendation in zip( dim_table["station"], dim_table["recommendation"] ) if recommendation == "review_3d_effects_before_2d" ) dimensionality = DimensionalitySummary( n_samples=int(total), frac_1d=frac_1d, frac_2d=frac_2d, frac_3d=frac_3d, strike_consensus_deg=strike_deg, strike_consensus_iqr_deg=strike_iqr, stations_recommending_3d_review=flagged, ) else: dimensionality = DimensionalitySummary(0, 0.0, 0.0, 0.0, None, None) priors = fit_distortion_priors_from_sites( collection, recursive=False, on_dup=on_dup, verbose=verbose ) generated_utc = ( datetime.now(timezone.utc) .isoformat(timespec="seconds") .replace("+00:00", "Z") ) return SurveyAuditReport( n_stations_input=n_input, excluded_stations=tuple(excluded), frequency_grid=grid_report, coverage=coverage, frequency_range_hz=frequency_range, error_ratio_p05=error_p05, error_ratio_p50=error_p50, error_ratio_p95=error_p95, station_spacing_m=spacing, elevation_coverage=elevation_coverage, crs_declared=False, dimensionality=dimensionality, static_shift_log10_sigma=priors["static_shift_log10_sigma"], distortion_gain_log10_sigma=priors["distortion_gain_log10_sigma"], distortion_twist_deg_sigma=priors["distortion_twist_deg_sigma"], distortion_shear_sigma=priors["distortion_shear_sigma"], distortion_anisotropy_sigma=priors["distortion_anisotropy_sigma"], generated_utc=generated_utc, metadata=metadata or {}, )