Source code for georges_core.frame

"""Module for handling of affine geometry transformations (rotations and translations).

This module provides support for affine geometry transformations, mainly through the `Frame` class. A typical use case,
the one that triggered the development of this module for ::py:module Zgoubidoo, is the problem of placing a sequence
with each object being placed with respect to the one preceeding it, with each object being potentially translate or
rotated. In such a case, the ::py:module Frame module allows to define a reference frame for each object being placed,
with the reference frame of the newly created object using the reference frame of the previous object as a reference
frame for its own positioning. The translations and rotations of the object are thus trivially expressed in its own
reference frame. The ::py:module Frame module then allows to query the coordinates and rotation information of the
object with respect to any other reference frame in the chain of transformations. In particular, the coordinates of the
origin of a frame along with its orientation in space, can be obtained with respect to a global (absolute) reference
frame.

Example:
    example TODO

"""
from __future__ import annotations

from typing import List, Mapping, NoReturn, Optional

import numpy as _np
import quaternion as _quaternion

from . import ureg as _ureg

_X = 0
_Y = 1
_Z = 2

_AXES = {"X": 0, "Y": 1, "Z": 2}


[docs] class FrameException(Exception): """Exception raised for errors in the Frame module.""" def __init__(self, m: str = ""): self.message = m
[docs] class Frame: """ A Frame object represents a reference frame for affine geometry transformations (rotations and translations). It has full support for transformation chaining through the notion of 'parent frame' and is able to provide rotation angles and translation offsets with respect to any parent among the linked list of parents. Additionnally Frames are unit-aware through the use of 'pint quantities'. The internal representations use the 'quaternion-numpy' module with the base units being radians and meters. This is transparent to the public interface which can use arbitrary units for the representations of lengths and angles. `Frame` handles quaternion rotations via `quaternion-numpy`: https://github.com/moble/quaternion . >>> f1 = Frame() >>> f1.translate_y(10 * _ureg.cm) #doctest: +ELLIPSIS <georges_core.frame.Frame object at 0x...> >>> f2 = Frame(parent=f1) >>> f2.parent == f1 True >>> f2.translate_x(1 * _ureg.cm) #doctest: +ELLIPSIS <georges_core.frame.Frame object at 0x...> >>> f2.get_origin(f1) [<Quantity(0.01, 'meter')>, <Quantity(0.0, 'meter')>, <Quantity(0.0, 'meter')>] >>> f2.o == f2.origin == f2.get_origin(None) True """ def __init__(self, parent: Optional[Frame] = None, reference: Optional[Frame] = None): """ Initialize a Frame with respect to a parent frame. If no parent is provided the newly created frame is considered to be a global reference frame. The frame is create with no rotation or translate with respect to its parent frame. Args: parent: parent frame, if None then the frame itself is considered as a global reference frame. reference: reference frame, all quantities are provided by default with respect to this frame. This allows to use the properties easily but can be modified on a case-by-case basis for each function. Alternatively the reference frame can be modified after the object creation. """ self._p: Optional[Frame] = parent self._r: Optional[Frame] = reference self._q: _np.quaternion = _np.quaternion(1, 0, 0, 0) self._o: _np.ndarray = _np.zeros(3) self._cache: Mapping = dict()
[docs] def __eq__(self, o: Frame) -> bool: """ Equality comparison with another Frame. Args: o: other frame to be compared with Returns: True if the two frames are strictly equal (same parent and equal rotation and origin) else otherwise Example: >>> f1 = Frame() >>> f2 = Frame() >>> f1.rotate_x(10 * _ureg.degree) #doctest: +ELLIPSIS <georges_core.frame.Frame object at 0x...> >>> f1 == f2 False >>> f2.rotate_x(10 * _ureg.degree) #doctest: +ELLIPSIS <georges_core.frame.Frame object at 0x...> >>> f1 == f2 True """ return self._p == o._p and self._q == o._q and _np.all(self._o == o._o)
@property def parent(self) -> Optional[Frame]: """ Provides the parent frame. Returns: parent frame, None in case the frame is a global reference frame. Examples: >>> f1 = Frame() >>> f1.parent is None True >>> f2 = Frame(parent=f1) >>> f2.parent == f1 True The 'parent' property can also be set: >>> f1 = Frame() >>> f1.parent is None True >>> f2 = Frame() >>> f2.parent is None True >>> f2.parent = f1 >>> f2.parent is f1 True >>> f2.parent == f1 True >>> f2.parent = None >>> f2.parent is None True """ return self._p @parent.setter def parent(self, p) -> NoReturn: """ Modifies the parent frame on the fly. Args: p: the new parent frame Returns: NoReturn """ self._p = p @property def reference(self) -> Optional[Frame]: """ Provides the reference frame with respect to which the quantities will be provided. None if not set. Returns: a frame serving as reference frame for the current frame (None if not set) Examples: >>> f1 = Frame(parent=None, reference=None) >>> f1.reference is None True >>> f2 = Frame(parent=f1, reference=f1) >>> f2.reference is f1 True >>> f2.reference == f1 True >>> f2.reference = None >>> f2.reference is None True """ return self._r @reference.setter def reference(self, r) -> NoReturn: """ Modifies the reference frame with respect to which the quantities are provided by default. Args: r: the new reference frame Returns: NoReturn """ self._r = r
[docs] def get_quaternion(self, ref: Optional[Frame] = None) -> _np.quaternion: """ Provides the quaternion representation of the rotation of the frame with respect to another reference frame. Args: ref: reference frame with respect to which the rotation quaternion is returned. If None then the rotation is provided with respect to the current reference frame. Returns: the quaternion representing the rotation with respect to a given reference frame. Examples: >>> f1 = Frame().rotate_z(20 * _ureg.degree) >>> f1.get_quaternion() quaternion(0.984807753012208, 0, 0, 0.17364817766693) >>> f2 = Frame(f1) >>> f2.rotate_x(10 * _ureg.degree).get_quaternion() #doctest: +ELLIPSIS quaternion(0.981060..., 0.085831..., 0.015134..., 0.172987...) >>> f2.get_quaternion(f1) #doctest: +ELLIPSIS quaternion(0.996194..., 0.087155..., 0, 0) """ ref = ref or self._r if ref is None and self._cache.get("q"): return self._cache["q"] if self._p is ref: q = self._q elif ref is self: q = _np.quaternion(1, 0, 0, 0) # Identity rotation with respect to oneself else: # Caution: this one DOES NOT commute q = self._p.get_quaternion(ref) * self._q # Recursion self._cache["q"] = q return q
quaternion = property(get_quaternion) q = property(get_quaternion) def _get_origin(self, ref: Optional[Frame] = None) -> _np.ndarray: """ Provides the offset representing the translation of the frame with respect to another reference frame. This method works in the internal unit representation of the class `Frame`. Args: ref: reference frame with respect to which the origin is returned. If None then the translation is provided with respect to the current reference frame. Retunrs: the offset (numpy array, no units) representing the translation with respect to a given reference frame. Examples: >>> f1 = Frame().translate_x(10 * _ureg.cm) >>> f1._get_origin() array([0.1, 0. , 0. ]) >>> f2 = Frame(f1).translate_y(100 * _ureg.cm) >>> f2._get_origin() array([0.1, 1. , 0. ]) >>> f2._get_origin(f1) array([0., 1., 0.]) """ ref = ref or self._r if ref is None and self._cache.get("o") is not None: return self._cache["o"] if self._p is ref: return self._o elif ref is self: return _np.zeros(3) # Identity translation with respect to oneself else: m = _quaternion.as_rotation_matrix(self.get_quaternion(ref)) o = self._p._get_origin(ref) + _np.matmul(m, self._o) self._cache["o"] = o return o o_ = property(_get_origin) origin_ = property(_get_origin)
[docs] def get_origin(self, ref: Optional[Frame] = None) -> List[_ureg.Quantity]: """Offset of the frame with respect to a reference frame. Provides the offset representing the translation of the frame with respect to another reference frame. This method supports units and returns `pint` quantities with dimensions of [LENGTH]. Args: ref: reference frame with respect to which the origin is returned. If None then the translation is provided with respect to the global reference frame. Returns: the offset (list of quantities with dimensions of [LENGTH]) representing the translation with respect to a given reference frame. Examples: >>> f1 = Frame().translate_x(10 * _ureg.cm) >>> f1.get_origin() [<Quantity(0.1, 'meter')>, <Quantity(0.0, 'meter')>, <Quantity(0.0, 'meter')>] >>> f2 = Frame(f1).translate_y(100 * _ureg.cm) >>> f2.get_origin() [<Quantity(0.1, 'meter')>, <Quantity(1.0, 'meter')>, <Quantity(0.0, 'meter')>] >>> f2.get_origin(f1) [<Quantity(0.0, 'meter')>, <Quantity(1.0, 'meter')>, <Quantity(0.0, 'meter')>] """ return list(map(lambda _: _ * _ureg.meter, self._get_origin(ref)))
o = property(get_origin) origin = property(get_origin) def _get_x(self, ref: Optional[Frame] = None) -> float: """X axis offset with respect to a reference frame (internal units). Provides the X axis offset representing the translation of the frame with respect to another reference frame. This method works in the internal unit representation of the class `Frame`. Args: ref: reference frame with respect to which the origin is returned. If None then the translation is provided with respect to the current reference frame. Returns: the X axis offset (float, no units) representing the X axis translation with respect to a given reference frame. Examples: >>> f1 = Frame().translate_x(10 * _ureg.cm) >>> f1._get_x() 0.1 >>> f2 = Frame(f1).translate_y(100 * _ureg.cm) >>> f2._get_x() 0.1 >>> f2._get_x(f1) 0.0 """ return self._get_origin(ref)[_X] x_ = property(_get_x)
[docs] def get_x(self, ref: Optional[Frame] = None) -> _ureg.Quantity: """X axis offset with respect to a reference frame. Provides the X axis offset representing the translation of the frame with respect to another reference frame. This method works with full support of pint units. >>> f1 = Frame().translate_x(10 * _ureg.cm) >>> f1.get_x() <Quantity(0.1, 'meter')> >>> f2 = Frame(f1).translate_y(100 * _ureg.cm) >>> f2.get_x() <Quantity(0.1, 'meter')> >>> f2.get_x(f1) <Quantity(0.0, 'meter')> Args: ref: reference frame with respect to which the origin is returned. If None then the translation is provided with respect to the current reference frame. Returns: the X axis offset (with units, pint quantity) representing the X axis translation with respect to a given reference frame. """ return self.get_origin(ref)[_X]
x = property(get_x) def _get_y(self, ref: Optional[Frame] = None) -> float: """ Provides the Y axis offset representing the translation of the frame with respect to another reference frame. This method works in the internal unit representation of the class `Frame`. >>> f1 = Frame().translate_y(10 * _ureg.cm) >>> f1._get_y() 0.1 >>> f2 = Frame(f1).translate_x(100 * _ureg.cm) >>> f2._get_y() 0.1 >>> f2._get_y(f1) 0.0 Args: ref: reference frame with respect to which the origin is returned. If None then the translation is provided with respect to the current reference frame. Returns: the Y axis offset (float, no units) representing the Y axis translation with respect to a given reference frame. """ return self._get_origin(ref)[_Y] y_ = property(_get_y)
[docs] def get_y(self, ref: Optional[Frame] = None) -> _ureg.Quantity: """Y axis offset with respect to a reference frame. Provides the Y axis offset representing the translation of the frame with respect to another reference frame. This method works with full support of pint units. Examples: >>> f1 = Frame().translate_y(10 * _ureg.cm) >>> f1.get_y() <Quantity(0.1, 'meter')> >>> f2 = Frame(f1).translate_x(100 * _ureg.cm) >>> f2.get_y() <Quantity(0.1, 'meter')> >>> f2.get_y(f1) <Quantity(0.0, 'meter')> Args: ref: reference frame with respect to which the origin is returned. If None then the translation is provided with respect to the current reference frame. Returns: the Y axis offset (with units, pint quantity) representing the Y axis translation with respect to a given reference frame. """ return self.get_origin(ref)[_Y]
y = property(get_y) def _get_z(self, ref: Optional[Frame] = None) -> float: """ Provides the Z axis offset representing the translation of the frame with respect to another reference frame. This method works in the internal unit representation of the class `Frame`. Examples: >>> f1 = Frame().translate_z(10 * _ureg.cm) >>> f1._get_z() 0.1 >>> f2 = Frame(f1).translate_x(100 * _ureg.cm) >>> f2._get_z() 0.1 >>> f2._get_z(f1) 0.0 Args: ref: reference frame with respect to which the origin is returned. If None then the translation is provided with respect to the current reference frame. Returns: the Z axis offset (float, no units) representing the Z axis translation with respect to a given reference frame. """ return self._get_origin(ref)[_Z] z_ = property(_get_z)
[docs] def get_z(self, ref: Optional[Frame] = None) -> _ureg.Quantity: """ Provides the Z axis offset representing the translation of the frame with respect to another reference frame. This method works with full support of pint units. >>> f1 = Frame().translate_z(10 * _ureg.cm) >>> f1.get_z() <Quantity(0.1, 'meter')> >>> f2 = Frame(f1).translate_x(100 * _ureg.cm) >>> f2.get_z() <Quantity(0.1, 'meter')> >>> f2.get_z(f1) <Quantity(0.0, 'meter')> Args: ref: reference frame with respect to which the origin is returned. If None then the translation is provided with respect to the current reference frame. Returns: the Z axis offset (with units, pint quantity) representing the Z axis translation with respect to a given reference frame. """ return self.get_origin(ref)[_Z]
z = property(get_z)
[docs] def get_rotation_matrix(self, ref: Optional[Frame] = None) -> _np.ndarray: """ Provides the rotation matrix representation of the quaternion with respect to another reference frame. Example: >>> f1 = Frame().rotate_x(10 * _ureg.degree).get_rotation_matrix() >>> f1 #doctest: +NORMALIZE_WHITESPACE array([[ 1. , 0. , 0. ], [ 0. , 0.98480775, -0.17364818], [ 0. , 0.17364818, 0.98480775]]) Args: ref: reference frame with respect to which the rotation matrix is returned Returns: the rotation matrix representation of the quaternion """ return _quaternion.as_rotation_matrix(self.get_quaternion(ref))
[docs] def get_euler_angles(self, ref: Optional[Frame] = None) -> _np.ndarray: """ Args: ref: Returns: """ return _quaternion.as_euler_angles(self.get_quaternion(ref))
[docs] def get_rotation_vector(self, ref: Optional[Frame] = None) -> _np.ndarray: """ Args: ref: Returns: """ return _quaternion.as_rotation_vector(self.get_quaternion(ref))
def _get_angles(self, ref: Optional[Frame] = None) -> _np.ndarray: """ Examples: >>> f1 = Frame() # TODO Args: ref: Returns: """ m = _quaternion.as_rotation_matrix(self.get_quaternion(ref)) m11 = m[1, 1] m22 = m[2, 2] if m11 > 1.0: m11 = 1.0 elif m11 < -1.0: m11 = -1.0 if m22 > 1.0: m22 = 1.0 elif m22 < -1.0: m22 = -1.0 return _np.array( [ -_np.pi / 2 + _np.arctan2(m[0, 0], m[1, 0]), _np.arccos(m11), _np.arccos(m22), ], )
[docs] def get_angles(self, ref: Optional[Frame] = None, units: _ureg.Unit = _ureg.radian) -> List[_ureg.Quantity]: """ Examples: >>> f1 = Frame() # TODO Args: ref: units: Returns: """ return list(map(lambda _: (_ * _ureg.radian).to(units), self._get_angles(ref)))
angles = property(get_angles) def _get_tx(self, ref: Optional[Frame] = None) -> float: """ Examples: >>> f1 = Frame() # TODO Args: ref: Returns: """ return self._get_angles(ref)[_X]
[docs] def get_tx(self, ref: Optional[Frame] = None) -> _ureg.Quantity: """ Examples: >>> f1 = Frame() # TODO Args: ref: Returns: """ return self._get_tx(ref) * _ureg.radian
tx = property(get_tx) def _get_ty(self, ref: Optional[Frame] = None) -> float: """ Examples: >>> f1 = Frame() # TODO Args: ref: Returns: """ return self._get_angles(ref)[_Y]
[docs] def get_ty(self, ref: Optional[Frame] = None) -> _ureg.Quantity: """ Examples: >>> f1 = Frame() # TODO Args: ref: Returns: """ return self._get_ty(ref) * _ureg.radian
ty = property(get_ty) def _get_tz(self, ref: Optional[Frame] = None) -> float: """ Examples: >>> f1 = Frame() # TODO Args: ref: Returns: """ return self._get_angles(ref)[_Z]
[docs] def get_tz(self, ref: Optional[Frame] = None) -> _ureg.Quantity: """ Examples: >>> f1 = Frame() # TODO Args: ref: Returns: """ return self._get_tz(ref) * _ureg.radian
tz = property(get_tz)
[docs] def __mul__(self, q: _np.quaternion) -> Frame: """ Provide a simple way to rotate the Frame in a generic way, by multiplying directly with a quaternion. Examples: >>> f = Frame() >>> q = _quaternion.from_rotation_vector([0, 0, 0.5]) >>> (f * q).tx <Quantity(-0.5, 'radian')> Args: q: a quaternion by which the frame is multiplied representing the rotation Returns: the rotated frame (in place), allows method chaining """ self._cache.pop("q", None) # Invalidates the cache self._q *= q return self
def _rotate(self, angles: _np.array) -> Frame: """ Args: angles: Returns: the rotated frame (in place), allows method chaining """ return self * _quaternion.from_rotation_vector(angles)
[docs] def rotate(self, angles: List[_ureg.Quantity]) -> Frame: """ Examples: >>> f1 = Frame() # TODO Args: angles: Returns: the rotated frame (in place), allows method chaining """ return self._rotate(_np.array(list(map(lambda _: _.m_as("radian"), angles))))
def _rotate_x(self, angle: float) -> Frame: """ Examples: >>> f1 = Frame() # TODO Args: angle: Returns: """ return self._rotate([angle, 0, 0])
[docs] def rotate_x(self, angle: _ureg.Quantity) -> Frame: """ Examples: >>> f1 = Frame() # TODO Args: angle: Returns: """ return self._rotate_x(angle.m_as("radian"))
def _rotate_y(self, angle: float) -> Frame: """ Examples: >>> f1 = Frame() # TODO Args: angle: Returns: """ return self._rotate([0, angle, 0])
[docs] def rotate_y(self, angle: _ureg.Quantity) -> Frame: """ Examples: >>> f1 = Frame() # TODO Args: angle: Returns: """ return self._rotate_y(angle.m_as("radian"))
def _rotate_z(self, angle: float) -> Frame: """ Examples: >>> f1 = Frame() # TODO Args: angle: Returns: """ return self._rotate([0, 0, angle])
[docs] def rotate_z(self, angle: _ureg.Quantity) -> Frame: """ Examples: >>> f1 = Frame() # TODO Args: angle: Returns: """ return self._rotate_z(angle.m_as("radian"))
[docs] def rotate_axis(self, axis: str, angle: float) -> Frame: """ Examples: >>> f1 = Frame() # TODO Args: axis: angle: Returns: """ if axis.lower() not in "xyz" or len(axis) > 1: raise FrameException("Invalid rotation axis for 'rotate_axis'") return getattr(self, f"rotate_{axis.lower()}")(angle)
[docs] def __add__(self, offset: List[_ureg.Quantity]) -> Frame: """ Provide a simple way to translate the Frame in a generic way, by adding an offset directly. This method is unit-aware and the offset must be provided as a list of pint Quantities. Examples: >>> f = Frame() >>> offset = [1.0 * _ureg.cm, 2.0 * _ureg.cm, 3.0 * _ureg.cm] >>> (f + offset).x <Quantity(0.01, 'meter')> >>> (f + offset).y <Quantity(0.04, 'meter')> >>> (f + offset).z <Quantity(0.09, 'meter')> Args: offset: a list representing the offset (elements of the list must be quantities of dimension [LENGTH]) Returns: the translated frame (in place) """ self._cache.pop("o", None) # Invalidaets the cache return self.translate(offset)
def _translate(self, offset: _np.ndarray) -> Frame: """ Examples: >>> f1 = Frame() # TODO Args: offset: Returns: """ self._o += offset return self
[docs] def translate(self, offset: List[_ureg.Quantity]) -> Frame: """ Translates the origin of the Frame with respect to the parent reference frame. The translations are extrinsic (done with respect to the axes of the parent frame). Examples: >>> f1 = Frame() >>> f1.translate([1.0 * _ureg.meter, 2.0 * _ureg.meter, 3.0 * _ureg.meter]).o [<Quantity(1.0, 'meter')>, <Quantity(2.0, 'meter')>, <Quantity(3.0, 'meter')>] >>> f2 = Frame(parent=f1) >>> f2.translate([1.0 * _ureg.meter, 2.0 * _ureg.meter, -3.0 * _ureg.meter]).o [<Quantity(2.0, 'meter')>, <Quantity(4.0, 'meter')>, <Quantity(0.0, 'meter')>] >>> f2.get_origin(f1) [<Quantity(1.0, 'meter')>, <Quantity(2.0, 'meter')>, <Quantity(-3.0, 'meter')>] >>> f2.rotate_x(180 * _ureg.degree).o [<Quantity(2.0, 'meter')>, <Quantity(4.0, 'meter')>, <Quantity(0.0, 'meter')>] >>> f2.get_origin(f1) [<Quantity(1.0, 'meter')>, <Quantity(2.0, 'meter')>, <Quantity(-3.0, 'meter')>] Args: offset: a list representing the offset (elements of the list must be quantities of dimension [LENGTH]) Returns: the translated frame (in place), allows method chaining """ if len(offset) != 3: raise FrameException("The offset must be of length 3.") return self._translate(_np.array(list(map(lambda _: _.m_as("m"), offset))))
def _translate_x(self, offset: float) -> Frame: """ Examples: >>> f1 = Frame() # TODO Args: offset: Returns: """ self._o[_X] += offset return self
[docs] def translate_x(self, offset: _ureg.Quantity) -> Frame: """ Examples: >>> f1 = Frame() # TODO Args: offset: Returns: """ return self._translate_x(offset.m_as("m"))
def _translate_y(self, offset: float) -> Frame: """ Examples: >>> f1 = Frame() # TODO Args: offset: Returns: """ self._o[_Y] += offset return self
[docs] def translate_y(self, offset: _ureg.Quantity) -> Frame: """ Examples: >>> f1 = Frame() # TODO Args: offset: Returns: """ return self._translate_y(offset.m_as("m"))
def _translate_z(self, offset: float) -> Frame: """ Examples: >>> f1 = Frame() # TODO Args: offset: Returns: """ self._o[_Z] += offset return self
[docs] def translate_z(self, offset: _ureg.Quantity) -> Frame: """ Example: >>> f1 = Frame() # TODO Args: offset: Returns: the translated frame (in place) """ return self._translate_z(offset.m_as("m"))
def _translate_axis(self, axis: str, offset: float) -> Frame: """ Example: >>> f1 = Frame() # TODO Args: axis: offset: Returns: the translated frame (in place) """ if axis.lower() not in "xyz" or len(axis) > 1: raise FrameException("Invalid rotation axis for 'translate_axis'") return getattr(self, f"_translate_{axis.lower()}")(offset)
[docs] def translate_axis(self, axis: str, offset: _ureg.Quantity) -> Frame: """ Example: >>> f1 = Frame() # TODO Args: axis: offset: Returns: the translated frame (in place) """ return self._translate_axis(axis, offset.m_as("m"))
[docs] def reset(self) -> Frame: """Reset the frame. Reset the frame (rotation and translation) with respect to the parent; the frame is thus equivalent to its parent frame. Examples: >>> f1 = Frame().rotate_x(1 * _ureg.radian) >>> f1.get_angles() #doctest: +ELLIPSIS [<Quantity(0.0, 'radian')>, <Quantity(1.0..., 'radian')>, <Quantity(1.0..., 'radian')>] >>> f1.reset().get_angles() [<Quantity(0.0, 'radian')>, <Quantity(0.0, 'radian')>, <Quantity(0.0, 'radian')>] Returns: the reseted frame itself (to allow method chaining). """ self._q: _np.quaternion = _np.quaternion(1, 0, 0, 0) self._o: _np.ndarray = _np.zeros(3) self._cache: Mapping = dict() return self
[docs] class FrameFrenet(Frame): def __mul__(self, q: _np.quaternion) -> Frame: if not _np.all(_np.isclose(_quaternion.as_rotation_vector(q)[1:], _np.array([0.0, 0.0]))): raise FrameException("Frenet frames cannot be multiplied (rotated) by arbitrary quaternions.") super().__mul__(q) def _rotate(self, angles: _np.array) -> Frame: """ Args: angles: Returns: the rotated frame (in place), allows method chaining """ return self * _quaternion.from_rotation_vector([angles[0], 0, 0])