Spaces:
Sleeping
Sleeping
"""Geometry objects for use by wrapping pathways.""" | |
from abc import ABC, abstractmethod | |
from sympy import Integer, acos, pi, sqrt, sympify, tan | |
from sympy.core.relational import Eq | |
from sympy.functions.elementary.trigonometric import atan2 | |
from sympy.polys.polytools import cancel | |
from sympy.physics.vector import Vector, dot | |
from sympy.simplify.simplify import trigsimp | |
__all__ = [ | |
'WrappingGeometryBase', | |
'WrappingCylinder', | |
'WrappingSphere', | |
] | |
class WrappingGeometryBase(ABC): | |
"""Abstract base class for all geometry classes to inherit from. | |
Notes | |
===== | |
Instances of this class cannot be directly instantiated by users. However, | |
it can be used to created custom geometry types through subclassing. | |
""" | |
def point(cls): | |
"""The point with which the geometry is associated.""" | |
pass | |
def point_on_surface(self, point): | |
"""Returns ``True`` if a point is on the geometry's surface. | |
Parameters | |
========== | |
point : Point | |
The point for which it's to be ascertained if it's on the | |
geometry's surface or not. | |
""" | |
pass | |
def geodesic_length(self, point_1, point_2): | |
"""Returns the shortest distance between two points on a geometry's | |
surface. | |
Parameters | |
========== | |
point_1 : Point | |
The point from which the geodesic length should be calculated. | |
point_2 : Point | |
The point to which the geodesic length should be calculated. | |
""" | |
pass | |
def geodesic_end_vectors(self, point_1, point_2): | |
"""The vectors parallel to the geodesic at the two end points. | |
Parameters | |
========== | |
point_1 : Point | |
The point from which the geodesic originates. | |
point_2 : Point | |
The point at which the geodesic terminates. | |
""" | |
pass | |
def __repr__(self): | |
"""Default representation of a geometry object.""" | |
return f'{self.__class__.__name__}()' | |
class WrappingSphere(WrappingGeometryBase): | |
"""A solid spherical object. | |
Explanation | |
=========== | |
A wrapping geometry that allows for circular arcs to be defined between | |
pairs of points. These paths are always geodetic (the shortest possible). | |
Examples | |
======== | |
To create a ``WrappingSphere`` instance, a ``Symbol`` denoting its radius | |
and ``Point`` at which its center will be located are needed: | |
>>> from sympy import symbols | |
>>> from sympy.physics.mechanics import Point, WrappingSphere | |
>>> r = symbols('r') | |
>>> pO = Point('pO') | |
A sphere with radius ``r`` centered on ``pO`` can be instantiated with: | |
>>> WrappingSphere(r, pO) | |
WrappingSphere(radius=r, point=pO) | |
Parameters | |
========== | |
radius : Symbol | |
Radius of the sphere. This symbol must represent a value that is | |
positive and constant, i.e. it cannot be a dynamic symbol, nor can it | |
be an expression. | |
point : Point | |
A point at which the sphere is centered. | |
See Also | |
======== | |
WrappingCylinder: Cylindrical geometry where the wrapping direction can be | |
defined. | |
""" | |
def __init__(self, radius, point): | |
"""Initializer for ``WrappingSphere``. | |
Parameters | |
========== | |
radius : Symbol | |
The radius of the sphere. | |
point : Point | |
A point on which the sphere is centered. | |
""" | |
self.radius = radius | |
self.point = point | |
def radius(self): | |
"""Radius of the sphere.""" | |
return self._radius | |
def radius(self, radius): | |
self._radius = radius | |
def point(self): | |
"""A point on which the sphere is centered.""" | |
return self._point | |
def point(self, point): | |
self._point = point | |
def point_on_surface(self, point): | |
"""Returns ``True`` if a point is on the sphere's surface. | |
Parameters | |
========== | |
point : Point | |
The point for which it's to be ascertained if it's on the sphere's | |
surface or not. This point's position relative to the sphere's | |
center must be a simple expression involving the radius of the | |
sphere, otherwise this check will likely not work. | |
""" | |
point_vector = point.pos_from(self.point) | |
if isinstance(point_vector, Vector): | |
point_radius_squared = dot(point_vector, point_vector) | |
else: | |
point_radius_squared = point_vector**2 | |
return Eq(point_radius_squared, self.radius**2) == True | |
def geodesic_length(self, point_1, point_2): | |
r"""Returns the shortest distance between two points on the sphere's | |
surface. | |
Explanation | |
=========== | |
The geodesic length, i.e. the shortest arc along the surface of a | |
sphere, connecting two points can be calculated using the formula: | |
.. math:: | |
l = \arccos\left(\mathbf{v}_1 \cdot \mathbf{v}_2\right) | |
where $\mathbf{v}_1$ and $\mathbf{v}_2$ are the unit vectors from the | |
sphere's center to the first and second points on the sphere's surface | |
respectively. Note that the actual path that the geodesic will take is | |
undefined when the two points are directly opposite one another. | |
Examples | |
======== | |
A geodesic length can only be calculated between two points on the | |
sphere's surface. Firstly, a ``WrappingSphere`` instance must be | |
created along with two points that will lie on its surface: | |
>>> from sympy import symbols | |
>>> from sympy.physics.mechanics import (Point, ReferenceFrame, | |
... WrappingSphere) | |
>>> N = ReferenceFrame('N') | |
>>> r = symbols('r') | |
>>> pO = Point('pO') | |
>>> pO.set_vel(N, 0) | |
>>> sphere = WrappingSphere(r, pO) | |
>>> p1 = Point('p1') | |
>>> p2 = Point('p2') | |
Let's assume that ``p1`` lies at a distance of ``r`` in the ``N.x`` | |
direction from ``pO`` and that ``p2`` is located on the sphere's | |
surface in the ``N.y + N.z`` direction from ``pO``. These positions can | |
be set with: | |
>>> p1.set_pos(pO, r*N.x) | |
>>> p1.pos_from(pO) | |
r*N.x | |
>>> p2.set_pos(pO, r*(N.y + N.z).normalize()) | |
>>> p2.pos_from(pO) | |
sqrt(2)*r/2*N.y + sqrt(2)*r/2*N.z | |
The geodesic length, which is in this case is a quarter of the sphere's | |
circumference, can be calculated using the ``geodesic_length`` method: | |
>>> sphere.geodesic_length(p1, p2) | |
pi*r/2 | |
If the ``geodesic_length`` method is passed an argument, the ``Point`` | |
that doesn't lie on the sphere's surface then a ``ValueError`` is | |
raised because it's not possible to calculate a value in this case. | |
Parameters | |
========== | |
point_1 : Point | |
Point from which the geodesic length should be calculated. | |
point_2 : Point | |
Point to which the geodesic length should be calculated. | |
""" | |
for point in (point_1, point_2): | |
if not self.point_on_surface(point): | |
msg = ( | |
f'Geodesic length cannot be calculated as point {point} ' | |
f'with radius {point.pos_from(self.point).magnitude()} ' | |
f'from the sphere\'s center {self.point} does not lie on ' | |
f'the surface of {self} with radius {self.radius}.' | |
) | |
raise ValueError(msg) | |
point_1_vector = point_1.pos_from(self.point).normalize() | |
point_2_vector = point_2.pos_from(self.point).normalize() | |
central_angle = acos(point_2_vector.dot(point_1_vector)) | |
geodesic_length = self.radius*central_angle | |
return geodesic_length | |
def geodesic_end_vectors(self, point_1, point_2): | |
"""The vectors parallel to the geodesic at the two end points. | |
Parameters | |
========== | |
point_1 : Point | |
The point from which the geodesic originates. | |
point_2 : Point | |
The point at which the geodesic terminates. | |
""" | |
pA, pB = point_1, point_2 | |
pO = self.point | |
pA_vec = pA.pos_from(pO) | |
pB_vec = pB.pos_from(pO) | |
if pA_vec.cross(pB_vec) == 0: | |
msg = ( | |
f'Can\'t compute geodesic end vectors for the pair of points ' | |
f'{pA} and {pB} on a sphere {self} as they are diametrically ' | |
f'opposed, thus the geodesic is not defined.' | |
) | |
raise ValueError(msg) | |
return ( | |
pA_vec.cross(pB.pos_from(pA)).cross(pA_vec).normalize(), | |
pB_vec.cross(pA.pos_from(pB)).cross(pB_vec).normalize(), | |
) | |
def __repr__(self): | |
"""Representation of a ``WrappingSphere``.""" | |
return ( | |
f'{self.__class__.__name__}(radius={self.radius}, ' | |
f'point={self.point})' | |
) | |
class WrappingCylinder(WrappingGeometryBase): | |
"""A solid (infinite) cylindrical object. | |
Explanation | |
=========== | |
A wrapping geometry that allows for circular arcs to be defined between | |
pairs of points. These paths are always geodetic (the shortest possible) in | |
the sense that they will be a straight line on the unwrapped cylinder's | |
surface. However, it is also possible for a direction to be specified, i.e. | |
paths can be influenced such that they either wrap along the shortest side | |
or the longest side of the cylinder. To define these directions, rotations | |
are in the positive direction following the right-hand rule. | |
Examples | |
======== | |
To create a ``WrappingCylinder`` instance, a ``Symbol`` denoting its | |
radius, a ``Vector`` defining its axis, and a ``Point`` through which its | |
axis passes are needed: | |
>>> from sympy import symbols | |
>>> from sympy.physics.mechanics import (Point, ReferenceFrame, | |
... WrappingCylinder) | |
>>> N = ReferenceFrame('N') | |
>>> r = symbols('r') | |
>>> pO = Point('pO') | |
>>> ax = N.x | |
A cylinder with radius ``r``, and axis parallel to ``N.x`` passing through | |
``pO`` can be instantiated with: | |
>>> WrappingCylinder(r, pO, ax) | |
WrappingCylinder(radius=r, point=pO, axis=N.x) | |
Parameters | |
========== | |
radius : Symbol | |
The radius of the cylinder. | |
point : Point | |
A point through which the cylinder's axis passes. | |
axis : Vector | |
The axis along which the cylinder is aligned. | |
See Also | |
======== | |
WrappingSphere: Spherical geometry where the wrapping direction is always | |
geodetic. | |
""" | |
def __init__(self, radius, point, axis): | |
"""Initializer for ``WrappingCylinder``. | |
Parameters | |
========== | |
radius : Symbol | |
The radius of the cylinder. This symbol must represent a value that | |
is positive and constant, i.e. it cannot be a dynamic symbol. | |
point : Point | |
A point through which the cylinder's axis passes. | |
axis : Vector | |
The axis along which the cylinder is aligned. | |
""" | |
self.radius = radius | |
self.point = point | |
self.axis = axis | |
def radius(self): | |
"""Radius of the cylinder.""" | |
return self._radius | |
def radius(self, radius): | |
self._radius = radius | |
def point(self): | |
"""A point through which the cylinder's axis passes.""" | |
return self._point | |
def point(self, point): | |
self._point = point | |
def axis(self): | |
"""Axis along which the cylinder is aligned.""" | |
return self._axis | |
def axis(self, axis): | |
self._axis = axis.normalize() | |
def point_on_surface(self, point): | |
"""Returns ``True`` if a point is on the cylinder's surface. | |
Parameters | |
========== | |
point : Point | |
The point for which it's to be ascertained if it's on the | |
cylinder's surface or not. This point's position relative to the | |
cylinder's axis must be a simple expression involving the radius of | |
the sphere, otherwise this check will likely not work. | |
""" | |
relative_position = point.pos_from(self.point) | |
parallel = relative_position.dot(self.axis) * self.axis | |
point_vector = relative_position - parallel | |
if isinstance(point_vector, Vector): | |
point_radius_squared = dot(point_vector, point_vector) | |
else: | |
point_radius_squared = point_vector**2 | |
return Eq(trigsimp(point_radius_squared), self.radius**2) == True | |
def geodesic_length(self, point_1, point_2): | |
"""The shortest distance between two points on a geometry's surface. | |
Explanation | |
=========== | |
The geodesic length, i.e. the shortest arc along the surface of a | |
cylinder, connecting two points. It can be calculated using Pythagoras' | |
theorem. The first short side is the distance between the two points on | |
the cylinder's surface parallel to the cylinder's axis. The second | |
short side is the arc of a circle between the two points of the | |
cylinder's surface perpendicular to the cylinder's axis. The resulting | |
hypotenuse is the geodesic length. | |
Examples | |
======== | |
A geodesic length can only be calculated between two points on the | |
cylinder's surface. Firstly, a ``WrappingCylinder`` instance must be | |
created along with two points that will lie on its surface: | |
>>> from sympy import symbols, cos, sin | |
>>> from sympy.physics.mechanics import (Point, ReferenceFrame, | |
... WrappingCylinder, dynamicsymbols) | |
>>> N = ReferenceFrame('N') | |
>>> r = symbols('r') | |
>>> pO = Point('pO') | |
>>> pO.set_vel(N, 0) | |
>>> cylinder = WrappingCylinder(r, pO, N.x) | |
>>> p1 = Point('p1') | |
>>> p2 = Point('p2') | |
Let's assume that ``p1`` is located at ``N.x + r*N.y`` relative to | |
``pO`` and that ``p2`` is located at ``r*(cos(q)*N.y + sin(q)*N.z)`` | |
relative to ``pO``, where ``q(t)`` is a generalized coordinate | |
specifying the angle rotated around the ``N.x`` axis according to the | |
right-hand rule where ``N.y`` is zero. These positions can be set with: | |
>>> q = dynamicsymbols('q') | |
>>> p1.set_pos(pO, N.x + r*N.y) | |
>>> p1.pos_from(pO) | |
N.x + r*N.y | |
>>> p2.set_pos(pO, r*(cos(q)*N.y + sin(q)*N.z).normalize()) | |
>>> p2.pos_from(pO).simplify() | |
r*cos(q(t))*N.y + r*sin(q(t))*N.z | |
The geodesic length, which is in this case a is the hypotenuse of a | |
right triangle where the other two side lengths are ``1`` (parallel to | |
the cylinder's axis) and ``r*q(t)`` (parallel to the cylinder's cross | |
section), can be calculated using the ``geodesic_length`` method: | |
>>> cylinder.geodesic_length(p1, p2).simplify() | |
sqrt(r**2*q(t)**2 + 1) | |
If the ``geodesic_length`` method is passed an argument ``Point`` that | |
doesn't lie on the sphere's surface then a ``ValueError`` is raised | |
because it's not possible to calculate a value in this case. | |
Parameters | |
========== | |
point_1 : Point | |
Point from which the geodesic length should be calculated. | |
point_2 : Point | |
Point to which the geodesic length should be calculated. | |
""" | |
for point in (point_1, point_2): | |
if not self.point_on_surface(point): | |
msg = ( | |
f'Geodesic length cannot be calculated as point {point} ' | |
f'with radius {point.pos_from(self.point).magnitude()} ' | |
f'from the cylinder\'s center {self.point} does not lie on ' | |
f'the surface of {self} with radius {self.radius} and axis ' | |
f'{self.axis}.' | |
) | |
raise ValueError(msg) | |
relative_position = point_2.pos_from(point_1) | |
parallel_length = relative_position.dot(self.axis) | |
point_1_relative_position = point_1.pos_from(self.point) | |
point_1_perpendicular_vector = ( | |
point_1_relative_position | |
- point_1_relative_position.dot(self.axis)*self.axis | |
).normalize() | |
point_2_relative_position = point_2.pos_from(self.point) | |
point_2_perpendicular_vector = ( | |
point_2_relative_position | |
- point_2_relative_position.dot(self.axis)*self.axis | |
).normalize() | |
central_angle = _directional_atan( | |
cancel(point_1_perpendicular_vector | |
.cross(point_2_perpendicular_vector) | |
.dot(self.axis)), | |
cancel(point_1_perpendicular_vector.dot(point_2_perpendicular_vector)), | |
) | |
planar_arc_length = self.radius*central_angle | |
geodesic_length = sqrt(parallel_length**2 + planar_arc_length**2) | |
return geodesic_length | |
def geodesic_end_vectors(self, point_1, point_2): | |
"""The vectors parallel to the geodesic at the two end points. | |
Parameters | |
========== | |
point_1 : Point | |
The point from which the geodesic originates. | |
point_2 : Point | |
The point at which the geodesic terminates. | |
""" | |
point_1_from_origin_point = point_1.pos_from(self.point) | |
point_2_from_origin_point = point_2.pos_from(self.point) | |
if point_1_from_origin_point == point_2_from_origin_point: | |
msg = ( | |
f'Cannot compute geodesic end vectors for coincident points ' | |
f'{point_1} and {point_2} as no geodesic exists.' | |
) | |
raise ValueError(msg) | |
point_1_parallel = point_1_from_origin_point.dot(self.axis) * self.axis | |
point_2_parallel = point_2_from_origin_point.dot(self.axis) * self.axis | |
point_1_normal = (point_1_from_origin_point - point_1_parallel) | |
point_2_normal = (point_2_from_origin_point - point_2_parallel) | |
if point_1_normal == point_2_normal: | |
point_1_perpendicular = Vector(0) | |
point_2_perpendicular = Vector(0) | |
else: | |
point_1_perpendicular = self.axis.cross(point_1_normal).normalize() | |
point_2_perpendicular = -self.axis.cross(point_2_normal).normalize() | |
geodesic_length = self.geodesic_length(point_1, point_2) | |
relative_position = point_2.pos_from(point_1) | |
parallel_length = relative_position.dot(self.axis) | |
planar_arc_length = sqrt(geodesic_length**2 - parallel_length**2) | |
point_1_vector = ( | |
planar_arc_length * point_1_perpendicular | |
+ parallel_length * self.axis | |
).normalize() | |
point_2_vector = ( | |
planar_arc_length * point_2_perpendicular | |
- parallel_length * self.axis | |
).normalize() | |
return (point_1_vector, point_2_vector) | |
def __repr__(self): | |
"""Representation of a ``WrappingCylinder``.""" | |
return ( | |
f'{self.__class__.__name__}(radius={self.radius}, ' | |
f'point={self.point}, axis={self.axis})' | |
) | |
def _directional_atan(numerator, denominator): | |
"""Compute atan in a directional sense as required for geodesics. | |
Explanation | |
=========== | |
To be able to control the direction of the geodesic length along the | |
surface of a cylinder a dedicated arctangent function is needed that | |
properly handles the directionality of different case. This function | |
ensures that the central angle is always positive but shifting the case | |
where ``atan2`` would return a negative angle to be centered around | |
``2*pi``. | |
Notes | |
===== | |
This function only handles very specific cases, i.e. the ones that are | |
expected to be encountered when calculating symbolic geodesics on uniformly | |
curved surfaces. As such, ``NotImplemented`` errors can be raised in many | |
cases. This function is named with a leader underscore to indicate that it | |
only aims to provide very specific functionality within the private scope | |
of this module. | |
""" | |
if numerator.is_number and denominator.is_number: | |
angle = atan2(numerator, denominator) | |
if angle < 0: | |
angle += 2 * pi | |
elif numerator.is_number: | |
msg = ( | |
f'Cannot compute a directional atan when the numerator {numerator} ' | |
f'is numeric and the denominator {denominator} is symbolic.' | |
) | |
raise NotImplementedError(msg) | |
elif denominator.is_number: | |
msg = ( | |
f'Cannot compute a directional atan when the numerator {numerator} ' | |
f'is symbolic and the denominator {denominator} is numeric.' | |
) | |
raise NotImplementedError(msg) | |
else: | |
ratio = sympify(trigsimp(numerator / denominator)) | |
if isinstance(ratio, tan): | |
angle = ratio.args[0] | |
elif ( | |
ratio.is_Mul | |
and ratio.args[0] == Integer(-1) | |
and isinstance(ratio.args[1], tan) | |
): | |
angle = 2 * pi - ratio.args[1].args[0] | |
else: | |
msg = f'Cannot compute a directional atan for the value {ratio}.' | |
raise NotImplementedError(msg) | |
return angle | |