Source code for pycsamt.inversion.backends.builtin

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Built-in dependency-light EM inversion backend.

The built-in backend provides a small SciPy-based inversion engine for
routine smoke tests, demos, and lightweight studies where optional
physics packages such as SimPEG or pyGIMLi are not available. It supports
natural-source 1-D soundings, stitched 2-D profiles, finite-difference
2-D MT profile inversion, and 1-D/stitched 2-D TDEM runs.

The backend parameterizes layered soundings in log10 resistivity and
log10 thickness. Profile mode either runs each station independently and
stitches the recovered columns, or, when ``profile_mode="fd2d"`` is
selected, solves a finite-difference 2-D natural-source problem against
the local :mod:`pycsamt.forward` solver.
"""

from __future__ import annotations

from typing import Any

import numpy as np

from ..base import BaseInversionBackend
from ..mesh import (
    InversionMesh,
    build_fd2d_grid,
    core_rho_from_start,
)
from ..model import StartingModel
from ..objective import (
    component_errors,
    component_mask,
    weighted_rms,
)
from ..regularization import (
    regularization_from_config,
    regularization_residual,
    regularization_weight,
)
from ..results import (
    InversionHistory,
    InversionResult,
    InversionUncertainty,
)

__all__ = ["Builtin1DBackend"]


[docs] class Builtin1DBackend(BaseInversionBackend): name = "builtin" supports = ( ("mt", "1d"), ("mt", "2d"), ("amt", "1d"), ("amt", "2d"), ("csamt", "1d"), ("csamt", "2d"), ("emap", "2d"), ("tdem", "1d"), ("tdem", "2d"), )
[docs] def run(self, data: Any | None = None) -> InversionResult: """Run the configured built-in inversion. Parameters ---------- data : mapping, object, sequence, or path-like, optional Optional data override for this run. When omitted, the backend uses ``self.config.data``. Values are coerced through :class:`pycsamt.inversion.data.EMData` by the base backend. Returns ------- InversionResult Backend-neutral result containing the recovered model, predicted response, RMS, objective value, uncertainty proxy, convergence history, and backend metadata. Raises ------ ValueError If the configured method does not receive the required data components. Natural-source runs require frequencies plus apparent resistivity and/or phase. TDEM runs require times plus values. ImportError If SciPy is unavailable when a runnable least-squares inversion is requested. NotImplementedError If the configured method/dimension pair is not listed in :attr:`supports`. Examples -------- >>> from pycsamt.inversion.backends.builtin import Builtin1DBackend >>> from pycsamt.inversion.config import InversionConfig >>> cfg = InversionConfig( ... method="mt", ... dimension="1d", ... backend="builtin", ... data={"freqs": [1.0, 10.0], ... "rho_a": [100.0, 120.0], ... "phase": [45.0, 47.0]}, ... max_iter=4, ... ) >>> result = Builtin1DBackend(cfg).run() # doctest: +SKIP """ self.check_supported() cfg = self.config em_data = self.prepare_data(data) if cfg.method == "tdem": if not em_data.has_tdem_response: raise ValueError( "builtin TDEM backend requires times plus values." ) elif not em_data.has_mt_response: raise ValueError( "builtin backend requires frequencies plus rho_a and/or phase." ) if cfg.dimension == "2d" and cfg.method != "tdem": mode = str(cfg.backend_options.get("profile_mode", "")).lower() if mode in {"fd2d", "2d", "physics", "finite_difference"}: return self._run_mt_2d_physics(em_data) if cfg.dimension == "2d": return self._run_profile(em_data) return self._run_sounding(em_data, station_index=None)
def _run_profile(self, em_data) -> InversionResult: cfg = self.config n_st = em_data.n_stations station_names = _station_names(em_data, n_st) station_x = _station_x(em_data, n_st) columns: list[np.ndarray] = [] station_results: list[InversionResult] = [] used_indices: list[int] = [] warnings: list[str] = [] z_centers = None for idx in range(n_st): sounding = _station_data(em_data, idx) try: result = self._run_sounding(sounding, station_index=idx) except Exception as exc: warnings.append( f"{station_names[idx]}: inversion failed: {exc}" ) continue station_results.append(result) used_indices.append(idx) columns.append(np.log10(result.model.resistivities)) if z_centers is None and result.mesh is not None: z_centers = np.asarray(result.mesh.z_centers, dtype=float) if not columns: raise RuntimeError("all station inversions failed.") rho_2d = np.stack(columns, axis=1) if z_centers is None: z_centers = np.arange(rho_2d.shape[0], dtype=float) used_names = [station_names[i] for i in used_indices] used_x = station_x[used_indices] model = { "rho_2d": rho_2d, "x_centers": used_x, "z_centers": z_centers, "station_x": used_x, "station_names": used_names, } mesh = InversionMesh( dimension="2d", x_centers=used_x, z_centers=z_centers, ) rms_values = np.asarray([r.rms for r in station_results], dtype=float) rms = ( float(np.nanmean(rms_values)) if rms_values.size else float("nan") ) uncertainty = _profile_uncertainty(station_results) history = _profile_history(station_results) status = "success" if not warnings else "needs_review" return InversionResult( method=cfg.method, dimension=cfg.dimension, backend=self.name, status=status, model=model, mesh=mesh, data=em_data, predicted=[r.predicted for r in station_results], rms=rms, objective=float( np.nansum([r.objective for r in station_results]) ), n_iter=int(np.sum([r.n_iter for r in station_results])), workdir=cfg.workdir, native=station_results, uncertainty=uncertainty, history=history, warnings=warnings, metadata={ **cfg.metadata, "profile_mode": "stitched_station_1d", "station_rms": rms_values.tolist(), }, ) def _run_sounding( self, em_data, *, station_index: int | None, ) -> InversionResult: cfg = self.config if cfg.method == "tdem": return self._run_tdem_sounding( em_data, station_index=station_index ) return self._run_mt_sounding(em_data, station_index=station_index) def _run_mt_2d_physics(self, em_data) -> InversionResult: """Run a finite-difference 2-D MT inversion using the forward solver.""" cfg = self.config try: from scipy.optimize import least_squares except ImportError as exc: raise ImportError( "builtin 2-D inversion requires scipy.optimize." ) from exc from ...forward import ( Grid2D, MT2DForward, make_padding, ) opts = cfg.backend_options start = StartingModel.coerce( cfg.starting_model, n_layers=cfg.n_layers ) station_x = _station_x(em_data, em_data.n_stations) station_names = _station_names(em_data, em_data.n_stations) base_grid, core_shape = build_fd2d_grid( start, station_x, opts, Grid2D, make_padding_func=make_padding, ) nz_core, nx_core = core_shape x0 = np.log10(core_rho_from_start(start, nz_core, nx_core)) lower, upper = _rho_grid_bounds(cfg.bounds, x0.size) observed, errors, components = _pack_2d_observed(em_data, cfg, opts) reg = regularization_from_config(cfg) reg_weight = regularization_weight(cfg, default=0.0) ref = np.log10(core_rho_from_start(start, nz_core, nx_core)) history = _HistoryRecorder(reg_weight=reg_weight) def residual(params: np.ndarray) -> np.ndarray: grid = _grid_with_core_model( base_grid, params.reshape(nz_core, nx_core) ) response = MT2DForward( np.asarray(em_data.frequencies, dtype=float), grid, verbose=bool(opts.get("forward_verbose", False)), ).run() predicted = _pack_2d_predicted(response, components) data_res = (predicted - observed) / errors parts = [data_res] reg_res = np.array([], dtype=float) reg_res = regularization_residual( params.reshape(nz_core, nx_core), reference=ref, regularization=reg, blocky_eps=float(opts.get("blocky_eps", 1e-2)), axes=("x", "z"), ) if reg_res.size and reg_weight > 0.0: parts.append(np.sqrt(reg_weight) * reg_res) residuals = np.concatenate(parts) history.add(data_res, reg_res, params) return residuals opt = least_squares( residual, x0.reshape(-1), bounds=(lower, upper), max_nfev=cfg.max_iter, xtol=cfg.tol, ftol=cfg.tol, gtol=cfg.tol, ) recovered_log = opt.x.reshape(nz_core, nx_core) recovered_rho = 10.0**recovered_log recovered_grid = _grid_with_core_model(base_grid, recovered_log) response = MT2DForward( np.asarray(em_data.frequencies, dtype=float), recovered_grid, verbose=bool(opts.get("forward_verbose", False)), ).run() predicted = _pack_2d_predicted(response, components) rms = weighted_rms(observed, predicted, errors) uncertainty = _uncertainty_from_lsq( opt, shape=(nz_core, nx_core), x_centers=None, z_centers=None, metadata={"parameter": "log10_rho", "mode": "fd2d"}, ) core_z = recovered_grid.z_centers[:nz_core] pad = int(recovered_grid.n_pad) core_x = recovered_grid.x_centers[pad : pad + nx_core] x_offset = float(getattr(recovered_grid, "_pycsamt_x_offset", 0.0)) core_x = core_x - x_offset mesh = InversionMesh( dimension="2d", x_centers=core_x, z_centers=core_z, native=recovered_grid, metadata={ "engine": "builtin_fd2d", "components": components, "grid_shape": [int(nz_core), int(nx_core)], "profile_mode": "fd2d", }, ) status = "converged" if opt.success else "needs_review" warnings = [] if opt.success else [str(opt.message)] return InversionResult( method=cfg.method, dimension="2d", backend=self.name, status=status, model={ "rho_2d": np.log10(recovered_rho), "x_centers": core_x, "z_centers": core_z, "station_x": station_x, "station_names": station_names, "rho_2d_linear": recovered_rho, }, mesh=mesh, data=em_data, predicted=response, rms=rms, objective=float(np.sum(opt.fun**2)), n_iter=int(opt.nfev), workdir=cfg.workdir, native=opt, uncertainty=uncertainty, history=history.to_history(metadata={"mode": "fd2d"}), warnings=warnings, metadata={ **cfg.metadata, "profile_mode": "fd2d", "optimizer_message": str(opt.message), "starting_model": start.to_dict(), "components": components, "regularization_weight": reg_weight, "regularization": reg.to_dict(), }, ) def _run_mt_sounding( self, em_data, *, station_index: int | None, ) -> InversionResult: cfg = self.config try: from scipy.optimize import least_squares except ImportError as exc: raise ImportError( "builtin inversion requires scipy.optimize." ) from exc from ...forward import MT1DForward start = StartingModel.coerce( cfg.starting_model, n_layers=cfg.n_layers ) n_layers = start.n_layers x0 = _pack(start) lower, upper = _bounds(cfg.bounds, n_layers) reg = regularization_from_config(cfg) reg_weight = regularization_weight(cfg, default=0.0) ref_log_rho = _reference_log10_rho(cfg, start) history = _HistoryRecorder(reg_weight=reg_weight) freqs = np.asarray(em_data.frequencies, dtype=float) obs_parts: list[np.ndarray] = [] err_parts: list[np.ndarray] = [] if em_data.rho_a is not None: rho_obs = np.log10(np.maximum(em_data.rho_a, 1e-12)) obs_parts.append(rho_obs) err_parts.append( component_errors( em_data.rho_a, cfg, component="rho", explicit=em_data.errors, relative=True, ) ) if cfg.include_phase and em_data.phase is not None: obs_parts.append(np.asarray(em_data.phase, dtype=float)) err_parts.append( component_errors(em_data.phase, cfg, component="phase") ) observed = np.concatenate(obs_parts) errors = np.concatenate(err_parts) fwd = MT1DForward(freqs=freqs) def residual(params: np.ndarray) -> np.ndarray: model = _unpack(params, n_layers) response = fwd.run(model.to_layered_model()) pred_parts: list[np.ndarray] = [] if em_data.rho_a is not None: pred_parts.append(np.log10(np.maximum(response.rho_a, 1e-12))) if cfg.include_phase and em_data.phase is not None: pred_parts.append(np.asarray(response.phase, dtype=float)) predicted = np.concatenate(pred_parts) data_res = (predicted - observed) / errors parts = [data_res] reg_res = np.array([], dtype=float) reg_res = regularization_residual( params[:n_layers], reference=ref_log_rho, regularization=reg, blocky_eps=float(cfg.backend_options.get("blocky_eps", 1e-2)), axes=("z",), ) if reg_res.size and reg_weight > 0.0: parts.append(np.sqrt(reg_weight) * reg_res) residuals = np.concatenate(parts) history.add(data_res, reg_res, params) return residuals opt = least_squares( residual, x0, bounds=(lower, upper), max_nfev=cfg.max_iter, xtol=cfg.tol, ftol=cfg.tol, gtol=cfg.tol, ) recovered = _unpack(opt.x, n_layers) response = fwd.run(recovered.to_layered_model()) pred_parts = [] if em_data.rho_a is not None: pred_parts.append(np.log10(np.maximum(response.rho_a, 1e-12))) if cfg.include_phase and em_data.phase is not None: pred_parts.append(np.asarray(response.phase, dtype=float)) predicted = np.concatenate(pred_parts) rms = weighted_rms(observed, predicted, errors) z_centers = _layer_centers(recovered.thicknesses) uncertainty = _uncertainty_from_lsq( opt, shape=(n_layers, 1), parameter_indices=np.arange(n_layers), x_centers=np.array([0.0]), z_centers=z_centers, metadata={"parameter": "log10_rho", "mode": "mt_1d"}, ) mesh = InversionMesh.for_1d(z_centers) status = "converged" if opt.success else "needs_review" warnings = [] if opt.success else [str(opt.message)] return InversionResult( method=cfg.method, dimension="1d", backend=self.name, status=status, model=recovered, mesh=mesh, data=em_data, predicted=response, rms=rms, objective=float(np.sum(opt.fun**2)), n_iter=int(opt.nfev), workdir=cfg.workdir, native=opt, uncertainty=uncertainty, history=history.to_history(metadata={"mode": "mt_1d"}), warnings=warnings, metadata={ **cfg.metadata, "optimizer_message": str(opt.message), "starting_model": start.to_dict(), "station_index": station_index, "regularization": reg.to_dict(), "regularization_weight": reg_weight, }, ) def _run_tdem_sounding( self, em_data, *, station_index: int | None, ) -> InversionResult: cfg = self.config try: from scipy.optimize import least_squares except ImportError as exc: raise ImportError( "builtin inversion requires scipy.optimize." ) from exc from ...forward import TEM1DForward start = StartingModel.coerce( cfg.starting_model, n_layers=cfg.n_layers ) n_layers = start.n_layers x0 = _pack(start) lower, upper = _bounds(cfg.bounds, n_layers) reg = regularization_from_config(cfg) reg_weight = regularization_weight(cfg, default=0.0) ref_log_rho = _reference_log10_rho(cfg, start) history = _HistoryRecorder(reg_weight=reg_weight) times = np.asarray(em_data.times, dtype=float) observed = _log_abs(np.asarray(em_data.values, dtype=float)) errors = component_errors( em_data.values, cfg, component="tdem", explicit=em_data.errors, relative=True, ) opts = cfg.backend_options fwd = TEM1DForward( times=times, loop_radius=float(opts.get("loop_radius", 50.0)), moment=float(opts.get("moment", 1.0)), n_freqs=int(opts.get("n_freqs", 32)), n_lam=int(opts.get("n_lam", 48)), ) def residual(params: np.ndarray) -> np.ndarray: model = _unpack(params, n_layers) response = fwd.run(model.to_layered_model()) predicted = _log_abs(response.dBz_dt) data_res = (predicted - observed) / errors parts = [data_res] reg_res = np.array([], dtype=float) reg_res = regularization_residual( params[:n_layers], reference=ref_log_rho, regularization=reg, blocky_eps=float(cfg.backend_options.get("blocky_eps", 1e-2)), axes=("z",), ) if reg_res.size and reg_weight > 0.0: parts.append(np.sqrt(reg_weight) * reg_res) residuals = np.concatenate(parts) history.add(data_res, reg_res, params) return residuals opt = least_squares( residual, x0, bounds=(lower, upper), max_nfev=cfg.max_iter, xtol=cfg.tol, ftol=cfg.tol, gtol=cfg.tol, ) recovered = _unpack(opt.x, n_layers) response = fwd.run(recovered.to_layered_model()) predicted = _log_abs(response.dBz_dt) rms = weighted_rms(observed, predicted, errors) z_centers = _layer_centers(recovered.thicknesses) uncertainty = _uncertainty_from_lsq( opt, shape=(n_layers, 1), parameter_indices=np.arange(n_layers), x_centers=np.array([0.0]), z_centers=z_centers, metadata={"parameter": "log10_rho", "mode": "tdem_1d"}, ) mesh = InversionMesh.for_1d(z_centers) status = "converged" if opt.success else "needs_review" warnings = [] if opt.success else [str(opt.message)] return InversionResult( method=cfg.method, dimension="1d", backend=self.name, status=status, model=recovered, mesh=mesh, data=em_data, predicted=response, rms=rms, objective=float(np.sum(opt.fun**2)), n_iter=int(opt.nfev), workdir=cfg.workdir, native=opt, uncertainty=uncertainty, history=history.to_history(metadata={"mode": "tdem_1d"}), warnings=warnings, metadata={ **cfg.metadata, "optimizer_message": str(opt.message), "starting_model": start.to_dict(), "station_index": station_index, "regularization": reg.to_dict(), "regularization_weight": reg_weight, "tdem_options": { "loop_radius": fwd.loop_radius, "moment": fwd.moment, "n_freqs": fwd.n_freqs, "n_lam": fwd.n_lam, }, }, )
def _pack(model: StartingModel) -> np.ndarray: return np.r_[ np.log10(model.resistivities), np.log10(model.thicknesses), ] def _unpack(params: np.ndarray, n_layers: int) -> StartingModel: params = np.asarray(params, dtype=float) resistivities = 10.0 ** params[:n_layers] thicknesses = 10.0 ** params[n_layers:] return StartingModel(resistivities, thicknesses, name="builtin_1d") def _reference_log10_rho(cfg: Any, start: StartingModel) -> np.ndarray: if cfg.reference_model is None: return np.log10(start.resistivities) reference = StartingModel.coerce( cfg.reference_model, n_layers=start.n_layers ) if reference.n_layers != start.n_layers: reference = start return np.log10(reference.resistivities) def _uncertainty_from_lsq( opt: Any, *, shape: tuple[int, int], parameter_indices: np.ndarray | None = None, x_centers: np.ndarray | None = None, z_centers: np.ndarray | None = None, metadata: dict[str, Any] | None = None, ) -> InversionUncertainty | None: jac = getattr(opt, "jac", None) fun = getattr(opt, "fun", None) if jac is None or fun is None: return None jac = np.asarray(jac, dtype=float) fun = np.asarray(fun, dtype=float) if jac.ndim != 2 or jac.size == 0: return None if parameter_indices is None: parameter_indices = np.arange(jac.shape[1]) parameter_indices = np.asarray(parameter_indices, dtype=int) j_sel = jac[:, parameter_indices] n_params = int(parameter_indices.size) dof = max(int(fun.size - n_params), 1) variance = float(np.sum(fun**2) / dof) try: cov = np.linalg.pinv(j_sel.T @ j_sel) * variance cov_diag = np.maximum(np.diag(cov), 0.0) except Exception: cov_diag = np.full(n_params, np.nan, dtype=float) sensitivity = np.sqrt(np.sum(j_sel**2, axis=0)) model_std = np.sqrt(cov_diag) confidence = _confidence_from_std(model_std, sensitivity) model_std_map = model_std.reshape(shape) sensitivity_map = sensitivity.reshape(shape) confidence_map = confidence.reshape(shape) return InversionUncertainty( model_std=model_std_map, covariance_diag=cov_diag.reshape(shape), sensitivity=sensitivity_map, confidence=confidence_map, station_confidence=np.nanmean(confidence_map, axis=0), depth_confidence=np.nanmean(confidence_map, axis=1), metadata={ "type": "least_squares_jacobian_proxy", "dof": dof, "variance": variance, "x_centers": None if x_centers is None else np.asarray(x_centers, dtype=float).tolist(), "z_centers": None if z_centers is None else np.asarray(z_centers, dtype=float).tolist(), **dict(metadata or {}), }, ) def _confidence_from_std( std: np.ndarray, sensitivity: np.ndarray ) -> np.ndarray: std = np.asarray(std, dtype=float) sensitivity = np.asarray(sensitivity, dtype=float) if not np.any(np.isfinite(std)): std_score = np.zeros_like(std, dtype=float) else: finite_std = std[np.isfinite(std)] scale = float(np.nanmedian(finite_std)) if finite_std.size else 1.0 scale = max(scale, 1e-12) std_score = 1.0 / (1.0 + np.nan_to_num(std, nan=scale) / scale) if not np.any(np.isfinite(sensitivity)) or np.nanmax(sensitivity) <= 0: sens_score = np.ones_like(sensitivity, dtype=float) else: sens_score = sensitivity / np.nanmax(sensitivity) confidence = np.sqrt( np.clip(std_score, 0.0, 1.0) * np.clip(sens_score, 0.0, 1.0) ) return np.clip(confidence, 0.0, 1.0) def _profile_uncertainty( results: list[InversionResult], ) -> InversionUncertainty | None: available = [r.uncertainty for r in results if r.uncertainty is not None] if not available: return None def stack_attr(name: str) -> np.ndarray | None: cols = [] for unc in available: value = getattr(unc, name, None) if value is None: return None arr = np.asarray(value, dtype=float) cols.append(arr.reshape(arr.shape[0], -1)[:, 0]) return np.stack(cols, axis=1) confidence = stack_attr("confidence") return InversionUncertainty( model_std=stack_attr("model_std"), covariance_diag=stack_attr("covariance_diag"), sensitivity=stack_attr("sensitivity"), confidence=confidence, station_confidence=( None if confidence is None else np.nanmean(confidence, axis=0) ), depth_confidence=( None if confidence is None else np.nanmean(confidence, axis=1) ), metadata={"type": "stitched_station_uncertainty"}, ) def _profile_history( results: list[InversionResult], ) -> InversionHistory | None: histories = [r.history for r in results if r.history is not None] if not histories: return None records: list[dict[str, Any]] = [] for station_index, history in enumerate(histories): for record in history.records: merged = dict(record) merged["station_index"] = float(station_index) records.append(merged) return InversionHistory( records=records, metadata={ "type": "stitched_station_history", "n_station_histories": len(histories), }, ) def _bounds( bounds: dict[str, Any], n_layers: int ) -> tuple[np.ndarray, np.ndarray]: rho_bounds = bounds.get("log10_rho", (-1.0, 6.0)) thick_bounds = bounds.get("log10_thickness", (0.0, 5.0)) lower = np.r_[ np.full(n_layers, float(rho_bounds[0])), np.full(n_layers - 1, float(thick_bounds[0])), ] upper = np.r_[ np.full(n_layers, float(rho_bounds[1])), np.full(n_layers - 1, float(thick_bounds[1])), ] return lower, upper class _HistoryRecorder: def __init__(self, *, reg_weight: float = 0.0) -> None: self.reg_weight = float(reg_weight) self.records: list[dict[str, float]] = [] def add( self, data_residual: np.ndarray, reg_residual: np.ndarray, params: np.ndarray, ) -> None: data_residual = np.asarray(data_residual, dtype=float).reshape(-1) reg_residual = np.asarray(reg_residual, dtype=float).reshape(-1) params = np.asarray(params, dtype=float).reshape(-1) phi_d = float(np.sum(data_residual**2)) phi_m = float(np.sum(reg_residual**2)) self.records.append( { "iteration": float(len(self.records)), "phi_d": phi_d, "phi_m": phi_m, "objective": phi_d + self.reg_weight * phi_m, "rms": float(np.sqrt(np.mean(data_residual**2))) if data_residual.size else float("nan"), "lambda": self.reg_weight, "model_norm": float(np.linalg.norm(params)), } ) def to_history( self, *, metadata: dict[str, Any] | None = None ) -> InversionHistory: return InversionHistory( records=self.records, metadata={ "type": "residual_evaluation", "regularization_weight": self.reg_weight, **dict(metadata or {}), }, ) def _rho_grid_bounds( bounds: dict[str, Any], n_params: int ) -> tuple[np.ndarray, np.ndarray]: rho_bounds = bounds.get("log10_rho", (-1.0, 6.0)) lower = np.full(n_params, float(rho_bounds[0]), dtype=float) upper = np.full(n_params, float(rho_bounds[1]), dtype=float) return lower, upper def _grid_with_core_model(base_grid: Any, log10_core_rho: np.ndarray) -> Any: rho_core = 10.0 ** np.asarray(log10_core_rho, dtype=float) pad = int(getattr(base_grid, "n_pad", 0)) rho = np.asarray(base_grid.resistivity, dtype=float).copy() nz_core, nx_core = rho_core.shape rho[:nz_core, pad : pad + nx_core] = rho_core if pad > 0: rho[:nz_core, :pad] = rho_core[:, :1] rho[:nz_core, pad + nx_core :] = rho_core[:, -1:] rho[nz_core:, :] = rho[nz_core - 1 : nz_core, :] grid = base_grid.__class__( dx=base_grid.dx.copy(), dz=base_grid.dz.copy(), resistivity=rho, x_stations=base_grid.x_stations.copy(), n_pad=pad, name="builtin_fd2d", ) grid._pycsamt_x_offset = float( getattr(base_grid, "_pycsamt_x_offset", 0.0) ) return grid def _pack_2d_observed( em_data, cfg: Any, opts: dict[str, Any], ) -> tuple[np.ndarray, np.ndarray, tuple[str, ...]]: raw_components = opts.get("components", opts.get("component", ("te",))) if isinstance(raw_components, str): raw_components = (raw_components,) modes = tuple( str(comp).lower() for comp in raw_components if str(comp).lower() in {"te", "tm"} ) or ("te",) obs_parts: list[np.ndarray] = [] err_parts: list[np.ndarray] = [] fields: list[str] = [] rho = ( None if em_data.rho_a is None else _station_freq_matrix(em_data.rho_a) ) phase = ( None if em_data.phase is None else _station_freq_matrix(em_data.phase) ) explicit_err = ( None if em_data.errors is None else _station_freq_matrix(em_data.errors) ) for component in modes: if rho is not None: mask = component_mask(rho, cfg, component="rho") obs_parts.append(np.log10(np.maximum(rho, 1e-12)).reshape(-1)) if explicit_err is not None: rel = component_errors( rho, cfg, component="rho", explicit=explicit_err, relative=True, ) err_parts.append(rel.reshape(-1)) else: err_parts.append( component_errors( rho, cfg, component="rho", relative=True ).reshape(-1) ) fields.append(f"{component}_rho") if not np.all(mask): masked = err_parts[-1].reshape(rho.shape) masked[~mask] = 1e30 err_parts[-1] = masked.reshape(-1) if cfg.include_phase and phase is not None: mask = component_mask(phase, cfg, component="phase") obs_parts.append(phase.reshape(-1)) err_parts.append( component_errors(phase, cfg, component="phase").reshape(-1) ) fields.append(f"{component}_phase") if not np.all(mask): masked = err_parts[-1].reshape(phase.shape) masked[~mask] = 1e30 err_parts[-1] = masked.reshape(-1) if not obs_parts: raise ValueError( "finite-difference 2-D inversion needs rho_a and/or phase data." ) return ( np.concatenate(obs_parts), np.concatenate(err_parts), tuple(fields), ) def _pack_2d_predicted( response: Any, components: tuple[str, ...] ) -> np.ndarray: parts: list[np.ndarray] = [] for component in components: mode, field = component.split("_", 1) if field == "rho": rho = getattr(response, f"rho_a_{mode}") parts.append( np.log10( np.maximum(np.asarray(rho, dtype=float).T, 1e-12) ).reshape(-1) ) elif field == "phase": phase = getattr(response, f"phase_{mode}") parts.append(np.asarray(phase, dtype=float).T.reshape(-1)) return np.concatenate(parts) def _station_freq_matrix(values: np.ndarray) -> np.ndarray: values = np.asarray(values, dtype=float) if values.ndim == 1: return values.reshape(1, -1) return values def _layer_centers(thicknesses: np.ndarray) -> np.ndarray: tops = np.r_[0.0, np.cumsum(thicknesses)] last = thicknesses[-1] if thicknesses.size else 1.0 bottoms = np.r_[tops[1:], tops[-1] + last] return 0.5 * (tops + bottoms) def _log_abs(values: np.ndarray) -> np.ndarray: values = np.asarray(values, dtype=float) return np.log10(np.maximum(np.abs(values), 1e-30)) def _station_data(em_data, idx: int): from ..data import EMData rho = None if em_data.rho_a is None else _row(em_data.rho_a, idx) phase = None if em_data.phase is None else _row(em_data.phase, idx) errors = None if em_data.errors is None else _row(em_data.errors, idx) names = _station_names(em_data, em_data.n_stations) xs = _station_x(em_data, em_data.n_stations) return EMData( method=em_data.method, frequencies=em_data.frequencies, times=em_data.times, rho_a=rho, phase=phase, values=None if em_data.values is None else _row(em_data.values, idx), errors=errors, station_names=[names[idx]], station_x=np.array([xs[idx]], dtype=float), source=em_data.source, metadata=em_data.metadata_dict(), ) def _row(arr: np.ndarray, idx: int) -> np.ndarray: arr = np.asarray(arr, dtype=float) if arr.ndim == 1: return arr.copy() return arr[idx, :].copy() def _station_names(em_data, n_st: int) -> list[str]: if em_data.station_names: return list(em_data.station_names) return [f"S{i:03d}" for i in range(n_st)] def _station_x(em_data, n_st: int) -> np.ndarray: if em_data.station_x is not None: return np.asarray(em_data.station_x, dtype=float) return np.arange(n_st, dtype=float) Builtin1DBackend.__doc__ = r""" Run PyCSAMT's built-in EM inversion engine. ``Builtin1DBackend`` is the dependency-light backend used when an inversion should run without SimPEG, pyGIMLi, Occam2D, or ModEM. Despite the historical class name, it supports 1-D soundings and selected 2-D profile workflows for MT, AMT, CSAMT, EMAP, and TDEM data. The backend minimizes a weighted residual of the form .. math:: \Phi(m) = \| W_d (d_\mathrm{pred}(m) - d_\mathrm{obs}) \|_2^2 + \lambda \Phi_m(m), where :math:`W_d` is built from component errors and :math:`\Phi_m` is supplied by :mod:`pycsamt.inversion.regularization`. Natural-source 1-D runs invert log10 apparent resistivity and optional phase. TDEM runs invert log10 absolute transient response. Stitched 2-D mode repeats the 1-D inversion station by station, while finite-difference 2-D mode uses the local MT profile forward solver. Parameters ---------- config : pycsamt.inversion.config.InversionConfig Normalized inversion configuration. The backend reads ``method``, ``dimension``, ``data``, ``starting_model``, ``reference_model``, ``bounds``, ``regularization``, ``max_iter``, ``tol``, ``workdir``, and ``backend_options`` from this object. Attributes ---------- name : str Backend registry name, always ``"builtin"``. supports : tuple of tuple Supported ``(method, dimension)`` combinations. Notes ----- Important ``backend_options`` entries include: ``profile_mode`` For natural-source 2-D runs, use ``"fd2d"`` or ``"finite_difference"`` to select true finite-difference profile inversion. Any other value uses stitched station-wise 1-D inversion. ``components`` Natural-source 2-D finite-difference components. Supported values are ``"te"``, ``"tm"``, or a sequence containing both. ``blocky_eps`` Small stabilizer used by blocky regularization. ``loop_radius``, ``moment``, ``n_freqs``, ``n_lam`` TDEM forward-model controls passed to :class:`pycsamt.forward.TEM1DForward`. Examples -------- Run a small MT sounding through the backend directly:: >>> from pycsamt.inversion.backends.builtin import Builtin1DBackend >>> from pycsamt.inversion.config import InversionConfig >>> cfg = InversionConfig( ... method="mt", ... dimension="1d", ... backend="builtin", ... data={"freqs": [1.0, 10.0], ... "rho_a": [100.0, 120.0], ... "phase": [45.0, 47.0]}, ... max_iter=4, ... ) >>> result = Builtin1DBackend(cfg).run() # doctest: +SKIP Run the same engine through the high-level workflow:: >>> from pycsamt.inversion.workflow import run_inversion >>> result = run_inversion( ... method="tdem", ... dimension="1d", ... backend="builtin", ... data={"times": [1e-5, 1e-4], ... "values": [1e-8, 2e-9]}, ... max_iter=4, ... ) # doctest: +SKIP Select finite-difference 2-D MT profile mode:: >>> from pycsamt.inversion.workflow import run_inversion >>> result = run_inversion( ... method="mt", ... dimension="2d", ... backend="builtin", ... data={"freqs": [1.0, 10.0], ... "rho_a": [[100.0, 120.0], [90.0, 110.0]], ... "phase": [[45.0, 47.0], [44.0, 46.0]], ... "station_x": [0.0, 500.0]}, ... backend_options={"profile_mode": "fd2d", ... "components": ("te",)}, ... max_iter=4, ... ) # doctest: +SKIP See Also -------- pycsamt.inversion.workflow.InversionWorkflow High-level orchestration API that usually instantiates this backend. pycsamt.inversion.config.InversionConfig Configuration object consumed by the backend. pycsamt.inversion.regularization.Regularization Shared regularization description wired into the residual. pycsamt.inversion.results.InversionResult Backend-neutral result returned by :meth:`run`. References ---------- .. [1] Aster, R. C., Borchers, B. and Thurber, C. H. (2018). *Parameter Estimation and Inverse Problems*, 3rd edition. Elsevier. .. [2] Constable, S. C., Parker, R. L. and Constable, C. G. (1987). Occam's inversion: A practical algorithm for generating smooth models from electromagnetic sounding data. *Geophysics*, 52(3), 289-300. .. [3] Chave, A. D. and Jones, A. G. (2012). *The Magnetotelluric Method: Theory and Practice*. Cambridge University Press. """