Source code for pycsamt.forward.maxwell.mesh

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Construct solver meshes from geological models and topography.

The builder preserves the canonical ``(z, x)`` / ``(z, y, x)`` array order,
adds geometric padding and air layers, extends boundary conductivity by nearest
cells, and records earth/air regions explicitly.  It constructs meshes and
models only; discretization and boundary conditions remain backend concerns.
"""

from __future__ import annotations

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

import numpy as np

from .contracts import MaxwellMesh, MaxwellProblem, ReceiverSet

__all__ = [
    "MeshDesign",
    "MeshQuality",
    "SolverMeshModel",
    "skin_depth_m",
    "build_solver_mesh",
]


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


def _count(value: int, name: str) -> int:
    if not isinstance(value, (int, np.integer)) or isinstance(value, bool):
        raise TypeError(f"{name} must be an integer.")
    result = int(value)
    if result < 0:
        raise ValueError(f"{name} cannot be negative.")
    return result


def _positive(value: float, name: str, *, minimum: float = 0.0) -> float:
    result = float(value)
    if not np.isfinite(result) or result <= minimum:
        raise ValueError(f"{name} must be finite and greater than {minimum}.")
    return result


def _centres_to_edges(centres: np.ndarray, name: str) -> np.ndarray:
    values = np.asarray(centres, dtype=float)
    if values.ndim != 1 or len(values) < 2:
        raise ValueError(f"{name} must contain at least two centres.")
    if not np.all(np.isfinite(values)) or not np.all(np.diff(values) > 0):
        raise ValueError(f"{name} must be finite and strictly increasing.")
    middle = (values[:-1] + values[1:]) / 2.0
    first = values[0] - (middle[0] - values[0])
    last = values[-1] + (values[-1] - middle[-1])
    return np.concatenate([[first], middle, [last]])


def _pad_axis(
    edges: np.ndarray, before: int, after: int, expansion: float
) -> np.ndarray:
    left_widths = np.diff(edges)[0] * expansion ** np.arange(before, 0, -1)
    right_widths = np.diff(edges)[-1] * expansion ** np.arange(1, after + 1)
    left = edges[0] - np.cumsum(left_widths[::-1])[::-1]
    right = edges[-1] + np.cumsum(right_widths)
    return np.concatenate([left, edges, right])


def _nearest_indices(source: np.ndarray, target: np.ndarray) -> np.ndarray:
    insertion = np.searchsorted(source, target)
    insertion = np.clip(insertion, 1, len(source) - 1)
    left = insertion - 1
    choose_right = np.abs(target - source[insertion]) < np.abs(
        target - source[left]
    )
    return np.where(choose_right, insertion, left)


def _slice_state(value: slice) -> list[int]:
    return [int(value.start), int(value.stop)]


[docs] @dataclass(frozen=True) class MeshDesign: """Configure geometric padding, air treatment, and quality targets. Parameters ---------- horizontal_padding_cells : int or pair of int, default=6 Number of padding cells before/after each horizontal core axis. A scalar is applied symmetrically. bottom_padding_cells : int, default=8 Number of cells beneath the geological model. air_layers : int, default=8 Number of cells above the geological reference surface. padding_expansion : float, default=1.35 Geometric growth factor away from the core mesh. air_expansion : float, default=1.25 Geometric growth factor upward through the air. air_conductivity_s_m : float, default=1e-8 Positive numerical conductivity assigned to air cells. minimum_cells_per_skin_depth : float, default=4.0 Advisory resolution target evaluated at the smallest skin depth. maximum_adjacent_ratio : float, default=1.5 Advisory upper bound for adjacent cell-width ratios. maximum_aspect_ratio : float, default=20.0 Advisory upper bound across cell widths. Examples -------- >>> design = MeshDesign(horizontal_padding_cells=(3, 5), air_layers=4) >>> design.horizontal_padding (3, 5) """ horizontal_padding_cells: int | tuple[int, int] = 6 bottom_padding_cells: int = 8 air_layers: int = 8 padding_expansion: float = 1.35 air_expansion: float = 1.25 air_conductivity_s_m: float = 1e-8 minimum_cells_per_skin_depth: float = 4.0 maximum_adjacent_ratio: float = 1.5 maximum_aspect_ratio: float = 20.0 def __post_init__(self) -> None: padding = self.horizontal_padding_cells if isinstance(padding, (int, np.integer)) and not isinstance( padding, bool ): normalized = (_count(padding, "horizontal_padding_cells"),) * 2 else: try: normalized = tuple(padding) except TypeError as exc: raise TypeError( "horizontal_padding_cells must be an integer or pair." ) from exc if len(normalized) != 2: raise ValueError( "horizontal_padding_cells must contain two values." ) normalized = ( _count(normalized[0], "horizontal_padding_cells[0]"), _count(normalized[1], "horizontal_padding_cells[1]"), ) object.__setattr__(self, "horizontal_padding_cells", normalized) object.__setattr__( self, "bottom_padding_cells", _count(self.bottom_padding_cells, "bottom_padding_cells"), ) object.__setattr__( self, "air_layers", _count(self.air_layers, "air_layers") ) object.__setattr__( self, "padding_expansion", _positive( self.padding_expansion, "padding_expansion", minimum=1.0 - 1e-15, ), ) object.__setattr__( self, "air_expansion", _positive( self.air_expansion, "air_expansion", minimum=1.0 - 1e-15 ), ) object.__setattr__( self, "air_conductivity_s_m", _positive(self.air_conductivity_s_m, "air_conductivity_s_m"), ) object.__setattr__( self, "minimum_cells_per_skin_depth", _positive( self.minimum_cells_per_skin_depth, "minimum_cells_per_skin_depth", ), ) object.__setattr__( self, "maximum_adjacent_ratio", _positive( self.maximum_adjacent_ratio, "maximum_adjacent_ratio", minimum=1.0 - 1e-15, ), ) object.__setattr__( self, "maximum_aspect_ratio", _positive( self.maximum_aspect_ratio, "maximum_aspect_ratio", minimum=1.0 - 1e-15, ), )
[docs] @property def horizontal_padding(self) -> tuple[int, int]: """Return normalized before/after horizontal padding counts. Returns ------- tuple of int Padding cells on the low and high sides of each horizontal axis. Examples -------- >>> MeshDesign(horizontal_padding_cells=3).horizontal_padding (3, 3) """ return self.horizontal_padding_cells
[docs] def to_dict(self) -> dict[str, Any]: """Return a JSON-compatible design representation. Returns ------- dict Versioned design state. Examples -------- >>> MeshDesign(air_layers=2).to_dict()["air_layers"] 2 """ return { "schema_version": 1, "horizontal_padding_cells": list(self.horizontal_padding), "bottom_padding_cells": self.bottom_padding_cells, "air_layers": self.air_layers, "padding_expansion": self.padding_expansion, "air_expansion": self.air_expansion, "air_conductivity_s_m": self.air_conductivity_s_m, "minimum_cells_per_skin_depth": self.minimum_cells_per_skin_depth, "maximum_adjacent_ratio": self.maximum_adjacent_ratio, "maximum_aspect_ratio": self.maximum_aspect_ratio, }
[docs] @classmethod def from_dict(cls, data: Mapping[str, Any]) -> MeshDesign: """Restore a validated mesh design. Parameters ---------- data : mapping State returned by :meth:`to_dict`. Returns ------- MeshDesign Restored design. Examples -------- >>> design = MeshDesign(horizontal_padding_cells=2) >>> MeshDesign.from_dict(design.to_dict()).horizontal_padding (2, 2) """ if data.get("schema_version") != 1: raise ValueError("unsupported MeshDesign schema version.") values = dict(data) values.pop("schema_version") values["horizontal_padding_cells"] = tuple( values["horizontal_padding_cells"] ) return cls(**values)
[docs] @dataclass(frozen=True) class MeshQuality: """Summarize numerical mesh quality and skin-depth resolution. Parameters ---------- cell_count : int Total number of cells including air and padding. minimum_cell_width_m, maximum_cell_width_m : float Extreme cell widths over all axes. maximum_aspect_ratio : float Ratio of maximum to minimum cell width. maximum_adjacent_ratio : float Worst neighboring width expansion on any axis. minimum_skin_depth_m : float Smallest skin depth across the requested physics range. cells_per_minimum_skin_depth : float Skin depth divided by the largest core cell width. warnings : tuple of str Advisory quality violations. Examples -------- >>> quality = MeshQuality(10, 1, 5, 5, 1.2, 100, 20, ()) >>> quality.acceptable True """ cell_count: int minimum_cell_width_m: float maximum_cell_width_m: float maximum_aspect_ratio: float maximum_adjacent_ratio: float minimum_skin_depth_m: float cells_per_minimum_skin_depth: float warnings: tuple[str, ...] = () def __post_init__(self) -> None: cell_count = _count(self.cell_count, "cell_count") if cell_count < 1: raise ValueError("cell_count must be positive.") numeric_names = ( "minimum_cell_width_m", "maximum_cell_width_m", "maximum_aspect_ratio", "maximum_adjacent_ratio", "minimum_skin_depth_m", "cells_per_minimum_skin_depth", ) values = { name: _positive(getattr(self, name), name) for name in numeric_names } if values["maximum_cell_width_m"] < values["minimum_cell_width_m"]: raise ValueError( "maximum_cell_width_m cannot be smaller than minimum_cell_width_m." ) messages = tuple(str(value).strip() for value in self.warnings) if any(not value for value in messages): raise ValueError("warnings cannot contain empty messages.") object.__setattr__(self, "cell_count", cell_count) for name, value in values.items(): object.__setattr__(self, name, value) object.__setattr__(self, "warnings", messages)
[docs] @property def acceptable(self) -> bool: """Return whether no advisory quality limits were violated. Returns ------- bool True when :attr:`warnings` is empty. Examples -------- >>> MeshQuality(1, 1, 1, 1, 1, 1, 1, ("coarse",)).acceptable False """ return not self.warnings
[docs] def to_dict(self) -> dict[str, Any]: """Return JSON-compatible mesh-quality diagnostics. Returns ------- dict Numeric diagnostics and warnings. Examples -------- >>> MeshQuality(1, 1, 1, 1, 1, 1, 1).to_dict()["cell_count"] 1 """ return { "cell_count": self.cell_count, "minimum_cell_width_m": self.minimum_cell_width_m, "maximum_cell_width_m": self.maximum_cell_width_m, "maximum_aspect_ratio": self.maximum_aspect_ratio, "maximum_adjacent_ratio": self.maximum_adjacent_ratio, "minimum_skin_depth_m": self.minimum_skin_depth_m, "cells_per_minimum_skin_depth": self.cells_per_minimum_skin_depth, "warnings": list(self.warnings), }
[docs] @dataclass(frozen=True) class SolverMeshModel: """Store a padded mesh, conductivity, regions, and construction record. Parameters ---------- mesh : MaxwellMesh Solver-neutral padded mesh. conductivity_s_m : ndarray Positive conductivity shaped like ``mesh``. Air cells contain the configured small numerical conductivity. earth_mask : ndarray of bool True for cells on or below local terrain. core_slices : tuple of slice Geological core location in canonical array order. design : MeshDesign Construction settings. quality : MeshQuality Mesh-quality diagnostics for the requested frequency range. source_shape : tuple of int Original geological model shape. Examples -------- Instances are normally created with :func:`build_solver_mesh`. """ mesh: MaxwellMesh conductivity_s_m: np.ndarray earth_mask: np.ndarray core_slices: tuple[slice, ...] design: MeshDesign quality: MeshQuality source_shape: tuple[int, ...] def __post_init__(self) -> None: if ( not isinstance(self.mesh, MaxwellMesh) or not isinstance(self.design, MeshDesign) or not isinstance(self.quality, MeshQuality) ): raise TypeError( "mesh, design, and quality must use Maxwell mesh contract types." ) conductivity = np.asarray(self.conductivity_s_m, dtype=float) earth = np.asarray(self.earth_mask, dtype=bool) if ( conductivity.shape != self.mesh.shape or earth.shape != self.mesh.shape ): raise ValueError( "conductivity_s_m and earth_mask must have mesh shape." ) if not np.all(np.isfinite(conductivity)) or np.any(conductivity <= 0): raise ValueError("conductivity_s_m must be positive and finite.") if len(self.core_slices) != self.mesh.dimension or tuple( conductivity[self.core_slices].shape ) != tuple(self.source_shape): raise ValueError("core_slices must select exactly source_shape.") object.__setattr__(self, "conductivity_s_m", _readonly(conductivity)) object.__setattr__(self, "earth_mask", _readonly(earth, bool)) object.__setattr__(self, "core_slices", tuple(self.core_slices)) object.__setattr__( self, "source_shape", tuple(int(value) for value in self.source_shape), )
[docs] @property def air_mask(self) -> np.ndarray: """Return the read-only complement of the earth mask. Returns ------- ndarray of bool Air-region mask shaped like ``mesh``. Examples -------- ``air_mask`` and ``earth_mask`` always partition the complete mesh. """ return _readonly(~self.earth_mask, bool)
[docs] @property def model_hash(self) -> str: """Return a deterministic digest of mesh, model, regions, and design. Returns ------- str SHA-256 digest suitable for provenance checks. Examples -------- A valid model hash always contains 64 hexadecimal characters. """ digest = hashlib.sha256() digest.update( np.ascontiguousarray(self.conductivity_s_m, dtype="<f8").tobytes() ) digest.update( np.ascontiguousarray(self.earth_mask, dtype=np.uint8).tobytes() ) digest.update( json.dumps( self.provenance(), sort_keys=True, separators=(",", ":") ).encode("utf-8") ) return digest.hexdigest()
[docs] def assess_receivers(self, receivers: ReceiverSet) -> tuple[str, ...]: """Return receiver-placement errors without modifying coordinates. Parameters ---------- receivers : ReceiverSet Candidate locations in the mesh coordinate system. Returns ------- tuple of str Empty when all receivers lie inside mesh bounds and no receiver is below the discretized local terrain surface. Examples -------- Use this check before :meth:`to_problem` when receiver coordinates are assembled independently from the mesh. """ if not isinstance(receivers, ReceiverSet): raise TypeError("receivers must be a ReceiverSet.") if receivers.dimension != self.mesh.dimension: return ("receiver and mesh dimensions differ",) errors = [] coordinates = receivers.coordinates_m x = coordinates[:, 0] if np.any( (x < self.mesh.x_edges_m[0]) | (x > self.mesh.x_edges_m[-1]) ): errors.append("receiver x coordinates fall outside the mesh") if self.mesh.dimension == 3: y = coordinates[:, 1] z = coordinates[:, 2] if np.any( (y < self.mesh.y_edges_m[0]) | (y > self.mesh.y_edges_m[-1]) ): errors.append("receiver y coordinates fall outside the mesh") else: z = coordinates[:, 1] if np.any( (z < self.mesh.z_edges_m[0]) | (z > self.mesh.z_edges_m[-1]) ): errors.append("receiver z coordinates fall outside the mesh") if not errors: ix = np.clip( np.searchsorted(self.mesh.x_edges_m, x, side="right") - 1, 0, self.mesh.shape[-1] - 1, ) if self.mesh.dimension == 2: columns = self.earth_mask[:, ix] else: iy = np.clip( np.searchsorted(self.mesh.y_edges_m, y, side="right") - 1, 0, self.mesh.shape[1] - 1, ) columns = self.earth_mask[:, iy, ix] first_earth = np.argmax(columns, axis=0) surface_depth = self.mesh.z_edges_m[first_earth] tolerance = ( np.finfo(float).eps * np.maximum(1.0, np.abs(surface_depth)) * 16 ) if np.any(z > surface_depth + tolerance): errors.append( "receiver z coordinates fall below local terrain" ) return tuple(errors)
[docs] def to_problem( self, frequencies_hz: Sequence[float], receivers: ReceiverSet, *, components: Sequence[str] = ("zxy", "zyx"), mark_air_inactive: bool = False, time_dependence: str = "exp(+iwt)", magnetic_permeability_h_m: float = 4e-7 * np.pi, metadata: Mapping[str, Any] | None = None, ) -> MaxwellProblem: """Create a validated Maxwell problem from this mesh model. Parameters ---------- frequencies_hz : sequence of float Positive simulation frequencies. receivers : ReceiverSet Receiver locations matching the mesh dimension. components : sequence of str, default=("zxy", "zyx") Requested canonical impedance components. mark_air_inactive : bool, default=False Use ``earth_mask`` as active cells. Keep false for formulations that solve conductive air explicitly. time_dependence : str, default="exp(+iwt)" Complex phasor convention. magnetic_permeability_h_m : float, default=4e-7*pi Uniform magnetic permeability. metadata : mapping or None, optional Additional problem provenance. Returns ------- MaxwellProblem Solver-neutral problem ready for adapter assessment. Examples -------- The generated problem includes ``mesh_model_hash`` in its metadata. """ errors = self.assess_receivers(receivers) if errors: raise ValueError("; ".join(errors)) provenance = {} if metadata is None else dict(metadata) provenance["mesh_model_hash"] = self.model_hash provenance["air_treatment"] = ( "inactive" if mark_air_inactive else "conductive" ) return MaxwellProblem( self.mesh, self.conductivity_s_m, frequencies_hz, receivers, tuple(components), self.earth_mask if mark_air_inactive else np.ones(self.mesh.shape, bool), time_dependence, magnetic_permeability_h_m, provenance, )
[docs] def provenance(self) -> dict[str, Any]: """Return JSON-compatible mesh-construction provenance. Returns ------- dict Mesh, design, quality, source shape, and core slices. Examples -------- The returned schema version is currently one. """ return { "schema_version": 1, "mesh": self.mesh.to_dict(), "design": self.design.to_dict(), "quality": self.quality.to_dict(), "source_shape": list(self.source_shape), "core_slices": [_slice_state(value) for value in self.core_slices], }
[docs] def to_npz(self, path: str | Path) -> Path: """Persist a solver mesh model without enabling pickle. Parameters ---------- path : str or pathlib.Path Destination archive. Returns ------- pathlib.Path Requested destination. Examples -------- Archives can be restored with :meth:`from_npz`. """ target = Path(path) np.savez_compressed( target, conductivity_s_m=self.conductivity_s_m, earth_mask=self.earth_mask, provenance_json=np.array( json.dumps(self.provenance(), sort_keys=True) ), ) return target
[docs] @classmethod def from_npz(cls, path: str | Path) -> SolverMeshModel: """Restore and validate a solver mesh archive without pickle. Parameters ---------- path : str or pathlib.Path Archive written by :meth:`to_npz`. Returns ------- SolverMeshModel Restored immutable mesh model. Examples -------- Restored arrays remain read-only after construction. """ 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 SolverMeshModel schema version.") quality_state = dict(state["quality"]) quality_state["warnings"] = tuple( quality_state.get("warnings", ()) ) return cls( MaxwellMesh.from_dict(state["mesh"]), archive["conductivity_s_m"], archive["earth_mask"], tuple(slice(*value) for value in state["core_slices"]), MeshDesign.from_dict(state["design"]), MeshQuality(**quality_state), tuple(state["source_shape"]), )
[docs] def skin_depth_m(resistivity_ohm_m: Any, frequency_hz: Any) -> np.ndarray: """Calculate electromagnetic skin depth for a non-magnetic conductor. Parameters ---------- resistivity_ohm_m, frequency_hz : array-like Positive resistivity and frequency, broadcast using NumPy rules. Returns ------- ndarray Skin depth in metres, ``sqrt(rho / (pi * mu0 * f))``. Examples -------- >>> round(float(skin_depth_m(100, 1))) 5033 >>> skin_depth_m([100, 400], 1).shape (2,) """ resistivity = np.asarray(resistivity_ohm_m, dtype=float) frequency = np.asarray(frequency_hz, dtype=float) if not np.all(np.isfinite(resistivity)) or np.any(resistivity <= 0): raise ValueError("resistivity_ohm_m must be positive and finite.") if not np.all(np.isfinite(frequency)) or np.any(frequency <= 0): raise ValueError("frequency_hz must be positive and finite.") return np.sqrt(resistivity / (np.pi * (4e-7 * np.pi) * frequency))
[docs] def build_solver_mesh( grid: Any, *, conductivity_s_m: Any | None = None, resistivity_ohm_m: Any | None = None, frequencies_hz: Sequence[float], topography: Any | None = None, design: MeshDesign | None = None, ) -> SolverMeshModel: """Build a padded Maxwell mesh from a geological cell-centre model. Parameters ---------- grid : pycsamt.ai.geology.GeologyGrid Source grid in canonical geological order. Its upper cell edge must be depth zero, the reference used by topography and receiver coordinates. conductivity_s_m, resistivity_ohm_m : array-like or None Supply exactly one positive model shaped like ``grid``. frequencies_hz : sequence of float Frequencies used only for skin-depth quality diagnostics. topography : pycsamt.ai.geology.TopographicSurface or None, optional Terrain aligned to ``grid``. When omitted, the geological top edge is treated as a flat earth surface. design : MeshDesign or None, optional Padding and quality configuration. Returns ------- SolverMeshModel Padded conductivity, earth/air regions, core mapping, and diagnostics. Examples -------- >>> from pycsamt.ai.geology import GeologyGrid >>> grid = GeologyGrid.regular_2d(nx=3, nz=2, dx_m=100, dz_m=50) >>> model = build_solver_mesh( ... grid, ... resistivity_ohm_m=np.full(grid.shape, 100), ... frequencies_hz=[10, 1], ... design=MeshDesign( ... horizontal_padding_cells=1, ... bottom_padding_cells=1, ... air_layers=1, ... ), ... ) >>> model.mesh.shape, model.core_slices ((4, 5), (slice(1, 3, None), slice(1, 4, None))) """ from ...ai.geology import GeologyGrid, TopographicSurface if not isinstance(grid, GeologyGrid): raise TypeError("grid must be a GeologyGrid.") if (conductivity_s_m is None) == (resistivity_ohm_m is None): raise ValueError( "supply exactly one of conductivity_s_m or resistivity_ohm_m." ) source = np.asarray( conductivity_s_m if conductivity_s_m is not None else resistivity_ohm_m, dtype=float, ) if ( source.shape != grid.shape or not np.all(np.isfinite(source)) or np.any(source <= 0) ): raise ValueError( f"the source property must be positive, finite, and shaped {grid.shape}." ) source_conductivity = ( source if conductivity_s_m is not None else 1.0 / source ) frequencies = np.asarray(frequencies_hz, dtype=float) if ( frequencies.ndim != 1 or len(frequencies) < 1 or not np.all(np.isfinite(frequencies)) or np.any(frequencies <= 0) ): raise ValueError( "frequencies_hz must be a non-empty positive finite vector." ) configuration = MeshDesign() if design is None else design if not isinstance(configuration, MeshDesign): raise TypeError("design must be a MeshDesign or None.") aligned_topography = isinstance(topography, TopographicSurface) and ( topography.grid.dimension == grid.dimension and topography.grid.crs == grid.crs and np.array_equal(topography.grid.x_m, grid.x_m) and np.array_equal(topography.grid.z_m, grid.z_m) and ( (topography.grid.y_m is None and grid.y_m is None) or np.array_equal(topography.grid.y_m, grid.y_m) ) ) if topography is not None and not aligned_topography: raise ValueError( "topography must be a TopographicSurface aligned to grid." ) before, after = configuration.horizontal_padding x_core_edges = _centres_to_edges(grid.x_m, "grid.x_m") x_edges = _pad_axis( x_core_edges, before, after, configuration.padding_expansion ) z_core_edges = _centres_to_edges(grid.z_m, "grid.z_m") if not np.isclose( z_core_edges[0], 0.0, rtol=0.0, atol=max(1e-10, abs(np.diff(z_core_edges)[0]) * 1e-10), ): raise ValueError("grid's upper cell edge must be depth zero.") z_earth_edges = _pad_axis( z_core_edges, 0, configuration.bottom_padding_cells, configuration.padding_expansion, ) if configuration.air_layers: first_width = np.diff(z_core_edges)[0] upward_widths = first_width * configuration.air_expansion ** np.arange( configuration.air_layers ) air_edges = z_core_edges[0] - np.cumsum(upward_widths)[::-1] z_edges = np.concatenate([air_edges, z_earth_edges]) else: z_edges = z_earth_edges y_edges = None if grid.dimension == 3: y_core_edges = _centres_to_edges(grid.y_m, "grid.y_m") y_edges = _pad_axis( y_core_edges, before, after, configuration.padding_expansion ) mesh = MaxwellMesh(x_edges, z_edges, y_edges, grid.crs) centres = mesh.cell_centres_m ix = _nearest_indices(grid.x_m, centres["x"]) iz = _nearest_indices(grid.z_m, centres["z"]) if grid.dimension == 2: mapped = source_conductivity[np.ix_(iz, ix)] surface_core = ( np.full(len(grid.x_m), z_core_edges[0]) if topography is None else topography.surface_depth_m ) surface = np.interp( centres["x"], grid.x_m, surface_core, left=surface_core[0], right=surface_core[-1], ) earth = centres["z"][:, None] >= surface[None, :] core_slices = ( slice( configuration.air_layers, configuration.air_layers + len(grid.z_m), ), slice(before, before + len(grid.x_m)), ) else: iy = _nearest_indices(grid.y_m, centres["y"]) mapped = source_conductivity[np.ix_(iz, iy, ix)] surface_core = ( np.full((len(grid.y_m), len(grid.x_m)), z_core_edges[0]) if topography is None else topography.surface_depth_m ) surface_x = np.vstack( [ np.interp( centres["x"], grid.x_m, row, left=row[0], right=row[-1] ) for row in surface_core ] ) surface = np.vstack( [ np.interp( centres["y"], grid.y_m, surface_x[:, column], left=surface_x[0, column], right=surface_x[-1, column], ) for column in range(surface_x.shape[1]) ] ).T earth = centres["z"][:, None, None] >= surface[None, :, :] core_slices = ( slice( configuration.air_layers, configuration.air_layers + len(grid.z_m), ), slice(before, before + len(grid.y_m)), slice(before, before + len(grid.x_m)), ) mapped = np.where(earth, mapped, configuration.air_conductivity_s_m) widths = mesh.cell_widths_m flat_widths = np.concatenate(tuple(widths.values())) adjacent = [] for values in widths.values(): if len(values) > 1: ratios = values[1:] / values[:-1] adjacent.extend(np.maximum(ratios, 1.0 / ratios)) maximum_adjacent = float(max(adjacent, default=1.0)) minimum_skin = float( np.min(skin_depth_m(1.0 / source_conductivity, np.max(frequencies))) ) core_widths = [ np.max(np.diff(x_core_edges)), np.max(np.diff(z_core_edges)), ] if grid.dimension == 3: core_widths.append(np.max(np.diff(y_core_edges))) cells_per_skin = minimum_skin / float(max(core_widths)) aspect = float(np.max(flat_widths) / np.min(flat_widths)) quality_warnings = [] if maximum_adjacent > configuration.maximum_adjacent_ratio: quality_warnings.append( f"adjacent cell ratio {maximum_adjacent:.3g} exceeds {configuration.maximum_adjacent_ratio:.3g}" ) if aspect > configuration.maximum_aspect_ratio: quality_warnings.append( f"global cell-width ratio {aspect:.3g} exceeds {configuration.maximum_aspect_ratio:.3g}" ) if cells_per_skin < configuration.minimum_cells_per_skin_depth: quality_warnings.append( f"minimum skin depth has {cells_per_skin:.3g} core cells; target is {configuration.minimum_cells_per_skin_depth:.3g}" ) quality = MeshQuality( int(np.prod(mesh.shape)), float(np.min(flat_widths)), float(np.max(flat_widths)), aspect, maximum_adjacent, minimum_skin, float(cells_per_skin), tuple(quality_warnings), ) return SolverMeshModel( mesh, mapped, earth, core_slices, configuration, quality, grid.shape )