Source code for pycsamt.forward.config2d

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

:class:`ForwardConfig2D` is the configuration object for
:class:`~pycsamt.forward.em2d.MT2DForward`.  It records every parameter
needed to build a :class:`~pycsamt.forward.grid2d.Grid2D` and run the
finite-difference solver, and can be exported to Python, JSON, or YAML.

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

    from pycsamt.forward.config2d import ForwardConfig2D

    # Write an annotated source-of-truth file
    ForwardConfig2D.write_template("my_2d.yml")

    # Edit my_2d.yml …

    # Load, validate, build grid, run solver
    cfg = ForwardConfig2D.from_file("my_2d.yml")
    cfg.validate()

    grid = cfg.to_grid()
    from pycsamt.forward.em2d import MT2DForward
    resp = MT2DForward(cfg.freq_grid(), grid).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__ = ["ForwardConfig2D"]

_FWD2D_SCHEMA: list[ConfigParameter] = [
    # ── Solver ───────────────────────────────────────────────────────────────
    ConfigParameter(
        "freq_min",
        "Minimum frequency in Hz. The frequency grid is "
        "np.logspace(log10(freq_min), log10(freq_max), n_freqs). "
        "Typical AMT lower bound: 1e-4 Hz.",
        "Solver",
    ),
    ConfigParameter(
        "freq_max",
        "Maximum frequency in Hz. Must be strictly greater than freq_min. "
        "Typical AMT upper bound: 1e4 Hz.",
        "Solver",
    ),
    ConfigParameter(
        "n_freqs",
        "Number of logarithmically spaced frequency points. "
        "15-30 points covers the AMT band adequately.",
        "Solver",
    ),
    # ── Grid ─────────────────────────────────────────────────────────────────
    ConfigParameter(
        "nx",
        "Number of core cells in the horizontal (x) direction, "
        "excluding padding. Minimum 20; 40-80 is typical.",
        "Grid",
    ),
    ConfigParameter(
        "nz",
        "Number of core cells in the vertical (z, depth) direction, "
        "excluding padding. Minimum 15; 30-60 is typical.",
        "Grid",
    ),
    ConfigParameter(
        "x_max",
        "Total horizontal extent of the core model region in metres. "
        "Should span all stations plus at least one skin-depth on each side.",
        "Grid",
    ),
    ConfigParameter(
        "z_max",
        "Total depth of the core model region in metres. "
        "Should be at least 3× the maximum investigation depth "
        "(≈ skin depth at the lowest frequency).",
        "Grid",
    ),
    ConfigParameter(
        "n_pad",
        "Number of exponentially growing padding cells added to both "
        "horizontal sides and the bottom.  8-12 cells is standard.",
        "Grid",
    ),
    ConfigParameter(
        "pad_factor",
        "Growth factor for padding cell sizes. Each successive padding "
        "cell is pad_factor times wider/deeper than the previous. "
        "Values 1.2-1.5 are appropriate; 1.3 is a safe default.",
        "Grid",
    ),
    # ── Earth model ──────────────────────────────────────────────────────────
    ConfigParameter(
        "bg_rho",
        "Background (starting) resistivity in Ω·m for the halfspace or "
        "anomaly model constructors. Ignored when model_type is 'random'.",
        "Earth Model",
    ),
    ConfigParameter(
        "model_type",
        "Model constructor to use. Options: 'halfspace' — uniform "
        "background; 'anomaly' — one rectangular conductive/resistive "
        "block on the background; 'random' — random layered model with "
        "optional lateral variation.",
        "Earth Model",
    ),
    ConfigParameter(
        "anomaly_rho",
        "Anomaly resistivity in Ω·m. Used only when model_type='anomaly'.",
        "Earth Model",
    ),
    ConfigParameter(
        "anomaly_x_lo",
        "Left edge of the anomaly in core x-coordinates [m]. "
        "Used only when model_type='anomaly'.",
        "Earth Model",
    ),
    ConfigParameter(
        "anomaly_x_hi",
        "Right 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 in depth [m].",
        "Earth Model",
    ),
    # ── Stations ─────────────────────────────────────────────────────────────
    ConfigParameter(
        "n_stations",
        "Number of MT stations distributed uniformly across the core region.",
        "Stations",
    ),
    ConfigParameter(
        "station_x_max",
        "x-coordinate of the rightmost station [m]. "
        "Defaults to x_max when null/None.",
        "Stations",
    ),
    # ── Output ───────────────────────────────────────────────────────────────
    ConfigParameter(
        "verbose",
        "Print per-frequency progress during the forward solve.",
        "Output",
    ),
]


[docs] @dataclass class ForwardConfig2D: """Collect settings for a 2-D MT finite-difference forward run. The configuration covers four areas: frequency grid, FD grid geometry, starting earth model, and station layout. Parameters ---------- freq_min, freq_max : float Frequency range [Hz]. n_freqs : int Number of logarithmically spaced frequencies. nx, nz : int Core cell counts (x, z). x_max, z_max : float Core region extents [m]. n_pad : int Padding cells per side. pad_factor : float Padding growth factor. bg_rho : float Background resistivity [Ω·m]. model_type : {'halfspace', 'anomaly', 'random'} Grid constructor to use. anomaly_rho : float Anomaly resistivity [Ω·m] (model_type='anomaly' only). anomaly_x_lo, anomaly_x_hi : float Horizontal anomaly bounds [m]. anomaly_z_lo, anomaly_z_hi : float Depth anomaly bounds [m]. n_stations : int Number of equally spaced surface stations. station_x_max : float or None Right-most station position [m]. verbose : bool Examples -------- Default halfspace:: >>> cfg = ForwardConfig2D() >>> cfg.model_type 'halfspace' >>> cfg.validate() Template round-trip:: >>> import tempfile, os >>> with tempfile.NamedTemporaryFile(suffix='.yml', delete=False) as f: ... path = f.name >>> _ = ForwardConfig2D.write_template(path) >>> cfg2 = ForwardConfig2D.from_file(path) >>> os.unlink(path) """ # ── Solver ─────────────────────────────────────────────────────────────── freq_min: float = 1e-3 freq_max: float = 1e3 n_freqs: int = 20 # ── Grid ───────────────────────────────────────────────────────────────── nx: int = 40 nz: int = 30 x_max: float = 10_000.0 z_max: float = 6_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_z_lo: float = 300.0 anomaly_z_hi: float = 1_500.0 # ── Stations ───────────────────────────────────────────────────────────── n_stations: int = 10 station_x_max: float | None = None # ── Output ─────────────────────────────────────────────────────────────── verbose: bool = True # ───────────────────────────────────────────────────────────────────────── # Validation # ─────────────────────────────────────────────────────────────────────────
[docs] def validate(self) -> None: """Raise :class:`ValueError` on invalid parameter values.""" 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.nx < 4 or self.nz < 4: raise ValueError("nx and nz must each be at least 4.") if self.x_max <= 0 or self.z_max <= 0: raise ValueError("x_max and 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 greater than 1.0.") if self.bg_rho <= 0: raise ValueError("bg_rho must be strictly positive.") _VALID = {"halfspace", "anomaly", "random"} if self.model_type not in _VALID: raise ValueError(f"model_type must be one of {_VALID!r}.") if self.model_type == "anomaly": if self.anomaly_rho <= 0: raise ValueError("anomaly_rho must be strictly positive.") if self.anomaly_x_lo >= self.anomaly_x_hi: raise ValueError("anomaly_x_lo must be < anomaly_x_hi.") if self.anomaly_z_lo >= self.anomaly_z_hi: raise ValueError("anomaly_z_lo must be < anomaly_z_hi.") if self.n_stations < 1: raise ValueError("n_stations must be at least 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) -> Grid2D: """Construct a :class:`~pycsamt.forward.grid2d.Grid2D` from this config. Parameters ---------- seed : int or None Random seed (used only for ``model_type='random'``). Returns ------- Grid2D """ from .grid2d import Grid2D kw = dict( nx=self.nx, nz=self.nz, x_max=self.x_max, z_max=self.z_max, n_pad=self.n_pad, pad_factor=self.pad_factor, n_stations=self.n_stations, station_x_max=self.station_x_max, ) if self.model_type == "halfspace": return Grid2D.halfspace(rho=self.bg_rho, **kw) if self.model_type == "anomaly": return Grid2D.with_anomaly( bg_rho=self.bg_rho, anomaly_rho=self.anomaly_rho, anomaly_bounds=( self.anomaly_x_lo, self.anomaly_x_hi, self.anomaly_z_lo, self.anomaly_z_hi, ), **kw, ) # random return Grid2D.random(seed=seed, **kw)
[docs] def to_solver_kwargs(self) -> dict[str, Any]: """Return kwargs for :class:`~pycsamt.forward.em2d.MT2DForward`.""" return dict(freqs=self.freq_grid(), verbose=self.verbose)
# ───────────────────────────────────────────────────────────────────────── # Config file I/O # ─────────────────────────────────────────────────────────────────────────
[docs] def to_template( self, path: str | Path = "forward_config_2d.py", *, fmt: str | None = None, ) -> Path: """Write to an annotated source-of-truth file.""" return write_config_template( path, self, _FWD2D_SCHEMA, fmt=fmt, title="PyCSAMT 2-D MT forward configuration", )
[docs] @classmethod def write_template( cls, path: str | Path = "forward_config_2d.py", *, fmt: str | None = None, ) -> Path: """Generate a documented source-of-truth file with default values. 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, ) -> ForwardConfig2D: """Load from a generated source-of-truth file. Parameters ---------- path : path-like strict : bool Returns ------- ForwardConfig2D """ values = read_config_file(path, cls, strict=strict) return cls(**values)
read = from_file # ───────────────────────────────────────────────────────────────────────── # Summary # ─────────────────────────────────────────────────────────────────────────
[docs] def summary(self) -> str: lines = [ "ForwardConfig2D", f" {'model_type':<22s} = {self.model_type!r}", f" {'bg_rho':<22s} = {self.bg_rho} Ω·m", f" {'freq range':<22s} = {self.freq_min:.3g}{self.freq_max:.3g} Hz ({self.n_freqs} pts)", f" {'grid (nx × nz)':<22s} = {self.nx} × {self.nz} (core, +{self.n_pad} pad)", f" {'x_max':<22s} = {self.x_max:.0f} m", f" {'z_max':<22s} = {self.z_max:.0f} m", f" {'n_stations':<22s} = {self.n_stations}", ] if self.model_type == "anomaly": lines.append( f" {'anomaly':<22s} = {self.anomaly_rho} Ω·m " f"x=[{self.anomaly_x_lo},{self.anomaly_x_hi}] " f"z=[{self.anomaly_z_lo},{self.anomaly_z_hi}]" ) return "\n".join(lines)
def __repr__(self) -> str: return self.summary()