# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Configuration for pyCSAMT 1-D forward modelling and dataset generation.
The module exposes :class:`ForwardConfig`, a dataclass that collects every
tuneable parameter used by :func:`~pycsamt.forward.batch.generate_dataset`
and the three 1-D forward solvers. Users can:
1. Call :meth:`ForwardConfig.write_template` to generate a fully annotated
source-of-truth file (Python, JSON, or YAML).
2. Edit the file to match their survey setup and earth-model priors.
3. Load the edited file with :meth:`ForwardConfig.from_file` and pass the
resulting object to :meth:`ForwardConfig.to_dataset_kwargs`.
Quick start
-----------
Generate a default template, edit it, run dataset generation::
from pycsamt.forward.config import ForwardConfig
# 1 — write an annotated source-of-truth file
ForwardConfig.write_template("my_forward.py")
# 2 — edit values in my_forward.py …
# 3 — load and run
cfg = ForwardConfig.from_file("my_forward.py")
cfg.validate()
from pycsamt.forward.batch import generate_dataset
ds = generate_dataset(**cfg.to_dataset_kwargs())
Alternatively, construct the config entirely in Python::
cfg = ForwardConfig(
solver="mt1d",
n_samples=20_000,
n_layers_min=3,
n_layers_max=7,
noise_level=0.03,
seed=42,
)
ds = generate_dataset(**cfg.to_dataset_kwargs())
"""
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__ = ["ForwardConfig"]
# ── parameter schema (documentation shown in generated config files) ────────
_FORWARD_CONFIG_SCHEMA: list[ConfigParameter] = [
# ── Solver ──────────────────────────────────────────────────────────────
ConfigParameter(
"solver",
"Forward solver to use. Accepted values are 'mt1d' (plane-wave "
"magnetotelluric), 'csamt1d' (controlled-source AMT, far-field "
"approximation), and 'tem1d' (central-loop time-domain EM with "
"step-off waveform). This value also governs which frequency/time "
"grid parameters are active.",
"Solver",
),
ConfigParameter(
"freq_min",
"Minimum frequency in Hz for the MT/CSAMT frequency grid. "
"The grid is constructed as numpy.logspace(log10(freq_min), "
"log10(freq_max), n_freqs). Typical AMT lower bound: 1e-4 Hz. "
"Ignored when solver is 'tem1d'.",
"Solver",
),
ConfigParameter(
"freq_max",
"Maximum frequency in Hz for the MT/CSAMT frequency grid. "
"Typical AMT upper bound: 1e5 Hz (CSAMT surveys reach 8192 Hz). "
"Must be strictly greater than freq_min. Ignored for TEM.",
"Solver",
),
ConfigParameter(
"n_freqs",
"Number of frequency points in the logarithmic frequency grid. "
"30 points per decade gives a smooth apparent-resistivity curve "
"for most network architectures. Minimum meaningful value is 4.",
"Solver",
),
ConfigParameter(
"time_min",
"Minimum time in seconds for the TEM step-off time-gate grid. "
"Constructed as numpy.logspace(log10(time_min), log10(time_max), "
"n_times). Typical TEM early gate: 1e-6 s. Ignored for MT/CSAMT.",
"Solver",
),
ConfigParameter(
"time_max",
"Maximum time in seconds for the TEM time-gate grid. Typical "
"TEM late gate: 1e-2 s. Must be strictly greater than time_min.",
"Solver",
),
ConfigParameter(
"n_times",
"Number of time-gate points in the logarithmic TEM grid. "
"25 points is a good compromise between model accuracy and feature "
"vector length for neural-network inversion.",
"Solver",
),
ConfigParameter(
"loop_radius",
"Transmitter loop radius in metres for TEM1D. The Hankel-transform "
"kernel uses this as the horizontal source extent. Ignored for "
"MT and CSAMT solvers.",
"Solver",
),
# ── Earth model ─────────────────────────────────────────────────────────
ConfigParameter(
"n_layers_min",
"Minimum number of earth layers (including the halfspace). When "
"n_layers_min equals n_layers_max the layer count is fixed; "
"otherwise it is drawn uniformly at random over [min, max] for "
"each synthetic sample. Minimum useful value is 2.",
"Earth Model",
),
ConfigParameter(
"n_layers_max",
"Maximum number of earth layers (including the halfspace). "
"Must be greater than or equal to n_layers_min. Samples with "
"fewer layers than the maximum are NaN-padded in the target vector.",
"Earth Model",
),
ConfigParameter(
"rho_min",
"Lower resistivity bound in Ω·m for the log-uniform prior used "
"by LayeredModel.random. Must be strictly positive. Typical "
"geophysical surveys cover 1–10 000 Ω·m.",
"Earth Model",
),
ConfigParameter(
"rho_max",
"Upper resistivity bound in Ω·m. Must be strictly greater than "
"rho_min. Very large values (> 100 000) extend the dynamic range "
"but reduce the density of conductive training samples.",
"Earth Model",
),
ConfigParameter(
"depth_max",
"Maximum depth in metres spanned by all layers except the "
"halfspace. Layer thicknesses are drawn from a Dirichlet-like "
"distribution that sums to this value, so it controls the total "
"investigation depth of each random model.",
"Earth Model",
),
ConfigParameter(
"geology",
"Optional geological prior scenario name passed to "
"LayeredModel.from_geology. When set, layer counts, resistivity "
"bounds, and depth ranges are drawn from the named prior instead "
"of the generic rho_min/rho_max/depth_max values. "
"Available scenarios: 'sedimentary', 'crystalline', 'geothermal', "
"'marine', 'permafrost'. Set to null/None to use the generic "
"random prior.",
"Earth Model",
),
# ── Dataset ─────────────────────────────────────────────────────────────
ConfigParameter(
"n_samples",
"Total number of (model, response) pairs to generate. Larger "
"datasets improve generalisation but increase generation time "
"linearly. 10 000–50 000 samples is typical for 1-D inversion "
"with five to seven layers.",
"Dataset",
),
ConfigParameter(
"noise_level",
"Relative noise standard deviation added to synthetic data after "
"the forward solve. A value of 0.05 means five percent Gaussian "
"noise on each data point. Set to 0.0 for noise-free data (not "
"recommended for training: the network will not generalise to "
"real field data).",
"Dataset",
),
ConfigParameter(
"noise_type",
"Noise model applied to synthetic responses. Options: "
"'gaussian' adds independent normal noise scaled by noise_level; "
"'multiplicative' scales each point by (1 + epsilon) where "
"epsilon ~ N(0, noise_level); 'field' mimics field data with "
"frequency-dependent noise and occasional spikes.",
"Dataset",
),
ConfigParameter(
"include_phase",
"Include impedance phase in the MT/CSAMT feature vector. "
"When True the feature vector has length 2*n_freqs "
"(log10(rho_a) concatenated with phase in degrees). "
"When False the feature vector has length n_freqs (rho_a only). "
"Setting this to False approximately halves network input size "
"at the cost of lost phase information. Ignored for TEM.",
"Dataset",
),
ConfigParameter(
"seed",
"Global random seed for reproducible dataset generation. "
"Individual worker seeds are derived from this value, so the same "
"seed always produces the same dataset regardless of n_jobs. "
"Set to null/None for a time-based random seed.",
"Dataset",
),
ConfigParameter(
"n_jobs",
"Number of parallel worker processes used by "
"generate_dataset. Use -1 to consume all available CPU cores. "
"Values greater than 1 require the script to be protected by "
"an 'if __name__ == \"__main__\":' guard on Windows.",
"Dataset",
),
# ── Output ──────────────────────────────────────────────────────────────
ConfigParameter(
"output_dir",
"Directory where the generated dataset (.npz file) is written. "
"The directory is created automatically if it does not exist. "
"Set to '.' to save in the current working directory. "
"Set to null/None to skip saving to disk (dataset is returned "
"from generate_dataset but not persisted).",
"Output",
),
ConfigParameter(
"output_name",
"Base file name for the saved dataset, without extension. "
"The extension '.npz' is added automatically. For example, "
"'mt1d_train' produces 'mt1d_train.npz' in output_dir. "
"Ignored when output_dir is null.",
"Output",
),
ConfigParameter(
"verbose",
"Print a progress bar and summary statistics while generating "
"the dataset. Set to False for batch scripts where only "
"the final dataset matters.",
"Output",
),
]
# ── dataclass ───────────────────────────────────────────────────────────────
[docs]
@dataclass
class ForwardConfig:
"""Collect settings that define a 1-D forward modelling / dataset run.
``ForwardConfig`` is the central configuration object for
:func:`~pycsamt.forward.batch.generate_dataset` and the three 1-D
solvers. All fields are plain Python scalars so that the dataclass can
be serialised to Python, JSON, and YAML without any numpy dependencies.
The recommended workflow is:
1. Generate a template with :meth:`write_template`.
2. Edit the values in the generated file.
3. Load the edited file with :meth:`from_file`.
4. Optionally call :meth:`validate` to catch range errors early.
5. Pass :meth:`to_dataset_kwargs` to :func:`generate_dataset`.
Parameters
----------
solver : {'mt1d', 'csamt1d', 'tem1d'}
Forward solver.
freq_min, freq_max : float
Frequency grid bounds [Hz] for MT/CSAMT.
n_freqs : int
Number of logarithmically spaced frequency points.
time_min, time_max : float
Time-gate grid bounds [s] for TEM.
n_times : int
Number of logarithmically spaced time-gate points.
loop_radius : float
TEM transmitter loop radius [m].
n_layers_min, n_layers_max : int
Layer count range (inclusive). Equal values fix the count.
rho_min, rho_max : float
Resistivity bounds [Ω·m] for the random prior.
depth_max : float
Maximum total depth [m] across all layers except the halfspace.
geology : str or None
Geological prior scenario name; overrides rho/depth bounds when set.
n_samples : int
Number of (model, response) pairs to generate.
noise_level : float
Relative noise standard deviation (0 = noise-free).
noise_type : str
Noise model: ``'gaussian'``, ``'multiplicative'``, ``'field'``.
include_phase : bool
Include impedance phase in the MT/CSAMT feature vector.
seed : int or None
Global random seed for reproducibility.
n_jobs : int
Parallel worker count (-1 = all cores).
output_dir : str or None
Directory for the saved ``.npz`` dataset. ``None`` skips saving.
output_name : str
Base file name (without extension) for the saved dataset.
verbose : bool
Print progress and summary.
Examples
--------
Default MT1D configuration::
>>> cfg = ForwardConfig()
>>> cfg.solver
'mt1d'
Fixed 5-layer configuration with geology prior::
>>> cfg = ForwardConfig(
... solver="mt1d",
... n_layers_min=5,
... n_layers_max=5,
... geology="sedimentary",
... n_samples=10_000,
... seed=0,
... )
Generate a template file and round-trip::
>>> path = ForwardConfig.write_template("forward_config.py")
>>> cfg = ForwardConfig.from_file(path)
>>> cfg.solver
'mt1d'
"""
# ── Solver ───────────────────────────────────────────────────────────────
solver: str = "mt1d"
freq_min: float = 1e-4
freq_max: float = 1e4
n_freqs: int = 30
time_min: float = 1e-6
time_max: float = 1e-2
n_times: int = 25
loop_radius: float = 50.0
# ── Earth model ──────────────────────────────────────────────────────────
n_layers_min: int = 3
n_layers_max: int = 7
rho_min: float = 1.0
rho_max: float = 10_000.0
depth_max: float = 2_000.0
geology: str | None = None
# ── Dataset ──────────────────────────────────────────────────────────────
n_samples: int = 10_000
noise_level: float = 0.05
noise_type: str = "gaussian"
include_phase: bool = True
seed: int | None = None
n_jobs: int = 1
# ── Output ───────────────────────────────────────────────────────────────
output_dir: str | None = "."
output_name: str = "forward_dataset"
verbose: bool = True
# ─────────────────────────────────────────────────────────────────────────
# Validation
# ─────────────────────────────────────────────────────────────────────────
[docs]
def validate(self) -> None:
"""Check parameter ranges and raise :class:`ValueError` on errors.
Call before a long dataset generation run to catch obvious
mistakes (e.g. ``freq_min >= freq_max``) before any compute is
spent.
Raises
------
ValueError
Descriptive message pointing to the offending parameter.
"""
_VALID_SOLVERS = {"mt1d", "csamt1d", "tem1d"}
if self.solver not in _VALID_SOLVERS:
raise ValueError(
f"solver must be one of {_VALID_SOLVERS!r}, got {self.solver!r}."
)
if self.solver in ("mt1d", "csamt1d"):
if self.freq_min <= 0.0:
raise ValueError("freq_min must be strictly positive.")
if self.freq_max <= self.freq_min:
raise ValueError(
"freq_max must be strictly greater than freq_min."
)
if self.n_freqs < 4:
raise ValueError("n_freqs must be at least 4.")
if self.solver == "tem1d":
if self.time_min <= 0.0:
raise ValueError("time_min must be strictly positive.")
if self.time_max <= self.time_min:
raise ValueError(
"time_max must be strictly greater than time_min."
)
if self.n_times < 4:
raise ValueError("n_times must be at least 4.")
if self.loop_radius <= 0.0:
raise ValueError("loop_radius must be strictly positive.")
if self.n_layers_min < 2:
raise ValueError(
"n_layers_min must be at least 2 (min: 1 layer + halfspace)."
)
if self.n_layers_max < self.n_layers_min:
raise ValueError("n_layers_max must be >= n_layers_min.")
if self.geology is None:
if self.rho_min <= 0.0:
raise ValueError("rho_min must be strictly positive.")
if self.rho_max <= self.rho_min:
raise ValueError(
"rho_max must be strictly greater than rho_min."
)
if self.depth_max <= 0.0:
raise ValueError("depth_max must be strictly positive.")
if self.n_samples < 1:
raise ValueError("n_samples must be at least 1.")
if not 0.0 <= self.noise_level <= 1.0:
raise ValueError("noise_level must be in [0, 1].")
_VALID_NOISE = {"gaussian", "multiplicative", "field"}
if self.noise_type not in _VALID_NOISE:
raise ValueError(
f"noise_type must be one of {_VALID_NOISE!r}, "
f"got {self.noise_type!r}."
)
# ─────────────────────────────────────────────────────────────────────────
# Assemblers
# ─────────────────────────────────────────────────────────────────────────
[docs]
def freq_grid(self) -> np.ndarray:
"""Return the logarithmically spaced frequency array [Hz].
This is the array that would be passed as *freqs* to the MT/CSAMT
solvers and to :func:`~pycsamt.forward.batch.generate_dataset`.
Returns
-------
numpy.ndarray, shape (n_freqs,)
"""
return np.logspace(
np.log10(self.freq_min), np.log10(self.freq_max), self.n_freqs
)
[docs]
def time_grid(self) -> np.ndarray:
"""Return the logarithmically spaced time-gate array [s].
This is the array that would be passed as *times* to the TEM solver
and to :func:`~pycsamt.forward.batch.generate_dataset`.
Returns
-------
numpy.ndarray, shape (n_times,)
"""
return np.logspace(
np.log10(self.time_min), np.log10(self.time_max), self.n_times
)
[docs]
def to_dataset_kwargs(self) -> dict[str, Any]:
"""Assemble keyword arguments for :func:`~pycsamt.forward.batch.generate_dataset`.
The returned dict is ready to be unpacked directly::
from pycsamt.forward.batch import generate_dataset
ds = generate_dataset(**cfg.to_dataset_kwargs())
Returns
-------
dict
All keyword arguments for ``generate_dataset``, with numpy
arrays built from the stored scalar parameters.
"""
kw: dict[str, Any] = dict(
solver=self.solver,
n_samples=self.n_samples,
n_layers=(
self.n_layers_min
if self.n_layers_min == self.n_layers_max
else (self.n_layers_min, self.n_layers_max)
),
rho_range=(self.rho_min, self.rho_max),
depth_max=self.depth_max,
loop_radius=self.loop_radius,
noise_level=self.noise_level,
noise_type=self.noise_type,
geology=self.geology,
include_phase=self.include_phase,
seed=self.seed,
n_jobs=self.n_jobs,
verbose=self.verbose,
)
if self.solver in ("mt1d", "csamt1d"):
kw["freqs"] = self.freq_grid()
kw["times"] = None
else:
kw["times"] = self.time_grid()
kw["freqs"] = None
if self.output_dir is not None:
out_dir = Path(self.output_dir).expanduser()
out_dir.mkdir(parents=True, exist_ok=True)
kw["output"] = str(out_dir / f"{self.output_name}.npz")
else:
kw["output"] = None
return kw
# ─────────────────────────────────────────────────────────────────────────
# Config file I/O
# ─────────────────────────────────────────────────────────────────────────
[docs]
def to_template(
self,
path: str | Path = "forward_config.py",
*,
fmt: str | None = None,
) -> Path:
"""Write this configuration to an annotated source-of-truth file.
Parameters
----------
path : path-like, default "forward_config.py"
Destination file. Suffixes ``.py``, ``.json``, ``.yml``,
and ``.yaml`` select the output format automatically.
fmt : {"py", "json", "yml", "yaml"}, optional
Explicit format override. When omitted, the suffix of *path*
is used. Paths without a suffix default to Python.
Returns
-------
pathlib.Path
Path of the generated file.
"""
return write_config_template(
path,
self,
_FORWARD_CONFIG_SCHEMA,
fmt=fmt,
title="PyCSAMT forward modelling configuration",
)
[docs]
@classmethod
def write_template(
cls,
path: str | Path = "forward_config.py",
*,
fmt: str | None = None,
) -> Path:
"""Generate a documented source-of-truth configuration file.
Creates a file with default parameter values and an inline comment
for every parameter explaining its role. Edit the generated file,
then load it with :meth:`from_file`.
Parameters
----------
path : path-like, default "forward_config.py"
Destination file. The suffix determines the output format
(``.py``, ``.json``, ``.yml``).
fmt : {"py", "json", "yml", "yaml"}, optional
Explicit format override.
Returns
-------
pathlib.Path
Path of the generated file.
Examples
--------
>>> from pycsamt.forward.config import ForwardConfig
>>> path = ForwardConfig.write_template("my_forward.yml")
>>> path.suffix
'.yml'
"""
return cls().to_template(path, fmt=fmt)
[docs]
@classmethod
def from_file(
cls,
path: str | Path,
*,
strict: bool = True,
) -> ForwardConfig:
"""Load a configuration from a source-of-truth file.
Parameters
----------
path : path-like
Python, JSON, YML, or YAML file generated by
:meth:`write_template` or following the same structure.
strict : bool, default True
If ``True``, unknown keys raise :class:`ValueError`.
If ``False``, unknown keys are silently ignored.
Returns
-------
ForwardConfig
Configuration populated from the file values.
Examples
--------
>>> ForwardConfig.write_template("forward_config.json")
PosixPath('forward_config.json')
>>> cfg = ForwardConfig.from_file("forward_config.json")
>>> cfg.solver
'mt1d'
"""
values = read_config_file(path, cls, strict=strict)
return cls(**values)
#: Alias — matches the convention used by ModEmConfig and OccamConfig.
read = from_file
# ─────────────────────────────────────────────────────────────────────────
# repr / summary
# ─────────────────────────────────────────────────────────────────────────
[docs]
def summary(self) -> str:
"""Return a human-readable multi-line summary of the configuration."""
lines = [
"ForwardConfig",
f" {'solver':<20s} = {self.solver!r}",
]
if self.solver in ("mt1d", "csamt1d"):
lines += [
f" {'freq_min':<20s} = {self.freq_min:.3g} Hz",
f" {'freq_max':<20s} = {self.freq_max:.3g} Hz",
f" {'n_freqs':<20s} = {self.n_freqs}",
]
else:
lines += [
f" {'time_min':<20s} = {self.time_min:.3g} s",
f" {'time_max':<20s} = {self.time_max:.3g} s",
f" {'n_times':<20s} = {self.n_times}",
f" {'loop_radius':<20s} = {self.loop_radius} m",
]
layer_s = (
str(self.n_layers_min)
if self.n_layers_min == self.n_layers_max
else f"{self.n_layers_min}–{self.n_layers_max}"
)
lines += [
f" {'n_layers':<20s} = {layer_s}",
]
if self.geology:
lines.append(f" {'geology':<20s} = {self.geology!r}")
else:
lines += [
f" {'rho_min':<20s} = {self.rho_min:.3g} Ω·m",
f" {'rho_max':<20s} = {self.rho_max:.3g} Ω·m",
f" {'depth_max':<20s} = {self.depth_max:.3g} m",
]
lines += [
f" {'n_samples':<20s} = {self.n_samples:,}",
f" {'noise_level':<20s} = {self.noise_level} ({self.noise_type})",
f" {'seed':<20s} = {self.seed!r}",
f" {'n_jobs':<20s} = {self.n_jobs}",
]
if self.output_dir:
lines.append(
f" {'output':<20s} = {self.output_dir}/{self.output_name}.npz"
)
return "\n".join(lines)
def __repr__(self) -> str:
return self.summary()