Source code for pycsamt.ai.domain_gap.simulator

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Reproducible corruption of canonical surveys for domain-gap training.

Every function in this module maps a validated
:class:`~pycsamt.ai.data.contracts.SurveyData` to a new, independently
validated :class:`~pycsamt.ai.data.contracts.SurveyData`. Corruptions are
composed from a single integer seed so a training run can be reproduced
exactly from its :class:`CorruptionConfig` and seed alone.

Two families of corruption are distinguished:

Systematic (bias-like)
    :func:`apply_static_shift` and :func:`apply_galvanic_distortion` model
    near-surface, frequency-independent effects that multiply the true
    impedance by a real matrix. They change the signal, not its declared
    uncertainty, beyond the linear scaling of that uncertainty.

Random (noise-like)
    :func:`add_heteroscedastic_noise`, :func:`apply_error_floor`,
    :func:`apply_dropout`, and :func:`inject_outliers` model acquisition
    noise, missing data, and undetected bad readings. These update
    ``impedance_error`` and ``valid`` so that masks and errors stay
    consistent with the injected corruption, per the M3 acceptance gate.

:func:`apply_corruption_suite` composes all of the above in a fixed,
documented order using seeds spawned from a single parent seed, and returns
a :class:`CorruptionRecord` describing exactly what was sampled.
"""

from __future__ import annotations

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

import numpy as np

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

__all__ = [
    "CorruptionConfig",
    "CorruptionRecord",
    "SEVERITY_PRESETS",
    "add_heteroscedastic_noise",
    "apply_error_floor",
    "apply_static_shift",
    "apply_galvanic_distortion",
    "apply_dropout",
    "inject_outliers",
    "perturb_coordinates",
    "apply_corruption_suite",
]

_COMPONENT_INDEX: dict[str, tuple[int, int]] = {
    "xx": (0, 0),
    "xy": (0, 1),
    "yx": (1, 0),
    "yy": (1, 1),
}


def _seed(value: int) -> int:
    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 be in [0, 2**64).")
    return result


def _rate(value: float, name: str) -> float:
    result = float(value)
    if not np.isfinite(result) or not (0.0 <= result <= 1.0):
        raise ValueError(f"{name} must be finite and in [0, 1].")
    return result


def _non_negative(value: float, name: str) -> float:
    result = float(value)
    if not np.isfinite(result) or result < 0.0:
        raise ValueError(f"{name} must be finite and non-negative.")
    return result


def _range(
    value: tuple[float, float], name: str, *, allow_negative: bool = False
) -> tuple[float, float]:
    lo, hi = float(value[0]), float(value[1])
    if not (np.isfinite(lo) and np.isfinite(hi)):
        raise ValueError(f"{name} bounds must be finite.")
    if not allow_negative and (lo < 0.0 or hi < 0.0):
        raise ValueError(f"{name} bounds must be non-negative.")
    if lo > hi:
        raise ValueError(f"{name} lower bound cannot exceed the upper bound.")
    return (lo, hi)


def _child_rng(rng: np.random.Generator) -> np.random.Generator:
    return np.random.default_rng(rng.integers(0, 2**63 - 1))


def _present_components(survey: SurveyData) -> dict[str, int]:
    return {
        name: survey.components.index(name)
        for name in _COMPONENT_INDEX
        if name in survey.components
    }


def _z_matrix(survey: SurveyData) -> np.ndarray:
    """Return a dense (station, frequency, 2, 2) view; absent parts are 0."""
    present = _present_components(survey)
    out = np.zeros(survey.shape[:2] + (2, 2), dtype=complex)
    for name, col in present.items():
        i, j = _COMPONENT_INDEX[name]
        out[:, :, i, j] = survey.impedance[:, :, col]
    return out, present


[docs] @dataclass(frozen=True) class CorruptionConfig: """Parameter ranges for one corruption pass over a :class:`SurveyData`. All defaults are zero/no-op so ``CorruptionConfig()`` is the clean synthetic control set required by the M3 gate. Parameters ---------- noise_level_range : (float, float), default=(0.0, 0.0) Bounds on the relative heteroscedastic noise standard deviation sampled independently per station/frequency observation. error_floor_fraction : float, default=0.0 Minimum declared ``impedance_error`` as a fraction of ``|Z|``. static_shift_log10_sigma : float, default=0.0 Std. dev. of the log\\ :sub:`10` per-station static-shift factor applied identically across all frequencies. distortion_gain_log10_sigma, distortion_twist_deg_sigma, distortion_shear_sigma, distortion_anisotropy_sigma : float, default=0.0 Std. dev. of the per-station Groom-Bailey-style gain, twist, shear, and anisotropy parameters of the injected galvanic distortion. station_dropout_rate, frequency_dropout_rate, random_dropout_rate : float, default=0.0 Probability that an entire station, an entire frequency (across all stations), or an individual observation is marked missing. outlier_rate : float, default=0.0 Fraction of remaining valid observations perturbed by a large, undetected multiplicative shift. outlier_log10_shift_range : (float, float), default=(0.5, 1.5) Bounds on the magnitude (in log\\ :sub:`10` decades) of injected outliers; the sign is randomized. coordinate_sigma_m, elevation_sigma_m : float, default=0.0 Std. dev. of Gaussian perturbation applied to station horizontal coordinates and elevation, respectively. Examples -------- >>> config = CorruptionConfig(noise_level_range=(0.01, 0.05)) >>> config.config_hash() == CorruptionConfig( ... noise_level_range=(0.01, 0.05) ... ).config_hash() True """ noise_level_range: tuple[float, float] = (0.0, 0.0) error_floor_fraction: float = 0.0 static_shift_log10_sigma: float = 0.0 distortion_gain_log10_sigma: float = 0.0 distortion_twist_deg_sigma: float = 0.0 distortion_shear_sigma: float = 0.0 distortion_anisotropy_sigma: float = 0.0 station_dropout_rate: float = 0.0 frequency_dropout_rate: float = 0.0 random_dropout_rate: float = 0.0 outlier_rate: float = 0.0 outlier_log10_shift_range: tuple[float, float] = (0.5, 1.5) coordinate_sigma_m: float = 0.0 elevation_sigma_m: float = 0.0 def __post_init__(self) -> None: object.__setattr__( self, "noise_level_range", _range(self.noise_level_range, "noise_level_range"), ) object.__setattr__( self, "error_floor_fraction", _non_negative(self.error_floor_fraction, "error_floor_fraction"), ) for name in ( "static_shift_log10_sigma", "distortion_gain_log10_sigma", "distortion_twist_deg_sigma", "distortion_shear_sigma", "distortion_anisotropy_sigma", "coordinate_sigma_m", "elevation_sigma_m", ): object.__setattr__( self, name, _non_negative(getattr(self, name), name) ) for name in ( "station_dropout_rate", "frequency_dropout_rate", "random_dropout_rate", "outlier_rate", ): object.__setattr__(self, name, _rate(getattr(self, name), name)) object.__setattr__( self, "outlier_log10_shift_range", _range( self.outlier_log10_shift_range, "outlier_log10_shift_range" ), )
[docs] def to_dict(self) -> dict[str, Any]: """Return a JSON-serializable, order-stable representation. Returns ------- dict Field values with a schema discriminator. Examples -------- >>> CorruptionConfig().to_dict()["schema_version"] 1 """ return { "schema_version": 1, "noise_level_range": list(self.noise_level_range), "error_floor_fraction": self.error_floor_fraction, "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, "station_dropout_rate": self.station_dropout_rate, "frequency_dropout_rate": self.frequency_dropout_rate, "random_dropout_rate": self.random_dropout_rate, "outlier_rate": self.outlier_rate, "outlier_log10_shift_range": list(self.outlier_log10_shift_range), "coordinate_sigma_m": self.coordinate_sigma_m, "elevation_sigma_m": self.elevation_sigma_m, }
[docs] @classmethod def from_dict(cls, data: Mapping[str, Any]) -> CorruptionConfig: """Restore and validate a serialized configuration. Parameters ---------- data : mapping State previously returned by :meth:`to_dict`. Returns ------- CorruptionConfig Validated immutable configuration. Examples -------- >>> state = CorruptionConfig(error_floor_fraction=0.02).to_dict() >>> CorruptionConfig.from_dict(state).error_floor_fraction 0.02 """ if data.get("schema_version") != 1: raise ValueError("unsupported CorruptionConfig schema version.") payload = dict(data) payload.pop("schema_version") payload["noise_level_range"] = tuple(payload["noise_level_range"]) payload["outlier_log10_shift_range"] = tuple( payload["outlier_log10_shift_range"] ) return cls(**payload)
[docs] def config_hash(self) -> str: """Return the SHA-256 digest of this configuration's canonical JSON. Returns ------- str Lowercase 64-character hexadecimal digest. Examples -------- >>> len(CorruptionConfig().config_hash()) 64 """ return canonical_hash(self.to_dict())
[docs] @dataclass(frozen=True) class CorruptionRecord: """Provenance of one applied :func:`apply_corruption_suite` call. Parameters ---------- config : CorruptionConfig Configuration the sampled parameters were drawn from. seed : int Parent seed used to spawn every corruption step's generator. severity : str or None Name of the severity preset used, when applicable. sampled : mapping Concrete, JSON-serializable per-step summary statistics (e.g. the number of dropped stations, or the mean sampled distortion gain). """ config: CorruptionConfig seed: int sampled: Mapping[str, Any] = field(default_factory=dict) severity: str | None = None
[docs] def to_dict(self) -> dict[str, Any]: """Return a JSON-serializable provenance record. Returns ------- dict Schema-versioned config, seed, severity, and sampled summary. Examples -------- >>> record = CorruptionRecord(CorruptionConfig(), seed=0) >>> record.to_dict()["schema_version"] 1 """ return { "schema_version": 1, "seed": self.seed, "severity": self.severity, "config_hash": self.config.config_hash(), "config": self.config.to_dict(), "sampled": dict(self.sampled), }
[docs] def add_heteroscedastic_noise( survey: SurveyData, *, level_range: tuple[float, float] = (0.02, 0.05), rng: np.random.Generator, ) -> SurveyData: """Add complex, per-observation heteroscedastic Gaussian noise. Parameters ---------- survey : SurveyData Clean or already-corrupted survey. level_range : (float, float), default=(0.02, 0.05) Bounds on the relative noise standard deviation sampled independently for every ``(station, frequency)`` pair and shared across components at that pair, mimicking correlated instrument noise. rng : numpy.random.Generator Source of randomness; callers control reproducibility. Returns ------- SurveyData New survey with perturbed impedance and an ``impedance_error`` that combines any pre-existing error with the injected noise in quadrature. Examples -------- >>> import numpy as np >>> from pycsamt.ai.data.contracts import SurveyData >>> z = np.full((1, 2, 1), 100 + 0j) >>> survey = SurveyData(z, [10.0, 1.0], ["S"], ["xy"], [[0.0, 0.0]]) >>> noisy = add_heteroscedastic_noise( ... survey, level_range=(0.05, 0.05), rng=np.random.default_rng(0) ... ) >>> noisy.impedance_error is not None True """ lo, hi = _range(level_range, "level_range") if lo == 0.0 and hi == 0.0: return survey n_station, n_frequency, n_component = survey.shape sigma_sf = rng.uniform(lo, hi, size=(n_station, n_frequency)) sigma = np.broadcast_to(sigma_sf[:, :, None], survey.shape) magnitude = np.abs(survey.impedance) noise = ( rng.normal(size=survey.shape) + 1j * rng.normal(size=survey.shape) ) / np.sqrt(2.0) injected_error = sigma * magnitude z_noisy = np.where( survey.valid, survey.impedance + injected_error * noise, survey.impedance, ) if survey.impedance_error is None: error = np.where(survey.valid, injected_error, np.nan) else: error = np.where( survey.valid, np.sqrt(survey.impedance_error**2 + injected_error**2), survey.impedance_error, ) return replace(survey, impedance=z_noisy, impedance_error=error)
[docs] def apply_error_floor( survey: SurveyData, *, floor_fraction: float ) -> SurveyData: """Clamp the declared error to a minimum fraction of ``|Z|``. Parameters ---------- survey : SurveyData Survey whose error floor should be enforced. floor_fraction : float Minimum ``impedance_error`` as a fraction of ``|Z|``. Zero is a no-op. Returns ------- SurveyData New survey with an error array at least as large as ``floor_fraction * |Z|`` on valid observations. Examples -------- >>> import numpy as np >>> from pycsamt.ai.data.contracts import SurveyData >>> z = np.full((1, 1, 1), 100 + 0j) >>> survey = SurveyData( ... z, ... [1.0], ... ["S"], ... ["xy"], ... [[0.0, 0.0]], ... impedance_error=np.ones((1, 1, 1)), ... ) >>> floored = apply_error_floor(survey, floor_fraction=0.5) >>> floored.impedance_error[0, 0, 0] 50.0 """ floor_fraction = _non_negative(floor_fraction, "floor_fraction") if floor_fraction == 0.0: return survey floor = floor_fraction * np.abs(survey.impedance) if survey.impedance_error is None: error = np.where(survey.valid, floor, np.nan) else: error = np.maximum(survey.impedance_error, floor) return replace(survey, impedance_error=error)
[docs] def apply_static_shift( survey: SurveyData, *, log10_sigma: float, rng: np.random.Generator, return_info: bool = False, ) -> SurveyData: """Multiply every present component by a per-station real factor. Static shift is modelled as a frequency-independent real scalar ``c_s = 10 ** N(0, log10_sigma)`` per station, applied identically to every impedance component and to the declared error, consistent with the linear scaling of a real multiplicative distortion. Parameters ---------- survey : SurveyData Survey to distort. log10_sigma : float Std. dev. of the log\\ :sub:`10` static-shift factor. Zero is a no-op. rng : numpy.random.Generator Source of randomness. Returns ------- SurveyData New survey with static shift applied. Examples -------- >>> import numpy as np >>> from pycsamt.ai.data.contracts import SurveyData >>> z = np.full((2, 1, 1), 100 + 0j) >>> survey = SurveyData(z, [1.0], ["A", "B"], ["xy"], [[0, 0], [1, 0]]) >>> shifted = apply_static_shift( ... survey, log10_sigma=0.1, rng=np.random.default_rng(0) ... ) >>> shifted.shape == survey.shape True """ log10_sigma = _non_negative(log10_sigma, "log10_sigma") if log10_sigma == 0.0: factor = np.ones(survey.n_stations) out = survey else: factor = 10.0 ** rng.normal(0.0, log10_sigma, size=survey.n_stations) z = survey.impedance * factor[:, None, None] error = ( None if survey.impedance_error is None else survey.impedance_error * factor[:, None, None] ) out = replace(survey, impedance=z, impedance_error=error) if return_info: return out, {"static_shift_factor": factor} return out
def _distortion_matrix( gain: np.ndarray, twist_deg: np.ndarray, shear: np.ndarray, anisotropy: np.ndarray, ) -> np.ndarray: """Build per-station real 2x2 distortion matrices. ``D = gain * R(twist) @ [[1 + anisotropy, shear], [shear, 1 - anisotropy]]``, a simplified Groom & Bailey (1989)-style parameterisation used here only to inject distortion, not to reproduce :func:`pycsamt.emtools.gb.groom_bailey_table`'s decomposition exactly. """ twist_rad = np.deg2rad(twist_deg) cos_t, sin_t = np.cos(twist_rad), np.sin(twist_rad) rotation = np.stack( [ np.stack([cos_t, -sin_t], axis=-1), np.stack([sin_t, cos_t], axis=-1), ], axis=-2, ) shear = np.clip(shear, -0.9, 0.9) anisotropy = np.clip(anisotropy, -0.9, 0.9) shape_matrix = np.stack( [ np.stack([1.0 + anisotropy, shear], axis=-1), np.stack([shear, 1.0 - anisotropy], axis=-1), ], axis=-2, ) return gain[:, None, None] * (rotation @ shape_matrix)
[docs] def apply_galvanic_distortion( survey: SurveyData, *, gain_log10_sigma: float = 0.0, twist_deg_sigma: float = 0.0, shear_sigma: float = 0.0, anisotropy_sigma: float = 0.0, rng: np.random.Generator, return_info: bool = False, ) -> SurveyData: """Inject a per-station real Groom & Bailey-style distortion matrix. Parameters ---------- survey : SurveyData Survey to distort. Must expose at least one impedance component; any of ``xx, xy, yx, yy`` absent from :attr:`SurveyData.components` is treated as zero for the purpose of building the dense 2x2 impedance used internally, which is an approximation for surveys that only store off-diagonal components. gain_log10_sigma, twist_deg_sigma, shear_sigma, anisotropy_sigma : float Std. dev. of the per-station gain (log\\ :sub:`10`), twist (degrees), shear, and anisotropy parameters. Zero for every parameter is a no-op. rng : numpy.random.Generator Source of randomness. Returns ------- SurveyData New survey with distorted impedance; declared error, if any, is scaled by the sampled gain as a first-order approximation. Examples -------- >>> import numpy as np >>> from pycsamt.ai.data.contracts import SurveyData >>> z = np.ones((1, 1, 2), dtype=complex) * (1 + 1j) >>> survey = SurveyData(z, [1.0], ["S"], ["xy", "yx"], [[0, 0]]) >>> distorted = apply_galvanic_distortion( ... survey, twist_deg_sigma=10.0, rng=np.random.default_rng(0) ... ) >>> distorted.shape == survey.shape True """ n_station = survey.n_stations if ( gain_log10_sigma == 0.0 and twist_deg_sigma == 0.0 and shear_sigma == 0.0 and anisotropy_sigma == 0.0 ): out = survey sampled = { "gain": np.ones(n_station), "twist_deg": np.zeros(n_station), "shear": np.zeros(n_station), "anisotropy": np.zeros(n_station), } return (out, sampled) if return_info else out gain = 10.0 ** rng.normal(0.0, gain_log10_sigma, size=n_station) twist_deg = rng.normal(0.0, twist_deg_sigma, size=n_station) shear = rng.normal(0.0, shear_sigma, size=n_station) anisotropy = rng.normal(0.0, anisotropy_sigma, size=n_station) D = _distortion_matrix(gain, twist_deg, shear, anisotropy) z_dense, present = _z_matrix(survey) distorted = np.einsum("sab,sfbc->sfac", D, z_dense) impedance = np.array(survey.impedance) for name, col in present.items(): i, j = _COMPONENT_INDEX[name] impedance[:, :, col] = distorted[:, :, i, j] error = None if survey.impedance_error is not None: error = survey.impedance_error * gain[:, None, None] sampled = { "gain": gain, "twist_deg": twist_deg, "shear": shear, "anisotropy": anisotropy, } out = replace(survey, impedance=impedance, impedance_error=error) return (out, sampled) if return_info else out
[docs] def apply_dropout( survey: SurveyData, *, station_rate: float = 0.0, frequency_rate: float = 0.0, random_rate: float = 0.0, rng: np.random.Generator, return_info: bool = False, ) -> SurveyData: """Mark stations, frequencies, or individual observations as missing. Dropped observations are invalidated by setting the impedance to ``NaN``; :class:`~pycsamt.ai.data.contracts.SurveyData` construction then recomputes :attr:`~pycsamt.ai.data.contracts.SurveyData.valid` from finiteness, so masks stay authoritative automatically. Parameters ---------- survey : SurveyData Survey to thin out. station_rate, frequency_rate, random_rate : float, default=0.0 Independent probabilities that a whole station (all frequencies and components), a whole frequency (all stations and components), or an individual observation is dropped. Effects are combined (a station or frequency dropout wins over a random one at the same cell). rng : numpy.random.Generator Source of randomness. Returns ------- SurveyData New survey with additional invalid observations. Examples -------- >>> import numpy as np >>> from pycsamt.ai.data.contracts import SurveyData >>> z = np.ones((4, 4, 1), dtype=complex) >>> survey = SurveyData( ... z, ... [4.0, 3.0, 2.0, 1.0], ... ["A", "B", "C", "D"], ... ["xy"], ... np.zeros((4, 2)), ... ) >>> thinned = apply_dropout( ... survey, station_rate=1.0, rng=np.random.default_rng(0) ... ) >>> thinned.n_valid 0 """ drop = np.zeros(survey.shape, dtype=bool) n_station, n_frequency, _ = survey.shape dropped_stations: list[int] = [] if station_rate > 0.0: mask = rng.random(n_station) < station_rate drop |= mask[:, None, None] dropped_stations = np.flatnonzero(mask).tolist() dropped_frequencies: list[int] = [] if frequency_rate > 0.0: mask = rng.random(n_frequency) < frequency_rate drop |= mask[None, :, None] dropped_frequencies = np.flatnonzero(mask).tolist() if random_rate > 0.0: drop |= rng.random(survey.shape) < random_rate impedance = np.where(drop, complex(np.nan, np.nan), survey.impedance) out = replace(survey, impedance=impedance) sampled = { "dropped_station_count": len(dropped_stations), "dropped_frequency_count": len(dropped_frequencies), "dropped_fraction": float(np.mean(drop)), } return (out, sampled) if return_info else out
[docs] def inject_outliers( survey: SurveyData, *, rate: float = 0.0, log10_shift_range: tuple[float, float] = (0.5, 1.5), rng: np.random.Generator, return_info: bool = False, ) -> SurveyData: """Perturb a random fraction of valid observations by a large factor. Outliers remain marked ``valid`` and keep their existing declared error, simulating a bad reading that quality control failed to flag — the case a robust inverter must tolerate. Parameters ---------- survey : SurveyData Survey to perturb. rate : float, default=0.0 Fraction of currently valid observations perturbed. Zero is a no-op. log10_shift_range : (float, float), default=(0.5, 1.5) Bounds on the outlier magnitude in log\\ :sub:`10` decades; the sign is randomized per outlier. rng : numpy.random.Generator Source of randomness. Returns ------- SurveyData New survey with a subset of valid impedance values shifted by ``10 ** (+/- shift)``. Examples -------- >>> import numpy as np >>> from pycsamt.ai.data.contracts import SurveyData >>> z = np.full((1, 10, 1), 100 + 0j) >>> survey = SurveyData( ... z, np.arange(10.0, 0.0, -1.0), ["S"], ["xy"], [[0, 0]] ... ) >>> corrupted = inject_outliers( ... survey, rate=0.5, rng=np.random.default_rng(0) ... ) >>> corrupted.shape == survey.shape True """ rate = _rate(rate, "rate") if rate == 0.0: return (survey, {"n_outliers": 0}) if return_info else survey lo, hi = _range(log10_shift_range, "log10_shift_range") valid_idx = np.flatnonzero(survey.valid) n_outliers = int(round(rate * valid_idx.size)) if n_outliers == 0: return (survey, {"n_outliers": 0}) if return_info else survey chosen = rng.choice(valid_idx, size=n_outliers, replace=False) shift = rng.uniform(lo, hi, size=n_outliers) sign = rng.choice([-1.0, 1.0], size=n_outliers) factor = 10.0 ** (sign * shift) impedance = np.array(survey.impedance) flat = impedance.reshape(-1) flat[chosen] = flat[chosen] * factor impedance = flat.reshape(survey.shape) out = replace(survey, impedance=impedance) return (out, {"n_outliers": n_outliers}) if return_info else out
[docs] def perturb_coordinates( survey: SurveyData, *, coordinate_sigma_m: float = 0.0, elevation_sigma_m: float = 0.0, rng: np.random.Generator, ) -> SurveyData: """Add Gaussian noise to station coordinates and elevation. Parameters ---------- survey : SurveyData Survey whose station geometry should be perturbed. coordinate_sigma_m, elevation_sigma_m : float, default=0.0 Std. dev. of Gaussian noise added to the horizontal (x, y) coordinates and to elevation, respectively. Elevation entries that are ``NaN`` (unknown) stay ``NaN``. rng : numpy.random.Generator Source of randomness. Returns ------- SurveyData New survey with perturbed :attr:`SurveyData.coordinates_m`. Examples -------- >>> import numpy as np >>> from pycsamt.ai.data.contracts import SurveyData >>> z = np.ones((1, 1, 1), dtype=complex) >>> survey = SurveyData(z, [1.0], ["S"], ["xy"], [[0.0, 0.0, 100.0]]) >>> moved = perturb_coordinates( ... survey, coordinate_sigma_m=5.0, rng=np.random.default_rng(0) ... ) >>> moved.coordinates_m.shape (1, 3) """ coordinates = np.array(survey.coordinates_m) if coordinate_sigma_m > 0.0: coordinates[:, :2] += rng.normal( 0.0, coordinate_sigma_m, size=(survey.n_stations, 2) ) if elevation_sigma_m > 0.0: finite = np.isfinite(coordinates[:, 2]) coordinates[finite, 2] += rng.normal( 0.0, elevation_sigma_m, size=int(finite.sum()) ) return replace(survey, coordinates_m=coordinates)
SEVERITY_PRESETS: Mapping[str, CorruptionConfig] = { "clean": CorruptionConfig(), "in_distribution": CorruptionConfig( noise_level_range=(0.01, 0.03), error_floor_fraction=0.02, static_shift_log10_sigma=0.05, distortion_gain_log10_sigma=0.02, distortion_twist_deg_sigma=3.0, distortion_shear_sigma=0.05, random_dropout_rate=0.02, outlier_rate=0.0, coordinate_sigma_m=1.0, ), "severe": CorruptionConfig( noise_level_range=(0.05, 0.15), error_floor_fraction=0.05, static_shift_log10_sigma=0.15, distortion_gain_log10_sigma=0.05, distortion_twist_deg_sigma=10.0, distortion_shear_sigma=0.15, station_dropout_rate=0.05, frequency_dropout_rate=0.05, random_dropout_rate=0.05, outlier_rate=0.02, coordinate_sigma_m=5.0, elevation_sigma_m=2.0, ), "held_out_corruption": CorruptionConfig( noise_level_range=(0.10, 0.25), error_floor_fraction=0.05, static_shift_log10_sigma=0.30, distortion_gain_log10_sigma=0.10, distortion_twist_deg_sigma=25.0, distortion_shear_sigma=0.40, distortion_anisotropy_sigma=0.30, station_dropout_rate=0.15, frequency_dropout_rate=0.15, random_dropout_rate=0.10, outlier_rate=0.08, outlier_log10_shift_range=(1.0, 2.5), coordinate_sigma_m=15.0, elevation_sigma_m=5.0, ), } """Named corruption suites for M3's clean/in-distribution/severe/OOD split."""
[docs] def apply_corruption_suite( survey: SurveyData, config: CorruptionConfig | None = None, *, severity: str | None = None, seed: int, ) -> tuple[SurveyData, CorruptionRecord]: """Apply the full, ordered M3 corruption pipeline from a single seed. Steps run in this fixed order: static shift, galvanic distortion, heteroscedastic noise, error floor, dropout, outliers, coordinate perturbation. Systematic distortions are applied to the clean signal before random noise and missingness, matching how these effects compose physically. Parameters ---------- survey : SurveyData Clean survey to corrupt. config : CorruptionConfig, optional Explicit configuration. Mutually exclusive with ``severity``. severity : str, optional Name of an entry in :data:`SEVERITY_PRESETS` to use as ``config``. seed : int Parent seed. Each step draws from an independently spawned child generator so adding a new step does not change earlier steps' draws. Returns ------- survey : SurveyData Corrupted survey. record : CorruptionRecord Provenance of the applied configuration and sampled parameters. Raises ------ ValueError If both or neither of ``config``/``severity`` are given, or ``severity`` is unknown. Examples -------- >>> import numpy as np >>> from pycsamt.ai.data.contracts import SurveyData >>> z = np.full((3, 5, 2), 100 + 50j) >>> survey = SurveyData( ... z, ... np.linspace(100, 1, 5), ... ["A", "B", "C"], ... ["xy", "yx"], ... np.zeros((3, 2)), ... ) >>> corrupted, record = apply_corruption_suite( ... survey, severity="in_distribution", seed=0 ... ) >>> record.severity 'in_distribution' """ if (config is None) == (severity is None): raise ValueError("exactly one of config or severity must be given.") if severity is not None: if severity not in SEVERITY_PRESETS: raise ValueError( f"unknown severity {severity!r}; choose from " f"{sorted(SEVERITY_PRESETS)}." ) config = SEVERITY_PRESETS[severity] parent = np.random.default_rng(_seed(seed)) sampled: dict[str, Any] = {} out = survey out, static_info = apply_static_shift( out, log10_sigma=config.static_shift_log10_sigma, rng=_child_rng(parent), return_info=True, ) sampled["static_shift_factor_mean"] = float( np.mean(static_info["static_shift_factor"]) ) out, distortion_sampled = apply_galvanic_distortion( out, gain_log10_sigma=config.distortion_gain_log10_sigma, twist_deg_sigma=config.distortion_twist_deg_sigma, shear_sigma=config.distortion_shear_sigma, anisotropy_sigma=config.distortion_anisotropy_sigma, rng=_child_rng(parent), return_info=True, ) sampled["distortion_twist_deg_mean"] = float( np.mean(distortion_sampled["twist_deg"]) ) if config.noise_level_range != (0.0, 0.0): out = add_heteroscedastic_noise( out, level_range=config.noise_level_range, rng=_child_rng(parent) ) if config.error_floor_fraction > 0.0: out = apply_error_floor( out, floor_fraction=config.error_floor_fraction ) out, dropout_sampled = apply_dropout( out, station_rate=config.station_dropout_rate, frequency_rate=config.frequency_dropout_rate, random_rate=config.random_dropout_rate, rng=_child_rng(parent), return_info=True, ) sampled.update(dropout_sampled) out, outlier_sampled = inject_outliers( out, rate=config.outlier_rate, log10_shift_range=config.outlier_log10_shift_range, rng=_child_rng(parent), return_info=True, ) sampled.update(outlier_sampled) if config.coordinate_sigma_m > 0.0 or config.elevation_sigma_m > 0.0: out = perturb_coordinates( out, coordinate_sigma_m=config.coordinate_sigma_m, elevation_sigma_m=config.elevation_sigma_m, rng=_child_rng(parent), ) record = CorruptionRecord( config=config, seed=_seed(seed), sampled=sampled, severity=severity ) return out, record