Source code for pycsamt.forward.maxwell.backends

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Capability checks and lazy registration for Maxwell solver backends.

Backends are integrations, not deep-learning frameworks.  Each adapter must
declare its physical and numerical scope before it can receive a problem.
Factories are stored lazily so optional solver packages are imported only when
a caller explicitly creates that backend.
"""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass
from threading import RLock
from types import MappingProxyType
from typing import (
    Any,
    Callable,
    Optional,
    Protocol,
    runtime_checkable,
)

import numpy as np

from .contracts import ForwardResult, MaxwellProblem
from .contracts_tri import TriProblem

__all__ = [
    "BackendCapabilities",
    "CompatibilityReport",
    "MaxwellBackend",
    "BackendRegistration",
    "BackendRegistry",
    "backend_registry",
    "register_backend",
    "unregister_backend",
    "create_backend",
    "list_backends",
]

_COMPONENTS = ("zxx", "zxy", "zyx", "zyy")
_TIME_CONVENTIONS = ("exp(+iwt)", "exp(-iwt)")


def _identifier(value: str, label: str) -> str:
    result = str(value).strip().lower().replace("_", "-")
    if not result or any(
        not (character.isalnum() or character in "-.") for character in result
    ):
        raise ValueError(
            f"{label} must use letters, numbers, hyphens, or dots."
        )
    return result


def _positive_limit(value: int | None, label: str) -> int | None:
    if value is None:
        return None
    if (
        not isinstance(value, (int, np.integer))
        or isinstance(value, bool)
        or int(value) < 1
    ):
        raise ValueError(f"{label} must be a positive integer or None.")
    return int(value)


[docs] @dataclass(frozen=True) class CompatibilityReport: """Describe whether a backend can solve a particular problem. Parameters ---------- backend_name : str Normalized backend identifier. compatible : bool Whether all hard capability requirements are satisfied. errors, warnings : tuple of str, optional Hard incompatibilities and advisory concerns. Examples -------- >>> report = CompatibilityReport("demo", False, ("3-D unsupported",)) >>> report.require() Traceback (most recent call last): ... ValueError: backend 'demo' is incompatible: 3-D unsupported """ backend_name: str compatible: bool errors: tuple[str, ...] = () warnings: tuple[str, ...] = () def __post_init__(self) -> None: name = _identifier(self.backend_name, "backend_name") errors = tuple(str(value).strip() for value in self.errors) warnings = tuple(str(value).strip() for value in self.warnings) if any(not value for value in errors + warnings): raise ValueError("compatibility messages cannot be empty.") if bool(self.compatible) == bool(errors): raise ValueError( "compatible must be true exactly when errors is empty." ) object.__setattr__(self, "backend_name", name) object.__setattr__(self, "compatible", bool(self.compatible)) object.__setattr__(self, "errors", errors) object.__setattr__(self, "warnings", warnings)
[docs] def require(self) -> None: """Raise a consolidated error when the report is incompatible. Raises ------ ValueError If one or more hard incompatibilities were found. Examples -------- >>> CompatibilityReport("demo", True).require() """ if self.errors: raise ValueError( f"backend {self.backend_name!r} is incompatible: " + "; ".join(self.errors) )
[docs] def to_dict(self) -> dict[str, Any]: """Return a JSON-compatible report. Returns ------- dict Compatibility state and messages. Examples -------- >>> CompatibilityReport("demo", True).to_dict()["compatible"] True """ return { "backend_name": self.backend_name, "compatible": self.compatible, "errors": list(self.errors), "warnings": list(self.warnings), }
[docs] @dataclass(frozen=True) class BackendCapabilities: """Declare a Maxwell adapter's supported physical and numerical scope. Parameters ---------- name, version : str Stable backend identifier and adapter/solver version. dimensions : tuple containing 2 and/or 3 Spatial problem dimensions genuinely supported. components : tuple of str Impedance tensor components the adapter can return. time_conventions : tuple of str, default=("exp(+iwt)",) Phasor conventions accepted without conversion. supports_nonuniform_mesh : bool, default=True Whether variable cell widths are supported. supports_inactive_cells : bool, default=False Whether ``MaxwellProblem.active_cells`` is honored. supports_topography : bool, default=False Whether an inactive/air mask may describe non-flat terrain. supports_anisotropy : bool, default=False Reserved declaration for future tensor-conductivity contracts. maximum_cells, maximum_frequencies : int or None, optional Enforced adapter limits. None means no declared limit. verified_benchmarks : tuple of str, optional Stable benchmark identifiers passed by this adapter version. Examples -------- >>> capability = BackendCapabilities("mt2d", "1.0", (2,), ("zxy", "zyx")) >>> capability.supports_dimension(2), capability.supports_component("zxy") (True, True) """ name: str version: str dimensions: tuple[int, ...] components: tuple[str, ...] time_conventions: tuple[str, ...] = ("exp(+iwt)",) supports_nonuniform_mesh: bool = True supports_inactive_cells: bool = False supports_topography: bool = False supports_anisotropy: bool = False maximum_cells: int | None = None maximum_frequencies: int | None = None verified_benchmarks: tuple[str, ...] = () def __post_init__(self) -> None: name = _identifier(self.name, "name") version = str(self.version).strip() if not version: raise ValueError("version cannot be empty.") dimensions = tuple(int(value) for value in self.dimensions) if ( not dimensions or len(set(dimensions)) != len(dimensions) or any(value not in (2, 3) for value in dimensions) ): raise ValueError( "dimensions must contain unique values drawn from (2, 3)." ) components = tuple( str(value).strip().lower() for value in self.components ) if ( not components or len(set(components)) != len(components) or any(value not in _COMPONENTS for value in components) ): raise ValueError( f"components must contain unique values drawn from {_COMPONENTS}." ) conventions = tuple( str(value).strip() for value in self.time_conventions ) if ( not conventions or len(set(conventions)) != len(conventions) or any(value not in _TIME_CONVENTIONS for value in conventions) ): raise ValueError( f"time_conventions must contain values drawn from {_TIME_CONVENTIONS}." ) benchmarks = tuple( str(value).strip() for value in self.verified_benchmarks ) if len(set(benchmarks)) != len(benchmarks) or any( not value for value in benchmarks ): raise ValueError( "verified_benchmarks must contain unique non-empty names." ) object.__setattr__(self, "name", name) object.__setattr__(self, "version", version) object.__setattr__(self, "dimensions", dimensions) object.__setattr__(self, "components", components) object.__setattr__(self, "time_conventions", conventions) object.__setattr__( self, "maximum_cells", _positive_limit(self.maximum_cells, "maximum_cells"), ) object.__setattr__( self, "maximum_frequencies", _positive_limit(self.maximum_frequencies, "maximum_frequencies"), ) object.__setattr__(self, "verified_benchmarks", benchmarks)
[docs] def supports_dimension(self, dimension: int) -> bool: """Return whether a spatial dimension is supported. Parameters ---------- dimension : int Requested dimension. Returns ------- bool Capability status. Examples -------- >>> BackendCapabilities("b", "1", (2,), ("zxy",)).supports_dimension(3) False """ return dimension in self.dimensions
[docs] def supports_component(self, component: str) -> bool: """Return whether an impedance component is supported. Parameters ---------- component : str Canonical tensor component name. Returns ------- bool Capability status. Examples -------- >>> BackendCapabilities("b", "1", (2,), ("zxy",)).supports_component( ... "ZYX" ... ) False """ return str(component).strip().lower() in self.components
[docs] def assess( self, problem: MaxwellProblem | TriProblem ) -> CompatibilityReport: """Assess a problem without invoking the numerical backend. Parameters ---------- problem : MaxwellProblem or TriProblem Validated problem contract, rectilinear or triangular. Both expose the same ``mesh``/``frequencies_hz``/``components``/ ``time_dependence``/``active_cells`` shape this method reads; only :attr:`~pycsamt.forward.maxwell.contracts.MaxwellMesh.cell_widths_m` is rectilinear-only, so the nonuniform-mesh check below is skipped (not applicable) for an unstructured :class:`~pycsamt.forward.maxwell.contracts_tri.TriMesh`. Returns ------- CompatibilityReport All hard errors plus advisory validation warnings. Examples -------- >>> from .contracts import MaxwellMesh, ReceiverSet >>> mesh = MaxwellMesh([0, 1, 2], [0, 1, 2]) >>> problem = MaxwellProblem( ... mesh, np.ones((2, 2)), [1], ReceiverSet([[0, 0]], ["S"]) ... ) >>> BackendCapabilities("b", "1", (2,), ("zxy", "zyx")).assess( ... problem ... ).compatible True """ if not isinstance(problem, (MaxwellProblem, TriProblem)): raise TypeError("problem must be a MaxwellProblem or TriProblem.") errors: list[str] = [] warnings: list[str] = [] if problem.mesh.dimension not in self.dimensions: errors.append( f"{problem.mesh.dimension}-D problems are unsupported" ) missing = [ value for value in problem.components if value not in self.components ] if missing: errors.append(f"unsupported impedance components: {missing}") if problem.time_dependence not in self.time_conventions: errors.append( f"time convention {problem.time_dependence!r} is unsupported" ) widths = getattr(problem.mesh, "cell_widths_m", None) if widths is not None: nonuniform = any( not np.allclose(value, value[0]) for value in widths.values() ) if nonuniform and not self.supports_nonuniform_mesh: errors.append("nonuniform meshes are unsupported") inactive = ~problem.active_cells if np.any(inactive) and not self.supports_inactive_cells: errors.append("inactive cells are unsupported") if ( np.any(inactive) and self.supports_inactive_cells and not self.supports_topography ): horizontal_axes = tuple(range(1, inactive.ndim)) inactive_per_depth = np.all(inactive, axis=horizontal_axes) reconstructed = inactive_per_depth.reshape( (-1,) + (1,) * len(horizontal_axes) ) if not np.array_equal( inactive, np.broadcast_to(reconstructed, inactive.shape) ): errors.append( "laterally varying inactive cells require topography support" ) cell_count = int(np.prod(problem.mesh.shape)) if self.maximum_cells is not None and cell_count > self.maximum_cells: errors.append( f"cell count {cell_count} exceeds limit {self.maximum_cells}" ) if ( self.maximum_frequencies is not None and len(problem.frequencies_hz) > self.maximum_frequencies ): errors.append( f"frequency count {len(problem.frequencies_hz)} exceeds limit {self.maximum_frequencies}" ) if not self.verified_benchmarks: warnings.append("backend declares no verified benchmarks") return CompatibilityReport( self.name, not errors, tuple(errors), tuple(warnings) )
[docs] def to_dict(self) -> dict[str, Any]: """Return a JSON-compatible capability declaration. Returns ------- dict Versioned capability state. Examples -------- >>> BackendCapabilities("b", "1", (2,), ("zxy",)).to_dict()[ ... "dimensions" ... ] [2] """ return { "schema_version": 1, "name": self.name, "version": self.version, "dimensions": list(self.dimensions), "components": list(self.components), "time_conventions": list(self.time_conventions), "supports_nonuniform_mesh": self.supports_nonuniform_mesh, "supports_inactive_cells": self.supports_inactive_cells, "supports_topography": self.supports_topography, "supports_anisotropy": self.supports_anisotropy, "maximum_cells": self.maximum_cells, "maximum_frequencies": self.maximum_frequencies, "verified_benchmarks": list(self.verified_benchmarks), }
[docs] @classmethod def from_dict(cls, data: Mapping[str, Any]) -> BackendCapabilities: """Restore a validated capability declaration. Parameters ---------- data : mapping State returned by :meth:`to_dict`. Returns ------- BackendCapabilities Restored declaration. Examples -------- >>> cap = BackendCapabilities("b", "1", (2,), ("zxy",)) >>> BackendCapabilities.from_dict(cap.to_dict()).name 'b' """ if data.get("schema_version") != 1: raise ValueError("unsupported BackendCapabilities schema version.") values = dict(data) values.pop("schema_version") for key in ( "dimensions", "components", "time_conventions", "verified_benchmarks", ): values[key] = tuple(values.get(key, ())) return cls(**values)
[docs] @runtime_checkable class MaxwellBackend(Protocol): """Runtime-checkable interface implemented by Maxwell adapters. Examples -------- A conforming adapter exposes immutable capabilities and a solve method. >>> class Demo: ... capabilities = BackendCapabilities( ... "demo", "1", (2,), ("zxy", "zyx") ... ) ... ... def solve(self, problem): ... raise NotImplementedError >>> isinstance(Demo(), MaxwellBackend) True """
[docs] @property def capabilities(self) -> BackendCapabilities: """Return the adapter's immutable capability declaration.""" ...
[docs] def solve(self, problem: MaxwellProblem) -> ForwardResult: """Solve a compatible problem and return canonical output.""" ...
BackendFactory = Callable[..., MaxwellBackend] AvailabilityProbe = Callable[[], tuple[bool, Optional[str]]]
[docs] @dataclass(frozen=True) class BackendRegistration: """Store one lazy backend factory and its availability probe. Parameters ---------- capabilities : BackendCapabilities Static capability declaration, available without importing the solver. factory : callable Factory returning a :class:`MaxwellBackend`. availability_probe : callable or None, optional Lightweight function returning ``(available, reason)``. Examples -------- >>> cap = BackendCapabilities("demo", "1", (2,), ("zxy",)) >>> registration = BackendRegistration(cap, lambda: object()) >>> registration.availability() (True, None) """ capabilities: BackendCapabilities factory: BackendFactory availability_probe: AvailabilityProbe | None = None def __post_init__(self) -> None: if not isinstance( self.capabilities, BackendCapabilities ) or not callable(self.factory): raise TypeError( "capabilities and a callable factory are required." ) if self.availability_probe is not None and not callable( self.availability_probe ): raise TypeError("availability_probe must be callable or None.")
[docs] def availability(self) -> tuple[bool, str | None]: """Return whether the optional backend can currently be created. Returns ------- available : bool Probe status. reason : str or None Human-readable reason when unavailable. Examples -------- >>> cap = BackendCapabilities("demo", "1", (2,), ("zxy",)) >>> BackendRegistration( ... cap, lambda: None, lambda: (False, "missing") ... ).availability() (False, 'missing') """ if self.availability_probe is None: return True, None try: available, reason = self.availability_probe() except Exception as exc: return False, f"availability probe failed: {exc}" normalized_reason = ( None if reason is None else str(reason).strip() or None ) if not available and normalized_reason is None: normalized_reason = "backend reported itself unavailable" return bool(available), normalized_reason
[docs] def create(self, **options: Any) -> MaxwellBackend: """Create and validate a backend instance. Parameters ---------- **options Backend-specific constructor options. Returns ------- MaxwellBackend Conforming adapter instance. Raises ------ RuntimeError If unavailable or the factory violates its registration. Examples -------- >>> cap = BackendCapabilities("demo", "1", (2,), ("zxy",)) >>> class Demo: ... capabilities = cap ... ... def solve(self, problem): ... raise NotImplementedError >>> BackendRegistration(cap, Demo).create().capabilities.name 'demo' """ available, reason = self.availability() if not available: raise RuntimeError( f"backend {self.capabilities.name!r} is unavailable: {reason}" ) instance = self.factory(**options) if not isinstance(instance, MaxwellBackend): raise RuntimeError( "backend factory returned an object that violates MaxwellBackend." ) if instance.capabilities != self.capabilities: raise RuntimeError( "backend instance capabilities differ from its registration." ) return instance
[docs] class BackendRegistry: """Thread-safe registry of lazy Maxwell backend factories. Examples -------- >>> registry = BackendRegistry() >>> cap = BackendCapabilities("demo", "1", (2,), ("zxy",)) >>> registry.register(BackendRegistration(cap, lambda: None)) >>> registry.names() ('demo',) """ def __init__(self) -> None: self._registrations: dict[str, BackendRegistration] = {} self._lock = RLock()
[docs] def register( self, registration: BackendRegistration, *, replace: bool = False ) -> None: """Register a backend under its declared capability name. Parameters ---------- registration : BackendRegistration Lazy backend definition. replace : bool, default=False Explicitly replace an existing registration. Raises ------ ValueError If the name already exists and replacement was not requested. Examples -------- >>> registry = BackendRegistry() >>> cap = BackendCapabilities("demo", "1", (2,), ("zxy",)) >>> registry.register(BackendRegistration(cap, lambda: None)) """ if not isinstance(registration, BackendRegistration): raise TypeError("registration must be a BackendRegistration.") name = registration.capabilities.name with self._lock: if name in self._registrations and not replace: raise ValueError(f"backend {name!r} is already registered.") self._registrations[name] = registration
[docs] def unregister(self, name: str) -> BackendRegistration: """Remove and return a registration. Parameters ---------- name : str Backend identifier. Returns ------- BackendRegistration Removed registration. Examples -------- >>> registry = BackendRegistry() >>> cap = BackendCapabilities("demo", "1", (2,), ("zxy",)) >>> registry.register(BackendRegistration(cap, lambda: None)) >>> registry.unregister("demo").capabilities.name 'demo' """ key = _identifier(name, "name") with self._lock: try: return self._registrations.pop(key) except KeyError as exc: raise KeyError(f"unknown Maxwell backend {key!r}.") from exc
[docs] def get(self, name: str) -> BackendRegistration: """Return one registration without creating its backend. Parameters ---------- name : str Backend identifier. Returns ------- BackendRegistration Lazy registration. Examples -------- >>> registry = BackendRegistry() >>> cap = BackendCapabilities("demo", "1", (2,), ("zxy",)) >>> registry.register(BackendRegistration(cap, lambda: None)) >>> registry.get("DEMO").capabilities.version '1' """ key = _identifier(name, "name") with self._lock: try: return self._registrations[key] except KeyError as exc: raise KeyError(f"unknown Maxwell backend {key!r}.") from exc
[docs] def names(self, *, available_only: bool = False) -> tuple[str, ...]: """Return sorted registered backend names. Parameters ---------- available_only : bool, default=False Exclude registrations whose availability probe fails. Returns ------- tuple of str Stable sorted names. Examples -------- >>> BackendRegistry().names() () """ with self._lock: registrations = tuple(self._registrations.items()) if available_only: registrations = tuple( (name, value) for name, value in registrations if value.availability()[0] ) return tuple(sorted(name for name, _ in registrations))
[docs] def describe(self) -> Mapping[str, Mapping[str, Any]]: """Return immutable availability and capability summaries. Returns ------- mapping Backend names mapped to JSON-compatible summaries. Examples -------- >>> BackendRegistry().describe() == {} True """ with self._lock: registrations = tuple(self._registrations.items()) descriptions = {} for name, registration in registrations: available, reason = registration.availability() descriptions[name] = MappingProxyType( { "available": available, "reason": reason, "capabilities": registration.capabilities.to_dict(), } ) return MappingProxyType(descriptions)
[docs] def create(self, name: str, **options: Any) -> MaxwellBackend: """Create a named backend through its lazy factory. Parameters ---------- name : str Registered backend identifier. **options Backend-specific constructor options. Returns ------- MaxwellBackend Validated adapter. Examples -------- See :meth:`BackendRegistration.create` for a complete adapter example. """ return self.get(name).create(**options)
backend_registry = BackendRegistry() """Process-wide registry used by the convenience functions."""
[docs] def register_backend( registration: BackendRegistration, *, replace: bool = False ) -> None: """Register a lazy backend in the process-wide registry. Parameters ---------- registration : BackendRegistration Backend definition. replace : bool, default=False Explicitly replace an existing name. Examples -------- Prefer a private :class:`BackendRegistry` in isolated applications and tests; this function is intended for adapter package initialization. """ backend_registry.register(registration, replace=replace)
[docs] def unregister_backend(name: str) -> BackendRegistration: """Remove a backend from the process-wide registry. Parameters ---------- name : str Backend identifier. Returns ------- BackendRegistration Removed definition. Examples -------- This operation is primarily useful for plugin teardown and tests. """ return backend_registry.unregister(name)
[docs] def create_backend(name: str, **options: Any) -> MaxwellBackend: """Create a registered Maxwell adapter lazily. Parameters ---------- name : str Backend identifier. **options Backend-specific constructor options. Returns ------- MaxwellBackend Validated backend instance. Examples -------- Backend packages register their factories before this function is called. """ return backend_registry.create(name, **options)
[docs] def list_backends( *, available_only: bool = False ) -> Mapping[str, Mapping[str, Any]]: """Describe registered Maxwell backends without creating them. Parameters ---------- available_only : bool, default=False Exclude unavailable optional backends. Returns ------- mapping Immutable capability and availability summaries. Examples -------- >>> isinstance(list_backends(), Mapping) True """ descriptions = backend_registry.describe() if not available_only: return descriptions return MappingProxyType( { name: value for name, value in descriptions.items() if value["available"] } )