Source code for pycsamt.ai.domain_gap.empirical

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Empirical field-calibrated corruption for synthetic EM surveys."""

from __future__ import annotations

from collections.abc import Mapping, Sequence
from dataclasses import dataclass, replace
from typing import Any

import numpy as np

from ..data.contracts import SurveyData
from ..data.manifest import canonical_hash

__all__ = [
    "EmpiricalCorruptionResult",
    "apply_empirical_corruption",
]


def _readonly(value: Any, dtype: Any | None = None) -> np.ndarray:
    """Return a read-only array copy."""
    array = np.array(value, dtype=dtype, copy=True)
    array.setflags(write=False)
    return array


def _seed(value: int) -> int:
    """Validate and normalize an unsigned random seed."""
    if not isinstance(value, (int, np.integer)) or isinstance(value, bool):
        raise TypeError("seed must be an integer")
    result = int(value)
    if result < 0 or result >= 2**64:
        raise ValueError("seed must lie in [0, 2**64)")
    return result


def _profiles(
    values: Any,
    field_frequencies_hz: np.ndarray,
    name: str,
) -> np.ndarray:
    """Validate station-by-frequency empirical profiles."""
    array = np.asarray(values, dtype=float)
    expected_frequency = field_frequencies_hz.size
    if (
        array.ndim != 2
        or array.shape[0] < 1
        or array.shape[1] != expected_frequency
        or not np.all(np.isfinite(array))
    ):
        raise ValueError(
            f"{name} must be finite with shape (n_station, "
            f"{expected_frequency})"
        )
    return array


def _bounded_profiles(
    values: Any,
    field_frequencies_hz: np.ndarray,
    name: str,
) -> np.ndarray:
    """Validate empirical reliability profiles in ``[0, 1]``."""
    array = _profiles(values, field_frequencies_hz, name)
    if np.any((array < 0) | (array > 1)):
        raise ValueError(f"{name} must lie in [0, 1]")
    return array


def _interpolate_profiles(
    profiles: np.ndarray,
    source_frequency_hz: np.ndarray,
    target_frequency_hz: np.ndarray,
) -> np.ndarray:
    """Interpolate profiles linearly in log-frequency with edge clamping."""
    order = np.argsort(source_frequency_hz)
    source = np.log10(source_frequency_hz[order])
    target = np.log10(target_frequency_hz)
    return np.stack(
        [np.interp(target, source, row[order]) for row in profiles]
    )


def _quantile_values(
    specification: Mapping[str, Sequence[float]],
    component: str,
) -> tuple[np.ndarray, np.ndarray]:
    """Return validated quantile levels and values for one component."""
    if component not in specification:
        raise ValueError(f"relative-error quantiles missing {component!r}")
    value = specification[component]
    if isinstance(value, Mapping):
        levels = np.asarray(value["levels"], dtype=float)
        quantiles = np.asarray(value["values"], dtype=float)
    else:
        raise TypeError("relative-error quantiles must be mappings")
    if (
        levels.ndim != 1
        or quantiles.shape != levels.shape
        or levels.size < 2
        or not np.all(np.isfinite(levels))
        or not np.all(np.isfinite(quantiles))
        or not np.all(np.diff(levels) > 0)
        or not np.all(np.diff(quantiles) >= 0)
        or levels[0] < 0
        or levels[-1] > 1
        or np.any(quantiles < 0)
    ):
        raise ValueError("relative-error quantile curves are invalid")
    return levels, quantiles


[docs] @dataclass(frozen=True) class EmpiricalCorruptionResult: """One corrupted survey and every sampled latent array. Parameters ---------- survey : SurveyData Corrupted survey with declared empirical impedance errors. seed : int Parent random seed. field_station_indices : ndarray of int Empirical field station selected for each synthetic station. static_log10_resistivity_factor : ndarray Sampled ``log10(rho_observed / rho_smooth)``. relative_error_fraction : ndarray Sampled impedance-error fractions by component. noise_realization : ndarray of complex Additive complex noise in impedance units. missing_mask : ndarray of bool True where an observation was removed. measurement_reliability, dimensionality_reliability, observation_reliability : ndarray Separate and combined reliability factors. """ survey: SurveyData seed: int field_station_indices: np.ndarray static_log10_resistivity_factor: np.ndarray relative_error_fraction: np.ndarray noise_realization: np.ndarray missing_mask: np.ndarray measurement_reliability: np.ndarray dimensionality_reliability: np.ndarray observation_reliability: np.ndarray def __post_init__(self) -> None: if not isinstance(self.survey, SurveyData): raise TypeError("survey must be a SurveyData") object.__setattr__(self, "seed", _seed(self.seed)) shapes = { "relative_error_fraction": self.survey.shape, "noise_realization": self.survey.shape, "missing_mask": self.survey.shape, "measurement_reliability": self.survey.shape, "dimensionality_reliability": self.survey.shape, "observation_reliability": self.survey.shape, } for name, shape in shapes.items(): value = np.asarray(getattr(self, name)) if value.shape != shape: raise ValueError(f"{name} must be shaped {shape}") object.__setattr__(self, name, _readonly(value)) static_shape = self.survey.shape[:2] static = np.asarray(self.static_log10_resistivity_factor, dtype=float) if static.shape != static_shape or not np.all(np.isfinite(static)): raise ValueError( "static_log10_resistivity_factor must be finite and shaped " f"{static_shape}" ) indices = np.asarray(self.field_station_indices, dtype=int) if indices.shape != (self.survey.n_stations,) or np.any(indices < 0): raise ValueError("field_station_indices have an invalid shape") object.__setattr__( self, "static_log10_resistivity_factor", _readonly(static), ) object.__setattr__( self, "field_station_indices", _readonly(indices, dtype=int) )
[docs] @property def record_hash(self) -> str: """Return a deterministic hash of seed and sampled summaries. Returns ------- str SHA-256 digest of JSON-compatible provenance. """ return canonical_hash(self.to_dict())
[docs] def to_dict(self) -> dict[str, Any]: """Return compact JSON-compatible corruption provenance. Returns ------- dict Seed, selected field stations, and sampled ranges. """ return { "schema_version": 1, "seed": self.seed, "field_station_indices": self.field_station_indices.tolist(), "static_log10_resistivity_factor_range": [ float(np.min(self.static_log10_resistivity_factor)), float(np.max(self.static_log10_resistivity_factor)), ], "relative_error_fraction_range": [ float(np.min(self.relative_error_fraction)), float(np.max(self.relative_error_fraction)), ], "missing_fraction": float(np.mean(self.missing_mask)), "measurement_reliability_mean": float( np.mean(self.measurement_reliability) ), "dimensionality_reliability_mean": float( np.mean(self.dimensionality_reliability) ), "observation_reliability_mean": float( np.mean(self.observation_reliability) ), }
[docs] def apply_empirical_corruption( survey: SurveyData, *, field_frequencies_hz: Any, static_log10_resistivity_profiles: Any, measurement_reliability_profiles: Any, dimensionality_reliability_profiles: Any, observation_reliability_profiles: Any, relative_error_quantiles: Mapping[str, Sequence[float]], seed: int, missing_rate_by_component: Mapping[str, float] | None = None, ) -> EmpiricalCorruptionResult: """Apply jointly sampled field profiles and empirical error quantiles. Parameters ---------- survey : SurveyData Clean synthetic survey. field_frequencies_hz : array-like Positive unique frequencies of the empirical field profiles. static_log10_resistivity_profiles : array-like Field ``log10(rho_observed / rho_smooth)`` profiles shaped ``(n_field_station, n_field_frequency)``. measurement_reliability_profiles, dimensionality_reliability_profiles, observation_reliability_profiles : array-like Aligned empirical reliability profiles in ``[0, 1]``. relative_error_quantiles : mapping Component names mapped to ``{"levels": ..., "values": ...}`` monotone empirical quantile curves. seed : int Parent random seed. missing_rate_by_component : mapping or None, optional Empirical independent missing probabilities. Missing observations are set to complex NaN and marked invalid. Returns ------- EmpiricalCorruptionResult Corrupted survey and complete sampled provenance. Notes ----- Static shift is supplied in apparent-resistivity space and therefore applied to impedance as ``10**(0.5 * log10_rho_factor)``. Examples -------- >>> clean = SurveyData( ... np.ones((2, 2, 1), complex), ... [10.0, 1.0], ... ["A", "B"], ... ["zxy"], ... [[0, 0], [1, 0]], ... ) >>> result = apply_empirical_corruption( ... clean, ... field_frequencies_hz=[10.0, 1.0], ... static_log10_resistivity_profiles=[[0.0, 0.0]], ... measurement_reliability_profiles=[[0.8, 0.7]], ... dimensionality_reliability_profiles=[[1.0, 0.5]], ... observation_reliability_profiles=[[0.8, 0.35]], ... relative_error_quantiles={ ... "zxy": {"levels": [0, 1], "values": [0.01, 0.05]} ... }, ... seed=0, ... ) >>> result.survey.shape (2, 2, 1) """ if not isinstance(survey, SurveyData): raise TypeError("survey must be a SurveyData") seed = _seed(seed) field_frequency = np.asarray(field_frequencies_hz, dtype=float) if ( field_frequency.ndim != 1 or field_frequency.size < 2 or not np.all(np.isfinite(field_frequency)) or np.any(field_frequency <= 0) or np.unique(field_frequency).size != field_frequency.size ): raise ValueError( "field_frequencies_hz must be positive, finite, and unique" ) static = _profiles( static_log10_resistivity_profiles, field_frequency, "static_log10_resistivity_profiles", ) measurement = _bounded_profiles( measurement_reliability_profiles, field_frequency, "measurement_reliability_profiles", ) dimensionality = _bounded_profiles( dimensionality_reliability_profiles, field_frequency, "dimensionality_reliability_profiles", ) combined = _bounded_profiles( observation_reliability_profiles, field_frequency, "observation_reliability_profiles", ) if not ( static.shape == measurement.shape == dimensionality.shape == combined.shape ): raise ValueError("all empirical profile arrays must have one shape") rng = np.random.default_rng(seed) indices = rng.integers(0, static.shape[0], size=survey.n_stations) target_frequency = survey.frequencies_hz static_target = _interpolate_profiles( static[indices], field_frequency, target_frequency ) measurement_target = _interpolate_profiles( measurement[indices], field_frequency, target_frequency ) dimensionality_target = _interpolate_profiles( dimensionality[indices], field_frequency, target_frequency ) combined_target = _interpolate_profiles( combined[indices], field_frequency, target_frequency ) impedance_factor = np.power(10.0, 0.5 * static_target) shifted = survey.impedance * impedance_factor[:, :, None] relative_error = np.empty(survey.shape, dtype=float) for component_index, component in enumerate(survey.components): levels, values = _quantile_values( relative_error_quantiles, component ) probability = rng.uniform( levels[0], levels[-1], size=survey.shape[:2] ) relative_error[:, :, component_index] = np.interp( probability, levels, values ) declared_error = relative_error * np.abs(shifted) standardized_noise = ( rng.normal(size=survey.shape) + 1j * rng.normal(size=survey.shape) ) / np.sqrt(2.0) noise = declared_error * standardized_noise corrupted = np.where(survey.valid, shifted + noise, survey.impedance) missing_rates = {} if missing_rate_by_component is None else dict( missing_rate_by_component ) missing = np.zeros(survey.shape, dtype=bool) for component_index, component in enumerate(survey.components): rate = float(missing_rates.get(component, 0.0)) if not np.isfinite(rate) or not 0 <= rate <= 1: raise ValueError("missing rates must be finite and lie in [0, 1]") if rate > 0: missing[:, :, component_index] = ( rng.random(survey.shape[:2]) < rate ) missing |= ~survey.valid corrupted = np.where(missing, complex(np.nan, np.nan), corrupted) declared_error = np.where(missing, np.nan, declared_error) corrupted_survey = replace( survey, impedance=corrupted, impedance_error=declared_error, ) shape = survey.shape return EmpiricalCorruptionResult( survey=corrupted_survey, seed=seed, field_station_indices=indices, static_log10_resistivity_factor=static_target, relative_error_fraction=relative_error, noise_realization=noise, missing_mask=missing, measurement_reliability=np.broadcast_to( measurement_target[:, :, None], shape ), dimensionality_reliability=np.broadcast_to( dimensionality_target[:, :, None], shape ), observation_reliability=np.broadcast_to( combined_target[:, :, None], shape ), )