Skip to content

Add support for the S1 (circle) manifold and its tangent space #19

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jul 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,16 @@ pip install "liecasadi @ git+https://github.com/ami-iit/lie-casadi.git"
| --------- | ------------------ |
| SO3 | 3D Rotations |
| SE3 | 3D Rigid Transform |
| S1 | Circle (1D angle) |

### 🚀 Operations


Being:

- $X, Y \in SO3, \ SE3$
- $X, Y \in SO3, \ SE3, \ S1$

- $w \in \text{SO3Tangent}, \ \text{SE3Tangent}$
- $w \in \text{SO3Tangent}, \ \text{SE3Tangent}, \ \text{S1Tangent}$

- $v \in \mathbb{R}^3$

Expand Down
1 change: 1 addition & 0 deletions src/liecasadi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
from .quaternion import Quaternion
from .so3 import SO3, SO3Tangent
from .se3 import SE3, SE3Tangent
from .s1 import S1, S1Tangent
from .dualquaternion import DualQuaternion
83 changes: 83 additions & 0 deletions src/liecasadi/s1.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import dataclasses
import casadi as cs

from liecasadi.hints import Angle, Scalar

def _wrap_to_pi(angle: Scalar) -> Scalar:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this function is continuous and differentiable :/
A possible solution could be to use something like

def _wrap_to_pi(angle: Scalar) -> Scalar:
	s = cs.sin(angle)
	c = cs.cos(angle) 
	return cs.atan2(s, c)

which should be continuous.
The issue is when angle == 0.0, but maybe adding a small tolerance np.finfo().eps could solve this.
@edxmorgan what do you think?

cc @edxmorgan

"""Wrap an angle to the range [-pi, pi]."""
s = cs.sin(angle)
c = cs.cos(angle)
return cs.atan2(s, c)

@dataclasses.dataclass
class S1:
"""Class representing the circle manifold S1."""

angle: Angle

def __repr__(self) -> str:
return f"S1 angle: {self.angle}"

@staticmethod
def Identity() -> "S1":
"""Return the identity element (0 radians)."""
return S1(0.0)

def as_angle(self) -> Angle:
"""Return the angle value."""
return self.angle

def inverse(self) -> "S1":
"""Return the inverse rotation."""
return S1(-self.angle)

def __mul__(self, other: "S1") -> "S1":
if type(self) is type(other):
return S1(_wrap_to_pi(self.angle + other.angle))
raise RuntimeError("[S1: __mul__] Please multiply with an S1 object.")

def __rmul__(self, other: "S1") -> "S1":
return self.__mul__(other)

def log(self) -> "S1Tangent":
"""Return the tangent vector via the logarithm map."""
return S1Tangent(_wrap_to_pi(self.angle))

def __sub__(self, other: "S1") -> "S1Tangent":
if type(self) is type(other):
return S1Tangent(_wrap_to_pi(self.angle - other.angle))
raise RuntimeError("[S1: __sub__] Please subtract an S1 object.")


@dataclasses.dataclass
class S1Tangent:
"""Tangent space element of S1."""

val: Scalar

def __repr__(self) -> str:
return f"S1Tangent: {self.val}"

def exp(self) -> S1:
"""Exponential map returning an S1 element."""
return S1(_wrap_to_pi(self.val))

def __add__(self, other: S1) -> S1:
if type(other) is S1:
return S1(_wrap_to_pi(other.angle + self.val))
raise RuntimeError("[S1Tangent: __add__] Please add an S1 object.")

def __radd__(self, other: S1) -> S1:
return self.__add__(other)

def __mul__(self, scalar: float) -> "S1Tangent":
if isinstance(scalar, (int, float)):
return S1Tangent(self.val * scalar)
raise RuntimeError("[S1Tangent: __mul__] Please multiply with a scalar.")

def __rmul__(self, scalar: float) -> "S1Tangent":
return self.__mul__(scalar)

def value(self) -> Scalar:
"""Return the underlying scalar value."""
return self.val
32 changes: 26 additions & 6 deletions tests/test_liecasadi.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
import numpy as np
import pytest
from scipy.spatial.transform import Rotation

from liecasadi import SE3, SO3, SE3Tangent, SO3Tangent
import casadi as cs
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With a new module, it could make sense to split the tests into multiple files. But we can do it in a different pr

Copy link
Contributor Author

@edxmorgan edxmorgan Jul 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right! Splitting the tests into separate files will make it look organized. I’ll open a PR for that.

from liecasadi import SE3, SO3, S1, SE3Tangent, SO3Tangent, S1Tangent

# quat generation
quat = (np.random.rand(4) - 0.5) * 5
Expand Down Expand Up @@ -110,10 +110,10 @@ def test_act():
mySE3_2 = SE3(pos=pos2, xyzw=quat2)


# def test_inverse():
assert (mySE3.inverse().transform() - manifSE3.inverse().transform()) == pytest.approx(
0.0, abs=1e-4
)
def test_inverse():
assert (mySE3.inverse().transform() - manifSE3.inverse().transform()) == pytest.approx(
0.0, abs=1e-4
)


def test_mul():
Expand Down Expand Up @@ -148,3 +148,23 @@ def test_sub_SE3():
assert (mySE3 - mySE3_2).exp().transform() - (
manifSE3 - manifSE3_2
).exp().transform() == pytest.approx(0.0, abs=1e-4)


def test_identity():
assert S1.Identity().as_angle() == 0.0

def test_mul():
a = S1(cs.pi / 4)
b = S1(cs.pi / 4)
c = a * b
assert float(c.as_angle()) == pytest.approx(float(cs.pi / 2))

def test_sub_and_log_exp():
a = S1(cs.pi / 3)
b = S1(cs.pi / 6)
diff = a - b
rebuilt = diff.exp() * b
assert float(rebuilt.as_angle()) == pytest.approx(float(cs.pi / 3))

tangent = a.log()
assert float(tangent.exp().as_angle()) == pytest.approx(float(cs.pi / 3))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

End of line missing!

Loading