r"""
The following introduces the
:py:func:`Coordinates class <pyfar.Coordinates>`
and the coordinate systems that are available in pyfar. Available sampling
schemes are listed at :py:mod:`spharpy.samplings <spharpy.samplings>`.
:ref:`Examples <gallery:/gallery/interactive/pyfar_coordinates.ipynb>` for
working with Coordinates objects are part of the pyfar gallery.
Different coordinate systems are frequently used in acoustics research and
handling sampling points and different systems can be cumbersome. The
Coordinates class was designed with this in mind. It stores coordinates in
cartesian coordinates internally and can convert to all coordinate systems
listed below. Additionally, the class can query and plot coordinates
points. Addition and subtraction are supported with numbers and Coordinates
objects, while multiplication and division are supported with numbers only.
All arithmetic operations are performed element-wise on Cartesian coordinates
using the appropriate operator.
Functions for converting coordinates not stored in a Coordinates object
are available for convenience. However, it is strongly recommended to use the
Coordinates class for all conversions.
.. _coordinate_systems:
Coordinate Systems
------------------
Each coordinate system has a unique name, e.g., `spherical_elevation`, and is
defined by three coordinates, in this case `azimuth`, `elevation`, and
`radius`. The available coordinate systems are shown in the image below.
|coordinate_systems|
.. _coordinates:
Coordinates
-----------
The unit for length for the coordinates is always meter, while the unit for
angles is radians. Each coordinate is unique, but can appear in multiple
coordinate systems, e.g., the `azimuth` angle is contained in two coordinate
systems (`spherical_colatitude` and `spherical_elevation`). The table below
lists all coordinates.
.. note::
All coordinates are returned as copies of the internal data. This means
that for example ``coordinates.x[0] = 0`` does not change
``coordinates.x``. This can be done using
.. code-block:: python
new_x = coordinates.x
new_x[0] = 0
coordinates.x = new_x
.. list-table::
:widths: 25 75
:header-rows: 1
* - Coordinate
- Descriptions
* - :py:func:`~pyfar.Coordinates.x`,
:py:func:`~pyfar.Coordinates.y`,
:py:func:`~pyfar.Coordinates.z`
- x, y, z coordinate of a right handed Cartesian coordinate system in
meter (:math:`-\infty` < x,y,z < :math:`\infty`).
* - :py:func:`~pyfar.Coordinates.azimuth`
- Counter clock-wise angle in the x-y plane of the right handed Cartesian
coordinate system in radians. :math:`0` radians are defined in positive
x-direction, :math:`\pi/2` radians in positive y-direction and so on
(:math:`-\infty` < azimuth < :math:`\infty`, :math:`2\pi`-cyclic).
* - :py:func:`~pyfar.Coordinates.colatitude`
- Angle in the x-z plane of the right handed Cartesian coordinate system
in radians. :math:`0` radians colatitude are defined in positive
z-direction, :math:`\pi/2` radians in positive x-direction, and
:math:`\pi` in negative z-direction
(:math:`0\leq` colatitude :math:`\leq\pi`). The colatitude is a
variation of the elevation angle.
* - :py:func:`~pyfar.Coordinates.elevation`
- Angle in the x-z plane of the right handed Cartesian coordinate system
in radians. :math:`0` radians elevation are defined in positive
x-direction, :math:`\pi/2` radians in positive z-direction, and
:math:`-\pi/2` in negative z-direction
(:math:`-\pi/2\leq` elevation :math:`\leq\pi/2`). The elevation is a
variation of the colatitude.
* - :py:func:`~pyfar.Coordinates.lateral`
- Counter clock-wise angle in the x-y plane of the right handed Cartesian
coordinate system in radians. :math:`0` radians are defined in positive
x-direction, :math:`\pi/2` radians in positive y-direction and
:math:`-\pi/2` in negative y-direction
(:math:`-\pi/2\leq` lateral :math:`\leq\pi/2`).
* - :py:func:`~pyfar.Coordinates.polar`
- Angle in the x-z plane of the right handed Cartesian coordinate system
in radians. :math:`0` radians polar angle are defined in positive
x-direction, :math:`\pi/2` radians in positive z-direction,
:math:`\pi` in negative x-direction and so on
(:math:`-\infty` < polar < :math:`\infty`, :math:`2\pi`-cyclic).
* - :py:func:`~pyfar.Coordinates.frontal`
- Angle in the y-z plane of the right handed Cartesian coordinate system
in radians. :math:`0` radians frontal angle are defined in positive
y-direction, :math:`\pi/2` radians in positive z-direction,
:math:`\pi` in negative y-direction and so on
(:math:`-\infty` < frontal < :math:`\infty`, :math:`2\pi`-cyclic).
* - :py:func:`~pyfar.Coordinates.upper`
- Angle in the x-z plane of the right handed Cartesian coordinate system
in radians. :math:`0` radians upper angle are defined in positive
x-direction, :math:`\pi/2` radians in positive z-direction, and
:math:`\pi` in negative x-direction
(:math:`0\leq` upper :math:`\leq\pi`).
* - :py:func:`~pyfar.Coordinates.radius`
- Distance to the origin of the right handed Cartesian coordinate system
in meters (:math:`0` < radius < :math:`\infty`).
* - :py:func:`~pyfar.Coordinates.rho`
- Radial distance to the the z-axis of the right handed Cartesian
coordinate system (:math:`0` < rho < :math:`\infty`).
.. |coordinate_systems| image:: resources/coordinate_systems.png
:width: 100%
:alt: pyfar coordinate systems
"""
import numpy as np
from scipy.spatial import cKDTree
from scipy.spatial.transform import Rotation as sp_rot
import re
from copy import deepcopy
import pyfar as pf
[docs]
class Coordinates():
r"""
Create a Coordinates class object from a set of points in the
right-handed cartesian coordinate system. See
see :ref:`coordinate_systems` and :ref:`coordinates` for
more information.
If you want to initialize in another
domain use :py:func:`from_spherical_colatitude`,
:py:func:`from_spherical_elevation`, :py:func:`from_spherical_front`,
:py:func:`from_spherical_side`, or :py:func:`from_cylindrical`
instead. For conversions from or into degree
use :py:func:`deg2rad` and :py:func:`rad2deg`.
Parameters
----------
x : ndarray, number
X coordinate of a right handed Cartesian coordinate system in
meters (:math:`-\infty` < x < :math:`\infty`).
y : ndarray, number
Y coordinate of a right handed Cartesian coordinate system in
meters (:math:`-\infty` < y < :math:`\infty`).
z : ndarray, number
Z coordinate of a right handed Cartesian coordinate system in
meters (:math:`-\infty` < z < :math:`\infty`).
weights: array like, number, optional
Weighting factors for coordinate points. The `shape` of the array
must match the `shape` of the individual coordinate arrays.
The default is ``None``.
comment : str, optional
Comment about the stored coordinate points. The default is
``""``, which initializes an empty string.
"""
_x: np.array = np.empty
_y: np.array = np.empty
_z: np.array = np.empty
_weights: np.array = None
_comment: str = None
def __init__(
self, x: np.array = np.asarray([]),
y: np.array = np.asarray([]),
z: np.array = np.asarray([]),
weights: np.array = None,
comment: str = "") -> None:
# init empty object
super(Coordinates, self).__init__()
# init cartesian coordinates
self._set_points(x, y, z)
# save meta data
self._set_weights(weights)
self._comment = comment
[docs]
@classmethod
def from_cartesian(
cls, x, y, z, weights: np.array = None, comment: str = ""):
r"""
Create a Coordinates class object from a set of points in the
right-handed cartesian coordinate system. See
see :ref:`coordinate_systems` and :ref:`coordinates` for
more information.
Parameters
----------
x : ndarray, number
X coordinate of a right handed Cartesian coordinate system in
meters (:math:`-\infty` < x < :math:`\infty`).
y : ndarray, number
Y coordinate of a right handed Cartesian coordinate system in
meters ():math:`-\infty` < y < :math:`\infty`).
z : ndarray, number
Z coordinate of a right handed Cartesian coordinate system in
meters (:math:`-\infty` < z < :math:`\infty`).
weights: array like, number, optional
Weighting factors for coordinate points. The `shape` of the array
must match the `shape` of the individual coordinate arrays.
The default is ``None``.
comment : str, optional
Comment about the stored coordinate points. The default is
``""``, which initializes an empty string.
Examples
--------
Create a coordinates object
>>> import pyfar as pf
>>> coordinates = pf.Coordinates.from_cartesian(0, 0, 1)
Or the using init
>>> import pyfar as pf
>>> coordinates = pf.Coordinates(0, 0, 1)
"""
return cls(x, y, z, weights=weights, comment=comment)
[docs]
@classmethod
def from_spherical_elevation(
cls, azimuth, elevation, radius, weights: np.array = None,
comment: str = ""):
"""Create a Coordinates class object from a set of points in the
spherical coordinate system. See
see :ref:`coordinate_systems` and :ref:`coordinates` for
more information.
Parameters
----------
azimuth : ndarray, double
Angle in radiant of rotation from the x-y-plane facing towards
positive x direction. Used for spherical and cylindrical coordinate
systems.
elevation : ndarray, double
Angle in radiant with respect to horizontal plane (x-z-plane).
Used for spherical coordinate systems.
radius : ndarray, double
Distance to origin for each point. Used for spherical coordinate
systems.
weights: array like, float, None, optional
Weighting factors for coordinate points. The `shape` of the array
must match the `shape` of the individual coordinate arrays.
The default is ``None``.
comment : str, optional
Comment about the stored coordinate points. The default is
``""``, which initializes an empty string.
Examples
--------
Create a coordinates object
>>> import pyfar as pf
>>> coordinates = pf.Coordinates.from_spherical_elevation(0, 0, 1)
"""
x, y, z = sph2cart(azimuth, np.pi / 2 - elevation, radius)
return cls(x, y, z, weights=weights, comment=comment)
[docs]
@classmethod
def from_spherical_colatitude(
cls, azimuth, colatitude, radius, weights: np.array = None,
comment: str = ""):
"""Create a Coordinates class object from a set of points in the
spherical coordinate system. See
see :ref:`coordinate_systems` and :ref:`coordinates` for
more information.
Parameters
----------
azimuth : ndarray, double
Angle in radiant of rotation from the x-y-plane facing towards
positive x direction. Used for spherical and cylindrical coordinate
systems.
colatitude : ndarray, double
Angle in radiant with respect to polar axis (z-axis). Used for
spherical coordinate systems.
radius : ndarray, double
Distance to origin for each point. Used for spherical coordinate
systems.
weights: array like, number, optional
Weighting factors for coordinate points. The `shape` of the array
must match the `shape` of the individual coordinate arrays.
The default is ``None``.
comment : str, optional
Comment about the stored coordinate points. The default is
``""``, which initializes an empty string.
Examples
--------
Create a coordinates object
>>> import pyfar as pf
>>> coordinates = pf.Coordinates.from_spherical_colatitude(0, 0, 1)
"""
x, y, z = sph2cart(azimuth, colatitude, radius)
return cls(x, y, z, weights=weights, comment=comment)
[docs]
@classmethod
def from_spherical_side(
cls, lateral, polar, radius, weights: np.array = None,
comment: str = ""):
"""Create a Coordinates class object from a set of points in the
spherical coordinate system. See
see :ref:`coordinate_systems` and :ref:`coordinates` for
more information.
Parameters
----------
lateral : ndarray, double
Angle in radiant with respect to horizontal plane (x-y-plane).
Used for spherical coordinate systems.
polar : ndarray, double
Angle in radiant of rotation from the x-z-plane facing towards
positive x direction. Used for spherical coordinate systems.
radius : ndarray, double
Distance to origin for each point. Used for spherical coordinate
systems.
weights: array like, number, optional
Weighting factors for coordinate points. The `shape` of the array
must match the `shape` of the individual coordinate arrays.
The default is ``None``.
comment : str, optional
Comment about the stored coordinate points. The default is
``""``, which initializes an empty string.
Examples
--------
Create a coordinates object
>>> import pyfar as pf
>>> coordinates = pf.Coordinates.from_spherical_side(0, 0, 1)
"""
x, z, y = sph2cart(polar, np.pi / 2 - lateral, radius)
return cls(x, y, z, weights=weights, comment=comment)
[docs]
@classmethod
def from_spherical_front(
cls, frontal, upper, radius, weights: np.array = None,
comment: str = ""):
"""Create a Coordinates class object from a set of points in the
spherical coordinate system. See
see :ref:`coordinate_systems` and :ref:`coordinates` for
more information.
Parameters
----------
frontal : ndarray, double
Angle in radiant of rotation from the y-z-plane facing towards
positive y direction. Used for spherical coordinate systems.
upper : ndarray, double
Angle in radiant with respect to polar axis (x-axis). Used for
spherical coordinate systems.
radius : ndarray, double
Distance to origin for each point. Used for spherical coordinate
systems.
weights: array like, number, optional
Weighting factors for coordinate points. The `shape` of the array
must match the `shape` of the individual coordinate arrays.
The default is ``None``.
comment : str, optional
Comment about the stored coordinate points. The default is
``""``, which initializes an empty string.
Examples
--------
Create a coordinates object
>>> import pyfar as pf
>>> coordinates = pf.Coordinates.from_spherical_front(0, 0, 1)
"""
y, z, x = sph2cart(frontal, upper, radius)
return cls(x, y, z, weights=weights, comment=comment)
[docs]
@classmethod
def from_cylindrical(
cls, azimuth, z, rho, weights: np.array = None,
comment: str = ""):
"""Create a Coordinates class object from a set of points in the
cylindrical coordinate system. See
see :ref:`coordinate_systems` and :ref:`coordinates` for
more information.
Parameters
----------
azimuth : ndarray, double
Angle in radiant of rotation from the x-y-plane facing towards
positive x direction. Used for spherical and cylindrical coordinate
systems.
z : ndarray, double
The z coordinate
rho : ndarray, double
Distance to origin for each point in the x-y-plane. Used for
cylindrical coordinate systems.
weights: array like, number, optional
Weighting factors for coordinate points. The `shape` of the array
must match the `shape` of the individual coordinate arrays.
The default is ``None``.
comment : str, optional
Comment about the stored coordinate points. The default is
``""``, which initializes an empty string.
Examples
--------
Create a coordinates object
>>> import pyfar as pf
>>> coordinates = pf.Coordinates.from_cylindrical(0, 0, 1)
"""
x, y, z = cyl2cart(azimuth, z, rho)
return cls(x, y, z, weights=weights, comment=comment)
@property
def weights(self):
"""Get sampling weights."""
return self._weights
@weights.setter
def weights(self, value):
"""Set sampling weights."""
self._set_weights(value)
@property
def comment(self):
"""Get comment."""
return self._comment
@comment.setter
def comment(self, value):
"""Set comment."""
if not isinstance(value, str):
raise TypeError("comment has to be of type string.")
else:
self._comment = value
@property
def cshape(self):
"""
Return channel shape.
The channel shape gives the shape of the coordinate points excluding
the last dimension, which is always 3.
"""
if self._x.size:
return self._x.shape
else:
return (0,)
@property
def cdim(self):
"""
Return channel dimension.
The channel dimension gives the number of dimensions of the coordinate
points excluding the last dimension.
"""
if self._x.size:
return self._x.ndim
else:
return 0
@property
def csize(self):
"""
Return channel size.
The channel size gives the number of points stored in the coordinates
object.
"""
return self._x.size
@property
def cartesian(self):
"""
Returns :py:func:`x`, :py:func:`y`, :py:func:`z`.
Right handed cartesian coordinate system. See
see :ref:`coordinate_systems` and :ref:`coordinates` for
more information.
"""
return np.atleast_2d(np.moveaxis(
np.array([self.x, self.y, self.z]), 0, -1))
@cartesian.setter
def cartesian(self, value):
self._set_points(value[..., 0], value[..., 1], value[..., 2])
@property
def spherical_elevation(self):
"""
Spherical coordinates according to the top pole elevation coordinate
system. :py:func:`azimuth`, :py:func:`elevation`,
:py:func:`radius`. See
see :ref:`coordinate_systems` and :ref:`coordinates` for
more information.
"""
azimuth, elevation, radius = cart2sph(self.x, self.y, self.z)
elevation = np.pi / 2 - elevation
return np.atleast_2d(np.moveaxis(
np.array([azimuth, elevation, radius]), 0, -1))
@spherical_elevation.setter
def spherical_elevation(self, value):
value[..., 1] = _check_array_limits(
value[..., 1], -np.pi/2, np.pi/2, 'elevation angle')
x, y, z = sph2cart(
value[..., 0], np.pi / 2 - value[..., 1], value[..., 2])
self._set_points(x, y, z)
@property
def spherical_colatitude(self):
"""
Spherical coordinates according to the top pole colatitude coordinate
system.
Returns :py:func:`azimuth`, :py:func:`colatitude`,
:py:func:`radius`. See
see :ref:`coordinate_systems` and :ref:`coordinates` for
more information.
"""
azimuth, colatitude, radius = cart2sph(self.x, self.y, self.z)
return np.atleast_2d(np.moveaxis(
np.array([azimuth, colatitude, radius]), 0, -1))
@spherical_colatitude.setter
def spherical_colatitude(self, value):
value[..., 1] = _check_array_limits(
value[..., 1], 0, np.pi, 'colatitude angle')
x, y, z = sph2cart(value[..., 0], value[..., 1], value[..., 2])
self._set_points(x, y, z)
@property
def spherical_side(self):
"""
Spherical coordinates according to the side pole coordinate system.
Returns :py:func:`lateral`, :py:func:`polar`, :py:func:`radius`. See
see :ref:`coordinate_systems` and :ref:`coordinates` for
more information.
"""
polar, lateral, radius = cart2sph(self.x, self.z, -self.y)
lateral = lateral - np.pi / 2
polar = np.mod(polar + np.pi / 2, 2 * np.pi) - np.pi / 2
return np.atleast_2d(np.moveaxis(
np.array([lateral, polar, radius]), 0, -1))
@spherical_side.setter
def spherical_side(self, value):
value[..., 0] = _check_array_limits(
value[..., 0], -np.pi/2, np.pi/2, 'polar angle')
x, z, y = sph2cart(
value[..., 1], np.pi / 2 - value[..., 0], value[..., 2])
self._set_points(x, y, z)
@property
def spherical_front(self):
"""
Spherical coordinates according to the frontal pole coordinate system.
Returns :py:func:`frontal`, :py:func:`upper`, :py:func:`radius`. See
see :ref:`coordinate_systems` and :ref:`coordinates` for
more information.
"""
frontal, upper, radius = cart2sph(self.y, self.z, self.x)
return np.atleast_2d(np.moveaxis(
np.array([frontal, upper, radius]), 0, -1))
@spherical_front.setter
def spherical_front(self, value):
value[..., 1] = _check_array_limits(
value[..., 1], 0, np.pi, 'frontal angle')
y, z, x = sph2cart(value[..., 0], value[..., 1], value[..., 2])
self._set_points(x, y, z)
@property
def cylindrical(self):
"""
Cylindrical coordinates.
Returns :py:func:`azimuth`, :py:func:`z`, :py:func:`rho`. See
see :ref:`coordinate_systems` and :ref:`coordinates` for
more information.
"""
azimuth, z, rho = cart2cyl(self.x, self.y, self.z)
return np.atleast_2d(np.moveaxis(
np.array([azimuth, z, rho]), 0, -1))
@cylindrical.setter
def cylindrical(self, value):
x, y, z = cyl2cart(value[..., 0], value[..., 1], value[..., 2])
self._set_points(x, y, z)
@property
def x(self):
r"""
X coordinate of a right handed Cartesian coordinate system in meters
(:math:`-\infty` < x < :math:`\infty`).
"""
self._check_empty()
return self._x.copy()
@x.setter
def x(self, value):
self._set_points(value, self.y, self.z)
@property
def y(self):
r"""
Y coordinate of a right handed Cartesian coordinate system in meters
(:math:`-\infty` < y < :math:`\infty`).
"""
self._check_empty()
return self._y.copy()
@y.setter
def y(self, value):
self._set_points(self.x, value, self.z)
@property
def z(self):
r"""
Z coordinate of a right handed Cartesian coordinate system in meters
(:math:`-\infty` < z < :math:`\infty`).
"""
self._check_empty()
return self._z.copy()
@z.setter
def z(self, value):
self._set_points(self.x, self.y, value)
@property
def rho(self):
r"""
Radial distance to the the z-axis of the right handed Cartesian
coordinate system (:math:`0` < rho < :math:`\infty`).
"""
return self.cylindrical[..., 2]
@rho.setter
def rho(self, rho):
cylindrical = self.cylindrical
cylindrical[..., 2] = rho
self.cylindrical = cylindrical
@property
def radius(self):
r"""
Distance to the origin of the right handed Cartesian coordinate system
in meters (:math:`0` < radius < :math:`\infty`).
"""
return np.sqrt(self.x**2 + self.y**2 + self.z**2)
@radius.setter
def radius(self, radius):
spherical_colatitude = self.spherical_colatitude
spherical_colatitude[..., 2] = radius
self.spherical_colatitude = spherical_colatitude
@property
def azimuth(self):
r"""
Counter clock-wise angle in the x-y plane of the right handed Cartesian
coordinate system in radians. :math:`0` radians are defined in positive
x-direction, :math:`\pi/2` radians in positive y-direction and so on
(:math:`-\infty` < azimuth < :math:`\infty`, :math:`2\pi`-cyclic).
"""
return self.spherical_colatitude[..., 0]
@azimuth.setter
def azimuth(self, azimuth):
spherical_colatitude = self.spherical_colatitude
spherical_colatitude[..., 0] = azimuth
self.spherical_colatitude = spherical_colatitude
@property
def elevation(self):
r"""
Angle in the x-z plane of the right handed Cartesian coordinate system
in radians. :math:`0` radians elevation are defined in positive
x-direction, :math:`\pi/2` radians in positive z-direction, and
:math:`-\pi/2` in negative z-direction
(:math:`-\pi/2\leq` elevation :math:`\leq\pi/2`). The elevation is a
variation of the colatitude.
"""
return self.spherical_elevation[..., 1]
@elevation.setter
def elevation(self, elevation):
spherical_elevation = self.spherical_elevation
spherical_elevation[..., 1] = elevation
self.spherical_elevation = spherical_elevation
@property
def colatitude(self):
r"""
Angle in the x-z plane of the right handed Cartesian coordinate system
in radians. :math:`0` radians colatitude are defined in positive
z-direction, :math:`\pi/2` radians in positive x-direction, and
:math:`\pi` in negative z-direction
(:math:`0\leq` colatitude :math:`\leq\pi`). The colatitude is a
variation of the elevation angle.
"""
return self.spherical_colatitude[..., 1]
@colatitude.setter
def colatitude(self, colatitude):
spherical_colatitude = self.spherical_colatitude
spherical_colatitude[..., 1] = colatitude
self.spherical_colatitude = spherical_colatitude
@property
def frontal(self):
r"""
Angle in the y-z plane of the right handed Cartesian coordinate system
in radians. :math:`0` radians frontal angle are defined in positive
y-direction, :math:`\pi/2` radians in positive z-direction,
:math:`\pi` in negative y-direction and so on
(:math:`-\infty` < frontal < :math:`\infty`, :math:`2\pi`-cyclic).
"""
return self.spherical_front[..., 0]
@frontal.setter
def frontal(self, frontal):
spherical_front = self.spherical_front
spherical_front[..., 0] = frontal
self.spherical_front = spherical_front
@property
def upper(self):
r"""
Angle in the x-z plane of the right handed Cartesian coordinate system
in radians. :math:`0` radians upper angle are defined in positive
x-direction, :math:`\pi/2` radians in positive z-direction, and
:math:`\pi` in negative x-direction
(:math:`0\leq` upper :math:`\leq\pi`).
"""
return self.spherical_front[..., 1]
@upper.setter
def upper(self, upper):
spherical_front = self.spherical_front
spherical_front[..., 1] = upper
self.spherical_front = spherical_front
@property
def lateral(self):
r"""
Counter clock-wise angle in the x-y plane of the right handed Cartesian
coordinate system in radians. :math:`0` radians are defined in positive
x-direction, :math:`\pi/2` radians in positive y-direction and
:math:`-\pi/2` in negative y-direction
(:math:`-\pi/2\leq` lateral :math:`\leq\pi/2`).
"""
return self.spherical_side[..., 0]
@lateral.setter
def lateral(self, lateral):
spherical_side = self.spherical_side
spherical_side[..., 0] = lateral
self.spherical_side = spherical_side
@property
def polar(self):
r"""
Angle in the x-z plane of the right handed Cartesian coordinate system
in radians. :math:`0` radians polar angle are defined in positive
x-direction, :math:`\pi/2` radians in positive z-direction,
:math:`\pi` in negative x-direction and so on
(:math:`-\infty` < polar < :math:`\infty`, :math:`2\pi`-cyclic).
"""
return self.spherical_side[..., 1]
@polar.setter
def polar(self, polar):
spherical_side = self.spherical_side
spherical_side[..., 1] = polar
self.spherical_side = spherical_side
[docs]
def show(self, mask=None, **kwargs):
"""
Show a scatter plot of the coordinate points.
Parameters
----------
mask : boolean numpy array, None, optional
Mask or indexes to highlight. Highlight points in red if
``mask==True``.
The default is ``None``, which plots all points in the same color.
kwargs : optional
Keyword arguments are passed to
:py:func:`matplotlib.pyplot.scatter`.
If a mask is provided and the key `c` is contained in kwargs, it
will be overwritten.
Returns
-------
ax : :py:class:`~mpl_toolkits.mplot3d.axes3d.Axes3D`
The axis used for the plot.
"""
if mask is None:
ax = pf.plot.scatter(self, **kwargs)
else:
mask = np.asarray(mask)
colors = np.full(self.cshape, pf.plot.color('b'))
colors[mask] = pf.plot.color('r')
ax = pf.plot.scatter(self, c=colors.flatten(), **kwargs)
ax.set_box_aspect([
np.ptp(self.x),
np.ptp(self.y),
np.ptp(self.z)])
ax.set_aspect('equal')
return ax
[docs]
def find_nearest(self, find, k=1, distance_measure='euclidean',
radius_tol=None):
"""
Find the k nearest coordinates points.
Parameters
----------
find : pf.Coordinates
Coordinates to which the nearest neighbors are searched.
k : int, optional
Number of points to return. k must be > 0. The default is ``1``.
distance_measure : string, optional
``'euclidean'``
distance is determined by the euclidean distance.
This is default.
``'spherical_radians'``
distance is determined by the great-circle distance
expressed in radians.
``'spherical_meter'``
distance is determined by the great-circle distance
expressed in meters.
radius_tol : float, None
For all spherical distance measures, the coordinates must be on
a sphere, so the radius must be constant. This parameter defines
the maximum allowed difference within the radii. Note that
increasing the tolerance decreases the accuracy of the search.
The default ``None`` uses a tolerance of two times the decimal
resolution, which is determined from the data type of the
coordinate points using ``numpy.finfo``.
Returns
-------
index : tuple of arrays
Indices of the neighbors. Arrays of shape ``(k, find.cshape)``
if k>1 else ``(find.cshape, )``.
distance : numpy array of floats
Distance between the points, after the given ``distance_measure``.
It's of shape (k, find.cshape).
Notes
-----
This is a wrapper for :py:class:`scipy.spatial.cKDTree`.
Examples
--------
Find frontal point from a spherical coordinate system
.. plot::
>>> import pyfar as pf
>>> import numpy as np
>>> coords = pf.Coordinates.from_spherical_elevation(
>>> np.arange(0, 360, 10)*np.pi/180, 0, 1)
>>> to_find = pf.Coordinates(1, 0, 0)
>>> index, distance = coords.find_nearest(to_find)
>>> ax = coords.show(index)
>>> distance
0.0
Find multidimensional points in multidimensional coordinates with k=1
>>> import pyfar as pf
>>> import numpy as np
>>> coords = pf.Coordinates(np.arange(9).reshape((3, 3)), 0, 1)
>>> to_find = pf.Coordinates(
>>> np.array([[0, 1], [2, 3]]), 0, 1)
>>> i, d = coords.find_nearest(to_find)
>>> coords[i] == find
True
>>> i
(array([[0, 0],
[0, 1]], dtype=int64),
array([[0, 1],
[2, 0]], dtype=int64))
>>> d
array([[0., 0.],
[0., 0.]])
Find multidimensional points in multidimensional coordinates with k=3
>>> import pyfar as pf
>>> import numpy as np
>>> coords = pf.Coordinates(np.arange(9).reshape((3, 3)), 0, 1)
>>> find = pf.Coordinates(
>>> np.array([[0, 1], [2, 3]]), 0, 1)
>>> i, d = coords.find_nearest(find, 3)
>>> # the k-th dimension is at the end
>>> i[0].shape
(3, 2, 2)
>>> # now just access the k=0 dimension
>>> coords[i][0].cartesian
array([[[0., 0., 1.],
[1., 0., 1.]],
[[2., 0., 1.],
[3., 0., 1.]]])
"""
# check the input
if radius_tol is None:
radius_tol = 2 * np.finfo(self.x.dtype).resolution
if not isinstance(radius_tol, float) or radius_tol < 0:
raise ValueError("radius_tol must be a non negative number.")
if not isinstance(k, int) or k <= 0 or k > self.csize:
raise ValueError("k must be an integer > 0 and <= self.csize.")
if not isinstance(find, Coordinates):
raise ValueError("find must be an pf.Coordinates object.")
allowed_measures = [
'euclidean', 'spherical_radians', 'spherical_meter']
if distance_measure not in allowed_measures:
raise ValueError(
f"distance_measure needs to be in {allowed_measures} and "
f"it is {distance_measure}")
# get target point in cartesian coordinates
points = find.cartesian
# get KDTree
kdtree = self._make_kdtree()
# query nearest neighbors
points = points.flatten() if find.csize == 1 else points
# nearest points
distance, index = kdtree.query(points, k=k)
if distance_measure in ['spherical_radians', 'spherical_meter']:
# determine validate radius
radius = np.concatenate(
(self.radius.flatten(), find.radius.flatten()))
delta_radius = np.max(radius) - np.min(radius)
if delta_radius > radius_tol:
raise ValueError(
f"find_nearest only works if all points have the same \
radius. Differences are larger than {radius_tol}")
radius = np.max(radius)
# convert cartesian coordinates to length on the great circle using
# the Haversine formula
distance = 2 * np.arcsin(distance / (2 * radius))
if distance_measure == 'spherical_meter':
# convert angle in radiant to distance on the sphere
# distance = 2*radius*pi*distance/(2*pi) = radius*distance
distance *= radius
if self.cdim == 1:
if k > 1:
index_multi = np.moveaxis(index, -1, 0)
index = np.empty((k), dtype=tuple)
for kk in range(k):
index[kk] = (index_multi[kk], )
else:
index = (index, )
else:
index_array = np.arange(self.csize).reshape(self.cshape)
index_multi = []
for dim in range(self.cdim):
index_multi.append([])
for i in index.flatten():
index_multi[dim].append(np.where(i == index_array)[dim][0])
index_multi[dim] = np.asarray(
index_multi[dim]).reshape(index.shape)
if k > 1:
index_multi = np.moveaxis(index_multi, -1, 0)
index = np.empty((k), dtype=tuple)
for kk in range(k):
index[kk] = tuple(index_multi[kk])
else:
index = tuple(index_multi)
if k > 1:
distance = np.moveaxis(distance, -1, 0)
return index, distance
[docs]
def find_within(
self, find, distance=0., distance_measure='euclidean',
atol=None, return_sorted=True, radius_tol=None):
"""
Find coordinates within a certain distance to the query points.
Parameters
----------
find : pf.Coordinates
Coordinates to which the nearest neighbors are searched.
distance : number, optional
Maximum allowed distance to the given points ``find``.
Distance must be >= 0. For just exact matches use ``0``.
The default is ``0``.
distance_measure : string, optional
``'euclidean'``
distance is determined by the euclidean distance.
This is default.
``'spherical_radians'``
distance is determined by the great-circle distance
expressed in radians.
``'spherical_meter'``
distance is determined by the great-circle distance
expressed in meters.
atol : float, None
Absolute tolerance for distance. The default ``None`` uses a
tolerance of two times the decimal resolution, which is
determined from the data type of the coordinate points
using :py:class:`numpy.finfo`.
return_sorted : bool, optional
Sorts returned indices if True and does not sort them if False.
The default is True.
radius_tol : float, None
For all spherical distance measures, the coordinates must be on
a sphere, so the radius must be constant. This parameter defines
the maximum allowed difference within the radii. Note that
increasing the tolerance decreases the accuracy of the search,
i.e., points that are within the search distance might not be
found or points outside the search distance may be returned.
The default ``None`` uses a tolerance of two times the decimal
resolution, which is determined from the data type of the
coordinate points using :py:class:`numpy.finfo`.
Returns
-------
index : tuple of array
Indices of the containing coordinates. Arrays of shape
(find.cshape).
Notes
-----
This is a wrapper for :py:class:`scipy.spatial.cKDTree`.
Compared to previous implementations, it supports self.ndim>1 as well.
Examples
--------
Find all point with 0.2 m distance from the frontal point
.. plot::
>>> import pyfar as pf
>>> import numpy as np
>>> coords = pf.Coordinates.from_spherical_elevation(
>>> np.arange(0, 360, 5)*np.pi/180, 0, 1)
>>> find = pf.Coordinates(.2, 0, 0)
>>> index = coords.find_within(find, 1)
>>> coords.show(index)
Find all point with 1m distance from two points
.. plot::
>>> import pyfar as pf
>>> coords = pf.Coordinates(np.arange(6), 0, 0)
>>> find = pf.Coordinates([2, 3], 0, 0)
>>> index = coords.find_within(find, 1)
>>> coords.show(index[0])
"""
# check the input
if radius_tol is None:
radius_tol = 2 * np.finfo(self.x.dtype).resolution
if atol is None:
atol = 2 * np.finfo(self.x.dtype).resolution
if float(distance) < 0:
raise ValueError("distance must be a non negative number.")
if not isinstance(atol, float) or atol < 0:
raise ValueError("atol must be a non negative number.")
if not isinstance(radius_tol, float) or radius_tol < 0:
raise ValueError("radius_tol must be a non negative number.")
if not isinstance(find, Coordinates):
raise ValueError("coords must be an pf.Coordinates object.")
if not isinstance(return_sorted, bool):
raise ValueError("return_sorted must be a bool.")
allowed_measures = [
'euclidean', 'spherical_radians', 'spherical_meter']
if distance_measure not in allowed_measures:
raise ValueError(
f"distance_measure needs to be in {allowed_measures} and "
f"it is {distance_measure}")
# get target point in cartesian coordinates
points = find.cartesian
# get KDTree
kdtree = self._make_kdtree()
# query nearest neighbors
points = points.flatten() if find.csize == 1 else points
# nearest points
if distance_measure == 'euclidean':
index = kdtree.query_ball_point(
points, distance + atol, return_sorted=return_sorted)
if distance_measure in ['spherical_radians', 'spherical_meter']:
# determine validate radius
radius = np.concatenate(
(self.radius.flatten(), find.radius.flatten()))
delta_radius = np.max(radius) - np.min(radius)
if delta_radius > radius_tol:
raise ValueError(
"find_within only works if all points have the same "
f"radius. Differences are larger than {radius_tol}")
radius = np.max(radius)
if distance_measure == 'spherical_radians':
# convert angle in radiant to distance on the sphere
# d = 2r*pi*d/(2*pi) = r*d
distance = radius * distance
# convert length on the great circle to euclidean distance
distance = 2 * radius * np.sin(distance / (2 * radius))
index = kdtree.query_ball_point(
points, distance + atol, return_sorted=return_sorted)
if self.cdim == 1:
if find.csize > 1:
for i in range(len(index)):
index[i] = (index[i], )
else:
index = (index, )
else:
index_array = np.arange(self.csize).reshape(self.cshape)
index_new = np.empty((find.csize), dtype=tuple)
for i in range(find.csize):
index_multi = []
if find.csize > 1:
for j in index[i]:
index_multi.append(np.where(j == index_array))
else:
for j in index:
index_multi.append(np.where(j == index_array))
index_multi = np.moveaxis(np.squeeze(
np.asarray(index_multi)), -1, 0)
if find.csize > 1:
index_new[i] = tuple(index_multi)
else:
index_new = tuple(index_multi)
index = index_new
return index
[docs]
def rotate(self, rotation: str, value=None, degrees=True, inverse=False):
"""
Rotate points stored in the object around the origin of coordinates.
This is a wrapper for :py:class:`scipy.spatial.transform.Rotation`
(see this class for more detailed information).
Parameters
----------
rotation : str
``'quat'``
Rotation given by quaternions.
``'matrix'``
Rotation given by matrixes.
``'rotvec'``
Rotation using rotation vectors.
``'xyz'``
Rotation using euler angles. Up to three letters. E.g., ``'x'``
will rotate about the x-axis only, while ``'xz'`` will rotate
about the x-axis and then about the z-axis. Use lower letters
for extrinsic rotations (rotations about the axes of the
original coordinate system xyz, which remains motionless) and
upper letters for intrinsic rotations (rotations about the axes
of the rotating coordinate system XYZ, solidary with the moving
body, which changes its orientation after each elemental
rotation).
value : number, array like
Amount of rotation in the format specified by `rotation` (see
above).
degrees : bool, optional
Pass angles in degrees if using ``'rotvec'`` or euler angles
(``'xyz'``). The default is ``True``. Use False to pass angles in
radians.
inverse : bool, optional
Apply inverse rotation. The default is ``False``.
Notes
-----
Points are converted to the cartesian right handed coordinate system
for the rotation.
Examples
--------
Get a coordinates object
>>> import pyfar as pf
>>> coords = pf.Coordinates(np.arange(-5, 5), 0, 0)
Rotate 45 degrees about the y-axis using
1. quaternions
>>> coordinates.rotate('quat', [0 , 0.38268343, 0 , 0.92387953])
2. a rotation matrix
>>> coordinates.rotate('matrix',
... [[ 0.70710678, 0 , 0.70710678],
... [ 0 , 1 , 0. ],
... [-0.70710678, 0 , 0.70710678]])
3. a rotation vector
>>> coordinates.rotate('rotvec', [0, 45, 0])
4. euler angles
>>> coordinates.rotate('XYZ', [0, 45, 0])
To see the result of the rotation use
>>> coordinates.show()
"""
# initialize rotation object
if rotation == 'quat':
rot = sp_rot.from_quat(value)
elif rotation == 'matrix':
rot = sp_rot.from_matrix(value)
elif rotation == 'rotvec':
if degrees:
value = np.asarray(value) / 180 * np.pi
rot = sp_rot.from_rotvec(value)
elif not bool(re.search('[^x-z]', rotation.lower())):
# only check if string contains xyz, everything else is checked
# in from_euler()
rot = sp_rot.from_euler(rotation, value, degrees)
else:
raise ValueError("rotation must be 'quat', 'matrix', 'rotvec', "
"or from ['x', 'y', 'z'] or ['X', 'Y', 'Z'] but "
f"is '{rotation}'")
# current shape
shape = self.cshape
# apply rotation
points = rot.apply(self.cartesian.reshape((self.csize, 3)), inverse)
# set points
self._set_points(
points[:, 0].reshape(shape),
points[:, 1].reshape(shape),
points[:, 2].reshape(shape))
[docs]
def copy(self):
"""Return a deep copy of the Coordinates object."""
return deepcopy(self)
def _encode(self):
"""Return dictionary for the encoding."""
return self.copy().__dict__
@classmethod
def _decode(cls, obj_dict):
"""Decode object based on its respective ``_encode`` counterpart."""
obj = cls()
obj.__dict__.update(obj_dict)
return obj
def _set_points(self, x, y, z):
"""
Convert all points into at least 1d numpy arrays and broadcast them
to the same shape by calling ``_check_points``,
then assign the points to self._x, self._y and self._z.
Parameters
----------
x : array like, number
First coordinate of the points in cartesian.
y : array like, number
Second coordinate of the points in cartesian.
z : array like, number
Third coordinate of the points in cartesian.
Set self._x, self._y and self._z, which are at least 1d numpy arrays
of self.cshape.
"""
# check input
x, y, z = self._check_points(x, y, z)
# set values
self._x = x
self._y = y
self._z = z
def _check_points(self, x, y, z):
"""
Convert all coordinates into at least 1d float64 arrays and
broadcast the shape of all three coordinates to the same shape.
The returned arrays are explicitly set to be writeable, to make sure
that the class does not become read-only.
Parameters
----------
x : array like, number
First coordinate of the points in cartesian.
y : array like, number
Second coordinate of the points in cartesian.
z : array like, number
Third coordinate of the points in cartesian.
Returns
-------
x : np.ndarray[float64]
broadcasted first coordinate of the points in cartesian.
y : np.ndarray[float64]
broadcasted second coordinate of the points in cartesian.
z : np.ndarray[float64]
broadcasted third coordinate of the points in cartesian.
"""
# cast to numpy array
x = np.atleast_1d(np.asarray(x, dtype=np.float64))
y = np.atleast_1d(np.asarray(y, dtype=np.float64))
z = np.atleast_1d(np.asarray(z, dtype=np.float64))
# determine shape
shapes = np.broadcast_shapes(x.shape, y.shape, z.shape)
# broadcast to same shape
x = np.broadcast_to(x, shapes)
y = np.broadcast_to(y, shapes)
z = np.broadcast_to(z, shapes)
# set writeable, to make sure that the class does not become read-only
x.setflags(write=True)
y.setflags(write=True)
z.setflags(write=True)
return x, y, z
def _set_weights(self, weights):
"""
If not None convert to float64 numpy array and check for size.
Parameters
----------
weights : array like, number
the weights for each point.
Set self._weights, which is an atleast_1d numpy array of shape
[L,M,...,N].
"""
# check weights and convert to numpy array if not None
weights = self._check_weights(weights)
# set class variable
self._weights = weights
def _check_weights(self, weights):
"""
Convert weights into float64 numpy array and check versus the csize.
It will be reshaped to the cshape if the csize matches.
Parameters
----------
weights : array like, number
the weights for each point, should be of size of self.csize.
Returns
-------
weights : np.ndarray[float64], None
The weights reshaped to the cshape of the coordinates if not None.
Otherwise None.
"""
# if None no further checks are needed
if weights is None:
return weights
# cast to np.array
weights = np.asarray(weights, dtype=np.float64)
# reshape according to self._points
assert weights.size == self.csize, \
"weights must have same size as self.csize"
weights = weights.reshape(self.cshape)
return weights
def _make_kdtree(self):
"""Make a numpy KDTree for fast search of nearest points."""
xyz = self.cartesian
kdtree = cKDTree(xyz.reshape((self.csize, 3)))
return kdtree
def __getitem__(self, index):
"""Return copied slice of Coordinates object at index."""
new = self.copy()
# slice points
new._x = np.atleast_1d(new._x[index])
new._y = np.atleast_1d(new._y[index])
new._z = np.atleast_1d(new._z[index])
# slice weights
if new._weights is not None:
new._weights = new._weights[index]
return new
def __array__(self, copy=True, dtype=None):
"""Instances of Coordinates behave like `numpy.ndarray`, array_like."""
# copy to avoid changing the coordinate system of the original object
return np.array(self.cartesian, copy=copy, dtype=dtype)
def __repr__(self):
"""Get info about Coordinates object."""
# object type
if self.cshape != (0,):
obj = f"{self.cdim}D Coordinates object with {self.csize} points "\
f"of cshape {self.cshape}"
else:
obj = "Empty Coordinates object"
# join information
_repr = obj + "\n"
# check for sampling weights
if self._weights is None:
_repr += "\nDoes not contain sampling weights"
else:
_repr += "\nContains sampling weights"
# check for comment
if self._comment != "":
_repr += f"\nComment: {self._comment}"
return _repr
def __eq__(self, other):
"""Check for equality of two objects."""
if self.cshape != other.cshape:
return False
eq_x = self._x == other._x
eq_y = self._y == other._y
eq_z = self._z == other._z
eq_weights = self._weights == other._weights
eq_comment = self._comment == other._comment
return (eq_x & eq_y & eq_z).all() & eq_weights & eq_comment
def __add__(self, other):
"""Add two numbers/Coordinates objects."""
return _arithmetics(self, other, 'add')
def __radd__(self, other):
"""Add two numbers/Coordinates objects."""
return _arithmetics(other, self, 'add')
def __sub__(self, other):
"""Subtract two numbers/Coordinates objects."""
return _arithmetics(self, other, 'sub')
def __rsub__(self, other):
"""Subtract two numbers/Coordinates objects."""
return _arithmetics(other, self, 'sub')
def __mul__(self, other):
"""Multiply Coordinates object with number."""
return _arithmetics(self, other, 'mul')
def __rmul__(self, other):
"""Multiply number with Coordinates object."""
return _arithmetics(other, self, 'mul')
def __div__(self, other):
"""Divide Coordinates object with number."""
return _arithmetics(self, other, 'div')
def __truediv__(self, other):
"""Divide Coordinates object with number."""
return _arithmetics(self, other, 'div')
def __rtruediv__(self, other):
"""Divide number with Coordinates object."""
return _arithmetics(other, self, 'div')
def __rdiv__(self, other):
"""Divide number with Coordinates object."""
return _arithmetics(other, self, 'div')
def _check_empty(self):
"""Check if object is empty."""
if self.cshape == (0,):
raise ValueError('Object is empty.')
[docs]
def dot(a, b):
r"""Dot product of two Coordinates objects.
.. math::
\vec{a} \cdot \vec{b}
= a_x \cdot b_x + a_y \cdot b_y + a_z \cdot b_z
Parameters
----------
a : pf.Coordinates
first argument, must be broadcastable with b
b : pf.Coordinates
second argument, much be broadcastable with a
Returns
-------
result : np.ndarray
array with the dot product of the two objects
Examples
--------
>>> import pyfar as pf
>>> a = pf.Coordinates(1, 0, 0)
>>> b = pf.Coordinates(1, 0, 0)
>>> pf.dot(a, b)
array([1.])
"""
if not isinstance(a, Coordinates) or not isinstance(b, Coordinates):
raise TypeError(
"Dot product is only possible with Coordinates objects.")
return a.x * b.x + a.y * b.y + a.z * b.z
[docs]
def cross(a, b):
r"""Cross product of two Coordinates objects.
.. math::
\vec{a} \times \vec{b}
= (a_y \cdot b_z - a_z \cdot b_y) \cdot \hat{x}
+ (a_z \cdot b_x - a_x \cdot b_z) \cdot \hat{y}
+ (a_x \cdot b_y - a_y \cdot b_x) \cdot \hat{z}
Parameters
----------
a : pf.Coordinates
first argument, must be broadcastable with b
b : pf.Coordinates
second argument, much be broadcastable with a
Returns
-------
result : pf.Coordinates
new Coordinates object with the cross product of the two objects
Examples
--------
>>> import pyfar as pf
>>> a = pf.Coordinates(1, 0, 0)
>>> b = pf.Coordinates(0, 1, 0)
>>> result = pf.cross(a, b)
>>> result.cartesian
array([0., 0., 1.])
"""
if not isinstance(a, Coordinates) or not isinstance(b, Coordinates):
raise TypeError(
"Dot product is only possible with Coordinates objects.")
new = Coordinates()
new.cartesian = np.zeros(np.broadcast_shapes(
a.cartesian.shape, b.cartesian.shape))
# apply cross product
new.x = a.y * b.z - a.z * b.y
new.y = a.z * b.x - a.x * b.z
new.z = a.x * b.y - a.y * b.x
return new
def _arithmetics(first, second, operation):
"""Add or Subtract two Coordinates objects, numbers or arrays.
Parameters
----------
first : Coordinates, number, array
first operand
second : Coordinates, number, array
second operand
operation : 'add', 'sub', 'mul', 'div'
whether to add or subtract the two objects
Returns
-------
new : Coordinates
result of the operation
"""
# convert data
data = []
num_objects = 0
for obj in [first, second]:
if isinstance(obj, Coordinates):
data.append(obj.cartesian)
num_objects += 1
elif isinstance(obj, (int, float)):
data.append(np.array(obj))
else:
if operation == 'add':
op = 'Addition'
elif operation == 'sub':
op = 'Subtraction'
elif operation == 'mul':
op = 'Multiplication'
elif operation == 'div':
op = 'Division'
raise TypeError(
f"{op} is only possible with Coordinates or number.")
if operation in ['mul', 'div'] and num_objects > 1:
raise TypeError(
"Multiplication and division are only possible with one "
"Coordinates object.")
# broadcast shapes
shape = np.broadcast_shapes(data[0].shape, data[1].shape)
new = pf.Coordinates()
new.cartesian = np.zeros(shape)
# perform operation
if operation == 'add':
new.cartesian = data[0] + data[1]
elif operation == 'sub':
new.cartesian = data[0] - data[1]
elif operation == 'mul':
new.cartesian = data[0] * data[1]
elif operation == 'div':
new.cartesian = data[0] / data[1]
return new
[docs]
def cart2sph(x, y, z):
r"""
Transforms from Cartesian to spherical coordinates.
Spherical coordinates follow the common convention in Physics/Mathematics.
The `colatitude` is measured downwards from the z-axis and is 0 at the
North Pole and pi at the South Pole. The `azimuth` is 0 at positive
x-direction and pi/2 at positive y-direction (counter clockwise rotation).
Cartesian coordinates follow the right hand rule.
.. math::
azimuth &= \arctan(\frac{y}{x}),
colatitude &= \arccos(\frac{z}{r}),
radius &= \sqrt{x^2 + y^2 + z^2}
.. math::
0 < azimuth < 2 \pi,
0 < colatitude < \pi
Parameters
----------
x : numpy array, number
X values
y : numpy array, number
Y values
z : numpy array, number
Z values
Returns
-------
azimuth : numpy array, number
Azimuth values
colatitude : numpy array, number
Colatitude values
radius : numpy array, number
Radii
Notes
-----
To ensure proper handling of the azimuth angle, the
:py:data:`numpy.arctan2` implementation from numpy is used.
"""
radius = np.sqrt(x**2 + y**2 + z**2)
z_div_r = np.divide(
z, radius, out=np.zeros_like(radius, dtype=float), where=radius != 0)
colatitude = np.arccos(z_div_r)
azimuth = np.mod(np.arctan2(y, x), 2 * np.pi)
return azimuth, colatitude, radius
[docs]
def sph2cart(azimuth, colatitude, radius):
r"""
Transforms from spherical to Cartesian coordinates.
Spherical coordinates follow the common convention in Physics/Mathematics.
The `colatitude` is measured downwards from the z-axis and is 0 at the
North Pole and pi at the South Pole. The `azimuth` is 0 at positive
x-direction and pi/2 at positive y-direction (counter clockwise rotation).
Cartesian coordinates follow the right hand rule.
.. math::
x &= radius \cdot \sin(colatitude) \cdot \cos(azimuth),
y &= radius \cdot \sin(colatitude) \cdot \sin(azimuth),
z &= radius \cdot \cos(colatitude)
.. math::
0 < azimuth < 2 \pi
0 < colatitude < \pi
Parameters
----------
azimuth : numpy array, number
Azimuth values
colatitude : numpy array, number
Colatitude values
radius : numpy array, number
Radii
Returns
-------
x : numpy array, number
X values
y : numpy array, number
Y values
z : numpy array, number
Z vales
"""
azimuth = np.atleast_1d(azimuth)
colatitude = np.atleast_1d(colatitude)
radius = np.atleast_1d(radius)
r_sin_cola = radius * np.sin(colatitude)
x = r_sin_cola * np.cos(azimuth)
y = r_sin_cola * np.sin(azimuth)
z = radius * np.cos(colatitude)
x[np.abs(x) < np.finfo(x.dtype).eps] = 0
y[np.abs(y) < np.finfo(y.dtype).eps] = 0
z[np.abs(z) < np.finfo(x.dtype).eps] = 0
return x, y, z
[docs]
def cart2cyl(x, y, z):
r"""
Transforms from Cartesian to cylindrical coordinates.
Cylindrical coordinates follow the convention that the `azimuth` is 0 at
positive x-direction and pi/2 at positive y-direction (counter clockwise
rotation). The `height` is identical to the z-coordinate and the `radius`
is measured orthogonal from the z-axis.
Cartesian coordinates follow the right hand rule.
.. math::
azimuth &= \arctan(\frac{y}{x}),
height &= z,
radius &= \sqrt{x^2 + y^2},
.. math::
0 < azimuth < 2 \pi
Parameters
----------
x : numpy array, number
X values
y : numpy array, number
Y values
z : numpy array, number
Z values
Returns
-------
azimuth : numpy array, number
Azimuth values
height : numpy array, number
Height values
radius : numpy array, number
Radii
Notes
-----
To ensure proper handling of the azimuth angle, the
:py:data:`numpy.arctan2` implementation from numpy is used.
"""
azimuth = np.mod(np.arctan2(y, x), 2 * np.pi)
if isinstance(z, np.ndarray):
height = z.copy()
else:
height = z
radius = np.sqrt(x**2 + y**2)
return azimuth, height, radius
[docs]
def cyl2cart(azimuth, height, radius):
r"""
Transforms from cylindrical to Cartesian coordinates.
Cylindrical coordinates follow the convention that the `azimuth` is 0 at
positive x-direction and pi/2 at positive y-direction (counter clockwise
rotation). The `height` is identical to the z-coordinate and the `radius`
is measured orthogonal from the z-axis.
Cartesian coordinates follow the right hand rule.
.. math::
x &= radius \cdot \cos(azimuth),
y &= radius \cdot \sin(azimuth),
z &= height
.. math::
0 < azimuth < 2 \pi
Parameters
----------
azimuth : numpy array, number
Azimuth values
height : numpy array, number
Height values
radius : numpy array, number
Radii
Returns
-------
x : numpy array, number
X values
y : numpy array, number
Y values
z : numpy array, number
Z values
Notes
-----
To ensure proper handling of the azimuth angle, the
:py:data:`numpy.arctan2` implementation from numpy is used.
"""
azimuth = np.atleast_1d(azimuth)
height = np.atleast_1d(height)
radius = np.atleast_1d(radius)
x = radius * np.cos(azimuth)
y = radius * np.sin(azimuth)
if isinstance(height, np.ndarray):
z = height.copy()
else:
z = height
x[np.abs(x) < np.finfo(x.dtype).eps] = 0
y[np.abs(y) < np.finfo(y.dtype).eps] = 0
z[np.abs(z) < np.finfo(x.dtype).eps] = 0
return x, y, z
[docs]
def rad2deg(coordinates, domain='spherical'):
"""
Convert a copy of coordinates in radians to degree.
Parameters
----------
coordinates : array like
N-dimensional array of shape `(..., 3)`.
domain : str, optional
Specifies what data are contained in `coordinates`
``'spherical'``
Spherical coordinates with angles contained in
``coordinates[..., 0:2]`` and radii in ``coordinates[..., 2]``.
The radii are ignored during the conversion.
``'cylindrical'``
Cylindrical coordinates with angles contained in
``coordinates[..., 0]``, heights contained in
``coordinates[..., 1]``, and radii in ``coordinates[..., 2]``.
The heights and radii are ignored during the conversion.
Returns
-------
coordinates : numpy array
The converted coordinates of the same shape as the input data.
"""
return _convert_angles(coordinates, domain, 180/np.pi)
[docs]
def deg2rad(coordinates, domain='spherical'):
"""
Convert a copy of coordinates in degree to radians.
Parameters
----------
coordinates : array like
N-dimensional array of shape `(..., 3)`.
domain : str, optional
Specifies what data are contained in `coordinates`
``'spherical'``
Spherical coordinates with angles contained in
``coordinates[..., 0:2]`` and radii in ``coordinates[..., 2]``.
The radii are ignored during the conversion.
``'cylindrical'``
Cylindrical coordinates with angles contained in
``coordinates[..., 0]``, heights contained in
``coordinates[..., 1]``, and radii in ``coordinates[..., 2]``.
The heights and radii are ignored during the conversion.
Returns
-------
coordinates : numpy array
The converted coordinates of the same shape as the input data.
"""
return _convert_angles(coordinates, domain, np.pi/180)
def _convert_angles(coordinates, domain, factor):
"""Private function called by rad2deg and deg2rad."""
# check coordinates
coordinates = np.atleast_2d(coordinates).astype(float)
if coordinates.shape[-1] != 3:
raise ValueError(('coordinates must be of shape (..., 3) but are of '
f'shape {coordinates.shape}'))
# check domain and create mask
if domain == 'spherical':
mask = [True, True, False]
elif domain == 'cylindrical':
mask = [True, False, False]
else:
raise ValueError(("domain must be 'spherical' or 'cylindrical' but "
f"is {domain}"))
# convert data
converted = coordinates.copy()
converted[..., mask] = converted[..., mask] * factor
return converted
def _check_array_limits(values, lower_limit, upper_limit, variable_name=None):
"""
Values will be clipped to its range if deviations are below 2 eps
for 32 bit float numbers otherwise Error is raised.
Notes
-----
This is mostly used for the colatitude angle.
Parameters
----------
values : np.ndarray
Input array angle
lower_limit : float
Lower limit for angle definition
upper_limit : float
Upper limit for angle definition
variable_name : string
Name of variable, just relevant for error message. 'value' by default.
Returns
-------
values : np.ndarray
Clipped input values
"""
if variable_name is None:
variable_name = 'values'
if any(values < lower_limit):
mask = values < lower_limit
eps = np.finfo(float).eps
if any(values[mask]+2*eps < lower_limit):
raise ValueError(
f'one or more {variable_name} are below '
f'{lower_limit} including 2 eps')
values[mask] = lower_limit
if any(values > upper_limit):
mask = values > upper_limit
eps = np.finfo(float).eps
if any(values[mask] + 2*eps > upper_limit):
raise ValueError(
f'one or more {variable_name} are above '
f'{upper_limit} including 2 eps')
values[mask] = upper_limit
return values