Source code for pycsamt.forward.maxwell.benchmarks

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Canonical analytic benchmarks for Maxwell backend validation.

The module defines executable benchmark cases rather than certifying any
backend by name. A backend becomes verified only when stored benchmark
outcomes identify its exact version, problem hashes, thresholds, and metrics.
"""

from __future__ import annotations

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

import numpy as np

from .backends import MaxwellBackend
from .contracts import ForwardResult, MaxwellMesh, MaxwellProblem, ReceiverSet

__all__ = [
    "BenchmarkThresholds",
    "BenchmarkMetrics",
    "BenchmarkOutcome",
    "MaxwellBenchmark",
    "BenchmarkReport",
    "half_space_impedance",
    "layered_earth_impedance",
    "half_space_benchmark",
    "layered_earth_benchmark",
    "run_benchmarks",
]

_MU0 = 4.0e-7 * np.pi
_COMPONENTS = ("zxx", "zxy", "zyx", "zyy")


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 _json_mapping(value: Mapping[str, Any]) -> Mapping[str, Any]:
    try:
        encoded = json.dumps(dict(value), sort_keys=True, allow_nan=False)
    except (TypeError, ValueError) as exc:
        raise ValueError(
            "metadata must contain finite JSON-compatible values."
        ) from exc
    return MappingProxyType(json.loads(encoded))


def _positive(value: float, name: str, *, allow_zero: bool = False) -> float:
    result = float(value)
    lower_ok = result >= 0 if allow_zero else result > 0
    if not np.isfinite(result) or not lower_ok:
        qualifier = "non-negative" if allow_zero else "positive"
        raise ValueError(f"{name} must be finite and {qualifier}.")
    return result


[docs] @dataclass(frozen=True) class BenchmarkThresholds: """Define quantitative acceptance limits for one benchmark. Parameters ---------- maximum_normalized_rms : float, default=0.05 Maximum complex root-sum-square error normalized by the reference. maximum_amplitude_relative_error : float, default=0.05 Maximum pointwise relative impedance-amplitude error. maximum_phase_error_deg : float, default=2.0 Maximum absolute circular phase error in degrees. minimum_valid_fraction : float, default=1.0 Minimum fraction of output values marked valid. require_convergence : bool, default=True Require every solve in backend diagnostics to converge. Examples -------- >>> limits = BenchmarkThresholds(maximum_phase_error_deg=1) >>> limits.maximum_phase_error_deg 1.0 """ maximum_normalized_rms: float = 0.05 maximum_amplitude_relative_error: float = 0.05 maximum_phase_error_deg: float = 2.0 minimum_valid_fraction: float = 1.0 require_convergence: bool = True def __post_init__(self) -> None: object.__setattr__( self, "maximum_normalized_rms", _positive( self.maximum_normalized_rms, "maximum_normalized_rms", allow_zero=True, ), ) object.__setattr__( self, "maximum_amplitude_relative_error", _positive( self.maximum_amplitude_relative_error, "maximum_amplitude_relative_error", allow_zero=True, ), ) object.__setattr__( self, "maximum_phase_error_deg", _positive( self.maximum_phase_error_deg, "maximum_phase_error_deg", allow_zero=True, ), ) valid = float(self.minimum_valid_fraction) if not np.isfinite(valid) or valid < 0 or valid > 1: raise ValueError("minimum_valid_fraction must be in [0, 1].") object.__setattr__(self, "minimum_valid_fraction", valid)
[docs] def to_dict(self) -> dict[str, Any]: """Return JSON-compatible acceptance limits. Returns ------- dict Versioned threshold state. Examples -------- >>> BenchmarkThresholds().to_dict()["schema_version"] 1 """ return { "schema_version": 1, "maximum_normalized_rms": self.maximum_normalized_rms, "maximum_amplitude_relative_error": ( self.maximum_amplitude_relative_error ), "maximum_phase_error_deg": self.maximum_phase_error_deg, "minimum_valid_fraction": self.minimum_valid_fraction, "require_convergence": self.require_convergence, }
[docs] @classmethod def from_dict(cls, data: Mapping[str, Any]) -> BenchmarkThresholds: """Restore validated benchmark thresholds. Parameters ---------- data : mapping State returned by :meth:`to_dict`. Returns ------- BenchmarkThresholds Restored limits. Examples -------- >>> limits = BenchmarkThresholds(maximum_normalized_rms=0.1) >>> BenchmarkThresholds.from_dict(limits.to_dict()) == limits True """ if data.get("schema_version") != 1: raise ValueError("unsupported BenchmarkThresholds schema version.") values = dict(data) values.pop("schema_version") return cls(**values)
[docs] @dataclass(frozen=True) class BenchmarkMetrics: """Store errors measured against one analytic reference. Parameters ---------- normalized_rms : float Complex normalized root-sum-square error. maximum_amplitude_relative_error : float Worst pointwise relative amplitude error. maximum_phase_error_deg : float Worst circular phase difference. valid_fraction : float Fraction of output values marked valid and finite. converged : bool Whether all backend solves converged. Examples -------- >>> BenchmarkMetrics(0.01, 0.02, 0.5, 1, True).converged True """ normalized_rms: float maximum_amplitude_relative_error: float maximum_phase_error_deg: float valid_fraction: float converged: bool def __post_init__(self) -> None: names = ( "normalized_rms", "maximum_amplitude_relative_error", "maximum_phase_error_deg", ) for name in names: value = _positive(getattr(self, name), name, allow_zero=True) object.__setattr__(self, name, value) valid = float(self.valid_fraction) if not np.isfinite(valid) or valid < 0 or valid > 1: raise ValueError("valid_fraction must be in [0, 1].") object.__setattr__(self, "valid_fraction", valid) object.__setattr__(self, "converged", bool(self.converged))
[docs] def to_dict(self) -> dict[str, Any]: """Return JSON-compatible benchmark metrics. Returns ------- dict Error values, validity, and convergence status. Examples -------- >>> BenchmarkMetrics(0, 0, 0, 1, True).to_dict()["valid_fraction"] 1.0 """ return { "normalized_rms": self.normalized_rms, "maximum_amplitude_relative_error": ( self.maximum_amplitude_relative_error ), "maximum_phase_error_deg": self.maximum_phase_error_deg, "valid_fraction": self.valid_fraction, "converged": self.converged, }
[docs] @dataclass(frozen=True) class BenchmarkOutcome: """Record the auditable outcome of one backend benchmark. Parameters ---------- benchmark_name, benchmark_hash : str Stable case identity and full content digest. backend_name, backend_version : str Exact adapter identity from the result. passed : bool Whether every configured acceptance criterion passed. metrics : BenchmarkMetrics Quantitative comparison with the reference. failures : tuple of str Human-readable failed criteria. Examples -------- >>> metrics = BenchmarkMetrics(0, 0, 0, 1, True) >>> outcome = BenchmarkOutcome( ... "half-space", "0" * 64, "demo", "1", True, metrics ... ) >>> outcome.passed True """ benchmark_name: str benchmark_hash: str backend_name: str backend_version: str passed: bool metrics: BenchmarkMetrics failures: tuple[str, ...] = () def __post_init__(self) -> None: name = str(self.benchmark_name).strip() backend = str(self.backend_name).strip() version = str(self.backend_version).strip() digest = str(self.benchmark_hash).strip().lower() if not name or not backend or not version: raise ValueError("benchmark and backend identity cannot be empty.") if len(digest) != 64 or any( value not in "0123456789abcdef" for value in digest ): raise ValueError("benchmark_hash must be a SHA-256 digest.") if not isinstance(self.metrics, BenchmarkMetrics): raise TypeError("metrics must be BenchmarkMetrics.") failures = tuple(str(value).strip() for value in self.failures) if any(not value for value in failures): raise ValueError("failures cannot contain empty messages.") if bool(self.passed) == bool(failures): raise ValueError( "passed must be true exactly when failures is empty." ) object.__setattr__(self, "benchmark_name", name) object.__setattr__(self, "benchmark_hash", digest) object.__setattr__(self, "backend_name", backend) object.__setattr__(self, "backend_version", version) object.__setattr__(self, "passed", bool(self.passed)) object.__setattr__(self, "failures", failures)
[docs] def to_dict(self) -> dict[str, Any]: """Return a JSON-compatible benchmark outcome. Returns ------- dict Case identity, backend identity, metrics, and failures. Examples -------- >>> metrics = BenchmarkMetrics(0, 0, 0, 1, True) >>> value = BenchmarkOutcome( ... "case", "0" * 64, "demo", "1", True, metrics ... ) >>> value.to_dict()["passed"] True """ return { "benchmark_name": self.benchmark_name, "benchmark_hash": self.benchmark_hash, "backend_name": self.backend_name, "backend_version": self.backend_version, "passed": self.passed, "metrics": self.metrics.to_dict(), "failures": list(self.failures), }
[docs] @dataclass(frozen=True) class MaxwellBenchmark: """Define one immutable problem and its expected impedance. Parameters ---------- name, description : str Stable identifier and scientific purpose. problem : MaxwellProblem Exact solver input. reference_impedance_v_a : complex ndarray Analytic reference with canonical problem output shape. thresholds : BenchmarkThresholds, optional Quantitative acceptance criteria. tags : sequence of str, optional Searchable labels such as ``analytic`` and ``half-space``. metadata : mapping, optional Finite JSON-compatible provenance. Examples -------- Cases are normally built with :func:`half_space_benchmark` or :func:`layered_earth_benchmark`. """ name: str description: str problem: MaxwellProblem reference_impedance_v_a: np.ndarray thresholds: BenchmarkThresholds = BenchmarkThresholds() tags: tuple[str, ...] = () metadata: Mapping[str, Any] = field( default_factory=lambda: MappingProxyType({}) ) def __post_init__(self) -> None: name = str(self.name).strip().lower().replace("_", "-") description = str(self.description).strip() if not name or not description: raise ValueError("name and description cannot be empty.") if not isinstance(self.problem, MaxwellProblem): raise TypeError("problem must be a MaxwellProblem.") if not isinstance(self.thresholds, BenchmarkThresholds): raise TypeError("thresholds must be BenchmarkThresholds.") reference = np.asarray(self.reference_impedance_v_a, dtype=complex) expected = ( self.problem.receivers.count, len(self.problem.frequencies_hz), len(self.problem.components), ) if reference.shape != expected: raise ValueError( f"reference_impedance_v_a must have shape {expected}." ) if not np.all(np.isfinite(reference)): raise ValueError("reference_impedance_v_a must be finite.") if np.any(np.abs(reference) == 0): raise ValueError("reference impedance magnitudes must be nonzero.") tags = tuple(str(value).strip().lower() for value in self.tags) if len(set(tags)) != len(tags) or any(not value for value in tags): raise ValueError("tags must contain unique non-empty values.") object.__setattr__(self, "name", name) object.__setattr__(self, "description", description) object.__setattr__( self, "reference_impedance_v_a", _readonly(reference) ) object.__setattr__(self, "tags", tags) object.__setattr__(self, "metadata", _json_mapping(self.metadata))
[docs] @property def benchmark_hash(self) -> str: """Return a deterministic digest of case inputs and thresholds. Returns ------- str SHA-256 benchmark identity. Examples -------- Benchmark hashes contain 64 hexadecimal characters. """ digest = hashlib.sha256() reference = np.ascontiguousarray( self.reference_impedance_v_a, dtype="<c16", ) digest.update(reference.tobytes()) digest.update( json.dumps( self.provenance(), sort_keys=True, separators=(",", ":"), ).encode("utf-8") ) return digest.hexdigest()
[docs] def evaluate(self, result: ForwardResult) -> BenchmarkOutcome: """Compare one canonical result with the analytic reference. Parameters ---------- result : ForwardResult Backend result for this exact problem. Returns ------- BenchmarkOutcome Metrics and every failed acceptance criterion. Raises ------ ValueError If the result belongs to a different problem or output axes. Examples -------- Exact references produce zero error when wrapped as backend results. """ if not isinstance(result, ForwardResult): raise TypeError("result must be a ForwardResult.") result.validate_against(self.problem) finite = np.isfinite(result.impedance_v_a.real) finite &= np.isfinite(result.impedance_v_a.imag) valid = result.valid & finite valid_fraction = float(np.mean(valid)) if np.any(valid): actual = result.impedance_v_a[valid] expected = self.reference_impedance_v_a[valid] difference = actual - expected denominator = float(np.sum(np.abs(expected) ** 2)) normalized_rms = float( np.sqrt(np.sum(np.abs(difference) ** 2) / denominator) ) amplitude_error = np.abs(np.abs(actual) - np.abs(expected)) amplitude_error /= np.abs(expected) maximum_amplitude = float(np.max(amplitude_error)) phase_error = np.angle(actual / expected, deg=True) maximum_phase = float(np.max(np.abs(phase_error))) else: unavailable = float(np.finfo(float).max) normalized_rms = unavailable maximum_amplitude = unavailable maximum_phase = unavailable metrics = BenchmarkMetrics( normalized_rms, maximum_amplitude, maximum_phase, valid_fraction, result.diagnostics.success, ) failures = self._failures(metrics) return BenchmarkOutcome( self.name, self.benchmark_hash, result.backend_name, result.backend_version, not failures, metrics, failures, )
[docs] def run(self, backend: MaxwellBackend) -> BenchmarkOutcome: """Execute and evaluate this case with a conforming backend. Parameters ---------- backend : MaxwellBackend Backend compatible with the benchmark problem. Returns ------- BenchmarkOutcome Auditable validation outcome. Examples -------- Backend exceptions propagate so infrastructure failures cannot be mistaken for numerical benchmark failures. """ if not isinstance(backend, MaxwellBackend): raise TypeError("backend must implement MaxwellBackend.") return self.evaluate(backend.solve(self.problem))
[docs] def provenance(self) -> dict[str, Any]: """Return JSON-compatible benchmark provenance. Returns ------- dict Case identity, problem hash, limits, tags, and metadata. Examples -------- The full conductivity model remains identified by ``problem_hash``. """ return { "schema_version": 1, "name": self.name, "description": self.description, "problem_hash": self.problem.problem_hash, "thresholds": self.thresholds.to_dict(), "tags": list(self.tags), "metadata": dict(self.metadata), }
def _failures(self, metrics: BenchmarkMetrics) -> tuple[str, ...]: failures = [] limits = self.thresholds if metrics.normalized_rms > limits.maximum_normalized_rms: failures.append( "normalized RMS " f"{metrics.normalized_rms:.6g} exceeds " f"{limits.maximum_normalized_rms:.6g}" ) maximum_amplitude = metrics.maximum_amplitude_relative_error if maximum_amplitude > limits.maximum_amplitude_relative_error: failures.append( "maximum amplitude relative error " f"{maximum_amplitude:.6g} exceeds " f"{limits.maximum_amplitude_relative_error:.6g}" ) if metrics.maximum_phase_error_deg > limits.maximum_phase_error_deg: failures.append( "maximum phase error " f"{metrics.maximum_phase_error_deg:.6g} deg exceeds " f"{limits.maximum_phase_error_deg:.6g} deg" ) if metrics.valid_fraction < limits.minimum_valid_fraction: failures.append( f"valid fraction {metrics.valid_fraction:.6g} is below " f"{limits.minimum_valid_fraction:.6g}" ) if limits.require_convergence and not metrics.converged: failures.append("one or more solver systems did not converge") return tuple(failures)
[docs] @dataclass(frozen=True) class BenchmarkReport: """Aggregate ordered outcomes from one backend benchmark run. Parameters ---------- outcomes : sequence of BenchmarkOutcome Non-empty ordered outcomes from one backend version. Examples -------- >>> metrics = BenchmarkMetrics(0, 0, 0, 1, True) >>> outcome = BenchmarkOutcome( ... "case", "0" * 64, "demo", "1", True, metrics ... ) >>> BenchmarkReport((outcome,)).passed True """ outcomes: tuple[BenchmarkOutcome, ...] def __post_init__(self) -> None: outcomes = tuple(self.outcomes) if not outcomes or any( not isinstance(value, BenchmarkOutcome) for value in outcomes ): raise ValueError("outcomes must contain BenchmarkOutcome values.") identities = { (value.backend_name, value.backend_version) for value in outcomes } if len(identities) != 1: raise ValueError("all outcomes must use one backend version.") names = [value.benchmark_name for value in outcomes] if len(set(names)) != len(names): raise ValueError("benchmark names must be unique in a report.") object.__setattr__(self, "outcomes", outcomes)
[docs] @property def passed(self) -> bool: """Return whether every benchmark passed. Returns ------- bool Aggregate acceptance status. Examples -------- A report fails if any contained outcome fails. """ return all(value.passed for value in self.outcomes)
[docs] @property def pass_fraction(self) -> float: """Return the fraction of cases that passed. Returns ------- float Passed case count divided by total cases. Examples -------- The value lies in the closed interval ``[0, 1]``. """ return sum(value.passed for value in self.outcomes) / len( self.outcomes )
[docs] def to_dict(self) -> dict[str, Any]: """Return a JSON-compatible benchmark report. Returns ------- dict Backend identity, summary, and ordered outcomes. Examples -------- Serialized reports retain every individual failure message. """ first = self.outcomes[0] return { "schema_version": 1, "backend_name": first.backend_name, "backend_version": first.backend_version, "passed": self.passed, "pass_fraction": self.pass_fraction, "outcomes": [value.to_dict() for value in self.outcomes], }
[docs] def half_space_impedance( resistivity_ohm_m: float, frequencies_hz: Any, *, time_dependence: str = "exp(+iwt)", ) -> np.ndarray: """Return analytic plane-wave impedance of a uniform half-space. Parameters ---------- resistivity_ohm_m : float Positive half-space resistivity. frequencies_hz : array-like Positive frequencies. time_dependence : {"exp(+iwt)", "exp(-iwt)"} Complex phasor convention. Returns ------- ndarray of complex ``sqrt(i*omega*mu0*rho)`` or its conjugate convention. Examples -------- >>> round(float(np.angle(half_space_impedance(100, 1), deg=True))) 45 """ resistivity = _positive(resistivity_ohm_m, "resistivity_ohm_m") frequencies = np.asarray(frequencies_hz, dtype=float) if not np.all(np.isfinite(frequencies)) or np.any(frequencies <= 0): raise ValueError("frequencies_hz must be positive and finite.") if time_dependence not in {"exp(+iwt)", "exp(-iwt)"}: raise ValueError("unsupported time_dependence.") impedance = np.sqrt(1j * 2 * np.pi * frequencies * _MU0 * resistivity) if time_dependence == "exp(-iwt)": impedance = np.conjugate(impedance) return _readonly(impedance)
[docs] def layered_earth_impedance( resistivity_ohm_m: Sequence[float], thickness_m: Sequence[float], frequencies_hz: Any, *, time_dependence: str = "exp(+iwt)", ) -> np.ndarray: """Return analytic 1-D MT impedance by upward layer recursion. Parameters ---------- resistivity_ohm_m : sequence of float Layer resistivities from surface to basal half-space. thickness_m : sequence of float Thickness of every layer except the basal half-space. frequencies_hz : array-like Positive evaluation frequencies. time_dependence : {"exp(+iwt)", "exp(-iwt)"} Complex phasor convention. Returns ------- ndarray of complex Surface impedance in frequency order. Examples -------- A single layer reduces exactly to the half-space expression: >>> np.allclose( ... layered_earth_impedance([100], [], [1, 10]), ... half_space_impedance(100, [1, 10]), ... ) True """ resistivity = np.asarray(resistivity_ohm_m, dtype=float) thickness = np.asarray(thickness_m, dtype=float) frequencies = np.asarray(frequencies_hz, dtype=float) if resistivity.ndim != 1 or len(resistivity) < 1: raise ValueError("resistivity_ohm_m must be a non-empty vector.") if thickness.shape != (len(resistivity) - 1,): raise ValueError("thickness_m must omit only the basal half-space.") 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(thickness)) or np.any(thickness <= 0): raise ValueError("thickness_m must be positive and finite.") if frequencies.ndim != 1 or len(frequencies) < 1: raise ValueError("frequencies_hz must be a non-empty vector.") if not np.all(np.isfinite(frequencies)) or np.any(frequencies <= 0): raise ValueError("frequencies_hz must be positive and finite.") if time_dependence not in {"exp(+iwt)", "exp(-iwt)"}: raise ValueError("unsupported time_dependence.") output = np.empty(len(frequencies), dtype=complex) for index, frequency in enumerate(frequencies): omega = 2 * np.pi * frequency wave_number = np.sqrt(1j * omega * _MU0 / resistivity[-1]) impedance = 1j * omega * _MU0 / wave_number for layer in range(len(resistivity) - 2, -1, -1): wave_number = np.sqrt(1j * omega * _MU0 / resistivity[layer]) intrinsic = 1j * omega * _MU0 / wave_number tangent = np.tanh(wave_number * thickness[layer]) numerator = impedance + intrinsic * tangent denominator = intrinsic + impedance * tangent impedance = intrinsic * numerator / denominator output[index] = impedance if time_dependence == "exp(-iwt)": output = np.conjugate(output) return _readonly(output)
[docs] def half_space_benchmark( mesh: MaxwellMesh, receivers: ReceiverSet, frequencies_hz: Sequence[float], *, resistivity_ohm_m: float = 100.0, components: Sequence[str] = ("zxy", "zyx"), time_dependence: str = "exp(+iwt)", thresholds: BenchmarkThresholds | None = None, ) -> MaxwellBenchmark: """Build a uniform-earth analytic benchmark. Parameters ---------- mesh, receivers : MaxwellMesh, ReceiverSet Solver geometry and observation locations. frequencies_hz : sequence of float Positive benchmark frequencies. resistivity_ohm_m : float, default=100 Uniform earth resistivity. components : sequence of str, default=("zxy", "zyx") Requested components. Diagonal 3-D components are excluded because their analytic reference is zero and relative metrics are undefined. time_dependence : str, default="exp(+iwt)" Complex phasor convention. thresholds : BenchmarkThresholds or None, optional Acceptance limits. Returns ------- MaxwellBenchmark Executable half-space case. Examples -------- >>> mesh = MaxwellMesh([0, 1, 2], [0, 1, 2]) >>> receivers = ReceiverSet([[0.5, 0]], ["S"]) >>> half_space_benchmark(mesh, receivers, [1]).name 'half-space' """ resistivity = _positive(resistivity_ohm_m, "resistivity_ohm_m") selected = _off_diagonal_components(components) problem = MaxwellProblem( mesh, np.full(mesh.shape, 1.0 / resistivity), frequencies_hz, receivers, selected, time_dependence=time_dependence, metadata={"benchmark": "half-space"}, ) base = half_space_impedance( resistivity, problem.frequencies_hz, time_dependence=time_dependence, ) reference = _tensor_reference(problem, base) return MaxwellBenchmark( "half-space", "Uniform isotropic earth analytic MT limit.", problem, reference, BenchmarkThresholds() if thresholds is None else thresholds, ("analytic", "half-space", f"{mesh.dimension}d"), {"resistivity_ohm_m": resistivity}, )
[docs] def layered_earth_benchmark( mesh: MaxwellMesh, receivers: ReceiverSet, frequencies_hz: Sequence[float], resistivity_ohm_m: Sequence[float], thickness_m: Sequence[float], *, components: Sequence[str] = ("zxy", "zyx"), time_dependence: str = "exp(+iwt)", thresholds: BenchmarkThresholds | None = None, ) -> MaxwellBenchmark: """Build a laterally uniform layered-earth benchmark. Parameters ---------- mesh, receivers, frequencies_hz Solver geometry, observations, and frequencies. resistivity_ohm_m : sequence of float Layer resistivities ending with a basal half-space. thickness_m : sequence of float Finite-layer thicknesses. Every cumulative interface must coincide with a mesh z edge. components, time_dependence, thresholds Output components, phasor convention, and acceptance limits. Returns ------- MaxwellBenchmark Executable analytic layered-earth case. Examples -------- >>> mesh = MaxwellMesh([0, 1, 2], [0, 1, 2]) >>> receivers = ReceiverSet([[0.5, 0]], ["S"]) >>> case = layered_earth_benchmark(mesh, receivers, [1], [10, 100], [1]) >>> case.name 'layered-earth' """ resistivity = np.asarray(resistivity_ohm_m, dtype=float) thickness = np.asarray(thickness_m, dtype=float) base = layered_earth_impedance( resistivity, thickness, frequencies_hz, time_dependence=time_dependence, ) if not np.isclose(mesh.z_edges_m[0], 0, atol=1e-12, rtol=0): raise ValueError("mesh z edges must begin at depth zero.") interfaces = np.cumsum(thickness) for interface in interfaces: tolerance = max(1e-10, abs(interface) * 1e-10) if not np.any(np.isclose(mesh.z_edges_m, interface, atol=tolerance)): raise ValueError( f"layer interface {interface:g} m is not a mesh z edge." ) layer_index = np.searchsorted( interfaces, mesh.cell_centres_m["z"], side="right", ) vertical = 1.0 / resistivity[layer_index] shape = (len(vertical),) + (1,) * (mesh.dimension - 1) conductivity = np.broadcast_to(vertical.reshape(shape), mesh.shape) selected = _off_diagonal_components(components) problem = MaxwellProblem( mesh, conductivity, frequencies_hz, receivers, selected, time_dependence=time_dependence, metadata={"benchmark": "layered-earth"}, ) reference = _tensor_reference(problem, base) return MaxwellBenchmark( "layered-earth", "Laterally uniform layered-earth analytic MT limit.", problem, reference, BenchmarkThresholds() if thresholds is None else thresholds, ("analytic", "layered-earth", f"{mesh.dimension}d"), { "resistivity_ohm_m": resistivity.tolist(), "thickness_m": thickness.tolist(), }, )
[docs] def run_benchmarks( backend: MaxwellBackend, benchmarks: Sequence[MaxwellBenchmark], ) -> BenchmarkReport: """Run an ordered benchmark collection with one backend version. Parameters ---------- backend : MaxwellBackend Backend under validation. benchmarks : sequence of MaxwellBenchmark Non-empty cases with unique names. Returns ------- BenchmarkReport Aggregate and per-case outcomes. Examples -------- Backend and numerical exceptions propagate rather than becoming false benchmark failures. """ if not isinstance(backend, MaxwellBackend): raise TypeError("backend must implement MaxwellBackend.") cases = tuple(benchmarks) if not cases or any( not isinstance(value, MaxwellBenchmark) for value in cases ): raise ValueError("benchmarks must contain MaxwellBenchmark values.") names = [value.name for value in cases] if len(set(names)) != len(names): raise ValueError("benchmark names must be unique.") return BenchmarkReport(tuple(value.run(backend) for value in cases))
def _off_diagonal_components(values: Sequence[str]) -> tuple[str, ...]: components = tuple(str(value).strip().lower() for value in values) if not components or len(set(components)) != len(components): raise ValueError("components must be non-empty and unique.") if any(value not in _COMPONENTS for value in components): raise ValueError("components contain an unknown impedance name.") if any(value in {"zxx", "zyy"} for value in components): raise ValueError("analytic relative metrics require zxy and/or zyx.") return components def _tensor_reference( problem: MaxwellProblem, base: np.ndarray, ) -> np.ndarray: values = np.empty( ( problem.receivers.count, len(problem.frequencies_hz), len(problem.components), ), dtype=complex, ) for index, component in enumerate(problem.components): sign = 1.0 if component == "zxy" else -1.0 values[:, :, index] = sign * base[None, :] return values