from __future__ import annotations
from enum import Enum, auto
from functools import cached_property
from math import prod
from typing import Any, cast
from ._base import LinOp, _output_checked_rvapply
from ._metric import (
_VECTOR_SPACES, _metric_is_hermitian_by_basis, _requires_euclidean_or_riesz, metric_rapply,
metric_rvapply,
)
from .._checks import CheckLevel
from ..contextual import Context
from ..space import (
CoordinateSpace,
DenseCoordinateSpace,
WeightedInnerProduct,
)
from ..types import DenseArray
from ..contextual import resolve_context_priority
class _DiagonalMode(Enum):
"""Private computation modes for diagonal coordinate operators."""
EUCLIDEAN = auto()
WEIGHTED_FUSED = auto()
GENERAL_METRIC = auto()
[docs]
class DiagonalLinOp(LinOp[CoordinateSpace, CoordinateSpace]):
r"""
Coordinatewise map ``x -> diagonal * x`` on one space.
The adjoint conjugates the diagonal on Euclidean spaces and is
``R_X^{-1} D^dagger R_X`` otherwise.
Parameters
----------
diagonal : DenseArray
Diagonal of shape ``space.shape``.
space : Space or None, optional
Domain and codomain; a vector space of ``diagonal.shape`` when omitted.
ctx : Context, str, or None, optional
Backend context; resolved from ``space`` when omitted.
check_level : {"none", "cheap", "standard", "strict"}, optional
Validation level; the ambient default when omitted.
Examples
--------
>>> import numpy as np
>>> import spacecore as sc
>>> ctx = sc.Context(sc.NumpyOps(), dtype=np.float64)
>>> X = sc.DenseCoordinateSpace((2,), ctx)
>>> D = sc.DiagonalLinOp(ctx.asarray([2.0, 3.0]), X, ctx)
>>> D.apply(ctx.asarray([4.0, 5.0]))
array([ 8., 15.])
"""
_fields = ("diagonal", "space:domain", "ctx")
_dynamic = ("diagonal",)
def _apply_core(self, x: Any) -> Any:
# Vector-shaped or fused: elementwise product. Else on flat coordinates.
# EUCLIDEAN and WEIGHTED_FUSED imply a vector-space domain; check them first.
if self._elementwise:
return self.diagonal * x
x_flat = self.domain._flatten_core(x)
y_flat = self._diag_flat * x_flat
return self.codomain._unflatten_core(y_flat)
def _rapply_core(self, y: Any) -> Any:
# Euclidean: conj diagonal. Fused: precomputed. Else wrap it with the metrics.
if self._mode is _DiagonalMode.EUCLIDEAN:
return self._euclidean_rapply_core(y)
if self._mode is _DiagonalMode.WEIGHTED_FUSED:
return self._diag_adjoint * y
return metric_rapply(
self.domain, self.codomain, self._euclidean_rapply_core, y
)
def _vapply_core(self, xs: Any) -> Any:
# Elementwise, broadcasting over the leading batch axis.
if self._elementwise:
return self.diagonal * xs
xs_flat = self.domain._flatten_batch_core(xs)
ys_flat = xs_flat * self._diag_flat
return self.codomain._unflatten_batch_core(ys_flat)
def _rvapply_core(self, ys: Any) -> Any:
# Batched form of _rapply_core.
if self._mode is _DiagonalMode.EUCLIDEAN:
return self._euclidean_rvapply_core(ys)
if self._mode is _DiagonalMode.WEIGHTED_FUSED:
return self._diag_adjoint * ys
return metric_rvapply(self.domain, self.codomain, self._euclidean_rvapply_core, ys)
def _euclidean_rapply_core(self, y: Any) -> Any:
# The adjoint diagonal, elementwise or on flat coordinates.
if self._elementwise:
return self._diag_adjoint * y
y_flat = self.codomain._flatten_core(y)
x_flat = self._diag_adjoint_flat * y_flat
return self.domain._unflatten_core(x_flat)
def _euclidean_rvapply_core(self, ys: Any) -> Any:
# Adjoint diagonal over the batch.
if self._elementwise:
return self._diag_adjoint * ys
ys_flat = self.codomain._flatten_batch_core(ys)
xs_flat = ys_flat * self._diag_adjoint_flat
return self.domain._unflatten_batch_core(xs_flat)
def __init__(
self,
diagonal: DenseArray,
space: CoordinateSpace | None = None,
ctx: Context | str | None = None,
check_level: CheckLevel | None = None,
) -> None:
# Infer a Euclidean space from the diagonal's shape if none is given.
ctx = resolve_context_priority(ctx, space)
ctx.assert_dense(diagonal)
if space is None:
space = DenseCoordinateSpace(tuple(diagonal.shape), ctx)
_requires_euclidean_or_riesz(space, space, "DiagonalLinOp")
super().__init__(space, space, ctx, check_level=check_level)
expected = tuple(self.domain.shape)
if tuple(diagonal.shape) != expected:
raise TypeError(
f"Expected diagonal.shape == space.shape == {expected}, got {diagonal.shape}"
)
# Cache flat and conjugated copies for the cores, then pick the strategy.
self.diagonal = diagonal
self._diag_flat = diagonal.reshape((prod(self.domain.shape),))
dtype = self.ops.get_dtype(diagonal)
self._diag_adjoint = (
self.ops.conj(diagonal) if self.ops.is_complex_dtype(dtype) else diagonal
)
self._diag_adjoint_flat = self._diag_adjoint.reshape((prod(self.domain.shape),))
self._elementwise = type(self.domain) in _VECTOR_SPACES
self._mode = self._select_mode()
def _select_mode(self) -> _DiagonalMode:
"""Select the diagonal computation mode once for this operator."""
# Euclidean: multiply by the diagonal; weighted: fused path; else general Riesz maps.
if self._elementwise and cast(Any, self.domain).is_euclidean:
return _DiagonalMode.EUCLIDEAN
if self._elementwise and type(cast(Any, self.domain).geometry) is WeightedInnerProduct:
return _DiagonalMode.WEIGHTED_FUSED
return _DiagonalMode.GENERAL_METRIC
@cached_property
def A(self) -> DenseArray:
"""Dense tensor representation of this diagonal operator."""
return self.to_dense()
rvapply = _output_checked_rvapply
[docs]
def to_matrix(self) -> DenseArray:
"""Return the flattened dense diagonal matrix representation."""
return self.ops.diag(self._diag_flat)
[docs]
def to_dense(self) -> DenseArray:
"""Return a dense tensor representation of this diagonal operator."""
matrix = self.to_matrix()
return self.ops.reshape(matrix, tuple(self.codomain.shape) + tuple(self.domain.shape))
[docs]
def is_hermitian(self) -> bool | None:
"""
Return whether this diagonal operator is structurally self-adjoint.
Returns
-------
bool or None
``True`` or ``False`` when the structure can be checked, otherwise
``None``.
"""
# Euclidean or diagonal-weighted: a diagonal commutes with the metric, so it is
# Hermitian iff the diagonal is real; any other metric is tested by basis.
domain = cast(Any, self.domain)
if not (domain.is_euclidean or isinstance(getattr(domain, "geometry", None), WeightedInnerProduct)):
return _metric_is_hermitian_by_basis(self)
try:
return bool(self.ops.allclose(self.diagonal, self._diag_adjoint))
except Exception:
return None