"""Module for the Vector class."""
from __future__ import annotations
import math
from typing import cast
import numpy as np
from matplotlib.axes import Axes
from mpl_toolkits.mplot3d import Axes3D
from skspatial._functions import np_float
from skspatial.objects._base_array import _BaseArray1D
from skspatial.plotting import _connect_points_3d
from skspatial.typing import array_like
class Vector(_BaseArray1D):
"""
A vector implemented as a 1D array.
The array is a subclass of :class:`numpy.ndarray`.
Parameters
----------
array : array_like
Input array.
Attributes
----------
dimension : int
Dimension of the vector.
Raises
------
ValueError
If the array is empty, the values are not finite,
or the dimension is not one.
Examples
--------
>>> from skspatial.objects import Vector
>>> vector = Vector([1, 2, 3])
>>> vector.dimension
3
The object inherits methods from :class:`numpy.ndarray`.
>>> vector.mean()
np.float64(2.0)
>>> Vector([])
Traceback (most recent call last):
...
ValueError: The array must not be empty.
>>> import numpy as np
>>> Vector([1, 2, np.nan])
Traceback (most recent call last):
...
ValueError: The values must all be finite.
>>> Vector([[1, 2], [3, 4]])
Traceback (most recent call last):
...
ValueError: The array must be 1D.
"""
[docs] @classmethod
def from_points(cls, point_a: array_like, point_b: array_like) -> Vector:
"""
Instantiate a vector from point A to point B.
Parameters
----------
point_a, point_b : array_like
Points defining the vector.
Returns
-------
Vector
Vector from point A to point B.
Examples
--------
>>> from skspatial.objects import Vector
>>> Vector.from_points([0, 0], [1, 0])
Vector([1, 0])
>>> Vector.from_points([5, 2], [-2, 8])
Vector([-7, 6])
>>> Vector.from_points([3, 1, 1], [7, 7, 0])
Vector([ 4, 6, -1])
"""
array_vector_ab = cast(np.ndarray, np.subtract(point_b, point_a))
return cls(array_vector_ab)
[docs] def norm(self, **kwargs) -> np.float64:
"""
Return the norm of the vector.
Parameters
----------
kwargs : dict, optional
Additional keywords passed to :func:`numpy.linalg.norm`.
Returns
-------
np.float64
Norm of the vector.
Examples
--------
>>> from skspatial.objects import Vector
>>> vector = Vector([1, 2, 3])
>>> vector.norm().round(3)
np.float64(3.742)
>>> vector.norm(ord=1)
np.float64(6.0)
>>> vector.norm(ord=0)
np.float64(3.0)
"""
return np.linalg.norm(self, **kwargs)
[docs] def unit(self) -> Vector:
"""
Return the unit vector in the same direction as the vector.
A unit vector is a vector with a magnitude of one.
Returns
-------
Vector
Unit vector.
Raises
------
ValueError
If the magnitude of the vector is zero.
Examples
--------
>>> from skspatial.objects import Vector
>>> Vector([1, 0]).unit()
Vector([1., 0.])
>>> Vector([-20, 0]).unit()
Vector([-1., 0.])
>>> Vector([1, 1]).unit().round(3)
Vector([0.707, 0.707])
>>> Vector([1, 1, 1]).unit().round(3)
Vector([0.577, 0.577, 0.577])
>>> Vector([0, 0]).unit()
Traceback (most recent call last):
...
ValueError: The magnitude must not be zero.
"""
magnitude = self.norm()
if magnitude == 0:
raise ValueError("The magnitude must not be zero.")
return cast(Vector, self / magnitude)
[docs] def is_zero(self, **kwargs: float) -> bool:
"""
Check if the vector is the zero vector.
The zero vector in n dimensions is the vector containing n zeros.
Parameters
----------
kwargs : dict, optional
Additional keywords passed to :func:`math.isclose`.
Returns
-------
bool
True if vector is the zero vector; false otherwise.
Examples
--------
>>> from skspatial.objects import Vector
>>> Vector([0, 0]).is_zero()
True
>>> Vector([1, 0]).is_zero()
False
>>> Vector([0, 0, 1e-4]).is_zero()
False
>>> Vector([0, 0, 1e-4]).is_zero(abs_tol=1e-3)
True
"""
return math.isclose(self.dot(self), 0, **kwargs)
[docs] def cross(self, other: array_like) -> Vector:
"""
Compute the cross product with another vector.
Parameters
----------
other : array_like
Other vector.
Returns
-------
Vector
3D vector perpendicular to both inputs.
Examples
--------
>>> from skspatial.objects import Vector
>>> Vector([1, 0]).cross([0, 1])
Vector([0, 0, 1])
>>> Vector([2, 5]).cross([1, 1])
Vector([ 0, 0, -3])
>>> Vector([1, 0]).cross([0, 1])
Vector([0, 0, 1])
>>> Vector([1, 1, 1]).cross([0, 1, 0])
Vector([-1, 0, 1])
"""
# Convert to 3D vectors so that cross product is also 3D.
vector_a = self.set_dimension(3)
vector_b = Vector(other).set_dimension(3)
return Vector(np.cross(vector_a, vector_b))
[docs] def cosine_similarity(self, other: array_like) -> np.float64:
"""
Return the cosine similarity of the vector with another.
This is the cosine of the angle between the vectors.
Parameters
----------
other : array_like
Other vector.
Returns
-------
np.float64
Cosine similarity.
Raises
------
ValueError
If either vector has a magnitude of zero.
Examples
--------
>>> from skspatial.objects import Vector
>>> Vector([1, 0]).cosine_similarity([0, 1])
np.float64(0.0)
>>> Vector([30, 0]).cosine_similarity([0, 20])
np.float64(0.0)
>>> Vector([1, 0]).cosine_similarity([-1, 0])
np.float64(-1.0)
>>> Vector([1, 0]).cosine_similarity([1, 1]).round(3)
np.float64(0.707)
>>> Vector([0, 0]).cosine_similarity([1, 1])
Traceback (most recent call last):
...
ValueError: The vectors must have non-zero magnitudes.
"""
denom = self.norm() * Vector(other).norm()
if denom == 0:
raise ValueError("The vectors must have non-zero magnitudes.")
cos_theta = self.dot(other) / denom
# Ensure that the output is in the range [-1, 1],
# so that the angle theta is defined.
clipped = np.clip(cos_theta, -1, 1)
return np.float64(clipped)
[docs] @np_float
def angle_between(self, other: array_like) -> float:
"""
Return the angle in radians between the vector and another.
Parameters
----------
other : array_like
Other vector.
Returns
-------
np.float64
Angle between vectors in radians.
Examples
--------
>>> import numpy as np
>>> from skspatial.objects import Vector
>>> Vector([1, 0]).angle_between([1, 0])
np.float64(0.0)
>>> Vector([1, 1, 1]).angle_between([1, 1, 1])
np.float64(0.0)
>>> angle = Vector([1, 0]).angle_between([1, 1])
>>> np.degrees(angle).round()
np.float64(45.0)
>>> angle = Vector([1, 0]).angle_between([-2, 0])
>>> np.degrees(angle).round()
np.float64(180.0)
"""
cos_theta = self.cosine_similarity(other)
return math.acos(cos_theta)
[docs] @np_float
def angle_signed(self, other: array_like) -> float:
"""
Return the signed angle in radians between the vector and another.
The vectors must be 2D.
Parameters
----------
other : array_like
Other vector.
Returns
-------
np.float64
Signed angle between vectors in radians.
Raises
------
ValueError
If the vectors are not 2D.
Examples
--------
>>> import numpy as np
>>> from skspatial.objects import Vector
>>> Vector([1, 0]).angle_signed([1, 0])
np.float64(0.0)
>>> np.degrees(Vector([1, 0]).angle_signed([0, 1]))
np.float64(90.0)
>>> np.degrees(Vector([1, 0]).angle_signed([0, -1]))
np.float64(-90.0)
>>> Vector([1, 0, 0]).angle_signed([0, -1, 0])
Traceback (most recent call last):
...
ValueError: The vectors must be 2D.
"""
if not (self.dimension == 2 and Vector(other).dimension == 2):
raise ValueError("The vectors must be 2D.")
dot = self.dot(other)
det = np.linalg.det([self, other])
return math.atan2(det, dot)
[docs] @np_float
def angle_signed_3d(self, other: array_like, direction_positive: array_like) -> float:
"""
Return the signed angle in radians between the vector and another.
The vectors must be 3D.
Parameters
----------
other : array_like
Other main input vector.
direction_positive : array_like
A vector perpendicular to the plane formed by the two main input vectors.
Returns
-------
np.float64
Signed angle between vectors in radians.
Raises
------
ValueError
If the vectors are not 3D.
If the positive direction vector is not perpendicular to the plane formed by the two main input vectors.
References
----------
https://stackoverflow.com/questions/5188561/signed-angle-between-two-3d-vectors-with-same-origin-within-the-same-plane
Notes
-----
This method uses the convention of right-handed rotation.
Examples
--------
>>> import numpy as np
>>> from skspatial.objects import Vector
>>> np.degrees(Vector([1, 0, 0]).angle_signed_3d([0, -1, 0], direction_positive=[0, 0, 2]))
np.float64(-90.0)
>>> np.degrees(Vector([1, 0, 0]).angle_signed_3d([0, -1, 0], direction_positive=[0, 0, -5]))
np.float64(90.0)
>>> Vector([1, 0]).angle_signed_3d([1, 0], [1, 0, 0])
Traceback (most recent call last):
...
ValueError: The vectors must be 3D.
>>> Vector([1, 0, 4]).angle_signed_3d([1, 0, 5], [1, 0])
Traceback (most recent call last):
...
ValueError: The vectors must be 3D.
"""
if not all([self.dimension == 3, Vector(other).dimension == 3, Vector(direction_positive).dimension == 3]):
raise ValueError("The vectors must be 3D.")
cross = self.cross(other)
if not cross.is_parallel(direction_positive):
raise ValueError(
(
"The positive direction vector must be perpendicular to the plane formed by the two main input "
"vectors."
),
)
direction_positive = Vector(direction_positive).unit()
return np.arctan2(cross.dot(direction_positive), self.dot(other))
[docs] def is_perpendicular(self, other: array_like, **kwargs: float) -> bool:
r"""
Check if the vector is perpendicular to another.
Two vectors :math:`u` and :math:`v` are perpendicular if
.. math::
u \cdot v = 0
Parameters
----------
other : array_like
Other vector.
kwargs : dict, optional
Additional keywords passed to :func:`math.isclose`.
Returns
-------
bool
True if the vector is perpendicular; false otherwise.
Examples
--------
>>> from skspatial.objects import Vector
>>> Vector([0, 1]).is_perpendicular([1, 0])
True
>>> Vector([-1, 5]).is_perpendicular([3, 4])
False
>>> Vector([2, 0, 0]).is_perpendicular([0, 0, 2])
True
The zero vector is perpendicular to all vectors.
>>> Vector([0, 0, 0]).is_perpendicular([1, 2, 3])
True
"""
return math.isclose(self.dot(other), 0, **kwargs)
[docs] def is_parallel(self, other: array_like, **kwargs: float) -> bool:
r"""
Check if the vector is parallel to another.
Two nonzero vectors :math:`u` and :math:`v` are parallel if
.. math::
\texttt{abs}(\texttt{cosine_similarity}(u, v)) = 1
The zero vector is parallel to all vectors.
Parameters
----------
other : array_like
Other vector.
kwargs : dict, optional
Additional keywords passed to :func:`math.isclose`.
Returns
-------
bool
True if the vector is parallel; false otherwise.
Examples
--------
>>> from skspatial.objects import Vector
>>> Vector([0, 1]).is_parallel([1, 0])
False
>>> Vector([1, 1]).is_parallel([1, 1])
True
>>> Vector([-1, 5]).is_parallel([2, -10])
True
>>> Vector([1, 2, 3]).is_parallel([3, 6, 9])
True
>>> Vector([1, 2, 3, 4]).is_parallel([-2, -4, -6, -8])
True
The zero vector is parallel to all vectors.
>>> Vector([1, 2, 3]).is_parallel([0, 0, 0])
True
"""
if self.is_zero(**kwargs) or Vector(other).is_zero(**kwargs):
# The zero vector is perpendicular to all vectors.
return True
similarity = self.cosine_similarity(other)
return math.isclose(abs(similarity), 1, **kwargs)
[docs] def side_vector(self, other: array_like) -> int:
"""
Find the side of the vector where another vector is directed.
Both vectors must be 2D.
Parameters
----------
other : array_like
Other 2D vector.
Returns
-------
int
1 if the other vector is to the right.
0 if the other is parallel.
-1 if the other is to the left.
Raises
------
ValueError
If the vectors are not 2D.
Examples
--------
>>> from skspatial.objects import Vector
>>> vector_target = Vector([0, 1])
The vector is parallel to the target vector.
>>> vector_target.side_vector([0, 2])
0
>>> vector_target.side_vector([0, -5])
0
The vector is to the right of the target vector.
>>> vector_target.side_vector([1, 1])
1
>>> vector_target.side_vector([1, -10])
1
The vector is to the left of the target vector.
>>> vector_target.side_vector([-3, 4])
-1
The vectors are not 2D.
>>> Vector([1]).side_vector([2])
Traceback (most recent call last):
...
ValueError: The vectors must be 2D.
>>> Vector([1, 0, 0]).side_vector([1, 2, 3])
Traceback (most recent call last):
...
ValueError: The vectors must be 2D.
"""
vector_other = Vector(other)
if self.dimension != 2 or vector_other.dimension != 2:
raise ValueError("The vectors must be 2D.")
product = np.cross(vector_other.set_dimension(3), self.set_dimension(3))
return int(np.sign(product[2]))
[docs] def scalar_projection(self, other: array_like) -> np.float64:
"""
Return the scalar projection of an other vector onto the vector.
Parameters
----------
other : array_like
Other vector.
Returns
-------
np.float64
Scalar projection.
Examples
--------
>>> from skspatial.objects import Vector
>>> Vector([0, 1]).scalar_projection([2, 1])
np.float64(1.0)
>>> Vector([-1, -1]).scalar_projection([1, 0]).round(3)
np.float64(-0.707)
>>> Vector([0, 100]).scalar_projection([9, 5])
np.float64(5.0)
>>> Vector([5, 0]).scalar_projection([-10, 3])
np.float64(-10.0)
"""
result = self.unit().dot(other)
return np.float64(result)
[docs] def project_vector(self, other: array_like) -> Vector:
"""
Project an other vector onto the vector.
Parameters
----------
other : array_like
Other vector.
Returns
-------
Vector
Vector projection.
Examples
--------
>>> from skspatial.objects import Vector
>>> Vector([0, 1]).project_vector([2, 1])
Vector([0., 1.])
>>> Vector([0, 100]).project_vector([2, 1])
Vector([0., 1.])
>>> Vector([0, 1]).project_vector([9, 5])
Vector([0., 5.])
>>> Vector([0, 100]).project_vector([9, 5])
Vector([0., 5.])
"""
return self.dot(other) / self.dot(self) * self
[docs] def different_direction(self, **kwargs: float) -> Vector:
"""
Return a simple vector that is in a different direction.
This is useful for finding a vector perpendicular to the original,
by taking the cross product of the original with the one in a different direction.
Parameters
----------
kwargs : dict, optional
Additional keywords passed to :meth:`Vector.is_zero` and :meth:`Vector.is_parallel`.
:meth:`Vector.is_zero` is used to ensure the input vector is not the zero vector,
and :meth:`Vector.is_parallel` is used to ensure the new vector is not parallel to the input.
Returns
-------
Vector
A unit vector in a different direction from the original.
Raises
------
ValueError
If the vector is the zero vector.
Examples
--------
>>> from skspatial.objects import Vector
>>> Vector([1]).different_direction()
Vector([-1])
>>> Vector([100]).different_direction()
Vector([-1])
>>> Vector([-100]).different_direction()
Vector([1])
>>> Vector([1, 0]).different_direction()
Vector([0., 1.])
>>> Vector([1, 1]).different_direction()
Vector([1., 0.])
>>> Vector([1, 1, 1, 1]).different_direction()
Vector([1., 0., 0., 0.])
"""
if self.is_zero(**kwargs):
raise ValueError("The vector must not be the zero vector.")
if self.dimension == 1:
return Vector([-np.sign(self[0])])
vector_different_direction = Vector(np.zeros(self.dimension))
vector_different_direction[0] = 1
if self.is_parallel(vector_different_direction, **kwargs):
vector_different_direction[0] = 0
vector_different_direction[1] = 1
return vector_different_direction
[docs] def plot_2d(self, ax_2d: Axes, point: array_like = (0, 0), scalar: float = 1, **kwargs) -> None:
"""
Plot a 2D vector.
The vector is plotted as an arrow.
Parameters
----------
ax_2d : Axes
Instance of :class:`~matplotlib.axes.Axes`.
point : array_like, optional
Position of the vector tail (default is origin).
scalar : {int, float}, optional
Value used to scale the vector (default 1).
kwargs : dict, optional
Additional keywords passed to :meth:`~matplotlib.axes.Axes.arrow`.
Examples
--------
.. plot::
:include-source:
>>> import matplotlib.pyplot as plt
>>> from skspatial.objects import Vector
>>> _, ax = plt.subplots()
>>> Vector([1, 1]).plot_2d(ax, point=(-3, 5), scalar=2, head_width=0.5)
>>> limits = ax.axis([-5, 5, 0, 10])
"""
x, y = point
dx, dy = scalar * self
ax_2d.arrow(x, y, dx, dy, **kwargs)
[docs] def plot_3d(self, ax_3d: Axes3D, point: array_like = (0, 0, 0), scalar: float = 1, **kwargs) -> None:
"""
Plot a 3D vector.
The vector is plotted by connecting two 3D points
(the head and tail of the vector).
Parameters
----------
ax_3d : Axes3D
Instance of :class:`~mpl_toolkits.mplot3d.axes3d.Axes3D`.
point : array_like, optional
Position of the vector tail (default is origin).
scalar : {int, float}, optional
Value used to scale the vector (default 1).
kwargs : dict, optional
Additional keywords passed to :meth:`~mpl_toolkits.mplot3d.axes3d.Axes3D.plot`.
Examples
--------
.. plot::
:include-source:
>>> import matplotlib.pyplot as plt
>>> from mpl_toolkits.mplot3d import Axes3D
>>> from skspatial.objects import Vector
>>> fig = plt.figure()
>>> ax = fig.add_subplot(111, projection='3d')
>>> Vector([-1, 1, 1]).plot_3d(ax, point=(1, 2, 3), c='r')
"""
point_2 = np.array(point) + scalar * self
_connect_points_3d(ax_3d, point, point_2, **kwargs)