# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""
Synthetic 1-D earth model generators.
The :class:`LayeredModel` dataclass is the central data object shared by
all forward solvers and the ML training pipeline. It stores the
subsurface resistivity distribution as a vector of layer resistivities
and thicknesses, and provides several class-method constructors for
generating geologically realistic random models.
Typical usage
-------------
>>> from pycsamt.forward.synthetic import LayeredModel
>>> m = LayeredModel.random(n_layers=5, seed=42)
>>> m.resistivity # array of Ω·m
>>> m.thickness # array of metres
>>> x = m.to_vector() # flat parameter vector for ML
>>> m2 = LayeredModel.from_vector(x, n_layers=5)
Geological priors
-----------------
``LayeredModel.from_geology(name)`` provides pre-set parameter ranges
for common geological scenarios:
``'sedimentary'``
Alternating conductive (clay/shale) and resistive (sand/carbonate)
layers, depth 0–3 km.
``'crystalline'``
Resistive upper crust (1000–10 000 Ω·m) grading to conductive
lower crust, depth 0–30 km.
``'geothermal'``
Resistive cap rock over a conductive heat source/fluid zone.
``'marine'``
Seawater (0.3 Ω·m) over resistive hydrocarbon reservoir, typical
for marine CSEM scenarios.
``'permafrost'``
Very resistive frozen layer (> 1000 Ω·m) over conductive unfrozen
sediments.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import numpy as np
from ..metadata.geology import CATALOG as _GEO_CATALOG
from ..metadata.geology import geology_prior as _geology_prior
__all__ = [
"LayeredModel",
"GEOLOGY_PRIORS",
]
# ─────────────────────────────────────────────────────────────────────────────
# Geological prior definitions — delegate to metadata.geology
# ─────────────────────────────────────────────────────────────────────────────
#: Backwards-compatible view of the geology catalog.
#: Prefer ``from pycsamt.metadata.geology import CATALOG`` for new code.
GEOLOGY_PRIORS: dict[str, dict] = {
name: _geology_prior(name) for name in _GEO_CATALOG.names()
}
# ─────────────────────────────────────────────────────────────────────────────
# LayeredModel
# ─────────────────────────────────────────────────────────────────────────────
[docs]
@dataclass
class LayeredModel:
"""
1-D layered earth model.
The earth has ``n_layers`` layers. The last layer is the halfspace
(infinite thickness). Thicknesses apply to layers 0 … n−2.
Parameters
----------
resistivity : array-like, shape (n_layers,)
Layer resistivities in Ω·m, ordered top → bottom.
thickness : array-like, shape (n_layers-1,)
Layer thicknesses in metres. The halfspace has no thickness.
depth : ndarray or None
Top-of-layer depths [m]; computed from thickness if not given.
name : str
Optional label (e.g. geological scenario name).
Examples
--------
>>> from pycsamt.forward.synthetic import LayeredModel
>>> m = LayeredModel(
... resistivity=[100, 10, 500],
... thickness=[300, 800],
... )
>>> m.n_layers
3
>>> m.depth
array([ 0., 300., 1100.])
"""
resistivity: np.ndarray
thickness: np.ndarray
depth: np.ndarray = field(default=None, repr=False)
name: str = ""
def __post_init__(self):
self.resistivity = np.asarray(self.resistivity, dtype=float)
self.thickness = np.asarray(self.thickness, dtype=float)
n = len(self.resistivity)
if len(self.thickness) != n - 1:
raise ValueError(
f"len(thickness) must be n_layers - 1 = {n - 1}, "
f"got {len(self.thickness)}"
)
if np.any(self.resistivity <= 0.0):
raise ValueError("All resistivities must be strictly positive.")
if np.any(self.thickness <= 0.0):
raise ValueError(
"All layer thicknesses must be strictly positive."
)
if self.depth is None:
self.depth = np.concatenate([[0.0], np.cumsum(self.thickness)])
# ─── read-only helpers ────────────────────────────────────────────────
[docs]
@property
def n_layers(self) -> int:
"""Number of layers including the halfspace."""
return len(self.resistivity)
[docs]
@property
def conductivity(self) -> np.ndarray:
"""Layer conductivities σ = 1/ρ [S/m]."""
return 1.0 / self.resistivity
# ─── ML serialisation ─────────────────────────────────────────────────
[docs]
def to_vector(self, *, log_rho: bool = True) -> np.ndarray:
"""
Flatten to a 1-D parameter vector suitable for ML targets.
The vector layout is ``[ρ₀, ρ₁, …, ρ_{n-1}, h₀, h₁, …, h_{n-2}]``
where ρ values are log₁₀(Ω·m) when *log_rho* is ``True``.
Parameters
----------
log_rho : bool
If ``True`` (default), resistivities are stored as
log₁₀(ρ). This normalises the dynamic range and improves
neural network convergence.
Returns
-------
v : ndarray, shape (2 * n_layers - 1,)
"""
rho = (
np.log10(self.resistivity) if log_rho else self.resistivity.copy()
)
return np.concatenate([rho, self.thickness])
[docs]
@classmethod
def from_vector(
cls,
v: np.ndarray,
n_layers: int,
*,
log_rho: bool = True,
name: str = "",
) -> LayeredModel:
"""
Reconstruct a :class:`LayeredModel` from a flat parameter vector.
Parameters
----------
v : ndarray, shape (2 * n_layers - 1,)
Parameter vector as produced by :meth:`to_vector`.
n_layers : int
Number of layers.
log_rho : bool
Whether resistivities in *v* are in log₁₀ space.
"""
rho = v[:n_layers]
thick = v[n_layers:]
if log_rho:
rho = 10.0**rho
return cls(resistivity=rho, thickness=thick, name=name)
# ─── constructors ─────────────────────────────────────────────────────
[docs]
@classmethod
def random(
cls,
n_layers: int = 5,
*,
rho_range: tuple[float, float] = (1.0, 10_000.0),
depth_max: float = 2000.0,
seed: int | np.random.Generator | None = None,
name: str = "random",
) -> LayeredModel:
"""
Generate a random layered model with log-uniform resistivities.
Layer thicknesses are drawn from a Dirichlet-like distribution
that sums to *depth_max* so that the model spans the full
target depth range.
Parameters
----------
n_layers : int
Number of layers (including halfspace).
rho_range : (low, high)
Resistivity bounds in Ω·m.
depth_max : float
Total depth spanned by the first (n-1) layers [m].
seed : int, Generator, or None
Random seed for reproducibility.
"""
rng = _ensure_rng(seed)
log_lo, log_hi = np.log10(rho_range[0]), np.log10(rho_range[1])
log_rho = rng.uniform(log_lo, log_hi, n_layers)
rho = 10.0**log_rho
# Dirichlet-like split: n_layers-1 thicknesses summing to depth_max
# n_layers-2 breaks → n_layers-1 fractions
breaks = np.sort(rng.uniform(0.0, 1.0, max(1, n_layers - 2)))
fracs = np.diff(np.concatenate([[0.0], breaks, [1.0]]))
fracs = np.maximum(fracs, 1e-3)
fracs /= fracs.sum()
thick = fracs * depth_max
return cls(resistivity=rho, thickness=thick, name=name)
[docs]
@classmethod
def blocky(
cls,
n_layers: int = 4,
*,
rho_background: float = 100.0,
rho_anomaly: float = 5.0,
anomaly_layer: int = 1,
depth_max: float = 1000.0,
equal_thickness: bool = True,
seed: int | np.random.Generator | None = None,
name: str = "blocky",
) -> LayeredModel:
"""
Build a model with a single conductive (or resistive) anomaly
embedded in a resistive (or conductive) background.
Parameters
----------
rho_background : float
Background layer resistivity [Ω·m].
rho_anomaly : float
Anomaly layer resistivity [Ω·m].
anomaly_layer : int
Zero-based index of the anomalous layer.
"""
rng = _ensure_rng(seed)
rho = np.full(n_layers, rho_background)
rho[anomaly_layer % n_layers] = rho_anomaly
if equal_thickness:
thick = np.full(n_layers - 1, depth_max / (n_layers - 1))
else:
thick = cls.random(
n_layers, depth_max=depth_max, seed=rng
).thickness
return cls(resistivity=rho, thickness=thick, name=name)
[docs]
@classmethod
def smooth(
cls,
n_layers: int = 10,
*,
rho_surface: float = 100.0,
rho_deep: float = 10.0,
depth_max: float = 5000.0,
perturbation: float = 0.2,
seed: int | np.random.Generator | None = None,
name: str = "smooth",
) -> LayeredModel:
"""
Build a model with a smooth gradient from surface to depth,
plus small random perturbations.
Parameters
----------
rho_surface : float
Resistivity at the surface [Ω·m].
rho_deep : float
Resistivity at *depth_max* [Ω·m].
perturbation : float
Fractional standard deviation of log₁₀(ρ) noise added to
the gradient. 0 = pure gradient.
"""
rng = _ensure_rng(seed)
log_rho = np.linspace(
np.log10(rho_surface), np.log10(rho_deep), n_layers
)
log_rho += rng.normal(0.0, perturbation, n_layers)
rho = 10.0**log_rho
rho = np.maximum(rho, 0.01)
thick = np.full(n_layers - 1, depth_max / (n_layers - 1))
return cls(resistivity=rho, thickness=thick, name=name)
[docs]
@classmethod
def from_geology(
cls,
name: str,
*,
seed: int | np.random.Generator | None = None,
) -> LayeredModel:
"""
Generate a random model using a predefined geological prior.
Parameters
----------
name : str
One of: ``'sedimentary'``, ``'crystalline'``,
``'geothermal'``, ``'marine'``, ``'permafrost'``.
Raises
------
KeyError
If *name* is not in :data:`GEOLOGY_PRIORS`.
"""
try:
p = _geology_prior(name)
except KeyError:
raise KeyError(
f"Unknown geology {name!r}. Available: {_GEO_CATALOG.names()}"
)
rng = _ensure_rng(seed)
n_lo, n_hi = p["n_layers"]
n = int(rng.integers(n_lo, n_hi + 1))
d_lo, d_hi = p["depth_max_range"]
depth_max = rng.uniform(d_lo, d_hi)
lr_lo, lr_hi = p["log_rho_range"]
log_rho = rng.uniform(lr_lo, lr_hi, n)
rho = 10.0**log_rho
breaks = np.sort(rng.uniform(0.0, 1.0, max(1, n - 2)))
fracs = np.diff(np.concatenate([[0.0], breaks, [1.0]]))
fracs = np.maximum(fracs, 1e-3)
fracs /= fracs.sum()
thick = fracs * depth_max
return cls(resistivity=rho, thickness=thick, name=name)
# ─── visualisation ────────────────────────────────────────────────────
[docs]
def plot(
self,
ax=None,
*,
log_scale: bool = True,
depth_max: float | None = None,
label: str | None = None,
**kwargs,
):
"""
Plot the 1-D resistivity–depth profile.
Parameters
----------
ax : Axes or None
Target axes. Created if not given.
log_scale : bool
Use log₁₀ scale on the resistivity axis.
depth_max : float or None
Maximum depth shown. Defaults to 1.2× the deepest interface.
label : str or None
Line label for legend.
Returns
-------
ax : Axes
"""
import matplotlib.pyplot as plt
if ax is None:
_, ax = plt.subplots(figsize=(3.5, 5))
rho = self.resistivity
# Build a staircase profile
depths_top = self.depth
depths_bot = np.concatenate(
[
depths_top[1:],
[
depths_top[-1] + self.thickness[-1]
if len(self.thickness)
else depths_top[-1] * 2
],
]
)
rho_steps = np.repeat(rho, 2)
depth_steps = np.zeros(2 * len(rho))
depth_steps[0::2] = depths_top
depth_steps[1::2] = depths_bot
kw = dict(color="steelblue", linewidth=1.5)
kw.update(kwargs)
if label is not None:
kw["label"] = label
ax.plot(rho_steps, depth_steps, **kw)
ax.invert_yaxis()
if log_scale:
ax.set_xscale("log")
dmax = depth_max or depths_bot[-1] * 1.05
ax.set_ylim(dmax, 0.0)
ax.set_xlabel(r"Resistivity (Ω·m)")
ax.set_ylabel("Depth (m)")
ax.set_title(self.name or "1-D earth model")
ax.grid(True, which="both", alpha=0.3)
return ax
# ─── repr ─────────────────────────────────────────────────────────────
def __repr__(self) -> str:
rho_str = np.array2string(
self.resistivity, precision=1, max_line_width=60
)
thick_str = np.array2string(
self.thickness, precision=1, max_line_width=60
)
return (
f"LayeredModel(n_layers={self.n_layers}, "
f"rho={rho_str}, thick={thick_str})"
)
# ─────────────────────────────────────────────────────────────────────────────
# Helper
# ─────────────────────────────────────────────────────────────────────────────
def _ensure_rng(seed) -> np.random.Generator:
"""Return a ``numpy.random.Generator`` from any seed-like input."""
if isinstance(seed, np.random.Generator):
return seed
return np.random.default_rng(seed)