Source code for pycsamt.forward.config3d

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Configuration for pyCSAMT quasi-3D MT forward modelling.

:class:`ForwardConfig3D` follows the same source-of-truth file pattern as
:class:`~pycsamt.forward.config.ForwardConfig` and
:class:`~pycsamt.forward.config2d.ForwardConfig2D`.

Quick start
-----------
::

    from pycsamt.forward.config3d import ForwardConfig3D

    ForwardConfig3D.write_template("my_3d.yml")   # annotated defaults

    # edit my_3d.yml …

    cfg = ForwardConfig3D.from_file("my_3d.yml")
    cfg.validate()

    grid = cfg.to_grid()
    from pycsamt.forward.em3d import MT3DForward
    resp = MT3DForward(cfg.freq_grid(), grid, verbose=cfg.verbose).run()
"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any

import numpy as np

from ..models.config_io import (
    ConfigParameter,
    read_config_file,
    write_config_template,
)

__all__ = ["ForwardConfig3D"]


_FWD3D_SCHEMA: list[ConfigParameter] = [
    # ── Solver ───────────────────────────────────────────────────────────────
    ConfigParameter(
        "freq_min",
        "Minimum frequency [Hz] for the logarithmic frequency grid.  "
        "Grid is np.logspace(log10(freq_min), log10(freq_max), n_freqs).",
        "Solver",
    ),
    ConfigParameter(
        "freq_max",
        "Maximum frequency [Hz].  Must exceed freq_min.",
        "Solver",
    ),
    ConfigParameter(
        "n_freqs",
        "Number of frequency points.  15–25 covers the AMT band well.",
        "Solver",
    ),
    ConfigParameter(
        "method",
        "Forward solver method.  Currently only 'quasi3d' is available: "
        "runs MT2DForward on orthogonal XZ and YZ profile slices and "
        "averages the impedance tensor contributions.",
        "Solver",
    ),
    # ── Grid ─────────────────────────────────────────────────────────────────
    ConfigParameter(
        "nx",
        "Number of core cells in x (easting), excluding padding.",
        "Grid",
    ),
    ConfigParameter(
        "ny",
        "Number of core cells in y (northing), excluding padding.",
        "Grid",
    ),
    ConfigParameter(
        "nz",
        "Number of core cells in z (depth), excluding padding.",
        "Grid",
    ),
    ConfigParameter(
        "x_max",
        "Core horizontal extent in x [m].  Should comfortably span all "
        "stations plus one skin-depth on each side.",
        "Grid",
    ),
    ConfigParameter(
        "y_max",
        "Core horizontal extent in y [m].",
        "Grid",
    ),
    ConfigParameter(
        "z_max",
        "Total depth of the core model [m].  Should be ≥ 3× the maximum "
        "investigation depth (skin depth at the lowest frequency).",
        "Grid",
    ),
    ConfigParameter(
        "n_pad",
        "Padding cells on each side in x and y and at the bottom in z.  "
        "8–12 cells is standard for MT FD modelling.",
        "Grid",
    ),
    ConfigParameter(
        "pad_factor",
        "Exponential growth factor for padding cell sizes.  "
        "Values in [1.2, 1.5]; 1.3 is a safe default.",
        "Grid",
    ),
    # ── Earth model ──────────────────────────────────────────────────────────
    ConfigParameter(
        "bg_rho",
        "Background resistivity [Ω·m] for 'halfspace' and 'block_anomaly' "
        "model types.",
        "Earth Model",
    ),
    ConfigParameter(
        "model_type",
        "Model constructor: 'halfspace' — uniform background; "
        "'block_anomaly' — one 3-D rectangular anomaly on the background; "
        "'random_layered' — random horizontal layers with optional lateral "
        "variation driven by Gaussian random fields.",
        "Earth Model",
    ),
    ConfigParameter(
        "anomaly_rho",
        "Anomaly resistivity [Ω·m].  Used only when model_type='block_anomaly'.",
        "Earth Model",
    ),
    ConfigParameter(
        "anomaly_x_lo",
        "Left edge of the 3-D anomaly in core x-coordinates [m].",
        "Earth Model",
    ),
    ConfigParameter(
        "anomaly_x_hi",
        "Right edge of the anomaly [m].",
        "Earth Model",
    ),
    ConfigParameter(
        "anomaly_y_lo",
        "Near edge of the anomaly in core y-coordinates [m].",
        "Earth Model",
    ),
    ConfigParameter(
        "anomaly_y_hi",
        "Far edge of the anomaly [m].",
        "Earth Model",
    ),
    ConfigParameter(
        "anomaly_z_lo",
        "Top of the anomaly in depth [m].",
        "Earth Model",
    ),
    ConfigParameter(
        "anomaly_z_hi",
        "Bottom of the anomaly [m].",
        "Earth Model",
    ),
    ConfigParameter(
        "n_layers",
        "Number of horizontal layers for model_type='random_layered'.",
        "Earth Model",
    ),
    ConfigParameter(
        "lateral_variation",
        "Enable Gaussian-random-field lateral resistivity perturbation for "
        "model_type='random_layered'.  Produces realistic 3-D structure.",
        "Earth Model",
    ),
    ConfigParameter(
        "corr_length",
        "GRF spatial correlation length [m] for model_type='random_layered'.  "
        "Larger values give smoother lateral variation.",
        "Earth Model",
    ),
    # ── Stations ─────────────────────────────────────────────────────────────
    ConfigParameter(
        "nx_stations",
        "Number of stations in the x direction (regular grid).",
        "Stations",
    ),
    ConfigParameter(
        "ny_stations",
        "Number of stations in the y direction (regular grid).  "
        "Total stations = nx_stations × ny_stations.",
        "Stations",
    ),
    # ── Output ───────────────────────────────────────────────────────────────
    ConfigParameter(
        "verbose",
        "Print per-profile progress during the forward solve.",
        "Output",
    ),
]


[docs] @dataclass class ForwardConfig3D: """Collect settings for a quasi-3D MT forward modelling run. Parameters ---------- freq_min, freq_max : float Frequency range [Hz]. n_freqs : int method : str Forward method ('quasi3d'). nx, ny, nz : int Core cell counts. x_max, y_max, z_max : float Core extents [m]. n_pad : int pad_factor : float bg_rho : float model_type : {'halfspace', 'block_anomaly', 'random_layered'} anomaly_rho : float anomaly_x_lo, anomaly_x_hi : float anomaly_y_lo, anomaly_y_hi : float anomaly_z_lo, anomaly_z_hi : float n_layers : int lateral_variation : bool corr_length : float nx_stations, ny_stations : int verbose : bool Examples -------- Default halfspace:: >>> cfg = ForwardConfig3D() >>> cfg.validate() >>> grid = cfg.to_grid() Block anomaly:: >>> cfg = ForwardConfig3D( ... model_type='block_anomaly', ... anomaly_rho=5.0, ... anomaly_x_lo=2000., anomaly_x_hi=6000., ... anomaly_y_lo=2000., anomaly_y_hi=6000., ... anomaly_z_lo=300., anomaly_z_hi=1500., ... ) """ # ── Solver ─────────────────────────────────────────────────────────────── freq_min: float = 1e-2 freq_max: float = 1e3 n_freqs: int = 15 method: str = "quasi3d" # ── Grid ───────────────────────────────────────────────────────────────── nx: int = 20 ny: int = 20 nz: int = 15 x_max: float = 8_000.0 y_max: float = 8_000.0 z_max: float = 4_000.0 n_pad: int = 8 pad_factor: float = 1.3 # ── Earth model ────────────────────────────────────────────────────────── bg_rho: float = 100.0 model_type: str = "halfspace" anomaly_rho: float = 5.0 anomaly_x_lo: float = 2_000.0 anomaly_x_hi: float = 6_000.0 anomaly_y_lo: float = 2_000.0 anomaly_y_hi: float = 6_000.0 anomaly_z_lo: float = 300.0 anomaly_z_hi: float = 1_500.0 n_layers: int = 4 lateral_variation: bool = True corr_length: float = 2_000.0 # ── Stations ───────────────────────────────────────────────────────────── nx_stations: int = 5 ny_stations: int = 5 # ── Output ─────────────────────────────────────────────────────────────── verbose: bool = True # ───────────────────────────────────────────────────────────────────────── # Validation # ─────────────────────────────────────────────────────────────────────────
[docs] def validate(self) -> None: """Raise :class:`ValueError` on invalid parameters.""" if self.freq_min <= 0 or self.freq_max <= self.freq_min: raise ValueError("freq_min must be > 0 and freq_max > freq_min.") if self.n_freqs < 2: raise ValueError("n_freqs must be at least 2.") if self.method not in {"quasi3d"}: raise ValueError( f"method must be 'quasi3d', got {self.method!r}." ) if any(n < 4 for n in (self.nx, self.ny, self.nz)): raise ValueError("nx, ny, nz must each be at least 4.") if any(v <= 0 for v in (self.x_max, self.y_max, self.z_max)): raise ValueError("x_max, y_max, z_max must be strictly positive.") if self.n_pad < 0: raise ValueError("n_pad must be non-negative.") if self.pad_factor <= 1.0: raise ValueError("pad_factor must be > 1.0.") if self.bg_rho <= 0: raise ValueError("bg_rho must be strictly positive.") valid = {"halfspace", "block_anomaly", "random_layered"} if self.model_type not in valid: raise ValueError(f"model_type must be one of {valid!r}.") if self.model_type == "block_anomaly": if self.anomaly_rho <= 0: raise ValueError("anomaly_rho must be strictly positive.") for lo, hi, ax in ( (self.anomaly_x_lo, self.anomaly_x_hi, "x"), (self.anomaly_y_lo, self.anomaly_y_hi, "y"), (self.anomaly_z_lo, self.anomaly_z_hi, "z"), ): if lo >= hi: raise ValueError( f"anomaly_{ax}_lo must be < anomaly_{ax}_hi." ) if self.nx_stations < 1 or self.ny_stations < 1: raise ValueError("nx_stations and ny_stations must be ≥ 1.")
# ───────────────────────────────────────────────────────────────────────── # Assemblers # ─────────────────────────────────────────────────────────────────────────
[docs] def freq_grid(self) -> np.ndarray: """Return the logarithmically spaced frequency array [Hz].""" return np.logspace( np.log10(self.freq_min), np.log10(self.freq_max), self.n_freqs )
[docs] def to_grid(self, seed=None) -> Grid3D: """Construct a :class:`~pycsamt.forward.grid3d.Grid3D` from this config. Parameters ---------- seed : int or None Random seed (used only for ``model_type='random_layered'``). Returns ------- Grid3D """ from .grid3d import Grid3D kw = dict( nx=self.nx, ny=self.ny, nz=self.nz, x_max=self.x_max, y_max=self.y_max, z_max=self.z_max, n_pad=self.n_pad, pad_factor=self.pad_factor, nx_stations=self.nx_stations, ny_stations=self.ny_stations, ) if self.model_type == "halfspace": return Grid3D.halfspace(rho=self.bg_rho, **kw) if self.model_type == "block_anomaly": return Grid3D.block_anomaly( bg_rho=self.bg_rho, anomaly_rho=self.anomaly_rho, bounds=( self.anomaly_x_lo, self.anomaly_x_hi, self.anomaly_y_lo, self.anomaly_y_hi, self.anomaly_z_lo, self.anomaly_z_hi, ), **kw, ) # random_layered return Grid3D.random_layered( n_layers=self.n_layers, lateral_variation=self.lateral_variation, corr_length=self.corr_length, seed=seed, **kw, )
[docs] def to_solver_kwargs(self) -> dict[str, Any]: """Return kwargs for :class:`~pycsamt.forward.em3d.MT3DForward`.""" return dict( freqs=self.freq_grid(), method=self.method, verbose=self.verbose, )
# ───────────────────────────────────────────────────────────────────────── # Config file I/O # ─────────────────────────────────────────────────────────────────────────
[docs] def to_template( self, path: str | Path = "forward_config_3d.py", *, fmt: str | None = None, ) -> Path: """Write an annotated source-of-truth file.""" return write_config_template( path, self, _FWD3D_SCHEMA, fmt=fmt, title="PyCSAMT quasi-3D MT forward configuration", )
[docs] @classmethod def write_template( cls, path: str | Path = "forward_config_3d.py", *, fmt: str | None = None, ) -> Path: """Generate a documented source-of-truth configuration file. Parameters ---------- path : path-like fmt : {"py", "json", "yml", "yaml"}, optional Returns ------- pathlib.Path """ return cls().to_template(path, fmt=fmt)
[docs] @classmethod def from_file( cls, path: str | Path, *, strict: bool = True, ) -> ForwardConfig3D: """Load from a source-of-truth file. Parameters ---------- path : path-like strict : bool Returns ------- ForwardConfig3D """ return cls(**read_config_file(path, cls, strict=strict))
read = from_file # ─────────────────────────────────────────────────────────────────────────
[docs] def summary(self) -> str: n_tot = self.nx_stations * self.ny_stations lines = [ "ForwardConfig3D", f" {'method':<24s} = {self.method!r}", f" {'model_type':<24s} = {self.model_type!r} (bg={self.bg_rho} Ω·m)", f" {'freq range':<24s} = {self.freq_min:.3g}{self.freq_max:.3g} Hz" f" ({self.n_freqs} pts)", f" {'grid (nx×ny×nz)':<24s} = {self.nx}×{self.ny}×{self.nz}" f" (core, +{self.n_pad} pad)", f" {'extents (x,y,z)':<24s} = {self.x_max:.0f} m × {self.y_max:.0f} m" f" × {self.z_max:.0f} m", f" {'stations':<24s} = {self.nx_stations}×{self.ny_stations}" f" = {n_tot} total", ] if self.model_type == "block_anomaly": lines.append( f" {'anomaly':<24s} = {self.anomaly_rho} Ω·m " f"x=[{self.anomaly_x_lo},{self.anomaly_x_hi}] " f"y=[{self.anomaly_y_lo},{self.anomaly_y_hi}] " f"z=[{self.anomaly_z_lo},{self.anomaly_z_hi}]" ) return "\n".join(lines)
def __repr__(self) -> str: return self.summary()