# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""
Parallelised synthetic EM dataset generator.
:func:`generate_dataset` is the single entry point. It creates a
large collection of (data, model) pairs suitable for training 1-D
neural network inverters.
Quick start
-----------
>>> import numpy as np
>>> from pycsamt.forward.batch import generate_dataset
>>> ds = generate_dataset(
... solver="mt1d",
... n_samples=5_000,
... freqs=np.logspace(-3, 4, 30),
... n_layers=(3, 7),
... noise_level=0.05,
... seed=42,
... n_jobs=4,
... output="mt1d_train.npz",
... )
>>> ds.X.shape # (5000, 60) — log10(rho_a) + phase at 30 freqs
>>> ds.y.shape # (5000, 9) — log10(rho) and thickness for up to 7 layers
Dataset layout
--------------
``X``
Feature matrix, shape ``(n_samples, n_features)``.
For MT: ``[log10(rho_a_0), …, log10(rho_a_{nf-1}), phi_0, …, phi_{nf-1}]``.
For TEM: ``[log10(|dBz/dt|_0), …, log10(|dBz/dt|_{nt-1})]``.
``y``
Target matrix, shape ``(n_samples, n_params)``.
``[log10(rho_0), …, log10(rho_{nl-1}), thick_0, …, thick_{nl-2}]``.
Padded with ``NaN`` for samples with fewer than ``max_layers`` layers.
``meta``
Structured array with per-sample metadata:
``n_layers``, ``depth_max``, ``noise_level``, ``seed``.
"""
from __future__ import annotations
import warnings
from concurrent.futures import (
ProcessPoolExecutor,
as_completed,
)
from dataclasses import dataclass
import numpy as np
__all__ = [
"generate_dataset",
"ForwardDataset",
"generate_dataset_3d",
"SurveyDataset3D",
]
# ─────────────────────────────────────────────────────────────────────────────
# Dataset container
# ─────────────────────────────────────────────────────────────────────────────
[docs]
@dataclass
class ForwardDataset:
"""
Numpy container for a batch of (features, targets) EM samples.
Parameters
----------
X : ndarray, shape (n_samples, n_features)
Model responses (log-scaled data).
y : ndarray, shape (n_samples, n_params)
Model parameters (log10 resistivity + thickness).
freqs : ndarray or None
Frequencies used (MT/CSAMT).
times : ndarray or None
Times used (TEM).
meta : structured ndarray
Per-sample metadata (n_layers, noise_level, …).
solver : str
Solver used to generate this dataset.
"""
X: np.ndarray
y: np.ndarray
freqs: np.ndarray | None = None
times: np.ndarray | None = None
meta: np.ndarray | None = None
solver: str = "mt1d"
[docs]
def save(self, path: str) -> None:
"""Save to a compressed ``.npz`` file."""
arrays = dict(X=self.X, y=self.y, solver=np.array(self.solver))
if self.freqs is not None:
arrays["freqs"] = self.freqs
if self.times is not None:
arrays["times"] = self.times
if self.meta is not None:
# structured arrays need special handling
arrays["meta_n_layers"] = self.meta["n_layers"]
arrays["meta_noise"] = self.meta["noise_level"]
np.savez_compressed(path, **arrays)
[docs]
@classmethod
def load(cls, path: str) -> ForwardDataset:
"""Load from a ``.npz`` file produced by :meth:`save`."""
d = np.load(path, allow_pickle=False)
freqs = d["freqs"] if "freqs" in d else None
times = d["times"] if "times" in d else None
meta = None
if "meta_n_layers" in d:
n = len(d["meta_n_layers"])
meta = np.zeros(
n, dtype=[("n_layers", "i4"), ("noise_level", "f4")]
)
meta["n_layers"] = d["meta_n_layers"]
meta["noise_level"] = d["meta_noise"]
return cls(
X=d["X"],
y=d["y"],
freqs=freqs,
times=times,
meta=meta,
solver=str(d["solver"]),
)
[docs]
def split(
self,
val_frac: float = 0.1,
test_frac: float = 0.1,
seed: int | None = None,
) -> tuple[ForwardDataset, ForwardDataset, ForwardDataset]:
"""
Split into train / validation / test sets.
Returns
-------
(train, val, test) : tuple of ForwardDataset
"""
rng = np.random.default_rng(seed)
n = len(self.X)
idx = rng.permutation(n)
n_test = int(n * test_frac)
n_val = int(n * val_frac)
test_idx = idx[:n_test]
val_idx = idx[n_test : n_test + n_val]
train_idx = idx[n_test + n_val :]
def _subset(indices):
m = self.meta[indices] if self.meta is not None else None
return ForwardDataset(
X=self.X[indices],
y=self.y[indices],
freqs=self.freqs,
times=self.times,
meta=m,
solver=self.solver,
)
return _subset(train_idx), _subset(val_idx), _subset(test_idx)
def __len__(self) -> int:
return len(self.X)
def __repr__(self) -> str:
return (
f"ForwardDataset(n={len(self.X)}, "
f"n_features={self.X.shape[1]}, "
f"n_params={self.y.shape[1]}, "
f"solver='{self.solver}')"
)
# ─────────────────────────────────────────────────────────────────────────────
# Worker (must be module-level for pickle in ProcessPoolExecutor)
# ─────────────────────────────────────────────────────────────────────────────
def _worker(args):
"""
Generate one (X, y) sample.
Parameters are passed as a single tuple to satisfy ProcessPoolExecutor.
"""
(
solver_name,
n_layers,
rho_range,
depth_max,
freqs,
times,
loop_radius,
noise_level,
noise_type,
geology,
include_phase,
seed,
) = args
from pycsamt.forward.noise import add_noise
from pycsamt.forward.synthetic import LayeredModel
rng = np.random.default_rng(seed)
n_lay = (
int(rng.integers(n_layers[0], n_layers[1] + 1))
if isinstance(n_layers, tuple)
else n_layers
)
if geology is not None:
model = LayeredModel.from_geology(geology, seed=rng)
else:
model = LayeredModel.random(
n_layers=n_lay,
rho_range=rho_range,
depth_max=depth_max,
seed=rng,
)
# Run forward solver
if solver_name == "mt1d":
from pycsamt.forward.em1d import MT1DForward
resp = MT1DForward(freqs).run(model)
elif solver_name == "tem1d":
from pycsamt.forward.em1d import TEM1DForward
resp = TEM1DForward(times, loop_radius=loop_radius).run(model)
elif solver_name == "csamt1d":
from pycsamt.forward.em1d import CSAMT1DForward
resp = CSAMT1DForward(freqs).run(model)
else:
raise ValueError(f"Unknown solver: {solver_name!r}")
# Apply noise
if noise_level > 0.0:
resp = add_noise(
resp, noise_type, level=noise_level, seed=rng.integers(2**31)
)
# Feature vector
x_vec = resp.to_array(log_rho=True, include_phase=include_phase)
# Target vector (fixed size, NaN-padded)
y_vec = model.to_vector(log_rho=True)
return x_vec, y_vec, model.n_layers, noise_level
# ─────────────────────────────────────────────────────────────────────────────
# Main entry point
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def generate_dataset(
solver: str = "mt1d",
n_samples: int = 10_000,
*,
freqs: np.ndarray | None = None,
times: np.ndarray | None = None,
n_layers: int | tuple[int, int] = (3, 7),
rho_range: tuple[float, float] = (1.0, 10_000.0),
depth_max: float = 2000.0,
loop_radius: float = 50.0,
noise_level: float = 0.05,
noise_type: str = "gaussian",
geology: str | None = None,
include_phase: bool = True,
seed: int | None = None,
n_jobs: int = 1,
output: str | None = None,
verbose: bool = True,
) -> ForwardDataset:
"""
Generate a batch of synthetic (data, model) pairs for ML training.
Parameters
----------
solver : {'mt1d', 'tem1d', 'csamt1d'}
Forward solver to use.
n_samples : int
Total number of samples.
freqs : ndarray or None
Frequencies [Hz] for MT/CSAMT. If None, uses
``np.logspace(-3, 4, 30)`` for MT.
times : ndarray or None
Times [s] for TEM. If None, uses ``np.logspace(-6, -2, 25)``.
n_layers : int or (lo, hi)
Fixed number of layers, or a range from which the count is
drawn uniformly at random per sample.
rho_range : (low, high)
Resistivity bounds in Ω·m.
depth_max : float
Maximum depth of the model [m].
loop_radius : float
TEM transmitter loop radius [m].
noise_level : float
Relative noise standard deviation. 0 = noise-free.
noise_type : str
Noise model: ``'gaussian'``, ``'multiplicative'``, ``'field'``.
geology : str or None
If given, models are drawn from :func:`LayeredModel.from_geology`
using this geological scenario name.
include_phase : bool
Include impedance phase in the MT feature vector.
seed : int or None
Base random seed. Worker seeds are derived deterministically.
n_jobs : int
Number of parallel worker processes. ``-1`` uses all CPU cores.
output : str or None
If given, save the dataset to a ``.npz`` file at this path.
verbose : bool
Print progress.
Returns
-------
ForwardDataset
Examples
--------
>>> import numpy as np
>>> from pycsamt.forward.batch import generate_dataset
>>> ds = generate_dataset(n_samples=100, seed=0)
>>> ds.X.shape[0]
100
"""
import os
solver = solver.lower().strip()
if solver not in ("mt1d", "tem1d", "csamt1d"):
raise ValueError(
f"Unknown solver {solver!r}. Use 'mt1d', 'tem1d', 'csamt1d'."
)
# Default grids
if freqs is None and solver in ("mt1d", "csamt1d"):
freqs = np.logspace(-3, 4, 30)
if times is None and solver == "tem1d":
times = np.logspace(-6, -2, 25)
warnings.warn(
"TEM1D forward uses scipy numerical integration — "
"generation may be slow for large n_samples. "
"Consider n_jobs > 1 or using a smaller n_samples for Phase 1.",
UserWarning,
stacklevel=2,
)
n_jobs_eff = os.cpu_count() if n_jobs == -1 else max(1, n_jobs)
# Build argument list (deterministic seeds from base seed)
rng_base = np.random.default_rng(seed)
child_seeds = rng_base.integers(0, 2**31, n_samples)
args_list = [
(
solver,
n_layers,
rho_range,
depth_max,
freqs,
times,
loop_radius,
noise_level,
noise_type,
geology,
include_phase,
int(child_seeds[i]),
)
for i in range(n_samples)
]
# Run workers
results = []
if n_jobs_eff == 1:
for i, args in enumerate(args_list):
results.append(_worker(args))
if verbose and (i + 1) % max(1, n_samples // 10) == 0:
print(f" {i + 1}/{n_samples} samples generated")
else:
with ProcessPoolExecutor(max_workers=n_jobs_eff) as pool:
futures = {
pool.submit(_worker, a): i for i, a in enumerate(args_list)
}
done = 0
for fut in as_completed(futures):
results.append(fut.result())
done += 1
if verbose and done % max(1, n_samples // 10) == 0:
print(f" {done}/{n_samples} samples generated")
# Assemble into arrays
x_list = [r[0] for r in results]
y_list = [r[1] for r in results]
nl_list = [r[2] for r in results]
nv_list = [r[3] for r in results]
# Pad y to equal length (max param vector size)
max_y = max(len(y) for y in y_list)
y_pad = np.full((n_samples, max_y), np.nan)
for i, y in enumerate(y_list):
y_pad[i, : len(y)] = y
X = np.vstack(x_list).astype(np.float32)
y_arr = y_pad.astype(np.float32)
meta_dt = np.dtype([("n_layers", "i4"), ("noise_level", "f4")])
meta = np.zeros(n_samples, dtype=meta_dt)
meta["n_layers"] = nl_list
meta["noise_level"] = nv_list
ds = ForwardDataset(
X=X,
y=y_arr,
freqs=freqs,
times=times,
meta=meta,
solver=solver,
)
if output is not None:
ds.save(output)
if verbose:
print(f"Dataset saved to {output}")
return ds
# ─────────────────────────────────────────────────────────────────────────────
# Pseudo-3D survey dataset container
# ─────────────────────────────────────────────────────────────────────────────
[docs]
@dataclass
class SurveyDataset3D:
"""
Numpy container for a pseudo-3D multi-station survey dataset.
Designed for training :class:`~pycsamt.ai.inversion.inv3d.GCNInverter3D`.
All surveys share the same fixed station grid so that a single
pre-computed adjacency matrix covers the whole dataset.
Parameters
----------
X : ndarray, shape ``(n_surveys, n_stations, n_features)``
Per-station MT/CSAMT feature vectors (log-scaled data).
y : ndarray, shape ``(n_surveys, n_stations, n_params)``
Per-station model parameters ``[log10(ρ), thickness]``.
coords : ndarray, shape ``(n_stations, 2)``
Station (easting, northing) coordinates in metres.
freqs : ndarray or None
Frequencies [Hz] used by the forward solver.
meta : structured ndarray or None
Per-survey metadata (``corr_length``, ``noise_level``).
solver : str
Solver used to generate this dataset.
"""
X: np.ndarray
y: np.ndarray
coords: np.ndarray
freqs: np.ndarray | None = None
meta: np.ndarray | None = None
solver: str = "mt1d"
[docs]
@property
def n_surveys(self) -> int:
"""Number of synthetic survey realisations."""
return int(self.X.shape[0])
[docs]
@property
def n_stations(self) -> int:
"""Number of stations per survey."""
return int(self.X.shape[1])
[docs]
@property
def n_features(self) -> int:
"""Feature dimensionality per station."""
return int(self.X.shape[2])
[docs]
@property
def n_params(self) -> int:
"""Parameter dimensionality per station (``2 × n_layers − 1``)."""
return int(self.y.shape[2])
[docs]
def save(self, path: str) -> None:
"""Save to a compressed ``.npz`` file."""
arrays = dict(
X=self.X,
y=self.y,
coords=self.coords,
solver=np.array(self.solver),
)
if self.freqs is not None:
arrays["freqs"] = self.freqs
if self.meta is not None:
arrays["meta_corr_length"] = self.meta["corr_length"]
arrays["meta_noise"] = self.meta["noise_level"]
np.savez_compressed(path, **arrays)
[docs]
@classmethod
def load(cls, path: str) -> SurveyDataset3D:
"""Load from a ``.npz`` file produced by :meth:`save`."""
d = np.load(path, allow_pickle=False)
freqs = d["freqs"] if "freqs" in d else None
meta = None
if "meta_corr_length" in d:
n = len(d["meta_corr_length"])
meta = np.zeros(
n, dtype=[("corr_length", "f4"), ("noise_level", "f4")]
)
meta["corr_length"] = d["meta_corr_length"]
meta["noise_level"] = d["meta_noise"]
return cls(
X=d["X"],
y=d["y"],
coords=d["coords"],
freqs=freqs,
meta=meta,
solver=str(d["solver"]),
)
[docs]
def split(
self,
val_frac: float = 0.1,
test_frac: float = 0.1,
seed: int | None = None,
) -> tuple[SurveyDataset3D, SurveyDataset3D, SurveyDataset3D]:
"""
Split into train / validation / test sets along the survey axis.
Returns
-------
(train, val, test) : tuple of SurveyDataset3D
"""
rng = np.random.default_rng(seed)
n = self.n_surveys
idx = rng.permutation(n)
n_test = int(n * test_frac)
n_val = int(n * val_frac)
test_idx = idx[:n_test]
val_idx = idx[n_test : n_test + n_val]
train_idx = idx[n_test + n_val :]
def _subset(indices):
m = self.meta[indices] if self.meta is not None else None
return SurveyDataset3D(
X=self.X[indices],
y=self.y[indices],
coords=self.coords,
freqs=self.freqs,
meta=m,
solver=self.solver,
)
return _subset(train_idx), _subset(val_idx), _subset(test_idx)
def __len__(self) -> int:
return self.n_surveys
def __repr__(self) -> str:
return (
f"SurveyDataset3D(n_surveys={self.n_surveys}, "
f"n_stations={self.n_stations}, "
f"n_features={self.n_features}, "
f"n_params={self.n_params}, "
f"solver='{self.solver}')"
)
# ─────────────────────────────────────────────────────────────────────────────
# Gaussian Random Field helper
# ─────────────────────────────────────────────────────────────────────────────
def _grf_cholesky(
coords: np.ndarray, corr_length: float, nugget: float = 1e-6
) -> np.ndarray:
"""
Cholesky factor of a squared-exponential covariance matrix.
The covariance between stations *i* and *j* is
.. math::
C_{ij} = \\exp\\!\\left(-\\frac{\\|\\mathbf{x}_i - \\mathbf{x}_j\\|^2}
{2\\,\\ell^2}\\right)
where :math:`\\ell` is the correlation length. A small nugget is
added to the diagonal for numerical stability.
Parameters
----------
coords : ndarray, shape (n_stations, 2)
Station (x, y) positions in metres.
corr_length : float
Spatial correlation length in metres.
nugget : float
Diagonal regularisation term (default 1e-6).
Returns
-------
L : ndarray, shape (n_stations, n_stations)
Lower-triangular Cholesky factor.
"""
d2 = np.sum(
(coords[:, None, :] - coords[None, :, :]) ** 2,
axis=-1,
)
C = np.exp(-d2 / (2.0 * corr_length**2))
C += nugget * np.eye(len(coords))
return np.linalg.cholesky(C)
# ─────────────────────────────────────────────────────────────────────────────
# Per-survey worker (module-level for ProcessPoolExecutor pickling)
# ─────────────────────────────────────────────────────────────────────────────
def _worker_3d(args):
"""
Generate one pseudo-3D survey (all stations, fixed station grid).
Parameters are packed into a single tuple so that
``ProcessPoolExecutor.submit`` can pickle the call.
"""
(
solver_name,
n_layers,
n_stations,
log_rho_mean,
log_rho_std,
thickness_range,
L_chol,
freqs,
noise_level,
noise_type,
include_phase,
seed,
) = args
from pycsamt.forward.em1d import MT1DForward
from pycsamt.forward.noise import add_noise
from pycsamt.forward.synthetic import LayeredModel
rng = np.random.default_rng(seed)
# ── spatially correlated log-resistivity fields ───────────────────────────
# For each layer k draw a GRF: z_k ~ N(0, I), then L @ z_k gives a sample
# from N(0, C). Scale by log_rho_std and shift by log_rho_mean.
log_rho_fields = np.empty((n_layers, n_stations))
for k in range(n_layers):
z = rng.standard_normal(n_stations)
log_rho_fields[k] = log_rho_mean + log_rho_std * (L_chol @ z)
# ── thicknesses: log-uniform per layer, spatially constant per survey ─────
log_t_lo = np.log10(float(thickness_range[0]))
log_t_hi = np.log10(float(thickness_range[1]))
thick = 10.0 ** rng.uniform(log_t_lo, log_t_hi, n_layers - 1)
# ── run forward solver at every station ───────────────────────────────────
fwd = MT1DForward(freqs)
x_list = []
y_list = []
for s in range(n_stations):
rho_s = 10.0 ** log_rho_fields[:, s]
# Clip to physically valid range before constructing the model
rho_s = np.maximum(rho_s, 1e-3)
model = LayeredModel(resistivity=rho_s, thickness=thick.copy())
resp = fwd.run(model)
if noise_level > 0.0:
resp = add_noise(
resp,
noise_type,
level=noise_level,
seed=int(rng.integers(2**31)),
)
x_list.append(
resp.to_array(log_rho=True, include_phase=include_phase)
)
y_list.append(model.to_vector(log_rho=True))
X_survey = np.array(x_list, dtype=np.float32) # (n_sta, n_feat)
y_survey = np.array(y_list, dtype=np.float32) # (n_sta, n_params)
return X_survey, y_survey
# ─────────────────────────────────────────────────────────────────────────────
# Main 3-D entry point
# ─────────────────────────────────────────────────────────────────────────────
[docs]
def generate_dataset_3d(
solver: str = "mt1d",
n_surveys: int = 1000,
*,
n_stations: int = 25,
n_layers: int = 4,
freqs: np.ndarray | None = None,
extent: float = 10_000.0,
corr_length: float = 2_000.0,
log_rho_mean: float = 2.0,
log_rho_std: float = 0.5,
thickness_range: tuple[float, float] = (100.0, 2_000.0),
station_layout: str = "grid",
noise_level: float = 0.03,
noise_type: str = "gaussian",
include_phase: bool = True,
seed: int | None = None,
n_jobs: int = 1,
output: str | None = None,
verbose: bool = True,
) -> SurveyDataset3D:
"""
Generate a pseudo-3D synthetic dataset for :class:`GCNInverter3D` training.
Each survey realisation consists of *n_stations* MT soundings whose
per-layer log-resistivities are drawn from a spatially correlated
Gaussian random field (GRF) with a squared-exponential covariance:
.. math::
C(\\mathbf{x}_i,\\mathbf{x}_j)
= \\exp\\!\\left(-\\frac{\\|\\mathbf{x}_i-\\mathbf{x}_j\\|^2}
{2\\,\\ell^2}\\right)
where :math:`\\ell` is *corr_length*. Layer thicknesses are drawn
log-uniformly per survey but kept spatially constant (flat-layer
assumption within one survey). All surveys share the same fixed
station grid so that a single adjacency matrix covers the whole
training set.
Parameters
----------
solver : str
Forward solver. Currently only ``'mt1d'`` is supported.
n_surveys : int
Number of independent survey realisations.
n_stations : int
Number of MT stations per survey.
n_layers : int
Fixed number of earth layers (including halfspace).
freqs : ndarray or None
Frequencies [Hz]. Defaults to ``np.logspace(-3, 4, 30)``.
extent : float
Side length of the square survey area in metres.
corr_length : float
Spatial correlation length of the GRF in metres. Set smaller
than station spacing for uncorrelated models; larger for smooth
lateral variation.
log_rho_mean : float
Mean of log₁₀(ρ) for all layers (default 2 → 100 Ω·m).
log_rho_std : float
Marginal standard deviation of the GRF in log₁₀(ρ) units.
thickness_range : (lo, hi)
Bounds [m] for log-uniform layer thickness draws.
station_layout : ``'grid'`` or ``'random'``
``'grid'`` places stations on a regular grid of
``⌈√n_stations⌉²`` points (default). ``'random'`` draws
uniform positions within ``[0, extent]²``.
noise_level : float
Relative noise standard deviation applied to each response.
noise_type : str
Noise model: ``'gaussian'``, ``'multiplicative'``, ``'field'``.
include_phase : bool
Include impedance phase in the feature vector.
seed : int or None
Base random seed. Worker seeds are derived deterministically.
n_jobs : int
Number of parallel worker processes. ``-1`` uses all CPU cores.
output : str or None
If given, save the dataset to a compressed ``.npz`` file.
verbose : bool
Print progress.
Returns
-------
SurveyDataset3D
Container with arrays ``X`` (n_surveys, n_stations, n_features),
``y`` (n_surveys, n_stations, n_params), and
``coords`` (n_stations, 2).
Examples
--------
>>> import numpy as np
>>> from pycsamt.forward.batch import generate_dataset_3d
>>> from pycsamt.ai.nets.gcn import build_adjacency
>>> ds = generate_dataset_3d(n_surveys=500, n_stations=16,
... n_layers=4, corr_length=2000., seed=0)
>>> ds.X.shape
(500, 16, 60)
>>> A = build_adjacency(ds.coords, radius=3000.)
>>> from pycsamt.ai.inversion.inv3d import GCNInverter3D
>>> inv = GCNInverter3D(n_features=ds.n_features, n_layers=4)
>>> inv.fit(ds.X, ds.y, adjacency=A, epochs=5, verbose=False)
"""
import os
solver = solver.lower().strip()
if solver != "mt1d":
raise ValueError(
f"generate_dataset_3d currently supports solver='mt1d'; "
f"got {solver!r}."
)
if freqs is None:
freqs = np.logspace(-3, 4, 30)
freqs = np.asarray(freqs, dtype=float)
# ── station coordinates (fixed for the whole dataset) ────────────────────
rng_base = np.random.default_rng(seed)
if station_layout == "grid":
side = int(np.ceil(np.sqrt(n_stations)))
gx = np.linspace(0.0, float(extent), side)
gy = np.linspace(0.0, float(extent), side)
gX, gY = np.meshgrid(gx, gy)
raw = np.column_stack([gX.ravel(), gY.ravel()])
coords = raw[:n_stations].astype(np.float32)
elif station_layout == "random":
coords = rng_base.uniform(0.0, float(extent), (n_stations, 2)).astype(
np.float32
)
else:
raise ValueError(
f"station_layout must be 'grid' or 'random'; got {station_layout!r}."
)
# ── pre-compute Cholesky factor (shared across all surveys) ───────────────
L_chol = _grf_cholesky(coords.astype(float), float(corr_length))
# ── per-survey worker arguments ───────────────────────────────────────────
child_seeds = rng_base.integers(0, 2**31, n_surveys)
args_list = [
(
solver,
int(n_layers),
int(n_stations),
float(log_rho_mean),
float(log_rho_std),
thickness_range,
L_chol,
freqs,
float(noise_level),
noise_type,
bool(include_phase),
int(child_seeds[i]),
)
for i in range(n_surveys)
]
n_jobs_eff = os.cpu_count() if n_jobs == -1 else max(1, n_jobs)
results = []
if n_jobs_eff == 1:
for i, a in enumerate(args_list):
results.append(_worker_3d(a))
if verbose and (i + 1) % max(1, n_surveys // 10) == 0:
print(f" {i + 1}/{n_surveys} surveys generated")
else:
with ProcessPoolExecutor(max_workers=n_jobs_eff) as pool:
futures = {
pool.submit(_worker_3d, a): i for i, a in enumerate(args_list)
}
done = 0
for fut in as_completed(futures):
results.append(fut.result())
done += 1
if verbose and done % max(1, n_surveys // 10) == 0:
print(f" {done}/{n_surveys} surveys generated")
# ── assemble into 3-D arrays ──────────────────────────────────────────────
X = np.stack([r[0] for r in results], axis=0).astype(np.float32)
y = np.stack([r[1] for r in results], axis=0).astype(np.float32)
meta_dt = np.dtype([("corr_length", "f4"), ("noise_level", "f4")])
meta = np.zeros(n_surveys, dtype=meta_dt)
meta["corr_length"] = float(corr_length)
meta["noise_level"] = float(noise_level)
ds = SurveyDataset3D(
X=X,
y=y,
coords=coords,
freqs=freqs,
meta=meta,
solver=solver,
)
if output is not None:
ds.save(output)
if verbose:
print(f"3D dataset saved to {output}")
return ds