Source code for yaw.core.coordinates

"""This module defines simple containers for coordiantes and distances. There
are currently two flavours, 3-dim Euclidean coordinates and distances, as well
as angular coordinates and distances in radian.
"""

from __future__ import annotations

from abc import ABC, abstractmethod
from collections.abc import Iterator, Sequence
from functools import total_ordering
from typing import TYPE_CHECKING, TypeVar

import numpy as np

from yaw.core.math import sgn

if TYPE_CHECKING:  # pragma: no cover
    from numpy.typing import ArrayLike, NDArray

__all__ = ["Coord3D", "CoordSky", "Dist3D", "DistSky"]


class Coordinate(ABC):
    """Base class for a vector of coordinates."""

    @abstractmethod
    def __init__(self, coords: dict[str, ArrayLike]) -> None:
        pass

    @classmethod
    @abstractmethod
    def from_array(cls, array) -> Coordinate:
        pass

    @classmethod
    @abstractmethod
    def from_coords(cls, coords: Sequence[Coordinate]) -> Coordinate:
        """Concatenate a sequence of coordinates into a new vector of
        coordates."""
        pass

    def __repr__(self) -> str:
        pass

    @property
    @abstractmethod
    def dim(self) -> tuple[str]:
        """A list of names of coordinates in the coordinate system."""
        pass

    @property
    def ndim(self) -> int:
        """The number of coordinates in the coordinate system."""
        return len(self.dim)

    @property
    @abstractmethod
    def values(self) -> NDArray[np.float64]:
        """The coordinate values cast into a numpy array with shape
        `(N, ndim)` or `(ndim,)` if there is only a single entry."""
        pass

    @abstractmethod
    def mean(self) -> Coordinate:
        """The mean coordinate (mean over all dimensions)."""
        pass

    @abstractmethod
    def to_3d(self) -> Coord3D:
        """Get the coordinates as 3-dim Euclidean coordiante :obj:`Coord3D`."""
        pass

    @abstractmethod
    def to_sky(self) -> CoordSky:
        """Get the coordinates as angular coordiante :obj:`CoordSky` in radian.

        .. Warning::
            During conversion, points loose their radial information. After
            back-transformation to :obj:`Coord3D` they lie on the unit sphere.
        """
        pass

    @abstractmethod
    def distance(self, other: Coordinate) -> Distance:
        pass


[docs] class Coord3D(Coordinate): """A representation of a vector of 3-dim Euclidean coordiantes (x, y, z).""" x: NDArray """The `x`-coordiante(s).""" y: NDArray """The `y`-coordiante(s).""" z: NDArray """The `z`-coordiante(s).""" def __init__(self, x: ArrayLike, y: ArrayLike, z: ArrayLike) -> None: """Create a new coordinate vector. Args: x (:obj:`float`, :obj:`NDArray`): The `x`-coordiante(s). y (:obj:`float`, :obj:`NDArray`): The `y`-coordiante(s). z (:obj:`float`, :obj:`NDArray`): The `z`-coordiante(s). .. Note:: The coordinate vector has a length, supports numpy-style indexing and iteration over the elements (always returning new coordinate vector instances). """ self.x = np.atleast_1d(x).astype(np.float64) self.y = np.atleast_1d(y).astype(np.float64) self.z = np.atleast_1d(z).astype(np.float64)
[docs] @classmethod def from_array(cls, array) -> Coord3D: """Create a new coordinate vector from an array of tuples of (`x`, `y`, `z`).""" x, y, z = np.transpose(array) return cls(x, y, z)
[docs] @classmethod def from_coords(cls, coords: Sequence[Coord3D]) -> Coord3D: x = np.concatenate([coord.x for coord in coords], axis=0) y = np.concatenate([coord.y for coord in coords], axis=0) z = np.concatenate([coord.z for coord in coords], axis=0) return cls(x, y, z)
def __repr__(self) -> str: x, y, z = self.x, self.y, self.z return f"{self.__class__.__name__}({x=}, {y=}, {z=})" def __len__(self) -> int: return len(self.x) def __getitem__(self, idx: ArrayLike) -> Coord3D: return self.__class__(x=self.x[idx], y=self.y[idx], z=self.z[idx]) def __iter__(self) -> Iterator[Coord3D]: for i in range(len(self)): yield self[i] @property def dim(self) -> tuple[str]: return ("x", "y", "z") @property def values(self) -> NDArray[np.float64]: return np.squeeze(np.transpose([self.x, self.y, self.z]))
[docs] def mean(self) -> Coord3D: return Coord3D(x=self.x.mean(), y=self.y.mean(), z=self.z.mean())
[docs] def to_3d(self) -> Coord3D: return self
[docs] def to_sky(self) -> CoordSky: x = self.x y = self.y z = self.z r_d2 = np.sqrt(x * x + y * y) r_d3 = np.sqrt(x * x + y * y + z * z) # transform x_normed = np.ones_like(x) # fallback for zero-division, arccos(1)=0.0 np.divide(x, r_d2, where=r_d2 > 0.0, out=x_normed) ra = np.arccos(x_normed) * sgn(y) % (2.0 * np.pi) dec = np.arcsin(self.z / r_d3) return CoordSky(ra, dec)
[docs] def distance(self, other: Coordinate) -> Dist3D: """Compute the Euclidean distance between two coordinate vectors. Coordinates are automatically converted before distance calculation. Args: other (:obj:`Coordinate`): Second coordinate vector. Returns: :obj:`Dist3D`: Euclidean distance between points in this and the other vector. """ c1 = self.to_3d() c2 = other.to_3d() return Dist3D( np.sqrt((c1.x - c2.x) ** 2 + (c1.y - c2.y) ** 2 + (c1.z - c2.z) ** 2) )
[docs] class CoordSky(Coordinate): """A representation of a vector of angular coordinates in radian. Angles follow the astronomical convention of R.A./Dec., i.e. declination values are in the range of [:math:`-\\pi`, :math:`\\pi`]. """ ra: NDArray """The right ascension coordinate(s).""" dec: NDArray """The declination coordinate(s).""" def __init__(self, ra: ArrayLike, dec: ArrayLike) -> None: """Create a new coordinate vector. Args: ra (:obj:`float`, :obj:`NDArray`): The right ascension coordinate(s). dec (:obj:`float`, :obj:`NDArray`): The declination coordinate(s). .. Note:: The coordinate vector has a length, supports numpy-style indexing and iteration over the elements (always returning new coordinate vector instances). """ self.ra = np.atleast_1d(ra).astype(np.float64) self.dec = np.atleast_1d(dec).astype(np.float64)
[docs] @classmethod def from_array(cls, array): """Create a new coordinate vector from an array of tuples of (`ra`, `dec`).""" ra, dec = np.transpose(array) return cls(ra, dec)
[docs] @classmethod def from_coords(cls, coords: Sequence[CoordSky]) -> CoordSky: ra = np.concatenate([coord.ra for coord in coords], axis=0) dec = np.concatenate([coord.dec for coord in coords], axis=0) return cls(ra, dec)
def __repr__(self) -> str: ra, dec = self.ra, self.dec return f"{self.__class__.__name__}({ra=}, {dec=})" def __len__(self) -> int: return len(self.ra) def __getitem__(self, idx: ArrayLike) -> CoordSky: return self.__class__(ra=self.ra[idx], dec=self.dec[idx]) def __iter__(self) -> Iterator[CoordSky]: for i in range(len(self)): yield self[i] @property def dim(self) -> tuple[str]: return ("ra", "dec") @property def values(self) -> NDArray[np.float64]: return np.squeeze(np.transpose([self.ra, self.dec]))
[docs] def mean(self) -> Coord3D: return self.to_3d().mean().to_sky()
[docs] def to_3d(self) -> Coord3D: cos_dec = np.cos(self.dec) return Coord3D( x=np.cos(self.ra) * cos_dec, y=np.sin(self.ra) * cos_dec, z=np.sin(self.dec) )
[docs] def to_sky(self) -> CoordSky: return self
[docs] def distance(self, other: Coordinate) -> DistSky: """Compute the angular distance in radian between two coordinate vectors. Coordinates are automatically converted before distance calculation. Args: other (:obj:`Coordinate`): Second coordinate vector. Returns: :obj:`DistSky`: Angular distance in radian between points in this and the other vector. """ # lazy shortcut self_3D = self.to_3d() other_3D = other.to_3d() dist = self_3D.distance(other_3D) return dist.to_sky()
_Tdist = TypeVar("_Tdist", bound="Distance") @total_ordering class Distance(ABC): """Base class for a vector of distances.""" def __init__(self, distance: ArrayLike) -> None: """Constructs a new vector of distances. Args: distance (:obj:`float`, :obj:`NDArray`): Distance values. .. Note:: The coordinate vector has a length, supports numpy-style indexing and iteration over the elements (always returning new coordinate vector instances). Additionally, distances implement element-wise addition and subtraction, as well as the comparison operators. """ self._values = np.atleast_1d(distance).astype(np.float64) @classmethod def from_dists(cls: _Tdist, dists: Sequence[_Tdist]) -> _Tdist: """Concatenate a sequence of distances into a new vector of distances.""" return cls(np.concatenate([dist._values for dist in dists], axis=0)) def __len__(self) -> int: return len(self._values) def __getitem__(self, idx: ArrayLike) -> CoordSky: return self.__class__(self._values[idx]) def __iter__(self) -> Iterator[CoordSky]: for i in range(len(self)): yield self[i] @property def values(self) -> NDArray[np.float64] | float: """The distances cast into a numpy array with shape `(N,)` or a scalar if there is only a single distance.""" return np.squeeze(self._values) def __repr__(self) -> str: return f"{self.__class__.__name__}({self.values})" def __format__(self, __format_spec: str) -> str: return self.values.__format__(__format_spec) def __eq__(self, other: object) -> ArrayLike[np.bool_]: if isinstance(other, self.__class__): return self.values == other.values return NotImplemented def __lt__(self, other: Distance) -> ArrayLike[np.bool_]: if isinstance(other, self.__class__): return self.values < other.values return NotImplemented @abstractmethod def __add__(self, other: object) -> _Tdist: pass @abstractmethod def __sub__(self, other: object) -> _Tdist: pass def min(self) -> _Tdist: """Compute the minimum value and return it as new `Distance` instance.""" return self.__class__(self.values.min()) def max(self) -> _Tdist: """Compute the maximum value and return it as new `Distance` instance.""" return self.__class__(self.values.max()) @abstractmethod def to_3d(self) -> Dist3D: """Convert the distance to the Euclidean distance.""" pass @abstractmethod def to_sky(self) -> DistSky: """Convert the distance to an angular separation in radian.""" pass
[docs] class Dist3D(Distance): """Implements a vector of Euclidean distances.""" def __add__(self, other: object) -> DistSky: if isinstance(other, Dist3D): dist_sky = self.to_sky() + other.to_sky() return dist_sky.to_3d() return NotImplemented def __sub__(self, other: object) -> DistSky: if isinstance(other, Dist3D): dist_sky = self.to_sky() - other.to_sky() return dist_sky.to_3d() return NotImplemented
[docs] def to_3d(self) -> Dist3D: return self
[docs] def to_sky(self) -> DistSky: if np.any(self._values > 2.0): raise ValueError("distance exceeds size of unit sphere") return DistSky(2.0 * np.arcsin(self.values / 2.0))
[docs] class DistSky(Distance): """Implements a vector of angular distances in radian.""" def __add__(self, other: object) -> DistSky: if isinstance(other, DistSky): return DistSky(self.values + other.values) return NotImplemented def __sub__(self, other: object) -> DistSky: if isinstance(other, DistSky): return DistSky(self.values - other.values) return NotImplemented
[docs] def to_3d(self) -> Dist3D: return Dist3D(2.0 * np.sin(self.values / 2.0))
[docs] def to_sky(self) -> DistSky: return self