Source code for PyHydroGeophysX.inversion.em1d

"""Occam-style 1D FDEM/TDEM inversion helpers."""

from __future__ import annotations

import math
import threading
from typing import Any, Callable, Dict, List, Optional

import numpy as np

_LN10 = math.log(10.0)

from PyHydroGeophysX._internal.optional_dependencies import BackendUnavailable
from PyHydroGeophysX._internal.utils import noop as _noop
from PyHydroGeophysX.forward.em1d import (
    _fdem_config,
    _tdem_config,
    _tdem_geometry,
    model_depth_profile,
)

LogFn = Callable[[str], None]

DEFAULT_INVERSION = {
    "n_layers": 15, "min_thickness": 1.0, "max_thickness": 40.0,
    "starting_resistivity": 100.0, "max_iterations": 30,
    "rel_error": 0.05, "noise_floor": 1e-14, "smoothness": 0.3,
    "lateral_smoothness": 0.0, "lci_passes": 1,
    # Line inversion. ``lci_mode`` picks how neighbouring soundings are tied
    # together: "simultaneous" solves the line as one system, "sequential" runs
    # the older block-coordinate passes, "off" inverts each sounding alone. The
    # mode only takes effect when ``lateral_smoothness`` is positive.
    "lci_mode": "simultaneous", "reference_distance": 10.0,
    # The formal coupled-line solver is bound-aware sparse TRF.  The tolerances
    # below are the balanced full-survey settings validated on trailcreek: the
    # stricter ftol=1e-6 spent all 90 evaluations in the weakly constrained
    # convergence tail without completing robust reweighting.  Gauss-Newton is
    # still available explicitly for fast previews and historical reproduction.
    "lci_solver": "trf", "lci_max_nfev": 90,
    "lci_ftol": 1e-4, "lci_xtol": 1e-6, "lci_gtol": 1e-5,
    # A multiplier on ``lateral_smoothness``, not a second knob. The two meet as
    # a product before the solver sees either, and the product is then squared
    # into the penalty weight, so 1.3 x 2.0 and 2.6 x 1.0 give the same section
    # to the last bit. It is kept at 1.0 and left alone; ``lateral_smoothness``
    # is the one to move, because it is the number the project file itself
    # records and it is one value rather than a product of two.
    "lateral_weight_scale": 1.0, "auto_lambda": True,
    # How the lateral tie between neighbouring soundings falls off with their
    # separation: the penalty scales as
    # ``(reference_distance / separation) ** lateral_distance_power``. 1.0 makes
    # the tie inversely proportional to distance; 0.0 ties every neighbouring
    # pair alike regardless of spacing.
    "lateral_distance_power": 1.0,
    "target_chi2": 1.0, "chi2_tolerance": 0.2, "max_lambda_trials": 5,
    "convergence_tolerance": 0.02, "min_iterations": 2,
    "reject_outliers": False, "outlier_threshold": 3.0,
    # Optional data-only Huber IRLS. Original errors remain available for QC;
    # effective errors grow for large residuals, but no imported gate is lost.
    "robust_errors": False, "robust_threshold": 3.0,
    "robust_passes": 3, "robust_max_error_factor": 10.0,
    "robust_min_unchanged_fraction": 0.0, "robust_target_chi2": 0.0,
    "robust_target_tolerance": 0.25,
    # Optional empirical resistive-background tendency. Weak absolute LM signal
    # can support a resistive half-space/background interpretation, but it does
    # not locate that resistivity in the shallow layers. The historical
    # ``shallow_prior_*`` key prefix remains for saved-config compatibility;
    # the penalty now spans the whole layer vector. A zero explicit resistivity
    # chooses ``effective starting half-space * factor`` instead.
    "shallow_prior_enabled": False, "shallow_prior_depth_m": 0.0,
    "shallow_prior_min_resistivity": 0.0,
    "shallow_prior_resistivity_factor": 2.0,
    # The signal trigger has its own fixed homogeneous reference, so changing
    # the soft model target does not redefine which observations trigger it.
    "shallow_prior_signal_reference_resistivity": 1000.0,
    "shallow_prior_weight": 1.0,
    "shallow_prior_window": 11, "shallow_prior_snr_ratio": 0.6,
    "shallow_prior_mode": "quality_trend", "shallow_prior_reference_gate": 2,
    "shallow_prior_signal_ratio": 0.8, "shallow_prior_noise_ratio_max": 2.0,
    "shallow_prior_signal_threshold": 0.0,
    "outlier_passes": 2, "min_data_fraction": 0.8,
    "min_gates_per_sounding": 3,
    # Data QC. ``min_rel_error`` floors and ``max_rel_error`` caps the stack
    # error a gate arrives with, before ``rel_error`` joins it in quadrature.
    # Both off by default; see :func:`_tdem_uncertainty`.
    "min_rel_error": 0.0, "max_rel_error": None,
    # Model bounds in ohm-m, as the optimiser's box constraint on log10 rho.
    # A layer the data cannot resolve is driven only by the regularisation, so
    # without a bound it walks until the box stops it; the width of the box then
    # sets how far a deep layer can rail, and a section whose deepest cells sit
    # on the bound is reporting the bound rather than the ground. The default
    # pair is wide (1 to 1e5) so it constrains nothing on well-resolved data;
    # narrow it to the range the target geology can plausibly span.
    "rho_min": 1.0, "rho_max": 1e5,
    # How far auto-lambda may scale the smoothness while chasing target_chi2.
    "scale_bounds": (1e-4, 1e4),
    # Cumulative sensitivity below which a cell is reported as unresolved. Named
    # here rather than left to the caller so a preset can move it and a panel can
    # move it back. The value repeats em1d_lci.DOI_SENSITIVITY_THRESHOLD rather
    # than importing it, because that module pulls in SciPy sparse and this one
    # is imported by the CLI and the Qt panel; a test asserts the two agree.
    "doi_threshold": 0.8,
}


#: Named starting points for the inversion settings.
#:
#: :data:`DEFAULT_INVERSION` has to serve everything from a 30 m ground sounding
#: to an airborne line that sees several hundred metres, so it is deliberately
#: unopinionated. A survey type that has been worked through can do better than
#: unopinionated, and that is what a preset carries: the settings a particular
#: kind of data was found to want, in one place, with the reason recorded beside
#: each one.
#:
#: A preset holds only what it changes. :func:`preset_inversion` merges it over
#: the defaults, so a key nobody has an opinion about stays wherever the
#: framework put it.
INVERSION_PRESETS: Dict[str, Dict[str, Any]] = {
    "generic": {},
    "ground_tem": {
        # A ground system's gates run from a few microseconds to a few hundred,
        # which is a hundred metres of diffusion depth, not four hundred. Twenty
        # layers over that range put the finest ones where the early gates
        # actually resolve something.
        "n_layers": 20, "min_thickness": 1.0, "max_thickness": 14.74,
        # A ground sounding keeps a median of four or five gates against twenty
        # layers. Chasing chi-square to 1 on that is fitting noise: the model
        # oscillates to pass through a handful of points. Fixing the
        # regularisation and accepting a higher misfit is the honest trade, and
        # it halves the upper tail of the recovered resistivity.
        "smoothness": 1.5, "lateral_smoothness": 1.3,
        "auto_lambda": False, "scale_bounds": (0.5, 2.0),
        # Preserve sparse LM/HM observations. Large residuals get a bounded
        # increase in effective error instead of being deleted. Legacy hard
        # rejection options remain available for reproducing older runs.
        "reject_outliers": False, "outlier_threshold": 3.0,
        "robust_errors": True, "robust_threshold": 3.0,
        "robust_passes": 3, "robust_max_error_factor": 10.0,
        "robust_min_unchanged_fraction": 0.70, "robust_target_chi2": 1.75,
        "robust_target_tolerance": 0.25,
        "shallow_prior_enabled": True,
        "shallow_prior_mode": "signal_threshold",
        # Weak absolute LM supports only a background-resistivity tendency, not
        # a shallow depth. Twice the effective starting half-space is a modest
        # one-sided target; stronger multipliers too readily manufacture the
        # result this empirical prior assumes.
        "shallow_prior_resistivity_factor": 2.0,
        "outlier_passes": 2, "min_data_fraction": 0.8,
        "min_gates_per_sounding": 3,
        # A stack error is itself estimated from a finite number of repeats, so
        # a gate that happens to stack quietly can report a few tenths of a
        # percent and then outweigh its neighbours by orders of magnitude.
        "min_rel_error": 0.03, "rel_error": 0.06,
        # A deep layer the data cannot resolve walks until the box stops it, so
        # the box decides how far it walks. Weathered bedrock does not reach
        # 1e5 ohm-m, and leaving room to means reporting the bound as if it
        # were a measurement.
        "rho_max": 1.0e4,
        # Cumulative sensitivity is summed from the bottom up and the deepest
        # layer is thick, so a low threshold saturates: on one survey more than
        # half the stations reported a depth of investigation at the very base
        # of the model, which is the metric running out rather than the data.
        "doi_threshold": 6.0,
        "auto_starting_model": True,
    },
}


def preset_inversion(name: str = "generic") -> Dict[str, Any]:
    """Inversion settings for a named survey type, over the framework defaults."""
    key = str(name).strip().lower()
    if key not in INVERSION_PRESETS:
        raise ValueError(
            f"inversion preset must be one of {sorted(INVERSION_PRESETS)}; "
            f"got {name!r}.")
    return {**DEFAULT_INVERSION, **INVERSION_PRESETS[key]}


def _layer_thicknesses(n_layers: int, min_thickness: float, max_thickness: float) -> np.ndarray:
    n_layers = max(2, int(n_layers))
    if n_layers == 2:
        return np.array([float(min_thickness)], dtype=float)
    return np.geomspace(float(min_thickness), float(max_thickness), n_layers - 1)


def _inversion_layer_thicknesses(inv: Dict[str, Any]) -> np.ndarray:
    n_layers = int(inv.get("n_layers", 15))
    explicit = np.asarray(inv.get("layer_thicknesses", []), dtype=float).ravel()
    if (
        explicit.size == n_layers - 1
        and np.all(np.isfinite(explicit))
        and np.all(explicit > 0.0)
    ):
        return explicit
    return _layer_thicknesses(
        n_layers,
        float(inv.get("min_thickness", 1.0)),
        float(inv.get("max_thickness", 40.0)),
    )


def _log_resistivity_bounds(inv: Dict[str, Any]) -> "tuple[float, float]":
    """``(log10 rho_min, log10 rho_max)`` for the optimiser's box constraint.

    Kept in one place so the per-sounding solver and the coupled line solver
    cannot drift apart on what a plausible resistivity is.
    """
    # Only a missing key or an explicit None falls back to the default. ``or``
    # would swallow a caller's 0.0 as well and hand back 1.0, turning a bad
    # setting into a silently different model.
    low = inv.get("rho_min", 1.0)
    high = inv.get("rho_max", 1e5)
    lo = float(1.0 if low is None else low)
    hi = float(1e5 if high is None else high)
    if not (0.0 < lo < hi):
        raise ValueError(
            f"rho_min must be positive and below rho_max; got {lo} and {hi}.")
    return math.log10(lo), math.log10(hi)


def _tdem_uncertainty(observed: np.ndarray, item: Dict[str, Any],
                      rel: float, floor: float,
                      min_rel: float = 0.0,
                      max_rel: Optional[float] = None) -> np.ndarray:
    """Per-gate uncertainty: the recorded stack error with ``rel`` added in quadrature.

    Two independent things go wrong with a gate. Its stack error says how
    repeatably it was measured, and the file records one per gate. Everything
    else, system calibration and the error in representing the ground as 1D
    layers, applies to every gate alike and the stack error knows nothing about
    it; that is what ``rel`` carries.

    Adding them in quadrature is how independent errors combine. It also
    behaves better than taking the larger of the two, which was the previous
    rule: quadrature leaves a gate that is already noisy essentially untouched,
    so the instrument's relative weighting between clean and noisy gates
    survives, where a floor flattens every gate below it to the same weight.

    Where the file carries no stack error for a gate, ``rel`` is the whole
    budget, so a partially populated column is still usable.

    ``rel`` is the size of that uniform term, not an amount to add on top of
    whatever is already there. Some formats store an error that has a uniform
    term folded in already, and a reader that knows this reports how much as
    ``item["uniform_error"]``; only the shortfall is then added in quadrature.
    Without that key the two are the same thing, so nothing changes for a file
    whose stored column is a bare stack error.

    ``min_rel`` and ``max_rel`` clamp the recorded stack error before the
    quadrature step.

    The floor is the half that matters. A stack error is itself estimated from a
    finite number of repeat transients, so it is a random variable in its own
    right: its relative scatter is of order 1/sqrt(2N), and a gate that happens
    to stack quietly can report a few tenths of a percent where the true
    repeatability is nearer a few percent. The gate enters the misfit weighted by
    the reciprocal of its error, so such a gate outweighs its neighbours by two
    orders of magnitude, and on a station carrying four or five gates it alone
    decides the model. Flooring at the repeatability the instrument can resolve
    removes that failure mode and leaves the relative weighting of every gate
    above the floor unchanged.

    The ceiling limits how far a noisy gate may be down-weighted: clipping a
    recorded 42 % error to 25 % makes that gate *more* influential, not less.
    It is useful only when very large reported errors would otherwise remove a
    retained gate from the fit in practice. To reject noisy gates, use the
    reader's ``max_relative_std`` setting instead. Both bounds default to off.
    """
    data_rel = np.asarray(item.get("relative_std", []), dtype=float).ravel()
    if data_rel.size == observed.size:
        data_rel = np.where(np.isfinite(data_rel) & (data_rel > 0.0), data_rel, 0.0)
        # Clamp only where a stack error was actually recorded. A gate whose
        # column is empty carries 0.0 as "unknown", and lifting that to the floor
        # would invent an error the file never claimed.
        recorded = data_rel > 0.0
        if min_rel > 0.0:
            data_rel = np.where(recorded, np.maximum(data_rel, float(min_rel)), data_rel)
        if max_rel is not None:
            data_rel = np.where(recorded, np.minimum(data_rel, float(max_rel)), data_rel)
        # Add only the shortfall. Where a file states that its recorded error
        # already carries a uniform term, that term is part of ``rel`` rather
        # than additional to it, and adding the whole of ``rel`` again would
        # count the same physical error twice. A gate with no recorded error
        # carries no baked-in term either, so ``rel`` is its whole budget.
        baked = float(item.get("uniform_error", 0.0) or 0.0)
        extra = math.sqrt(max(float(rel) ** 2 - max(baked, 0.0) ** 2, 0.0))
        total = np.where(recorded, np.hypot(data_rel, extra), float(rel))
        return total * np.abs(observed) + floor
    return rel * np.abs(observed) + floor


def _occam_1d(forward_vec: Callable[[np.ndarray], np.ndarray], dobs_vec: np.ndarray,
              unc_vec: np.ndarray, n_layers: int, inv: Dict[str, Any], log: LogFn,
              jacobian_vec: Optional[Callable[[np.ndarray], np.ndarray]] = None):
    """Smooth fixed-layer fit with optional LCI neighbor regularization.

    ``jacobian_vec`` returns ``d(predicted) / d(sigma)`` shaped
    ``(n_data, n_layers)``. Given one, the optimizer is handed the analytic
    derivative of the whole residual instead of differencing it: SciPy's
    two-point rule costs one extra forward call per layer per step, which on a
    20-layer model is 20 forwards spent to learn what one sensitivity call
    already knows. Without one the numerical route still works, so a forward
    operator that cannot supply a sensitivity keeps running.
    """
    from scipy.optimize import least_squares
    lam = float(inv.get("smoothness", 0.3))
    start_res = float(inv.get("starting_resistivity", 100.0))
    lo, hi = _log_resistivity_bounds(inv)
    start_model = np.asarray(inv.get("starting_model", []), dtype=float).ravel()
    if start_model.size == n_layers and np.all(np.isfinite(start_model)):
        x0 = np.clip(np.log10(np.clip(start_model, 10.0 ** lo, 10.0 ** hi)), lo, hi)
    else:
        x0 = float(np.clip(np.log10(max(start_res, 1.0)), lo, hi)) * np.ones(n_layers)
    lateral_model = np.asarray(inv.get("lateral_reference", []), dtype=float).ravel()
    lateral_weight = float(inv.get("lateral_weight", 0.0))
    if (
        lateral_model.size == n_layers
        and np.all(np.isfinite(lateral_model))
        and lateral_weight > 0.0
    ):
        lateral_log = np.log10(np.clip(lateral_model, 10.0 ** lo, 10.0 ** hi))
    else:
        lateral_log = np.array([], dtype=float)
    unc_vec = np.clip(unc_vec, 1e-30, None)
    from .em1d_priors import shallow_prior_terms
    prior_lower, prior_weights = shallow_prior_terms(inv, _inversion_layer_thicknesses(
        {**inv, "n_layers": n_layers}))
    convergence: List[float] = []

    def residual(logres: np.ndarray) -> np.ndarray:
        sigma = 1.0 / np.power(10.0, logres)
        pred = np.asarray(forward_vec(sigma), dtype=float)
        data_res = (pred - dobs_vec) / unc_vec
        smooth = lam * np.diff(logres)
        lateral = (
            lateral_weight * (logres - lateral_log)
            if lateral_log.size else np.array([], dtype=float)
        )
        prior = prior_weights * np.maximum(prior_lower - logres, 0.)
        return np.concatenate([data_res, smooth, lateral, prior])

    # The two regularization blocks are linear in logres, so their rows are the
    # same matrix at every model and are built once here. Only the data rows
    # need the forward operator.
    smooth_rows = lam * (np.eye(n_layers, k=1) - np.eye(n_layers))[:n_layers - 1]
    lateral_rows = (lateral_weight * np.eye(n_layers) if lateral_log.size
                    else np.zeros((0, n_layers)))

    def jacobian(logres: np.ndarray) -> np.ndarray:
        """d(residual) / d(log10 resistivity), all three blocks.

        The chain rule through ``sigma = 10**(-logres)`` contributes
        ``d sigma / d logres = -ln(10) * sigma``, the same factor the coupled
        line solver applies in :func:`em1d_lci._sensitivity_line`.
        """
        sigma = 1.0 / np.power(10.0, logres)
        jac = np.asarray(jacobian_vec(sigma), dtype=float)
        if jac.shape != (dobs_vec.size, n_layers):
            raise ValueError(
                f"the forward operator returned a Jacobian of shape {jac.shape}, "
                f"expected {(dobs_vec.size, n_layers)}.")
        data_rows = (jac * (-_LN10 * sigma)[None, :]) / unc_vec[:, None]
        prior_rows = np.diag(-prior_weights * (logres < prior_lower))
        return np.vstack([data_rows, smooth_rows, lateral_rows, prior_rows])

    # SciPy's ``max_nfev`` is the number of outer residual evaluations. Numerical
    # Jacobian probes are additional calls, so multiplying by ``n_layers`` here
    # makes a 20-layer line inversion unnecessarily hundreds of evaluations long.
    max_nfev = max(4, int(inv.get("max_iterations", 30)))
    def on_iteration(intermediate) -> None:
        """Record the data-only chi-square from SciPy's outer optimizer."""
        fun = getattr(intermediate, "fun", None)
        if fun is None:
            x = np.asarray(getattr(intermediate, "x", intermediate), dtype=float)
            fun = residual(x)
        data_res = np.asarray(fun, dtype=float).ravel()[:dobs_vec.size]
        convergence.append(float(np.mean(data_res ** 2)))

    kwargs: Dict[str, Any] = {"max_nfev": max_nfev, "xtol": 1e-8, "ftol": 1e-8}
    if jacobian_vec is not None:
        kwargs["jac"] = jacobian
    # SciPy < 1.16 has no callback argument. Keep those supported environments
    # working; their quality page falls back to the final chi-square chart.
    try:
        import inspect
        if "callback" in inspect.signature(least_squares).parameters:
            kwargs["callback"] = on_iteration
    except Exception:  # pragma: no cover - defensive compatibility path
        pass
    sol = least_squares(residual, x0, bounds=(lo, hi), method="trf", **kwargs)
    res = np.power(10.0, sol.x)
    data_res = residual(sol.x)[: dobs_vec.size]
    chi2 = float(np.mean(data_res ** 2))
    if not convergence or not np.isclose(convergence[-1], chi2):
        convergence.append(chi2)
    log(f"  inversion done: {sol.nfev} forward evals, chi2={chi2:.3f}")
    return res, chi2, int(sol.nfev), convergence


def _occam_with_optional_rejection(
    forward_vec: Callable[[np.ndarray], np.ndarray],
    dobs_vec: np.ndarray,
    unc_vec: np.ndarray,
    n_layers: int,
    inv: Dict[str, Any],
    log: LogFn,
    jacobian_vec: Optional[Callable[[np.ndarray], np.ndarray]] = None,
):
    """Run Occam with optional robust errors, or legacy hard rejection.

    Robust errors take precedence and preserve every gate. The simultaneous
    line solver has its own survey-wide reweighting/rejection routines.
    This counterpart covers a single sounding, independent line inversion and
    sequential LCI without changing their solver when rejection is disabled.
    The retained fraction is measured against the sounding's original data and
    every re-fit is warm-started from the previous model.
    """
    dobs_vec = np.asarray(dobs_vec, dtype=float).ravel()
    unc_vec = np.asarray(unc_vec, dtype=float).ravel()
    if dobs_vec.size != unc_vec.size:
        raise ValueError("observed data and uncertainty must have the same length.")
    keep = np.ones(dobs_vec.size, dtype=bool)
    if bool(inv.get("robust_errors", False)):
        from .robust_errors import reweight_errors, robust_error_options

        if inv.get("reject_outliers", False):
            log("Robust errors enabled: hard rejection is bypassed; all gates retained.")
        total_nfev = 0
        histories = []

        def robust_solve(effective, previous):
            nonlocal total_nfev
            settings = dict(inv)
            if previous is not None:
                settings["starting_model"] = previous[0]
            fitted = _occam_1d(forward_vec, dobs_vec, effective, n_layers,
                               settings, log, jacobian_vec)
            total_nfev += int(fitted[2])
            histories.extend(fitted[3])
            return fitted

        fitted, _, robust = reweight_errors(
            dobs_vec, unc_vec, robust_solve,
            lambda fit: forward_vec(1.0 / np.clip(fit[0], 1e-12, None)),
            history=lambda fit: fit[3], log=log, **robust_error_options(inv))
        # Keep the established result layout; report raw-error chi2 as the main
        # metric so increasing errors cannot masquerade as a better raw fit.
        info = {"enabled": False, "n_start": int(keep.size), "kept": int(keep.size),
                "dropped": 0, "passes": [], "robust": robust,
                "stopped_because": "robust error weighting; no hard rejection"}
        return fitted[0], robust["chi2_original"], total_nfev, histories, keep, info
    enabled = bool(inv.get("reject_outliers", False))
    threshold = float(inv.get("outlier_threshold", 3.0))
    passes = max(0, int(inv.get("outlier_passes", 2)))
    fraction = float(np.clip(inv.get("min_data_fraction", 0.8), 0.0, 1.0))
    min_gates = max(0, int(inv.get("min_gates_per_sounding", 3)))
    floor = min(dobs_vec.size, max(
        int(math.ceil(fraction * dobs_vec.size)), min(min_gates, dobs_vec.size)))
    info: Dict[str, Any] = {
        "enabled": enabled,
        "threshold": threshold,
        "n_start": int(dobs_vec.size),
        "floor": int(floor),
        "passes": [],
        "kept": int(dobs_vec.size),
        "dropped": 0,
        "stopped_because": "disabled" if not enabled else "",
    }
    histories: List[float] = []
    total_nfev = 0
    working_inv = dict(inv)

    def solve(mask: np.ndarray):
        def selected_forward(sigma: np.ndarray) -> np.ndarray:
            return np.asarray(forward_vec(sigma), dtype=float).ravel()[mask]

        selected_jacobian = None
        if jacobian_vec is not None:
            def selected_jacobian(sigma: np.ndarray) -> np.ndarray:
                return np.asarray(jacobian_vec(sigma), dtype=float)[mask, :]

        return _occam_1d(
            selected_forward, dobs_vec[mask], unc_vec[mask], n_layers,
            working_inv, log, selected_jacobian)

    res, chi2, nfev, convergence = solve(keep)
    total_nfev += int(nfev)
    histories.extend(convergence)
    if enabled and not passes:
        info["stopped_because"] = "no rejection passes requested"
    if not enabled or not passes or not dobs_vec.size:
        return res, chi2, total_nfev, histories, keep, info

    for pass_index in range(1, passes + 1):
        sigma = 1.0 / np.clip(res, 1e-12, None)
        predicted = np.asarray(forward_vec(sigma), dtype=float).ravel()
        residual = np.abs((predicted - dobs_vec) / np.clip(unc_vec, 1e-30, None))
        candidates = np.flatnonzero(keep & (residual > threshold))
        allowed = int(keep.sum()) - floor
        if not candidates.size:
            info["stopped_because"] = "nothing left above the cut"
            break
        if allowed <= 0:
            info["stopped_because"] = f"at the {fraction:.0%} floor"
            break
        if candidates.size > allowed:
            order = candidates[np.argsort(-residual[candidates])]
            candidates = order[:allowed]
        keep[candidates] = False
        working_inv = {**working_inv, "starting_model": np.asarray(res, dtype=float)}
        res, chi2, nfev, convergence = solve(keep)
        total_nfev += int(nfev)
        histories.extend(convergence)
        info["passes"].append({
            "pass": pass_index,
            "dropped": int(candidates.size),
            "kept": int(keep.sum()),
            "chi2": float(chi2),
        })
        log(
            f"  rejected {candidates.size} gate(s) over {threshold:g} sigma, "
            f"{int(keep.sum())} left -> chi2 {chi2:.3f}")
        if int(keep.sum()) == floor:
            info["stopped_because"] = f"at the {fraction:.0%} floor"
            break
    if not info["stopped_because"]:
        info["stopped_because"] = f"all {passes} pass(es) used"
    info["kept"] = int(keep.sum())
    info["dropped"] = int(dobs_vec.size - keep.sum())
    return res, chi2, total_nfev, histories, keep, info


#: One warmed-up forward operator per worker thread, per distinct survey.
#:
#: ``Simulation1DLayered`` does a substantial one-time setup on its first call
#: and caches it on the instance: measured on a ground TDEM station it is around
#: 8 s the first time and 18 ms afterwards, and the setup grows with the number
#: of receiver times, so modelling the receiver's analog filter makes it an order
#: of magnitude more expensive than it is without.
#:
#: A line inversion re-uses one layer grid and one system geometry for every
#: station, so a fresh instance per station pays that setup hundreds of times
#: over for nothing. It cannot simply be shared, because two threads calling
#: ``getJ`` on one instance corrupt the state it caches. Keeping the instances in
#: thread-local storage gives one per worker rather than one per station, which
#: is the saving without the race: on a 600-station line that is around ten
#: warm-ups instead of twelve hundred.
_MODELER_CACHE = threading.local()

#: How many distinct surveys one thread keeps warm. A joint LM+HM run needs two
#: per distinct geometry, and a bounded number keeps a long survey from holding
#: every simulation it ever built.
#:
#: Eight was enough while a line shared one geometry. It is not enough now that
#: the transmitter-receiver distance is read per station: a survey binned at
#: :data:`~PyHydroGeophysX.workflows.em1d.STATION_DISTANCE_BIN_M` presents about
#: twenty-five distinct distances, so two moments need fifty entries and a cache
#: of eight evicts an operator before it is reused. That is the difference
#: between fourteen seconds and twenty milliseconds per station.
_MODELER_CACHE_SIZE = 64


def _modeler_key(thick: np.ndarray, config: Any) -> tuple:
    """Identity of a forward operator: same key means the instance is reusable.

    Everything the operator was built from has to appear here. Two stations that
    differ only in sensor height need different instances, and silently sharing
    one would return another station's geometry with no sign that it had.
    """
    def _blob(value: Any) -> Any:
        if isinstance(value, np.ndarray):
            return value.tobytes()
        if isinstance(value, (list, tuple)):
            return tuple(_blob(item) for item in value)
        if isinstance(value, dict):
            return tuple(sorted((str(k), _blob(v)) for k, v in value.items()))
        return value

    fields = tuple(sorted(
        (name, _blob(getattr(config, name)))
        for name in vars(config)
    ))
    return (np.asarray(thick, dtype=float).tobytes(), fields)


def _support_times(times: np.ndarray) -> np.ndarray:
    """Station gate times, padded so the forward has something to interpolate on.

    SimPEG builds a spline over the receiver times and needs more of them than a
    heavily gated station provides. The padding is forward-only support: the
    residual still uses the station's own gates, and no observed value is
    interpolated or invented.
    """
    times = np.asarray(times, dtype=float).ravel()
    if times.size >= 5:
        return times
    lower = max(float(np.min(times)) * 0.5, 1e-9)
    upper = max(float(np.max(times)) * 2.0, lower * 4.0)
    return np.unique(np.concatenate([times, np.geomspace(lower, upper, 7)]))


def _moment_gate_times(geometry: Dict[str, Any], times: np.ndarray) -> np.ndarray:
    """Every gate centre the moment records, or the station's own as a fallback.

    Modelling the instrument's whole gate set rather than one station's surviving
    subset is what lets a line share a single forward operator: the receiver
    times then depend on the moment, not on which gates that station happened to
    keep. It also gives the analog-filter reconstruction more nodes to work with
    than a three-gate station would, so the shared operator is at least as
    accurate as the per-station one it replaces.
    """
    windows = geometry.get("gate_windows") or {}
    centres = np.asarray(windows.get("centre", []), dtype=float).ravel()
    if centres.size >= 2 and np.all(np.isfinite(centres)) and np.all(centres > 0.0):
        return np.unique(centres)
    return _support_times(times)


def _thread_local_modeler(thick: np.ndarray, geometry: Dict[str, Any],
                          model_times: np.ndarray):
    """A forward operator for this survey, warmed up once per worker thread.

    Call this from the thread that will use the result, not from the thread that
    assembles the work. SimPEG's simulation caches the sensitivity on itself, so
    two threads calling ``getJ`` on one instance race: one of them can read the
    cache mid-write and get ``None`` back where a matrix should be. Building the
    blocks on the main thread and handing the same instance to a pool of workers
    is exactly that race, and it surfaces as a Jacobian with no dimensions.

    The cache is keyed on the whole survey configuration, so a line re-uses one
    warmed-up simulation per moment per thread rather than building one per
    station. A pool of N workers pays the setup N times instead of once, which
    on a 540-station line is still two orders of magnitude fewer builds.
    """
    try:
        from PyHydroGeophysX.forward.tdem_forward import TDEMForwardModeling
    except Exception as exc:  # noqa: BLE001
        raise BackendUnavailable(str(exc))
    config = _tdem_config(geometry, model_times)
    cache = getattr(_MODELER_CACHE, "items", None)
    if cache is None:
        cache = _MODELER_CACHE.items = {}
    key = _modeler_key(thick, config)
    modeler = cache.get(key)
    if modeler is None:
        modeler = TDEMForwardModeling(thicknesses=thick, survey_config=config)
        if len(cache) >= _MODELER_CACHE_SIZE:
            cache.pop(next(iter(cache)))
        cache[key] = modeler
    return modeler


[docs] def tdem_moment_blocks(data: Dict[str, Any], geom: Dict[str, Any], inv: Dict[str, Any], thick: np.ndarray) -> List[Dict[str, Any]]: """One forward block per usable moment at a TDEM station. Stations carrying separate ``LM`` and ``HM`` gate sets produce one block each; a plain single-response station produces one block named ``TDEM``. Returning the same shape for both is what lets the per-sounding inversion and the coupled line inversion share this assembly instead of each writing its own copy of the uncertainty and gate-selection rules. """ try: from PyHydroGeophysX.forward.tdem_forward import TDEMForwardModeling except Exception as exc: # noqa: BLE001 raise BackendUnavailable(str(exc)) scale = float(inv.get("data_scale", 1.0)) rel = float(inv.get("rel_error", 0.05)) floor = float(inv.get("noise_floor", 1e-18)) * scale min_rel = float(inv.get("min_rel_error", 0.0)) max_rel = inv.get("max_rel_error") max_rel = float(max_rel) if max_rel is not None else None sign = float(geom.get("response_sign", 1.0)) moments = dict(data.get("moments", {})) items = ([(name, dict(moments[name])) for name in ("LM", "HM") if name in moments] or [("TDEM", dict(data))]) blocks: List[Dict[str, Any]] = [] for name, item in items: times = np.asarray(item.get("times", []), dtype=float).ravel() observed = np.asarray(item.get("response", []), dtype=float).ravel() * scale if not times.size or observed.size != times.size: continue # The turn-off ramp and the gate windows belong to the moment, not to # the station, so the geometry a block needs is the moment's. geometry = _tdem_geometry(data, geom, item.get("transmitter")) # Model every gate the instrument records, not just the ones this station # kept, and select afterwards. The forward operator then depends on the # moment and the layer grid alone, so a line re-uses one warmed-up # simulation instead of building one per station. See _MODELER_CACHE for # what that is worth. Modelling gates a station rejected costs nothing: # they are never compared against an observation. model_times = _moment_gate_times(geometry, times) channel_indices = np.asarray([ int(np.argmin(np.abs(model_times - value))) for value in times ], dtype=int) if not np.allclose(model_times[channel_indices], times, rtol=1e-6, atol=0.0): # A station whose gates are not on the instrument's own grid, which a # re-binned or hand-edited file can produce. Fall back to modelling # exactly what it holds rather than selecting the wrong channels. model_times = _support_times(times) channel_indices = np.asarray([ int(np.argmin(np.abs(model_times - value))) for value in times ], dtype=int) blocks.append({ "name": name, "times": times, "model_times": model_times, "channel_indices": channel_indices, "observed": observed, "uncertainty": _tdem_uncertainty(observed, item, rel, floor, min_rel, max_rel), "sign": sign, # What the operator is, rather than the operator itself. Blocks are # assembled here on one thread and evaluated on a pool of workers, # so each worker resolves its own instance at call time; see # _thread_local_modeler for why sharing one is not safe. "thicknesses": thick, "geometry": geometry, }) if not blocks: raise ValueError("The joint TDEM sounding has no usable LM or HM gates.") return blocks
def _block_modeler(item: Dict[str, Any]): """The forward operator for one block, resolved on the calling thread.""" return _thread_local_modeler( item["thicknesses"], item["geometry"], item["model_times"]) def _moment_forward(blocks: List[Dict[str, Any]]) -> Callable[[np.ndarray], np.ndarray]: """Predicted response for the gates that were actually measured.""" def forward_vec(sigma: np.ndarray) -> np.ndarray: return np.concatenate([ item["sign"] * np.asarray( _block_modeler(item).forward(sigma), dtype=float).ravel()[ : item["model_times"].size][item["channel_indices"]] for item in blocks ]) return forward_vec def _moment_jacobian(blocks: List[Dict[str, Any]]) -> Callable[[np.ndarray], np.ndarray]: """Analytic d(response)/d(sigma), row-selected to match ``_moment_forward``.""" def jacobian(sigma: np.ndarray) -> np.ndarray: return np.vstack([ item["sign"] * np.asarray( _block_modeler(item).sensitivity(sigma), dtype=float)[ : item["model_times"].size][item["channel_indices"], :] for item in blocks ]) return jacobian def _fdem_pieces(data: Dict[str, Any], geom: Dict[str, Any], inv: Dict[str, Any], thick: np.ndarray): """Observed vector, uncertainty, forward, and Jacobian for one FDEM sounding. Data are ordered ``[real over frequency, imag over frequency]``, matching :func:`fdem_invert`. SimPEG's ``dpred`` interleaves the two, so the same row selection is applied to the response and to the Jacobian; that shared selection is what keeps the two consistent. """ try: from PyHydroGeophysX.forward.fdem_forward import FDEMForwardModeling except Exception as exc: # noqa: BLE001 raise BackendUnavailable(str(exc)) freqs = np.asarray(data["frequencies"], dtype=float).ravel() scale = float(inv.get("data_scale", 1.0)) obs_r = np.asarray(data["real"], dtype=float).ravel() * scale obs_i = np.asarray(data["imag"], dtype=float).ravel() * scale rel = float(inv.get("rel_error", 0.05)) floor = float(inv.get("noise_floor", 1e-14)) * scale amp = np.abs(obs_r + 1j * obs_i) observed = np.concatenate([obs_r, obs_i]) uncertainty = np.concatenate([rel * amp + floor, rel * amp + floor]) modeler = FDEMForwardModeling( thicknesses=thick, survey_config=_fdem_config(geom, freqs)) nf = int(freqs.size) rows = np.concatenate([np.arange(0, 2 * nf, 2), np.arange(1, 2 * nf, 2)]) def forward_vec(sigma: np.ndarray) -> np.ndarray: resp = np.asarray(modeler.forward(sigma)).ravel() if resp.size == 2 * nf and not np.iscomplexobj(resp): resp = resp[0::2] + 1j * resp[1::2] resp = np.asarray(resp, dtype=complex).ravel()[:nf] return np.concatenate([resp.real, resp.imag]) def jacobian(sigma: np.ndarray) -> np.ndarray: return np.asarray( modeler.simulation.getJ(sigma), dtype=float)[rows, :] return observed, uncertainty, forward_vec, jacobian, modeler, freqs
[docs] def build_sounding_block(data: Dict[str, Any], geom: Dict[str, Any], inv: Dict[str, Any], method: str = "TDEM", *, position: float = 0.0, line: int = 0, label: str = ""): """Package one sounding for the coupled line inversion. The observed vector, uncertainty, and forward operator are built by the same code the per-sounding inversion uses, so a station fits the same data whether it is solved alone or as part of a line. The Jacobian comes from SimPEG's analytic sensitivity rather than from finite differences. """ from PyHydroGeophysX.inversion.em1d_lci import SoundingBlock thick = _inversion_layer_thicknesses(inv) if str(method).upper() == "FDEM": observed, uncertainty, forward_vec, jacobian, _, _ = _fdem_pieces( data, geom, inv, thick) else: blocks = tdem_moment_blocks(data, geom, inv, thick) observed = np.concatenate([item["observed"] for item in blocks]) uncertainty = np.concatenate([item["uncertainty"] for item in blocks]) forward_vec = _moment_forward(blocks) jacobian = _moment_jacobian(blocks) from .em1d_priors import shallow_prior_terms prior_lower, prior_weights = shallow_prior_terms(inv, thick) return SoundingBlock( forward=forward_vec, jacobian=jacobian, dobs=observed, uncertainty=uncertainty, position=float(position), line=int(line), label=str(label), prior_lower=prior_lower, prior_weights=prior_weights)
[docs] def fdem_invert(data: Dict[str, Any], geom: Dict[str, Any], inv: Dict[str, Any], log: LogFn = _noop) -> Dict[str, Any]: """Invert an FDEM sounding for a layered resistivity model (Occam 1D).""" n_layers = int(inv.get("n_layers", 15)) thick = _inversion_layer_thicknesses(inv) dobs_vec, unc_vec, forward_vec, jacobian_vec, _, freqs = _fdem_pieces( data, geom, inv, thick) obs_r, obs_i = dobs_vec[: freqs.size], dobs_vec[freqs.size:] log(f"FDEM inversion: {freqs.size} freqs, {n_layers} layers") res, chi2, nfev, convergence, fit_mask, outliers = ( _occam_with_optional_rejection( forward_vec, dobs_vec, unc_vec, n_layers, inv, log, jacobian_vec)) sigma = 1.0 / np.clip(res, 1e-12, None) pred = forward_vec(sigma) pred_r, pred_i = pred[: freqs.size], pred[freqs.size:] depth, res_step = model_depth_profile(thick, res) return {"method": "FDEM", "frequencies": freqs, "obs_real": obs_r, "obs_imag": obs_i, "pred_real": pred_r, "pred_imag": pred_i, "thickness": thick, "resistivity": res, "conductivity": sigma, "depth": depth, "resistivity_step": res_step, "chi2": chi2, "n_data": int(fit_mask.sum()), "fit_mask": fit_mask, "outliers": outliers, "robust": outliers.get("robust", {"enabled": False}), "chi2_effective": outliers.get("robust", {}).get("chi2_effective", chi2), "nfev": nfev, "n_layers": n_layers, "convergence": convergence}
[docs] def tdem_invert(data: Dict[str, Any], geom: Dict[str, Any], inv: Dict[str, Any], log: LogFn = _noop) -> Dict[str, Any]: """Invert a TDEM sounding for a layered resistivity model (Occam 1D).""" try: from PyHydroGeophysX.forward.tdem_forward import TDEMForwardModeling except Exception as exc: # noqa: BLE001 raise BackendUnavailable(str(exc)) times = np.asarray(data["times"], dtype=float).ravel() scale = float(inv.get("data_scale", 1.0)) dobs = np.asarray(data["response"], dtype=float).ravel() * scale rel = float(inv.get("rel_error", 0.05)); floor = float(inv.get("noise_floor", 1e-18)) * scale min_rel = float(inv.get("min_rel_error", 0.0)) max_rel = inv.get("max_rel_error") max_rel = float(max_rel) if max_rel is not None else None unc = _tdem_uncertainty(dobs, data, rel, floor, min_rel, max_rel) n_layers = int(inv.get("n_layers", 15)) thick = _inversion_layer_thicknesses(inv) geometry = _tdem_geometry(data, geom) modeler = TDEMForwardModeling( thicknesses=thick, survey_config=_tdem_config(geometry, times)) log(f"TDEM inversion: {times.size} times, {n_layers} layers") sign = float(geometry.get("response_sign", 1.0)) def forward_vec(sigma: np.ndarray) -> np.ndarray: return sign * np.asarray(modeler.forward(sigma), dtype=float).ravel()[: times.size] def jacobian_vec(sigma: np.ndarray) -> np.ndarray: return sign * np.asarray(modeler.sensitivity(sigma), dtype=float)[: times.size] res, chi2, nfev, convergence, fit_mask, outliers = ( _occam_with_optional_rejection( forward_vec, dobs, unc, n_layers, inv, log, jacobian_vec)) sigma = 1.0 / np.clip(res, 1e-12, None) pred = forward_vec(sigma) depth, res_step = model_depth_profile(thick, res) return {"method": "TDEM", "times": times, "obs": dobs, "pred": pred, "thickness": thick, "resistivity": res, "conductivity": sigma, "depth": depth, "resistivity_step": res_step, "chi2": chi2, "n_data": int(fit_mask.sum()), "fit_mask": fit_mask, "outliers": outliers, "robust": outliers.get("robust", {"enabled": False}), "chi2_effective": outliers.get("robust", {}).get("chi2_effective", chi2), "nfev": nfev, "n_layers": n_layers, "convergence": convergence}
[docs] def tdem_joint_invert( data: Dict[str, Any], geom: Dict[str, Any], inv: Dict[str, Any], log: LogFn = _noop, ) -> Dict[str, Any]: """Invert all available LM/HM gates at one station for one shared 1D model.""" try: from PyHydroGeophysX.forward.tdem_forward import TDEMForwardModeling except Exception as exc: # noqa: BLE001 raise BackendUnavailable(str(exc)) moments = dict(data.get("moments", {})) if not moments: return tdem_invert(data, geom, inv, log=log) n_layers = int(inv.get("n_layers", 15)) thick = _inversion_layer_thicknesses(inv) blocks = tdem_moment_blocks(data, geom, inv, thick) observed = np.concatenate([item["observed"] for item in blocks]) uncertainty = np.concatenate([item["uncertainty"] for item in blocks]) log( "Joint LM+HM inversion: " + ", ".join(f"{item['name']}={item['times'].size}" for item in blocks) + f" gates, {n_layers} shared layers" ) forward_vec = _moment_forward(blocks) res, chi2, nfev, convergence, fit_mask, outliers = ( _occam_with_optional_rejection( forward_vec, observed, uncertainty, n_layers, inv, log, _moment_jacobian(blocks))) sigma = 1.0 / np.clip(res, 1e-12, None) predicted = forward_vec(sigma) predictions: Dict[str, Dict[str, np.ndarray]] = {} offset = 0 for item in blocks: count = int(item["times"].size) predictions[item["name"]] = { "times": item["times"], "obs": item["observed"], "pred": predicted[offset:offset + count], "fit_mask": fit_mask[offset:offset + count], } if outliers.get("robust", {}).get("enabled"): for key in ("uncertainty_original", "uncertainty_effective", "weights"): predictions[item["name"]][key] = np.asarray( outliers["robust"][key][offset:offset + count]) offset += count depth, res_step = model_depth_profile(thick, res) return { "method": "TDEM", "joint_moments": True, "moments": predictions, "obs": observed, "pred": predicted, "thickness": thick, "resistivity": res, "conductivity": sigma, "depth": depth, "resistivity_step": res_step, "chi2": chi2, "n_data": int(fit_mask.sum()), "fit_mask": fit_mask, "outliers": outliers, "robust": outliers.get("robust", {"enabled": False}), "chi2_effective": outliers.get("robust", {}).get("chi2_effective", chi2), "nfev": nfev, "n_layers": n_layers, "convergence": convergence, }
__all__ = [ "DEFAULT_INVERSION", "build_sounding_block", "fdem_invert", "tdem_invert", "tdem_joint_invert", "tdem_moment_blocks", ]