Source code for pycsamt.airborne.qc

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0

"""Shared structural QC and inspection for airborne EM datasets.

These utilities deliberately assess representation quality, completeness, and
metadata consistency. They do not invent universal geophysical thresholds for
signal-to-noise ratio, frequency range, or anomaly quality. Technology-specific
scientific QC can later be registered on top of this common structural layer.

:class:`AirborneQCIssue` inherits :class:`~pycsamt.api.property.PyCSAMTObject`
as a lightweight immutable finding, the same choice made for
:class:`~pycsamt.metadata.quality.QualityComment` and for the registry
definitions in :mod:`pycsamt.airborne.registry`.
:class:`AirborneInspection` and :class:`AirborneQCReport` inherit
:class:`~pycsamt.core.base.CoreObject`, matching
:mod:`pycsamt.airborne.base`: they aggregate and summarize, rather than
perform electromagnetic arithmetic themselves.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any

import numpy as np

from ..api.property import PyCSAMTObject
from ..core.base import CoreObject

if TYPE_CHECKING:
    from ..emtf.document import EMTF
from .base import AirborneEMDataset, AirborneEMLine, AirborneEMRecord
from .registry import (
    get_airborne_technology,
    identify_airborne_technologies,
)
from .validation import emtf_class

__all__ = [
    "AirborneQCIssue",
    "AirborneInspection",
    "AirborneQCReport",
    "inspect_airborne",
    "assess_airborne_qc",
]


[docs] @dataclass(frozen=True, repr=False) class AirborneQCIssue(PyCSAMTObject): """One structural/metadata QC finding. Parameters ---------- code : str Short machine-readable finding code, for example ``"missing_reference_station"``. Lower-cased on construction. severity : {"info", "warning", "error"} Finding severity. ``"error"`` reflects an internally inconsistent scientific state (for example a non-positive frequency axis), not merely incomplete or sparse data; see :func:`assess_airborne_qc`. message : str Human-readable description. Stripped on construction. line_id : str, optional Flight line the finding applies to, when scoped to one line. sample_id : str, optional Sample the finding applies to, when scoped to one record. Raises ------ ValueError If ``code`` is empty, or ``severity`` is not one of ``"info"``, ``"warning"``, ``"error"``. """ code: str severity: str message: str line_id: str | None = None sample_id: str | None = None def __post_init__(self) -> None: code = str(self.code).strip().lower() severity = str(self.severity).strip().lower() if not code: raise ValueError("QC issue code must be non-empty") if severity not in {"info", "warning", "error"}: raise ValueError("QC severity must be info, warning, or error") object.__setattr__(self, "code", code) object.__setattr__(self, "severity", severity) object.__setattr__(self, "message", str(self.message).strip())
[docs] @dataclass(repr=False) class AirborneInspection(CoreObject): """Compact scientific inventory of an airborne object. Returned by :func:`inspect_airborne` for a dataset, line, record, or bare :class:`~pycsamt.emtf.EMTF`; the fields below are filled in as far as they are meaningful for that ``object_type`` (for example a single record leaves ``n_lines``/``bbox`` at their defaults). Parameters ---------- object_type : {"dataset", "line", "record", "emtf"} Kind of object the inventory describes. technologies : tuple of str, optional Canonical technologies identified on the object; see :func:`~pycsamt.airborne.registry.identify_airborne_technologies`. n_lines, n_samples, n_records : int, default 0 Counts meaningful at and above the object's own level. transfer_function_names : tuple of str, optional Sorted union of transfer-function names present. bbox : BBox, optional Geographic bounding box, when applicable and available. attrs : dict, optional Object-type-specific extra fields (for example ``sample_id`` for a record, or ``name``/``method`` for a dataset). """ object_type: str technologies: tuple[str, ...] = field(default_factory=tuple) n_lines: int = 0 n_samples: int = 0 n_records: int = 0 transfer_function_names: tuple[str, ...] = field(default_factory=tuple) bbox: Any | None = None attrs: dict[str, Any] = field(default_factory=dict)
[docs] @dataclass(repr=False) class AirborneQCReport(CoreObject): """Common structural QC report for an airborne dataset. Returned by :func:`assess_airborne_qc`. Parameters ---------- technologies : tuple of str Canonical technologies identified across the dataset. metrics : dict Dataset-level scalar metrics (coverage fractions, counts); see :func:`assess_airborne_qc` for the exact keys. line_metrics : dict of str to dict Per-line metrics keyed by ``line_id``; see :func:`_line_metrics` for the exact keys. issues : tuple of AirborneQCIssue, optional Individual findings, most to least specific in scope (per-sample, then per-line, then dataset-wide) in the order they were raised. """ technologies: tuple[str, ...] metrics: dict[str, Any] line_metrics: dict[str, dict[str, Any]] issues: tuple[AirborneQCIssue, ...] = field(default_factory=tuple)
[docs] @property def status(self) -> str: """Return ``"error"``, ``"warning"``, or ``"pass"``. The worst severity present in :attr:`issues`, or ``"pass"`` when there are none. """ severities = {issue.severity for issue in self.issues} if "error" in severities: return "error" if "warning" in severities: return "warning" return "pass"
[docs] @property def errors(self) -> tuple[AirborneQCIssue, ...]: """Return only the ``"error"``-severity issues.""" return tuple( issue for issue in self.issues if issue.severity == "error" )
[docs] @property def warnings(self) -> tuple[AirborneQCIssue, ...]: """Return only the ``"warning"``-severity issues.""" return tuple( issue for issue in self.issues if issue.severity == "warning" )
[docs] def inspect_airborne(obj: Any) -> AirborneInspection: """Return a compact inventory for dataset, line, record, or EMTF object. Parameters ---------- obj : AirborneEMDataset, AirborneEMLine, AirborneEMRecord, or EMTF Object to summarize. Returns ------- AirborneInspection Inventory populated as far as meaningful for *obj*'s type. Raises ------ TypeError If *obj* is none of the supported types. """ technologies = identify_airborne_technologies(obj) if isinstance(obj, AirborneEMDataset): return AirborneInspection( object_type="dataset", technologies=technologies, n_lines=obj.n_lines, n_samples=obj.n_samples, n_records=obj.n_records, transfer_function_names=obj.transfer_function_names, bbox=obj.bbox, attrs={"name": obj.name, "method": obj.method}, ) if isinstance(obj, AirborneEMLine): return AirborneInspection( object_type="line", technologies=technologies, n_lines=1, n_samples=obj.n_samples, n_records=obj.n_records, transfer_function_names=obj.transfer_function_names, bbox=obj.bbox, attrs={"line_id": obj.line_id}, ) if isinstance(obj, AirborneEMRecord): return AirborneInspection( object_type="record", technologies=technologies, n_records=1, transfer_function_names=obj.transfer_function_names, attrs={"sample_id": obj.sample_id}, ) if isinstance(obj, emtf_class()): return AirborneInspection( object_type="emtf", technologies=technologies, n_records=1, transfer_function_names=tuple(obj.transfer_functions), attrs={"product_id": obj.product_id, "subtype": obj.subtype}, ) raise TypeError( "inspect_airborne expects AirborneEMDataset, AirborneEMLine, " "AirborneEMRecord, or EMTF" )
def _finite_complex_fraction(value: Any) -> tuple[int, int]: """Return ``(n_finite, n_total)`` element counts for *value*. A complex element counts as finite only when both its real and imaginary parts are finite, matching the missing-value convention (``nan + nan*1j``) used for absent EMTF matrix components. """ arr = np.asarray(value) if arr.size == 0: return 0, 0 if np.iscomplexobj(arr): finite = np.isfinite(arr.real) & np.isfinite(arr.imag) else: finite = np.isfinite(arr) return int(np.count_nonzero(finite)), int(arr.size) def _navigation_location_count(line: AirborneEMLine) -> int: """Count samples with a finite geographic or projected position.""" nav = line.navigation mask = np.zeros(nav.n_samples, dtype=bool) if nav.latitude is not None and nav.longitude is not None: mask |= np.isfinite(nav.latitude) & np.isfinite(nav.longitude) if nav.easting is not None and nav.northing is not None: mask |= np.isfinite(nav.easting) & np.isfinite(nav.northing) return int(np.count_nonzero(mask)) def _clearance_count(line: AirborneEMLine) -> int: """Count samples with a finite explicit or derived clearance.""" values = line.navigation.clearance_values if values is None: return 0 return int(np.count_nonzero(np.isfinite(values))) def _reference_present(doc: EMTF, technology: str) -> bool: """Return whether *doc* carries reference-station metadata. The check is technology-specific because reference metadata is stored differently per adapter: MobileMT's ground electric reference lives under ``doc.attrs["mobilemt"]["reference_station"]`` (see :mod:`pycsamt.airborne.mobilemt`), while ZTEM/AirMt route their fixed ground magnetic reference through the shared :attr:`~pycsamt.emtf.EMTF.processing`/``remote_reference`` field instead (see :mod:`pycsamt.airborne.ztem`, :mod:`pycsamt.airborne.afmag`). Technologies without a ``reference_required`` contract are not checked here at all; see :func:`assess_airborne_qc`. """ if technology == "mobilemt": meta = doc.attrs.get("mobilemt") return bool( isinstance(meta, dict) and isinstance(meta.get("reference_station"), dict) and meta["reference_station"].get("station_id") ) if technology in {"ztem", "airmt"}: processing = doc.processing remote = None if processing is None else processing.remote_reference return bool(remote is not None and remote.site) return True def _line_metrics(line: AirborneEMLine) -> dict[str, Any]: """Return one line's ``n_samples``/``n_records``/coverage-fraction metrics, keyed exactly as documented on :attr:`AirborneQCReport.line_metrics`.""" n_samples = line.n_samples n_records = line.n_records return { "n_samples": n_samples, "n_records": n_records, "record_coverage_fraction": ( float(n_records / n_samples) if n_samples else 0.0 ), "location_fraction": ( float(_navigation_location_count(line) / n_samples) if n_samples else 0.0 ), "clearance_fraction": ( float(_clearance_count(line) / n_samples) if n_samples else 0.0 ), "transfer_function_names": line.transfer_function_names, }
[docs] def assess_airborne_qc(dataset: AirborneEMDataset) -> AirborneQCReport: """Assess common structural completeness and metadata consistency. Parameters ---------- dataset : AirborneEMDataset Dataset to assess. Returns ------- AirborneQCReport Dataset-level and per-line metrics plus individual findings. See :attr:`AirborneQCReport.metrics` for the exact dataset-level keys this function populates (record/EMTF coverage fractions, valid-frequency and finite-response fractions, primary/derived transfer-function counts, variance/covariance coverage fractions, and reference-metadata coverage). Raises ------ TypeError If *dataset* is not an :class:`AirborneEMDataset`. Notes ----- The report is intentionally descriptive. A missing record, missing covariance, or absent coordinates can be important without being a universal processing failure, so only internally inconsistent scientific states -- currently just a non-positive or non-finite frequency axis on an attached EMTF -- are classified as ``"error"``. Everything else that is merely incomplete or sparse is reported at ``"info"``/``"warning"`` severity; see :class:`AirborneQCIssue`. """ if not isinstance(dataset, AirborneEMDataset): raise TypeError("dataset must be an AirborneEMDataset") technologies = identify_airborne_technologies(dataset) issues: list[AirborneQCIssue] = [] line_metrics = { line.line_id: _line_metrics(line) for line in dataset.iter_lines() } total_samples = dataset.n_samples total_records = dataset.n_records emtf_records = 0 valid_frequency_records = 0 finite_count = 0 value_count = 0 tf_count = 0 primary_tf_count = 0 derived_tf_count = 0 variance_count = 0 invsig_count = 0 resid_count = 0 required_reference_records = 0 present_reference_records = 0 for line in dataset.iter_lines(): if line.n_records < line.n_samples: issues.append( AirborneQCIssue( code="missing_em_records", severity="info", message=( f"{line.n_samples - line.n_records} navigation " "samples have no attached EM record" ), line_id=line.line_id, ) ) if _navigation_location_count(line) == 0: issues.append( AirborneQCIssue( code="missing_navigation_coordinates", severity="warning", message="line has no finite geographic/projected position", line_id=line.line_id, ) ) explicit_line_tech = identify_airborne_technologies(line) if len(explicit_line_tech) > 1: issues.append( AirborneQCIssue( code="mixed_line_technology", severity="warning", message=( "line contains multiple technologies: " + ", ".join(explicit_line_tech) ), line_id=line.line_id, ) ) for record in line.iter_records(): doc = record.emtf if doc is None: issues.append( AirborneQCIssue( code="record_without_emtf", severity="info", message="record has no EMTF transfer-function payload", line_id=line.line_id, sample_id=record.sample_id, ) ) continue emtf_records += 1 freq = doc.frequency if ( freq is not None and np.asarray(freq).size > 0 and np.all(np.isfinite(freq)) and np.all(np.asarray(freq) > 0.0) ): valid_frequency_records += 1 else: issues.append( AirborneQCIssue( code="invalid_frequency_axis", severity="error", message=( "EMTF record has no valid positive frequency axis" ), line_id=line.line_id, sample_id=record.sample_id, ) ) record_technologies = identify_airborne_technologies(record) for technology in record_technologies: definition = get_airborne_technology(technology) if definition is None or not definition.reference_required: continue required_reference_records += 1 if _reference_present(doc, technology): present_reference_records += 1 else: issues.append( AirborneQCIssue( code="missing_reference_station", severity="warning", message=( f"{definition.label} record lacks explicit " "reference-station metadata" ), line_id=line.line_id, sample_id=record.sample_id, ) ) for tf in doc.transfer_functions.values(): tf_count += 1 finite, total = _finite_complex_fraction(tf.data) finite_count += finite value_count += total definition = tf.definition is_primary = ( definition is None or definition.intention == "primary" ) if not is_primary: derived_tf_count += 1 continue primary_tf_count += 1 if tf.get_estimate("VAR") is not None: variance_count += 1 if tf.get_estimate("INVSIGCOV") is not None: invsig_count += 1 if tf.get_estimate("RESIDCOV") is not None: resid_count += 1 metrics: dict[str, Any] = { "n_lines": dataset.n_lines, "n_samples": total_samples, "n_records": total_records, "n_emtf_records": emtf_records, "record_coverage_fraction": ( float(total_records / total_samples) if total_samples else 0.0 ), "emtf_coverage_fraction": ( float(emtf_records / total_samples) if total_samples else 0.0 ), "valid_frequency_record_fraction": ( float(valid_frequency_records / emtf_records) if emtf_records else 0.0 ), "finite_response_fraction": ( float(finite_count / value_count) if value_count else 0.0 ), "n_transfer_functions": tf_count, "n_primary_transfer_functions": primary_tf_count, "n_derived_transfer_functions": derived_tf_count, "variance_tf_fraction": ( float(variance_count / primary_tf_count) if primary_tf_count else 0.0 ), "inverse_signal_covariance_tf_fraction": ( float(invsig_count / primary_tf_count) if primary_tf_count else 0.0 ), "residual_covariance_tf_fraction": ( float(resid_count / primary_tf_count) if primary_tf_count else 0.0 ), "reference_metadata_fraction": ( float(present_reference_records / required_reference_records) if required_reference_records else 1.0 ), } if len(technologies) > 1: issues.append( AirborneQCIssue( code="mixed_dataset_technology", severity="info", message=( "dataset contains multiple airborne technologies: " + ", ".join(technologies) ), ) ) return AirborneQCReport( technologies=technologies, metrics=metrics, line_metrics=line_metrics, issues=tuple(issues), )