wrapping_geometry.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641
  1. """Geometry objects for use by wrapping pathways."""
  2. from abc import ABC, abstractmethod
  3. from sympy import Integer, acos, pi, sqrt, sympify, tan
  4. from sympy.core.relational import Eq
  5. from sympy.functions.elementary.trigonometric import atan2
  6. from sympy.polys.polytools import cancel
  7. from sympy.physics.vector import Vector, dot
  8. from sympy.simplify.simplify import trigsimp
  9. __all__ = [
  10. 'WrappingGeometryBase',
  11. 'WrappingCylinder',
  12. 'WrappingSphere',
  13. ]
  14. class WrappingGeometryBase(ABC):
  15. """Abstract base class for all geometry classes to inherit from.
  16. Notes
  17. =====
  18. Instances of this class cannot be directly instantiated by users. However,
  19. it can be used to created custom geometry types through subclassing.
  20. """
  21. @property
  22. @abstractmethod
  23. def point(cls):
  24. """The point with which the geometry is associated."""
  25. pass
  26. @abstractmethod
  27. def point_on_surface(self, point):
  28. """Returns ``True`` if a point is on the geometry's surface.
  29. Parameters
  30. ==========
  31. point : Point
  32. The point for which it's to be ascertained if it's on the
  33. geometry's surface or not.
  34. """
  35. pass
  36. @abstractmethod
  37. def geodesic_length(self, point_1, point_2):
  38. """Returns the shortest distance between two points on a geometry's
  39. surface.
  40. Parameters
  41. ==========
  42. point_1 : Point
  43. The point from which the geodesic length should be calculated.
  44. point_2 : Point
  45. The point to which the geodesic length should be calculated.
  46. """
  47. pass
  48. @abstractmethod
  49. def geodesic_end_vectors(self, point_1, point_2):
  50. """The vectors parallel to the geodesic at the two end points.
  51. Parameters
  52. ==========
  53. point_1 : Point
  54. The point from which the geodesic originates.
  55. point_2 : Point
  56. The point at which the geodesic terminates.
  57. """
  58. pass
  59. def __repr__(self):
  60. """Default representation of a geometry object."""
  61. return f'{self.__class__.__name__}()'
  62. class WrappingSphere(WrappingGeometryBase):
  63. """A solid spherical object.
  64. Explanation
  65. ===========
  66. A wrapping geometry that allows for circular arcs to be defined between
  67. pairs of points. These paths are always geodetic (the shortest possible).
  68. Examples
  69. ========
  70. To create a ``WrappingSphere`` instance, a ``Symbol`` denoting its radius
  71. and ``Point`` at which its center will be located are needed:
  72. >>> from sympy import symbols
  73. >>> from sympy.physics.mechanics import Point, WrappingSphere
  74. >>> r = symbols('r')
  75. >>> pO = Point('pO')
  76. A sphere with radius ``r`` centered on ``pO`` can be instantiated with:
  77. >>> WrappingSphere(r, pO)
  78. WrappingSphere(radius=r, point=pO)
  79. Parameters
  80. ==========
  81. radius : Symbol
  82. Radius of the sphere. This symbol must represent a value that is
  83. positive and constant, i.e. it cannot be a dynamic symbol, nor can it
  84. be an expression.
  85. point : Point
  86. A point at which the sphere is centered.
  87. See Also
  88. ========
  89. WrappingCylinder: Cylindrical geometry where the wrapping direction can be
  90. defined.
  91. """
  92. def __init__(self, radius, point):
  93. """Initializer for ``WrappingSphere``.
  94. Parameters
  95. ==========
  96. radius : Symbol
  97. The radius of the sphere.
  98. point : Point
  99. A point on which the sphere is centered.
  100. """
  101. self.radius = radius
  102. self.point = point
  103. @property
  104. def radius(self):
  105. """Radius of the sphere."""
  106. return self._radius
  107. @radius.setter
  108. def radius(self, radius):
  109. self._radius = radius
  110. @property
  111. def point(self):
  112. """A point on which the sphere is centered."""
  113. return self._point
  114. @point.setter
  115. def point(self, point):
  116. self._point = point
  117. def point_on_surface(self, point):
  118. """Returns ``True`` if a point is on the sphere's surface.
  119. Parameters
  120. ==========
  121. point : Point
  122. The point for which it's to be ascertained if it's on the sphere's
  123. surface or not. This point's position relative to the sphere's
  124. center must be a simple expression involving the radius of the
  125. sphere, otherwise this check will likely not work.
  126. """
  127. point_vector = point.pos_from(self.point)
  128. if isinstance(point_vector, Vector):
  129. point_radius_squared = dot(point_vector, point_vector)
  130. else:
  131. point_radius_squared = point_vector**2
  132. return Eq(point_radius_squared, self.radius**2) == True
  133. def geodesic_length(self, point_1, point_2):
  134. r"""Returns the shortest distance between two points on the sphere's
  135. surface.
  136. Explanation
  137. ===========
  138. The geodesic length, i.e. the shortest arc along the surface of a
  139. sphere, connecting two points can be calculated using the formula:
  140. .. math::
  141. l = \arccos\left(\mathbf{v}_1 \cdot \mathbf{v}_2\right)
  142. where $\mathbf{v}_1$ and $\mathbf{v}_2$ are the unit vectors from the
  143. sphere's center to the first and second points on the sphere's surface
  144. respectively. Note that the actual path that the geodesic will take is
  145. undefined when the two points are directly opposite one another.
  146. Examples
  147. ========
  148. A geodesic length can only be calculated between two points on the
  149. sphere's surface. Firstly, a ``WrappingSphere`` instance must be
  150. created along with two points that will lie on its surface:
  151. >>> from sympy import symbols
  152. >>> from sympy.physics.mechanics import (Point, ReferenceFrame,
  153. ... WrappingSphere)
  154. >>> N = ReferenceFrame('N')
  155. >>> r = symbols('r')
  156. >>> pO = Point('pO')
  157. >>> pO.set_vel(N, 0)
  158. >>> sphere = WrappingSphere(r, pO)
  159. >>> p1 = Point('p1')
  160. >>> p2 = Point('p2')
  161. Let's assume that ``p1`` lies at a distance of ``r`` in the ``N.x``
  162. direction from ``pO`` and that ``p2`` is located on the sphere's
  163. surface in the ``N.y + N.z`` direction from ``pO``. These positions can
  164. be set with:
  165. >>> p1.set_pos(pO, r*N.x)
  166. >>> p1.pos_from(pO)
  167. r*N.x
  168. >>> p2.set_pos(pO, r*(N.y + N.z).normalize())
  169. >>> p2.pos_from(pO)
  170. sqrt(2)*r/2*N.y + sqrt(2)*r/2*N.z
  171. The geodesic length, which is in this case is a quarter of the sphere's
  172. circumference, can be calculated using the ``geodesic_length`` method:
  173. >>> sphere.geodesic_length(p1, p2)
  174. pi*r/2
  175. If the ``geodesic_length`` method is passed an argument, the ``Point``
  176. that doesn't lie on the sphere's surface then a ``ValueError`` is
  177. raised because it's not possible to calculate a value in this case.
  178. Parameters
  179. ==========
  180. point_1 : Point
  181. Point from which the geodesic length should be calculated.
  182. point_2 : Point
  183. Point to which the geodesic length should be calculated.
  184. """
  185. for point in (point_1, point_2):
  186. if not self.point_on_surface(point):
  187. msg = (
  188. f'Geodesic length cannot be calculated as point {point} '
  189. f'with radius {point.pos_from(self.point).magnitude()} '
  190. f'from the sphere\'s center {self.point} does not lie on '
  191. f'the surface of {self} with radius {self.radius}.'
  192. )
  193. raise ValueError(msg)
  194. point_1_vector = point_1.pos_from(self.point).normalize()
  195. point_2_vector = point_2.pos_from(self.point).normalize()
  196. central_angle = acos(point_2_vector.dot(point_1_vector))
  197. geodesic_length = self.radius*central_angle
  198. return geodesic_length
  199. def geodesic_end_vectors(self, point_1, point_2):
  200. """The vectors parallel to the geodesic at the two end points.
  201. Parameters
  202. ==========
  203. point_1 : Point
  204. The point from which the geodesic originates.
  205. point_2 : Point
  206. The point at which the geodesic terminates.
  207. """
  208. pA, pB = point_1, point_2
  209. pO = self.point
  210. pA_vec = pA.pos_from(pO)
  211. pB_vec = pB.pos_from(pO)
  212. if pA_vec.cross(pB_vec) == 0:
  213. msg = (
  214. f'Can\'t compute geodesic end vectors for the pair of points '
  215. f'{pA} and {pB} on a sphere {self} as they are diametrically '
  216. f'opposed, thus the geodesic is not defined.'
  217. )
  218. raise ValueError(msg)
  219. return (
  220. pA_vec.cross(pB.pos_from(pA)).cross(pA_vec).normalize(),
  221. pB_vec.cross(pA.pos_from(pB)).cross(pB_vec).normalize(),
  222. )
  223. def __repr__(self):
  224. """Representation of a ``WrappingSphere``."""
  225. return (
  226. f'{self.__class__.__name__}(radius={self.radius}, '
  227. f'point={self.point})'
  228. )
  229. class WrappingCylinder(WrappingGeometryBase):
  230. """A solid (infinite) cylindrical object.
  231. Explanation
  232. ===========
  233. A wrapping geometry that allows for circular arcs to be defined between
  234. pairs of points. These paths are always geodetic (the shortest possible) in
  235. the sense that they will be a straight line on the unwrapped cylinder's
  236. surface. However, it is also possible for a direction to be specified, i.e.
  237. paths can be influenced such that they either wrap along the shortest side
  238. or the longest side of the cylinder. To define these directions, rotations
  239. are in the positive direction following the right-hand rule.
  240. Examples
  241. ========
  242. To create a ``WrappingCylinder`` instance, a ``Symbol`` denoting its
  243. radius, a ``Vector`` defining its axis, and a ``Point`` through which its
  244. axis passes are needed:
  245. >>> from sympy import symbols
  246. >>> from sympy.physics.mechanics import (Point, ReferenceFrame,
  247. ... WrappingCylinder)
  248. >>> N = ReferenceFrame('N')
  249. >>> r = symbols('r')
  250. >>> pO = Point('pO')
  251. >>> ax = N.x
  252. A cylinder with radius ``r``, and axis parallel to ``N.x`` passing through
  253. ``pO`` can be instantiated with:
  254. >>> WrappingCylinder(r, pO, ax)
  255. WrappingCylinder(radius=r, point=pO, axis=N.x)
  256. Parameters
  257. ==========
  258. radius : Symbol
  259. The radius of the cylinder.
  260. point : Point
  261. A point through which the cylinder's axis passes.
  262. axis : Vector
  263. The axis along which the cylinder is aligned.
  264. See Also
  265. ========
  266. WrappingSphere: Spherical geometry where the wrapping direction is always
  267. geodetic.
  268. """
  269. def __init__(self, radius, point, axis):
  270. """Initializer for ``WrappingCylinder``.
  271. Parameters
  272. ==========
  273. radius : Symbol
  274. The radius of the cylinder. This symbol must represent a value that
  275. is positive and constant, i.e. it cannot be a dynamic symbol.
  276. point : Point
  277. A point through which the cylinder's axis passes.
  278. axis : Vector
  279. The axis along which the cylinder is aligned.
  280. """
  281. self.radius = radius
  282. self.point = point
  283. self.axis = axis
  284. @property
  285. def radius(self):
  286. """Radius of the cylinder."""
  287. return self._radius
  288. @radius.setter
  289. def radius(self, radius):
  290. self._radius = radius
  291. @property
  292. def point(self):
  293. """A point through which the cylinder's axis passes."""
  294. return self._point
  295. @point.setter
  296. def point(self, point):
  297. self._point = point
  298. @property
  299. def axis(self):
  300. """Axis along which the cylinder is aligned."""
  301. return self._axis
  302. @axis.setter
  303. def axis(self, axis):
  304. self._axis = axis.normalize()
  305. def point_on_surface(self, point):
  306. """Returns ``True`` if a point is on the cylinder's surface.
  307. Parameters
  308. ==========
  309. point : Point
  310. The point for which it's to be ascertained if it's on the
  311. cylinder's surface or not. This point's position relative to the
  312. cylinder's axis must be a simple expression involving the radius of
  313. the sphere, otherwise this check will likely not work.
  314. """
  315. relative_position = point.pos_from(self.point)
  316. parallel = relative_position.dot(self.axis) * self.axis
  317. point_vector = relative_position - parallel
  318. if isinstance(point_vector, Vector):
  319. point_radius_squared = dot(point_vector, point_vector)
  320. else:
  321. point_radius_squared = point_vector**2
  322. return Eq(trigsimp(point_radius_squared), self.radius**2) == True
  323. def geodesic_length(self, point_1, point_2):
  324. """The shortest distance between two points on a geometry's surface.
  325. Explanation
  326. ===========
  327. The geodesic length, i.e. the shortest arc along the surface of a
  328. cylinder, connecting two points. It can be calculated using Pythagoras'
  329. theorem. The first short side is the distance between the two points on
  330. the cylinder's surface parallel to the cylinder's axis. The second
  331. short side is the arc of a circle between the two points of the
  332. cylinder's surface perpendicular to the cylinder's axis. The resulting
  333. hypotenuse is the geodesic length.
  334. Examples
  335. ========
  336. A geodesic length can only be calculated between two points on the
  337. cylinder's surface. Firstly, a ``WrappingCylinder`` instance must be
  338. created along with two points that will lie on its surface:
  339. >>> from sympy import symbols, cos, sin
  340. >>> from sympy.physics.mechanics import (Point, ReferenceFrame,
  341. ... WrappingCylinder, dynamicsymbols)
  342. >>> N = ReferenceFrame('N')
  343. >>> r = symbols('r')
  344. >>> pO = Point('pO')
  345. >>> pO.set_vel(N, 0)
  346. >>> cylinder = WrappingCylinder(r, pO, N.x)
  347. >>> p1 = Point('p1')
  348. >>> p2 = Point('p2')
  349. Let's assume that ``p1`` is located at ``N.x + r*N.y`` relative to
  350. ``pO`` and that ``p2`` is located at ``r*(cos(q)*N.y + sin(q)*N.z)``
  351. relative to ``pO``, where ``q(t)`` is a generalized coordinate
  352. specifying the angle rotated around the ``N.x`` axis according to the
  353. right-hand rule where ``N.y`` is zero. These positions can be set with:
  354. >>> q = dynamicsymbols('q')
  355. >>> p1.set_pos(pO, N.x + r*N.y)
  356. >>> p1.pos_from(pO)
  357. N.x + r*N.y
  358. >>> p2.set_pos(pO, r*(cos(q)*N.y + sin(q)*N.z).normalize())
  359. >>> p2.pos_from(pO).simplify()
  360. r*cos(q(t))*N.y + r*sin(q(t))*N.z
  361. The geodesic length, which is in this case a is the hypotenuse of a
  362. right triangle where the other two side lengths are ``1`` (parallel to
  363. the cylinder's axis) and ``r*q(t)`` (parallel to the cylinder's cross
  364. section), can be calculated using the ``geodesic_length`` method:
  365. >>> cylinder.geodesic_length(p1, p2).simplify()
  366. sqrt(r**2*q(t)**2 + 1)
  367. If the ``geodesic_length`` method is passed an argument ``Point`` that
  368. doesn't lie on the sphere's surface then a ``ValueError`` is raised
  369. because it's not possible to calculate a value in this case.
  370. Parameters
  371. ==========
  372. point_1 : Point
  373. Point from which the geodesic length should be calculated.
  374. point_2 : Point
  375. Point to which the geodesic length should be calculated.
  376. """
  377. for point in (point_1, point_2):
  378. if not self.point_on_surface(point):
  379. msg = (
  380. f'Geodesic length cannot be calculated as point {point} '
  381. f'with radius {point.pos_from(self.point).magnitude()} '
  382. f'from the cylinder\'s center {self.point} does not lie on '
  383. f'the surface of {self} with radius {self.radius} and axis '
  384. f'{self.axis}.'
  385. )
  386. raise ValueError(msg)
  387. relative_position = point_2.pos_from(point_1)
  388. parallel_length = relative_position.dot(self.axis)
  389. point_1_relative_position = point_1.pos_from(self.point)
  390. point_1_perpendicular_vector = (
  391. point_1_relative_position
  392. - point_1_relative_position.dot(self.axis)*self.axis
  393. ).normalize()
  394. point_2_relative_position = point_2.pos_from(self.point)
  395. point_2_perpendicular_vector = (
  396. point_2_relative_position
  397. - point_2_relative_position.dot(self.axis)*self.axis
  398. ).normalize()
  399. central_angle = _directional_atan(
  400. cancel(point_1_perpendicular_vector
  401. .cross(point_2_perpendicular_vector)
  402. .dot(self.axis)),
  403. cancel(point_1_perpendicular_vector.dot(point_2_perpendicular_vector)),
  404. )
  405. planar_arc_length = self.radius*central_angle
  406. geodesic_length = sqrt(parallel_length**2 + planar_arc_length**2)
  407. return geodesic_length
  408. def geodesic_end_vectors(self, point_1, point_2):
  409. """The vectors parallel to the geodesic at the two end points.
  410. Parameters
  411. ==========
  412. point_1 : Point
  413. The point from which the geodesic originates.
  414. point_2 : Point
  415. The point at which the geodesic terminates.
  416. """
  417. point_1_from_origin_point = point_1.pos_from(self.point)
  418. point_2_from_origin_point = point_2.pos_from(self.point)
  419. if point_1_from_origin_point == point_2_from_origin_point:
  420. msg = (
  421. f'Cannot compute geodesic end vectors for coincident points '
  422. f'{point_1} and {point_2} as no geodesic exists.'
  423. )
  424. raise ValueError(msg)
  425. point_1_parallel = point_1_from_origin_point.dot(self.axis) * self.axis
  426. point_2_parallel = point_2_from_origin_point.dot(self.axis) * self.axis
  427. point_1_normal = (point_1_from_origin_point - point_1_parallel)
  428. point_2_normal = (point_2_from_origin_point - point_2_parallel)
  429. if point_1_normal == point_2_normal:
  430. point_1_perpendicular = Vector(0)
  431. point_2_perpendicular = Vector(0)
  432. else:
  433. point_1_perpendicular = self.axis.cross(point_1_normal).normalize()
  434. point_2_perpendicular = -self.axis.cross(point_2_normal).normalize()
  435. geodesic_length = self.geodesic_length(point_1, point_2)
  436. relative_position = point_2.pos_from(point_1)
  437. parallel_length = relative_position.dot(self.axis)
  438. planar_arc_length = sqrt(geodesic_length**2 - parallel_length**2)
  439. point_1_vector = (
  440. planar_arc_length * point_1_perpendicular
  441. + parallel_length * self.axis
  442. ).normalize()
  443. point_2_vector = (
  444. planar_arc_length * point_2_perpendicular
  445. - parallel_length * self.axis
  446. ).normalize()
  447. return (point_1_vector, point_2_vector)
  448. def __repr__(self):
  449. """Representation of a ``WrappingCylinder``."""
  450. return (
  451. f'{self.__class__.__name__}(radius={self.radius}, '
  452. f'point={self.point}, axis={self.axis})'
  453. )
  454. def _directional_atan(numerator, denominator):
  455. """Compute atan in a directional sense as required for geodesics.
  456. Explanation
  457. ===========
  458. To be able to control the direction of the geodesic length along the
  459. surface of a cylinder a dedicated arctangent function is needed that
  460. properly handles the directionality of different case. This function
  461. ensures that the central angle is always positive but shifting the case
  462. where ``atan2`` would return a negative angle to be centered around
  463. ``2*pi``.
  464. Notes
  465. =====
  466. This function only handles very specific cases, i.e. the ones that are
  467. expected to be encountered when calculating symbolic geodesics on uniformly
  468. curved surfaces. As such, ``NotImplemented`` errors can be raised in many
  469. cases. This function is named with a leader underscore to indicate that it
  470. only aims to provide very specific functionality within the private scope
  471. of this module.
  472. """
  473. if numerator.is_number and denominator.is_number:
  474. angle = atan2(numerator, denominator)
  475. if angle < 0:
  476. angle += 2 * pi
  477. elif numerator.is_number:
  478. msg = (
  479. f'Cannot compute a directional atan when the numerator {numerator} '
  480. f'is numeric and the denominator {denominator} is symbolic.'
  481. )
  482. raise NotImplementedError(msg)
  483. elif denominator.is_number:
  484. msg = (
  485. f'Cannot compute a directional atan when the numerator {numerator} '
  486. f'is symbolic and the denominator {denominator} is numeric.'
  487. )
  488. raise NotImplementedError(msg)
  489. else:
  490. ratio = sympify(trigsimp(numerator / denominator))
  491. if isinstance(ratio, tan):
  492. angle = ratio.args[0]
  493. elif (
  494. ratio.is_Mul
  495. and ratio.args[0] == Integer(-1)
  496. and isinstance(ratio.args[1], tan)
  497. ):
  498. angle = 2 * pi - ratio.args[1].args[0]
  499. else:
  500. msg = f'Cannot compute a directional atan for the value {ratio}.'
  501. raise NotImplementedError(msg)
  502. return angle