Source code for pycsamt.ai.geology.layers

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Correlated stratigraphic interfaces and layered resistivity priors.

Layer interfaces are stored as depth below the grid datum and have shape
``(n_interface, nx)`` in 2-D or ``(n_interface, ny, nx)`` in 3-D.  Resulting
resistivity arrays follow the geology convention ``(nz, nx)`` or
``(nz, ny, nx)``.
"""

from __future__ import annotations

import hashlib
import json
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from pathlib import Path
from types import MappingProxyType
from typing import Any

import numpy as np

from ..data.manifest import canonical_hash
from .fields import (
    GaussianCorrelation,
    GeologyGrid,
    generate_gaussian_field,
)

__all__ = [
    "ElectricalLayer",
    "LayeredGeology",
    "generate_layered_geology",
]


def _readonly(value: Any, dtype: Any | None = None) -> np.ndarray:
    array = np.array(value, dtype=dtype, copy=True)
    array.setflags(write=False)
    return array


def _freeze_json(value: Any) -> Any:
    if isinstance(value, Mapping):
        return MappingProxyType(
            {str(key): _freeze_json(item) for key, item in value.items()}
        )
    if isinstance(value, list):
        return tuple(_freeze_json(item) for item in value)
    return value


def _thaw_json(value: Any) -> Any:
    if isinstance(value, Mapping):
        return {str(key): _thaw_json(item) for key, item in value.items()}
    if isinstance(value, (list, tuple)):
        return [_thaw_json(item) for item in value]
    return value


def _seed(value: int) -> int:
    if not isinstance(value, (int, np.integer)) or isinstance(value, bool):
        raise TypeError("seed must be an integer.")
    result = int(value)
    if result < 0 or result >= 2**64:
        raise ValueError("seed must be in [0, 2**64).")
    return result


def _child_seed(root: int, label: str) -> int:
    payload = f"pycsamt.layers\0{root}\0{label}".encode()
    return int.from_bytes(hashlib.sha256(payload).digest()[:8], "big")


def _sequence(
    value: float | Sequence[float], count: int, name: str
) -> np.ndarray:
    if np.isscalar(value):
        result = np.full(count, float(value), dtype=float)
    else:
        result = np.asarray(value, dtype=float)
        if result.shape != (count,):
            raise ValueError(
                f"{name} must be scalar or contain {count} values."
            )
    if not np.all(np.isfinite(result)):
        raise ValueError(f"{name} must contain finite values.")
    return result


def _horizontal_shape(grid: GeologyGrid) -> tuple[int, ...]:
    return (
        (len(grid.x_m),)
        if grid.dimension == 2
        else (len(grid.y_m), len(grid.x_m))
    )


def _horizontal_gaussian(
    grid: GeologyGrid,
    correlation: GaussianCorrelation,
    seed: int,
    boundary: str,
) -> np.ndarray:
    shape = _horizontal_shape(grid)
    spacing = (
        (grid.spacing_m[-1],)
        if grid.dimension == 2
        else (grid.spacing_m[1], grid.spacing_m[2])
    )
    rng = np.random.default_rng(seed)
    if boundary == "periodic":
        white = rng.standard_normal(shape)
        starts = None
    else:
        core = rng.standard_normal(shape)
        pads = tuple((size // 2, size - size // 2) for size in shape)
        white = np.pad(core, pads, mode="reflect")
        starts = tuple(pad[0] for pad in pads)
    axes = [
        2 * np.pi * np.fft.fftfreq(size, d=step)
        for size, step in zip(white.shape, spacing)
    ]
    if grid.dimension == 2:
        (kx,) = np.meshgrid(axes[0], indexing="ij")
        exponent = np.square(kx * correlation.length_x_m)
    else:
        ky, kx = np.meshgrid(axes[0], axes[1], indexing="ij")
        angle = np.deg2rad(correlation.azimuth_deg)
        major = kx * np.cos(angle) + ky * np.sin(angle)
        minor = -kx * np.sin(angle) + ky * np.cos(angle)
        exponent = np.square(major * correlation.length_x_m) + np.square(
            minor * correlation.length_y_m
        )
    values = np.fft.ifftn(np.fft.fftn(white) * np.exp(-0.25 * exponent)).real
    if starts is not None:
        values = values[
            tuple(
                slice(start, start + size)
                for start, size in zip(starts, shape)
            )
        ]
    deviation = float(np.std(values))
    if deviation <= np.finfo(float).eps:
        raise ValueError(
            "interface correlation produced a numerically constant field."
        )
    return (values - np.mean(values)) / deviation


def _project_interfaces(
    surfaces: np.ndarray,
    top: float,
    bottom: float,
    minimum: float,
) -> tuple[np.ndarray, float]:
    projected = np.array(surfaces, copy=True)
    count = projected.shape[0]
    if count == 0:
        return projected, 0.0
    for index in range(count):
        lower = top + minimum * (index + 1)
        upper = bottom - minimum * (count - index)
        projected[index] = np.clip(projected[index], lower, upper)
    for index in range(1, count):
        projected[index] = np.maximum(
            projected[index], projected[index - 1] + minimum
        )
    for index in range(count - 2, -1, -1):
        projected[index] = np.minimum(
            projected[index], projected[index + 1] - minimum
        )
    changed = ~np.isclose(projected, surfaces, rtol=0.0, atol=1e-10)
    return projected, float(np.mean(changed))


[docs] @dataclass(frozen=True) class ElectricalLayer: """Define the electrical distribution of one stratigraphic unit. Parameters ---------- name : str Unique non-empty unit name. resistivity_ohm_m : float Positive median resistivity in ohm metres. log10_std : float, default=0.0 Non-negative standard deviation of within-unit log10 resistivity. heterogeneity : GaussianCorrelation or None, optional Spatial correlation model required when ``log10_std`` is positive. resistivity_bounds_ohm_m : tuple of float or None, optional Positive inclusive lower/upper clipping bounds. Examples -------- A homogeneous conductive cover: >>> cover = ElectricalLayer("conductive cover", 10.0) >>> cover.log10_resistivity 1.0 A heterogeneous basement: >>> basement = ElectricalLayer( ... "basement", ... 1000.0, ... log10_std=0.2, ... heterogeneity=GaussianCorrelation(1000, 200), ... resistivity_bounds_ohm_m=(100, 5000), ... ) """ name: str resistivity_ohm_m: float log10_std: float = 0.0 heterogeneity: GaussianCorrelation | None = None resistivity_bounds_ohm_m: tuple[float, float] | None = None def __post_init__(self) -> None: name = str(self.name).strip() resistivity = float(self.resistivity_ohm_m) deviation = float(self.log10_std) if not name: raise ValueError("name cannot be empty.") if not np.isfinite(resistivity) or resistivity <= 0: raise ValueError("resistivity_ohm_m must be finite and positive.") if not np.isfinite(deviation) or deviation < 0: raise ValueError("log10_std must be finite and non-negative.") if deviation > 0 and not isinstance( self.heterogeneity, GaussianCorrelation ): raise ValueError( "positive log10_std requires a GaussianCorrelation." ) if ( deviation == 0 and self.heterogeneity is not None and not isinstance(self.heterogeneity, GaussianCorrelation) ): raise TypeError( "heterogeneity must be a GaussianCorrelation or None." ) bounds = self.resistivity_bounds_ohm_m if bounds is not None: if len(bounds) != 2: raise ValueError( "resistivity_bounds_ohm_m must contain two values." ) bounds = (float(bounds[0]), float(bounds[1])) if ( not np.all(np.isfinite(bounds)) or bounds[0] <= 0 or bounds[0] >= bounds[1] ): raise ValueError( "resistivity bounds must be finite, positive, and increasing." ) if not bounds[0] <= resistivity <= bounds[1]: raise ValueError( "median resistivity must lie within resistivity bounds." ) object.__setattr__(self, "name", name) object.__setattr__(self, "resistivity_ohm_m", resistivity) object.__setattr__(self, "log10_std", deviation) object.__setattr__(self, "resistivity_bounds_ohm_m", bounds)
[docs] @property def log10_resistivity(self) -> float: """Return the median resistivity in log10 ohm metres. Returns ------- float ``log10(resistivity_ohm_m)``. Examples -------- >>> ElectricalLayer("unit", 100).log10_resistivity 2.0 """ return float(np.log10(self.resistivity_ohm_m))
[docs] def to_dict(self) -> dict[str, Any]: """Return a JSON-serializable unit definition. Returns ------- dict Electrical distribution and optional correlation state. Examples -------- >>> ElectricalLayer("unit", 100).to_dict()["resistivity_ohm_m"] 100.0 """ return { "schema_version": 1, "name": self.name, "resistivity_ohm_m": self.resistivity_ohm_m, "log10_std": self.log10_std, "heterogeneity": None if self.heterogeneity is None else self.heterogeneity.to_dict(), "resistivity_bounds_ohm_m": None if self.resistivity_bounds_ohm_m is None else list(self.resistivity_bounds_ohm_m), }
[docs] @classmethod def from_dict(cls, data: Mapping[str, Any]) -> ElectricalLayer: """Restore a validated electrical layer. Parameters ---------- data : mapping State returned by :meth:`to_dict`. Returns ------- ElectricalLayer Immutable unit definition. Examples -------- >>> unit = ElectricalLayer("unit", 50) >>> ElectricalLayer.from_dict(unit.to_dict()) == unit True """ if data.get("schema_version", 1) != 1: raise ValueError("unsupported ElectricalLayer schema version.") correlation = data.get("heterogeneity") return cls( data["name"], data["resistivity_ohm_m"], data.get("log10_std", 0.0), None if correlation is None else GaussianCorrelation.from_dict(correlation), data.get("resistivity_bounds_ohm_m"), )
[docs] @dataclass(frozen=True) class LayeredGeology: """Immutable discretized layered electrical geology. Parameters ---------- grid : GeologyGrid Target model grid. layers : sequence of ElectricalLayer Units ordered shallowest to deepest. interface_depth_m : ndarray Interface surfaces shaped ``(n_layer - 1, *horizontal_shape)``. layer_index : ndarray of int Zero-based unit index for every model cell, shaped like ``grid``. resistivity_ohm_m : ndarray Positive cell resistivities shaped like ``grid``. seed : int Root generation seed. boundary, interface_policy : str Recorded generation policies. minimum_thickness_m : float Enforced minimum vertical separation. adjusted_interface_fraction : float Fraction of interface samples changed by projection. generation_config : mapping, optional Finite JSON-compatible requested interface means, relief amplitudes, and correlation models needed to regenerate the surfaces. Examples -------- >>> grid = GeologyGrid.regular_2d(nx=8, nz=6, dx_m=100, dz_m=50) >>> layers = [ ... ElectricalLayer("cover", 10), ... ElectricalLayer("basement", 1000), ... ] >>> model = generate_layered_geology(grid, layers, [150], seed=1) >>> model.resistivity_ohm_m.shape (6, 8) >>> model.n_layers 2 """ grid: GeologyGrid layers: tuple[ElectricalLayer, ...] interface_depth_m: np.ndarray layer_index: np.ndarray resistivity_ohm_m: np.ndarray seed: int boundary: str interface_policy: str minimum_thickness_m: float adjusted_interface_fraction: float = 0.0 generation_config: Mapping[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: if not isinstance(self.grid, GeologyGrid): raise TypeError("grid must be a GeologyGrid.") layers = tuple(self.layers) if not layers or any( not isinstance(layer, ElectricalLayer) for layer in layers ): raise ValueError("layers must contain ElectricalLayer objects.") if len({layer.name for layer in layers}) != len(layers): raise ValueError("layer names must be unique.") horizontal = _horizontal_shape(self.grid) interfaces = np.asarray(self.interface_depth_m, dtype=float) expected_interfaces = (len(layers) - 1,) + horizontal if interfaces.shape != expected_interfaces or not np.all( np.isfinite(interfaces) ): raise ValueError( f"interface_depth_m must be finite and shaped {expected_interfaces}." ) indices = np.asarray(self.layer_index) if indices.shape != self.grid.shape or not np.issubdtype( indices.dtype, np.integer ): raise ValueError( f"layer_index must be an integer array shaped {self.grid.shape}." ) if np.any(indices < 0) or np.any(indices >= len(layers)): raise ValueError("layer_index contains an unavailable unit index.") resistivity = np.asarray(self.resistivity_ohm_m, dtype=float) if ( resistivity.shape != self.grid.shape or not np.all(np.isfinite(resistivity)) or np.any(resistivity <= 0) ): raise ValueError( f"resistivity_ohm_m must be finite, positive, and shaped {self.grid.shape}." ) minimum = float(self.minimum_thickness_m) adjusted = float(self.adjusted_interface_fraction) if not np.isfinite(minimum) or minimum <= 0: raise ValueError( "minimum_thickness_m must be finite and positive." ) if not np.isfinite(adjusted) or adjusted < 0 or adjusted > 1: raise ValueError("adjusted_interface_fraction must be in [0, 1].") if self.boundary not in {"reflect", "periodic"}: raise ValueError("boundary must be 'reflect' or 'periodic'.") if self.interface_policy not in {"raise", "project"}: raise ValueError("interface_policy must be 'raise' or 'project'.") generation = dict(self.generation_config) try: generation = json.loads( json.dumps(generation, sort_keys=True, allow_nan=False) ) except (TypeError, ValueError) as exc: raise ValueError( "generation_config must contain finite JSON-serializable values." ) from exc top, bottom = self.grid.extent_m["z"] boundaries = np.concatenate( [ np.full((1,) + horizontal, top), interfaces, np.full((1,) + horizontal, bottom), ], axis=0, ) if np.any(np.diff(boundaries, axis=0) < minimum - 1e-9): raise ValueError( "interfaces violate model boundaries or minimum thickness." ) depth = self.grid.z_m.reshape( (len(self.grid.z_m),) + (1,) * len(horizontal) ) expected_indices = np.zeros(self.grid.shape, dtype=np.int16) for surface in interfaces: expected_indices += depth >= surface if not np.array_equal(indices, expected_indices): raise ValueError( "layer_index is inconsistent with interface depths." ) for index, layer in enumerate(layers): values = resistivity[indices == index] if values.size == 0: continue if layer.log10_std == 0 and not np.allclose( values, layer.resistivity_ohm_m, rtol=1e-12, atol=0.0 ): raise ValueError( f"homogeneous layer {layer.name!r} has inconsistent resistivity values." ) if layer.resistivity_bounds_ohm_m is not None and ( np.any(values < layer.resistivity_bounds_ohm_m[0]) or np.any(values > layer.resistivity_bounds_ohm_m[1]) ): raise ValueError( f"layer {layer.name!r} violates its resistivity bounds." ) object.__setattr__(self, "layers", layers) object.__setattr__(self, "interface_depth_m", _readonly(interfaces)) object.__setattr__(self, "layer_index", _readonly(indices, np.int16)) object.__setattr__(self, "resistivity_ohm_m", _readonly(resistivity)) object.__setattr__(self, "seed", _seed(self.seed)) object.__setattr__(self, "minimum_thickness_m", minimum) object.__setattr__(self, "adjusted_interface_fraction", adjusted) object.__setattr__(self, "generation_config", _freeze_json(generation))
[docs] @property def n_layers(self) -> int: """Return the number of stratigraphic units. Returns ------- int Number of electrical layers. Examples -------- >>> grid = GeologyGrid.regular_2d(nx=2, nz=2, dx_m=1, dz_m=1) >>> model = generate_layered_geology( ... grid, ... [ElectricalLayer("a", 1), ElectricalLayer("b", 2)], ... [1], ... seed=0, ... ) >>> model.n_layers 2 """ return len(self.layers)
[docs] @property def model_hash(self) -> str: """Return a platform-stable digest of model values and provenance. Returns ------- str SHA-256 digest. Examples -------- >>> grid = GeologyGrid.regular_2d(nx=2, nz=2, dx_m=1, dz_m=1) >>> model = generate_layered_geology( ... grid, ... [ElectricalLayer("a", 1), ElectricalLayer("b", 2)], ... [1], ... seed=0, ... ) >>> len(model.model_hash) 64 """ digest = hashlib.sha256() digest.update( np.ascontiguousarray(self.resistivity_ohm_m, dtype="<f8").tobytes() ) digest.update( np.ascontiguousarray(self.layer_index, dtype="<i2").tobytes() ) digest.update( np.ascontiguousarray(self.interface_depth_m, dtype="<f8").tobytes() ) digest.update(canonical_hash(self.provenance()).encode("ascii")) return digest.hexdigest()
[docs] def layer_mask(self, layer: int | str) -> np.ndarray: """Return a read-only Boolean mask for one unit. Parameters ---------- layer : int or str Zero-based index or exact layer name. Returns ------- ndarray of bool Mask shaped like the geological grid. Raises ------ KeyError If a named unit is unavailable. IndexError If a numeric index is outside the layer range. Examples -------- >>> grid = GeologyGrid.regular_2d(nx=2, nz=2, dx_m=1, dz_m=1) >>> model = generate_layered_geology( ... grid, ... [ElectricalLayer("a", 1), ElectricalLayer("b", 2)], ... [1], ... seed=0, ... ) >>> model.layer_mask("a").shape (2, 2) """ if isinstance(layer, str): names = tuple(item.name for item in self.layers) try: index = names.index(layer) except ValueError as exc: raise KeyError(f"unknown layer {layer!r}.") from exc else: if not isinstance(layer, (int, np.integer)) or isinstance( layer, bool ): raise TypeError( "layer must be an integer index or exact name." ) index = int(layer) if index < 0 or index >= self.n_layers: raise IndexError( f"layer index {index} is outside [0, {self.n_layers})." ) return _readonly(self.layer_index == index, bool)
[docs] def interface(self, index: int) -> np.ndarray: """Return one read-only interface-depth surface. Parameters ---------- index : int Zero-based interface between layers ``index`` and ``index + 1``. Returns ------- ndarray Shape ``(nx,)`` in 2-D or ``(ny, nx)`` in 3-D. Examples -------- >>> grid = GeologyGrid.regular_2d(nx=3, nz=2, dx_m=1, dz_m=1) >>> model = generate_layered_geology( ... grid, ... [ElectricalLayer("a", 1), ElectricalLayer("b", 2)], ... [1], ... seed=0, ... ) >>> model.interface(0).shape (3,) """ if ( not isinstance(index, int) or isinstance(index, bool) or index < 0 or index >= self.n_layers - 1 ): raise IndexError( f"interface index must be in [0, {self.n_layers - 1})." ) return self.interface_depth_m[index]
[docs] def summary(self) -> dict[str, Any]: """Return compact JSON-compatible geological diagnostics. Returns ------- dict Shape, unit fractions, interface ranges, resistivity range, and interface-adjustment fraction. Examples -------- >>> grid = GeologyGrid.regular_2d(nx=2, nz=2, dx_m=1, dz_m=1) >>> model = generate_layered_geology( ... grid, ... [ElectricalLayer("a", 1), ElectricalLayer("b", 2)], ... [1], ... seed=0, ... ) >>> model.summary()["n_layers"] 2 """ return { "shape": list(self.grid.shape), "n_layers": self.n_layers, "layer_fractions": { layer.name: float(np.mean(self.layer_index == index)) for index, layer in enumerate(self.layers) }, "interface_ranges_m": [ [float(np.min(surface)), float(np.max(surface))] for surface in self.interface_depth_m ], "resistivity_range_ohm_m": [ float(np.min(self.resistivity_ohm_m)), float(np.max(self.resistivity_ohm_m)), ], "adjusted_interface_fraction": self.adjusted_interface_fraction, "generation_config": _thaw_json(self.generation_config), }
[docs] def provenance(self) -> dict[str, Any]: """Return generation provenance without cell arrays. Returns ------- dict Grid, layer definitions, seed, and interface policies. Examples -------- >>> grid = GeologyGrid.regular_2d(nx=2, nz=2, dx_m=1, dz_m=1) >>> model = generate_layered_geology( ... grid, ... [ElectricalLayer("a", 1), ElectricalLayer("b", 2)], ... [1], ... seed=0, ... ) >>> model.provenance()["seed"] 0 """ return { "schema_version": 1, "grid": self.grid.to_dict(), "layers": [layer.to_dict() for layer in self.layers], "seed": self.seed, "boundary": self.boundary, "interface_policy": self.interface_policy, "minimum_thickness_m": self.minimum_thickness_m, "adjusted_interface_fraction": self.adjusted_interface_fraction, }
[docs] def to_npz(self, path: str | Path) -> Path: """Persist the layered model in a pickle-free compressed archive. Parameters ---------- path : str or pathlib.Path Destination NPZ path. Returns ------- pathlib.Path Requested destination. Examples -------- >>> from tempfile import TemporaryDirectory >>> grid = GeologyGrid.regular_2d(nx=2, nz=2, dx_m=1, dz_m=1) >>> model = generate_layered_geology( ... grid, ... [ElectricalLayer("a", 1), ElectricalLayer("b", 2)], ... [1], ... seed=0, ... ) >>> with TemporaryDirectory() as directory: ... path = model.to_npz(Path(directory) / "layers.npz") ... restored = LayeredGeology.from_npz(path) >>> restored.model_hash == model.model_hash True """ target = Path(path) np.savez_compressed( target, interface_depth_m=self.interface_depth_m, layer_index=self.layer_index, resistivity_ohm_m=self.resistivity_ohm_m, provenance_json=np.array( json.dumps(self.provenance(), sort_keys=True) ), ) return target
[docs] @classmethod def from_npz(cls, path: str | Path) -> LayeredGeology: """Load and validate a layered model without enabling pickle. Parameters ---------- path : str or pathlib.Path Archive written by :meth:`to_npz`. Returns ------- LayeredGeology Immutable restored model. Examples -------- >>> from tempfile import TemporaryDirectory >>> model = generate_layered_geology( ... GeologyGrid.regular_2d(nx=2, nz=2, dx_m=1, dz_m=1), ... [ElectricalLayer("a", 1), ElectricalLayer("b", 2)], ... [1], ... seed=0, ... ) >>> with TemporaryDirectory() as directory: ... path = model.to_npz(Path(directory) / "m.npz") ... restored = LayeredGeology.from_npz(path) >>> np.array_equal(restored.layer_index, model.layer_index) True """ with np.load(Path(path), allow_pickle=False) as archive: state = json.loads(str(archive["provenance_json"].item())) if state.get("schema_version") != 1: raise ValueError("unsupported LayeredGeology schema version.") return cls( GeologyGrid.from_dict(state["grid"]), tuple( ElectricalLayer.from_dict(item) for item in state["layers"] ), archive["interface_depth_m"], archive["layer_index"], archive["resistivity_ohm_m"], state["seed"], state["boundary"], state["interface_policy"], state["minimum_thickness_m"], state["adjusted_interface_fraction"], state.get("generation_config", {}), )
[docs] def generate_layered_geology( grid: GeologyGrid, layers: Sequence[ElectricalLayer], mean_interface_depth_m: Sequence[float], *, seed: int, interface_relief_std_m: float | Sequence[float] = 0.0, interface_correlation: GaussianCorrelation | Sequence[GaussianCorrelation] | None = None, minimum_thickness_m: float | None = None, interface_policy: str = "project", boundary: str = "reflect", ) -> LayeredGeology: """Generate a correlated 2-D section or 3-D layered electrical volume. Parameters ---------- grid : GeologyGrid Regular target grid. layers : sequence of ElectricalLayer Units ordered shallowest to deepest. At least one is required. mean_interface_depth_m : sequence of float One mean depth for every adjacent layer pair. seed : int Explicit root seed. Labeled child seeds independently drive interfaces and within-layer heterogeneity. interface_relief_std_m : float or sequence, default=0.0 Interface depth standard deviations in metres. interface_correlation : GaussianCorrelation, sequence, or None, optional Horizontal correlation model(s). Required for every interface with positive relief. A single model is shared across interfaces. minimum_thickness_m : float or None, optional Minimum separation between top boundary, interfaces, and bottom boundary. Default is one vertical cell. interface_policy : {"project", "raise"}, default="project" Project invalid/crossing surfaces into the feasible domain or reject the realization. Projection fraction is recorded on the result. boundary : {"reflect", "periodic"}, default="reflect" Boundary policy for all correlated fields. Returns ------- LayeredGeology Immutable interfaces, unit indices, resistivity, and provenance. Raises ------ ValueError If layer/interface counts disagree, thickness is infeasible, relief lacks correlation, a 3-D correlation lacks ``length_y_m``, or strict interface validation fails. Examples -------- Laterally varying three-layer 2-D model: >>> grid = GeologyGrid.regular_2d(nx=24, nz=16, dx_m=100, dz_m=50) >>> units = [ ... ElectricalLayer("cover", 20), ... ElectricalLayer("sediments", 100), ... ElectricalLayer("basement", 1000), ... ] >>> model = generate_layered_geology( ... grid, ... units, ... [200, 500], ... seed=5, ... interface_relief_std_m=[30, 50], ... interface_correlation=GaussianCorrelation(600, 100), ... ) >>> model.interface_depth_m.shape (2, 24) >>> set(np.unique(model.layer_index)) <= {0, 1, 2} True """ if not isinstance(grid, GeologyGrid): raise TypeError("grid must be a GeologyGrid.") units = tuple(layers) if not units or any( not isinstance(layer, ElectricalLayer) for layer in units ): raise ValueError("layers must contain at least one ElectricalLayer.") if len({layer.name for layer in units}) != len(units): raise ValueError("layer names must be unique.") interface_count = len(units) - 1 means = np.asarray(mean_interface_depth_m, dtype=float) if means.shape != (interface_count,) or not np.all(np.isfinite(means)): raise ValueError( f"mean_interface_depth_m must contain {interface_count} finite values." ) if interface_count and not np.all(np.diff(means) > 0): raise ValueError("mean interface depths must be strictly increasing.") relief = _sequence( interface_relief_std_m, interface_count, "interface_relief_std_m" ) if np.any(relief < 0): raise ValueError( "interface relief standard deviations must be non-negative." ) if boundary not in {"reflect", "periodic"}: raise ValueError("boundary must be 'reflect' or 'periodic'.") if interface_policy not in {"project", "raise"}: raise ValueError("interface_policy must be 'project' or 'raise'.") seed = _seed(seed) dz = grid.spacing_m[0] minimum = dz if minimum_thickness_m is None else float(minimum_thickness_m) if not np.isfinite(minimum) or minimum <= 0: raise ValueError("minimum_thickness_m must be finite and positive.") top, bottom = grid.extent_m["z"] if minimum * len(units) > bottom - top + 1e-12: raise ValueError( "minimum thickness is infeasible for the grid depth and layer count." ) if interface_count == 0: correlations: tuple[GaussianCorrelation | None, ...] = () elif isinstance(interface_correlation, GaussianCorrelation): correlations = (interface_correlation,) * interface_count elif interface_correlation is None: correlations = (None,) * interface_count else: correlations = tuple(interface_correlation) if len(correlations) != interface_count or any( not isinstance(item, GaussianCorrelation) for item in correlations ): raise ValueError( f"interface_correlation must contain {interface_count} GaussianCorrelation objects." ) horizontal = _horizontal_shape(grid) surfaces = np.empty((interface_count,) + horizontal, dtype=float) for index in range(interface_count): if relief[index] == 0: surfaces[index].fill(means[index]) else: correlation = correlations[index] if correlation is None: raise ValueError( "positive interface relief requires interface_correlation." ) correlation.validate_grid(grid) surfaces[index] = means[index] + relief[ index ] * _horizontal_gaussian( grid, correlation, _child_seed(seed, f"interface/{index}"), boundary, ) projected, adjusted = _project_interfaces(surfaces, top, bottom, minimum) if interface_policy == "raise" and adjusted > 0: raise ValueError( "generated interfaces violate boundaries or minimum thickness." ) if interface_policy == "project": surfaces = projected depth_shape = (len(grid.z_m),) + (1,) * len(horizontal) depth = grid.z_m.reshape(depth_shape) indices = np.zeros(grid.shape, dtype=np.int16) for surface in surfaces: indices += depth >= surface resistivity = np.empty(grid.shape, dtype=float) for index, layer in enumerate(units): if layer.log10_std > 0: layer.heterogeneity.validate_grid(grid) field = generate_gaussian_field( grid, layer.heterogeneity, seed=_child_seed(seed, f"heterogeneity/{index}"), boundary=boundary, ).values values = np.power( 10.0, layer.log10_resistivity + layer.log10_std * field ) if layer.resistivity_bounds_ohm_m is not None: values = np.clip(values, *layer.resistivity_bounds_ohm_m) else: values = layer.resistivity_ohm_m mask = indices == index resistivity[mask] = values if np.isscalar(values) else values[mask] return LayeredGeology( grid, units, surfaces, indices, resistivity, seed, boundary, interface_policy, minimum, adjusted, { "mean_interface_depth_m": means.tolist(), "interface_relief_std_m": relief.tolist(), "interface_correlation": [ None if item is None else item.to_dict() for item in correlations ], }, )