OpenSMI-Core 0.5__tar.gz → 0.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opensmi_core-0.6/.github/workflows/checks.yml +72 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/.github/workflows/python-publish.yml +4 -1
- {opensmi_core-0.5 → opensmi_core-0.6}/PKG-INFO +1 -1
- {opensmi_core-0.5 → opensmi_core-0.6}/pyproject.toml +11 -5
- {opensmi_core-0.5 → opensmi_core-0.6}/src/OpenSMI_Core.egg-info/PKG-INFO +1 -1
- {opensmi_core-0.5 → opensmi_core-0.6}/src/OpenSMI_Core.egg-info/SOURCES.txt +3 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/src/OpenSMI_Core.egg-info/scm_file_list.json +3 -0
- opensmi_core-0.6/src/OpenSMI_Core.egg-info/scm_version.json +8 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/base_ua_object.py +1 -3
- opensmi_core-0.6/src/opensmi/core/rsl.py +271 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/subscription_manager.py +8 -1
- {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/ua_enums.py +1 -1
- {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/ua_node_util.py +4 -2
- opensmi_core-0.6/tests/test_rsl.py +495 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/tools/auto_generate_units.py +3 -0
- opensmi_core-0.5/src/OpenSMI_Core.egg-info/scm_version.json +0 -8
- {opensmi_core-0.5 → opensmi_core-0.6}/.github/workflows/api-docs.yml +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/.gitignore +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/.gitlab-ci.yml +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/LICENSE +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/LICENSES/MIT.txt +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/README.md +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/setup.cfg +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/src/OpenSMI_Core.egg-info/dependency_links.txt +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/src/OpenSMI_Core.egg-info/requires.txt +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/src/OpenSMI_Core.egg-info/top_level.txt +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/__init__.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/async_task_mixin.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/base_server.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/config.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/enums.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/errors.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/frozen.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/hook.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/lifecycle_mixin.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/logging.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/meta.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/mixin_helpers.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/protocols.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/repr_mixin.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/signal.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/ua.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/unit_all.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/unit_base.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/unset.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/tests/test_signal.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.6}/tests/test_unit.py +0 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
name: Checks
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ main ]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_call: # lets the publishing workflow reuse this one
|
|
8
|
+
secrets:
|
|
9
|
+
CODECOV_TOKEN:
|
|
10
|
+
required: false
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
lint:
|
|
17
|
+
name: Ruff and basedpyright
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v4
|
|
22
|
+
|
|
23
|
+
- uses: astral-sh/setup-uv@v7
|
|
24
|
+
with:
|
|
25
|
+
python-version: "3.12"
|
|
26
|
+
enable-cache: true
|
|
27
|
+
|
|
28
|
+
- name: Install dependencies
|
|
29
|
+
run: uv sync --group dev --all-extras
|
|
30
|
+
|
|
31
|
+
# - name: Ruff lint
|
|
32
|
+
# run: uv run ruff check .
|
|
33
|
+
|
|
34
|
+
- name: Ruff format check
|
|
35
|
+
run: uv run ruff format --check .
|
|
36
|
+
|
|
37
|
+
- name: basedpyright
|
|
38
|
+
run: uv run basedpyright
|
|
39
|
+
|
|
40
|
+
test:
|
|
41
|
+
runs-on: ubuntu-latest
|
|
42
|
+
strategy:
|
|
43
|
+
fail-fast: false
|
|
44
|
+
matrix:
|
|
45
|
+
python-version: [ "3.11", "3.12", "3.13", "3.14" ]
|
|
46
|
+
|
|
47
|
+
steps:
|
|
48
|
+
- uses: actions/checkout@v4
|
|
49
|
+
|
|
50
|
+
- uses: astral-sh/setup-uv@v7
|
|
51
|
+
with:
|
|
52
|
+
python-version: ${{ matrix.python-version }}
|
|
53
|
+
enable-cache: true
|
|
54
|
+
|
|
55
|
+
- name: Install dependencies
|
|
56
|
+
run: uv sync --group dev
|
|
57
|
+
|
|
58
|
+
- name: Run tests with coverage
|
|
59
|
+
run: >
|
|
60
|
+
uv run pytest
|
|
61
|
+
-n auto
|
|
62
|
+
--cov
|
|
63
|
+
--cov-report=term-missing
|
|
64
|
+
--cov-report=xml
|
|
65
|
+
|
|
66
|
+
- name: Upload coverage to Codecov
|
|
67
|
+
uses: codecov/codecov-action@v5
|
|
68
|
+
with:
|
|
69
|
+
token: ${{ secrets.CODECOV_TOKEN }}
|
|
70
|
+
files: coverage.xml
|
|
71
|
+
flags: py${{ matrix.python-version }}
|
|
72
|
+
fail_ci_if_error: false # don't block on Codecov outages or fork PRs without the token
|
|
@@ -10,12 +10,15 @@ name: Upload Python Package
|
|
|
10
10
|
|
|
11
11
|
on:
|
|
12
12
|
release:
|
|
13
|
-
types: [published]
|
|
13
|
+
types: [ published ]
|
|
14
14
|
|
|
15
15
|
permissions:
|
|
16
16
|
contents: read
|
|
17
17
|
|
|
18
18
|
jobs:
|
|
19
|
+
checks:
|
|
20
|
+
uses: ./.github/workflows/checks.yml
|
|
21
|
+
|
|
19
22
|
release-build:
|
|
20
23
|
runs-on: ubuntu-latest
|
|
21
24
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: OpenSMI-Core
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6
|
|
4
4
|
Summary: Smart Machine Interface core functionality for server and client applications.
|
|
5
5
|
Author-email: Carsten Harms <Carsten.Harms@dfki.de>, Simon Jungbluth <Simon.Jungbluth@smartfactory.de>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -61,7 +61,9 @@ dev = [
|
|
|
61
61
|
"pytest-xdist>=3",
|
|
62
62
|
"pytest-html>=4",
|
|
63
63
|
"pytest-timeout>=2",
|
|
64
|
-
"openpyxl"
|
|
64
|
+
"openpyxl", # to load UNECE Excel to autogenerate units
|
|
65
|
+
"scipy~=1.17.1", # for testing, serves as reference implementation. newer versions dropped Python 3.11
|
|
66
|
+
"numpy~=2.4.6" # for scipy, newer versions dropped Python 3.11
|
|
65
67
|
]
|
|
66
68
|
|
|
67
69
|
[tool.setuptools]
|
|
@@ -133,10 +135,10 @@ author = "OpenSMI Contributors"
|
|
|
133
135
|
|
|
134
136
|
[tool.ruff.lint.per-file-ignores]
|
|
135
137
|
"tests/**" = ["ANN", "D", "T", "SLF001"]
|
|
136
|
-
"src/opensmi/core/unit_all.py" = [
|
|
137
|
-
"E501",
|
|
138
|
-
"RUF001",
|
|
139
|
-
"CPY",
|
|
138
|
+
"src/opensmi/core/unit_all.py" = [# auto-generated
|
|
139
|
+
"E501", # line too long
|
|
140
|
+
"RUF001", # String contains ambiguous ~
|
|
141
|
+
"CPY", # copyright
|
|
140
142
|
]
|
|
141
143
|
|
|
142
144
|
[tool.ruff.format]
|
|
@@ -180,3 +182,7 @@ theme = "readthedocs"
|
|
|
180
182
|
# verbose = 1
|
|
181
183
|
# warnings-as-errors = true
|
|
182
184
|
intersphinx = ["https://opcua-asyncio.readthedocs.io/en/latest/objects.inv"] # TODO(CaHa): does not work
|
|
185
|
+
|
|
186
|
+
[tool.coverage.run]
|
|
187
|
+
source = ["src/opensmi/core"]
|
|
188
|
+
branch = true
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: OpenSMI-Core
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6
|
|
4
4
|
Summary: Smart Machine Interface core functionality for server and client applications.
|
|
5
5
|
Author-email: Carsten Harms <Carsten.Harms@dfki.de>, Simon Jungbluth <Simon.Jungbluth@smartfactory.de>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -4,6 +4,7 @@ LICENSE
|
|
|
4
4
|
README.md
|
|
5
5
|
pyproject.toml
|
|
6
6
|
.github/workflows/api-docs.yml
|
|
7
|
+
.github/workflows/checks.yml
|
|
7
8
|
.github/workflows/python-publish.yml
|
|
8
9
|
LICENSES/MIT.txt
|
|
9
10
|
src/OpenSMI_Core.egg-info/PKG-INFO
|
|
@@ -28,6 +29,7 @@ src/opensmi/core/meta.py
|
|
|
28
29
|
src/opensmi/core/mixin_helpers.py
|
|
29
30
|
src/opensmi/core/protocols.py
|
|
30
31
|
src/opensmi/core/repr_mixin.py
|
|
32
|
+
src/opensmi/core/rsl.py
|
|
31
33
|
src/opensmi/core/signal.py
|
|
32
34
|
src/opensmi/core/subscription_manager.py
|
|
33
35
|
src/opensmi/core/ua.py
|
|
@@ -36,6 +38,7 @@ src/opensmi/core/ua_node_util.py
|
|
|
36
38
|
src/opensmi/core/unit_all.py
|
|
37
39
|
src/opensmi/core/unit_base.py
|
|
38
40
|
src/opensmi/core/unset.py
|
|
41
|
+
tests/test_rsl.py
|
|
39
42
|
tests/test_signal.py
|
|
40
43
|
tests/test_unit.py
|
|
41
44
|
tools/auto_generate_units.py
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"files": [
|
|
3
3
|
".github/workflows/api-docs.yml",
|
|
4
|
+
".github/workflows/checks.yml",
|
|
4
5
|
".github/workflows/python-publish.yml",
|
|
5
6
|
".gitignore",
|
|
6
7
|
".gitlab-ci.yml",
|
|
@@ -23,6 +24,7 @@
|
|
|
23
24
|
"src/opensmi/core/mixin_helpers.py",
|
|
24
25
|
"src/opensmi/core/protocols.py",
|
|
25
26
|
"src/opensmi/core/repr_mixin.py",
|
|
27
|
+
"src/opensmi/core/rsl.py",
|
|
26
28
|
"src/opensmi/core/signal.py",
|
|
27
29
|
"src/opensmi/core/subscription_manager.py",
|
|
28
30
|
"src/opensmi/core/ua.py",
|
|
@@ -31,6 +33,7 @@
|
|
|
31
33
|
"src/opensmi/core/unit_all.py",
|
|
32
34
|
"src/opensmi/core/unit_base.py",
|
|
33
35
|
"src/opensmi/core/unset.py",
|
|
36
|
+
"tests/test_rsl.py",
|
|
34
37
|
"tests/test_signal.py",
|
|
35
38
|
"tests/test_unit.py",
|
|
36
39
|
"tools/auto_generate_units.py"
|
|
@@ -50,9 +50,7 @@ class BaseUaObject(Generic[_ServerType]):
|
|
|
50
50
|
if parent is not None:
|
|
51
51
|
self.parent = parent # via setter to allow optional runtime type checking
|
|
52
52
|
self._ua_node: Node | None = None
|
|
53
|
-
self._logger: structlog.BoundLogger = structlog.get_logger(
|
|
54
|
-
f"opensmi.{self.__class__.__name__}", name=self.name
|
|
55
|
-
)
|
|
53
|
+
self._logger: structlog.BoundLogger = structlog.get_logger(f"opensmi.{self.__class__.__name__}", name=self.name)
|
|
56
54
|
|
|
57
55
|
super().__init__(**kwargs) # cooperative __init__() after we are done
|
|
58
56
|
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 OpenSMI Contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""Pose types with unit handling for OPC UA, plus light-weight pose math.
|
|
6
|
+
|
|
7
|
+
Based on the OPC UA Relative Spatial Location (OPC 10000-210), see
|
|
8
|
+
https://reference.opcfoundation.org/specs/OPC-10000-210/full
|
|
9
|
+
|
|
10
|
+
.. warning::
|
|
11
|
+
|
|
12
|
+
The math (``@``, ``inverse()``, ``transform()``) is pure Python on Euler angles,
|
|
13
|
+
meant for occasional use only. It is slow in bulk and accumulates rounding error when
|
|
14
|
+
chained. Use a dedicated library (e.g. NumPy, SciPy) for anything heavier.
|
|
15
|
+
|
|
16
|
+
Euler angles are not unique, so results may differ from the inputs while describing the
|
|
17
|
+
same rotation. Compare with ``isclose()``, not ``==``.
|
|
18
|
+
|
|
19
|
+
Rotations are intrinsic z-y'-x'' (yaw, pitch, roll), equivalent to extrinsic x-y-z.
|
|
20
|
+
``a @ b`` expresses ``b`` in the frame of ``a`` and uses the left operand's units.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import math
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from dataclasses import dataclass, replace
|
|
28
|
+
from functools import cache
|
|
29
|
+
from typing import Final
|
|
30
|
+
|
|
31
|
+
from asyncua import ua
|
|
32
|
+
|
|
33
|
+
from opensmi.core import Unit
|
|
34
|
+
|
|
35
|
+
_Vec = tuple[float, float, float]
|
|
36
|
+
_Mat = tuple[float, float, float, float, float, float, float, float, float] # row-major
|
|
37
|
+
|
|
38
|
+
_GIMBAL_EPS: Final = 1e-12
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _matrix_of(o: Orientation) -> _Mat:
|
|
42
|
+
"""Rotation matrix R = Rz(c) @ Ry(b) @ Rx(a) (intrinsic z-y'-x'')."""
|
|
43
|
+
k = _angle_factor(o.unit, Unit.RADIAN)
|
|
44
|
+
sa, ca = math.sin(o.a * k), math.cos(o.a * k)
|
|
45
|
+
sb, cb = math.sin(o.b * k), math.cos(o.b * k)
|
|
46
|
+
sc, cc = math.sin(o.c * k), math.cos(o.c * k)
|
|
47
|
+
return (
|
|
48
|
+
cb * cc, sa * sb * cc - ca * sc, ca * sb * cc + sa * sc,
|
|
49
|
+
cb * sc, sa * sb * sc + ca * cc, ca * sb * sc - sa * cc,
|
|
50
|
+
-sb, sa * cb, ca * cb,
|
|
51
|
+
) # fmt: skip
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _orientation_from(m: _Mat, unit: Unit) -> Orientation:
|
|
55
|
+
"""Inverse of ``_matrix_of``. At pitch ±90° (gimbal lock) yaw is set to 0."""
|
|
56
|
+
r00, _, _, r10, r11, r12, r20, r21, r22 = m
|
|
57
|
+
b = math.asin(max(-1.0, min(1.0, -r20)))
|
|
58
|
+
if abs(r20) < 1.0 - _GIMBAL_EPS:
|
|
59
|
+
a, c = math.atan2(r21, r22), math.atan2(r10, r00)
|
|
60
|
+
else:
|
|
61
|
+
a, c = math.atan2(-r12, r11), 0.0
|
|
62
|
+
k = _angle_factor(Unit.RADIAN, unit)
|
|
63
|
+
return Orientation(a * k, b * k, c * k, unit)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _matmul(p: _Mat, q: _Mat) -> _Mat:
|
|
67
|
+
return tuple( # type: ignore
|
|
68
|
+
sum(p[3 * i + k] * q[3 * k + j] for k in range(3)) for i in range(3) for j in range(3)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _transpose(m: _Mat) -> _Mat:
|
|
73
|
+
return m[0], m[3], m[6], m[1], m[4], m[7], m[2], m[5], m[8]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _rotate(m: _Mat, v: _Vec) -> _Vec:
|
|
77
|
+
x, y, z = v
|
|
78
|
+
return (
|
|
79
|
+
m[0] * x + m[1] * y + m[2] * z,
|
|
80
|
+
m[3] * x + m[4] * y + m[5] * z,
|
|
81
|
+
m[6] * x + m[7] * y + m[8] * z,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# TODO(CaHa): Maybe attach conversion factor to Unit. UNECE table has this information
|
|
86
|
+
_LENGTH: Final = {
|
|
87
|
+
Unit.MILLIMETRE: 0.001,
|
|
88
|
+
Unit.CENTIMETRE: 0.01,
|
|
89
|
+
Unit.METRE: 1.0,
|
|
90
|
+
}
|
|
91
|
+
_ANGLE: Final = {
|
|
92
|
+
Unit.DEGREE_UNIT_OF_ANGLE: math.pi / 180,
|
|
93
|
+
Unit.RADIAN: 1.0,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _require_supported(unit: Unit, table: dict[Unit, float]) -> None:
|
|
98
|
+
if unit not in table:
|
|
99
|
+
msg = f"unsupported unit {unit!r}, expected one of {list(table)}"
|
|
100
|
+
raise ValueError(msg)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _make_factor(table: dict[Unit, float]) -> Callable[[Unit, Unit], float]:
|
|
104
|
+
@cache
|
|
105
|
+
def factor(src: Unit, dst: Unit) -> float:
|
|
106
|
+
_require_supported(src, table)
|
|
107
|
+
_require_supported(dst, table)
|
|
108
|
+
return table[src] / table[dst]
|
|
109
|
+
|
|
110
|
+
return factor
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
_length_factor: Final = _make_factor(_LENGTH)
|
|
114
|
+
_angle_factor: Final = _make_factor(_ANGLE)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True, slots=True)
|
|
118
|
+
class Position:
|
|
119
|
+
"""Cartesian position defined by X, Y, and Z coordinates.
|
|
120
|
+
|
|
121
|
+
see https://reference.opcfoundation.org/specs/OPC-10000-210/b-1
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
x: float = 0.0
|
|
125
|
+
y: float = 0.0
|
|
126
|
+
z: float = 0.0
|
|
127
|
+
unit: Unit = Unit.MILLIMETRE
|
|
128
|
+
|
|
129
|
+
def __post_init__(self) -> None:
|
|
130
|
+
"""Verify unit post initialization."""
|
|
131
|
+
_require_supported(self.unit, _LENGTH)
|
|
132
|
+
|
|
133
|
+
def to(self, unit: Unit) -> Position:
|
|
134
|
+
"""Convert this position to one with given ``unit``."""
|
|
135
|
+
if unit == self.unit:
|
|
136
|
+
return self
|
|
137
|
+
f = _length_factor(self.unit, unit)
|
|
138
|
+
return Position(f * self.x, f * self.y, f * self.z, unit)
|
|
139
|
+
|
|
140
|
+
def as_tuple(self) -> tuple[float, float, float]:
|
|
141
|
+
"""Return ``(x, y, z)`` in this position's unit."""
|
|
142
|
+
return self.x, self.y, self.z
|
|
143
|
+
|
|
144
|
+
def is_close(self, other: Position, *, rel_tol: float = 1e-9, abs_tol: float = 1e-9) -> bool:
|
|
145
|
+
"""Approximate equality; ``other`` is converted to this position's unit."""
|
|
146
|
+
o = other.to(self.unit)
|
|
147
|
+
return all(
|
|
148
|
+
math.isclose(p, q, rel_tol=rel_tol, abs_tol=abs_tol)
|
|
149
|
+
for p, q in zip(self.as_tuple(), o.as_tuple(), strict=True)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True, slots=True)
|
|
154
|
+
class Orientation:
|
|
155
|
+
"""Angular orientation defined by A=roll (X), B=pitch (Y), C=yaw (Z).
|
|
156
|
+
|
|
157
|
+
The rotations are applied as intrinsic ``z-y'-x''`` (yaw, then pitch, then roll),
|
|
158
|
+
which is equivalent to extrinsic ``x-y-z``.
|
|
159
|
+
|
|
160
|
+
see https://reference.opcfoundation.org/specs/OPC-10000-210/b-2
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
a: float = 0.0
|
|
164
|
+
"""Rotation around x axis (roll)"""
|
|
165
|
+
b: float = 0.0
|
|
166
|
+
"""Rotation around y axis (pitch)"""
|
|
167
|
+
c: float = 0.0
|
|
168
|
+
"""Rotation around z axis (yaw)"""
|
|
169
|
+
unit: Unit = Unit.DEGREE_UNIT_OF_ANGLE
|
|
170
|
+
|
|
171
|
+
def __post_init__(self) -> None:
|
|
172
|
+
"""Verify unit post initialization."""
|
|
173
|
+
_require_supported(self.unit, _ANGLE)
|
|
174
|
+
|
|
175
|
+
def to(self, unit: Unit) -> Orientation:
|
|
176
|
+
"""Convert this orientation to one with given ``unit``."""
|
|
177
|
+
if unit == self.unit:
|
|
178
|
+
return self
|
|
179
|
+
f = _angle_factor(self.unit, unit)
|
|
180
|
+
return Orientation(f * self.a, f * self.b, f * self.c, unit)
|
|
181
|
+
|
|
182
|
+
def as_tuple(self) -> tuple[float, float, float]:
|
|
183
|
+
"""Return ``(a, b, c)`` in this orientation's unit."""
|
|
184
|
+
return self.a, self.b, self.c
|
|
185
|
+
|
|
186
|
+
def is_close(self, other: Orientation, *, abs_tol: float = 1e-9) -> bool:
|
|
187
|
+
"""Approximate equality of the *rotation*, regardless of Euler representation."""
|
|
188
|
+
return all(
|
|
189
|
+
math.isclose(p, q, rel_tol=0.0, abs_tol=abs_tol)
|
|
190
|
+
for p, q in zip(_matrix_of(self), _matrix_of(other), strict=True)
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass(frozen=True, slots=True)
|
|
195
|
+
class Pose:
|
|
196
|
+
"""Pose defined by Cartesian `Position` and angular `Orientation`.
|
|
197
|
+
|
|
198
|
+
Corresponds to OPC UA data type ``3DFrameType``.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
position: Position = Position()
|
|
202
|
+
orientation: Orientation = Orientation()
|
|
203
|
+
|
|
204
|
+
def with_position(self, position: Position) -> Pose:
|
|
205
|
+
"""Return a copy of this Pose with given ``position`` keeping the orientation."""
|
|
206
|
+
return replace(self, position=position)
|
|
207
|
+
|
|
208
|
+
def with_orientation(self, orientation: Orientation) -> Pose:
|
|
209
|
+
"""Return a copy of this Pose with given ``orientation`` keeping the position."""
|
|
210
|
+
return replace(self, orientation=orientation)
|
|
211
|
+
|
|
212
|
+
def __matmul__(self, other: Pose) -> Pose:
|
|
213
|
+
"""Compose two poses.
|
|
214
|
+
|
|
215
|
+
``other`` is expressed in this pose's frame; the result is expressed in this
|
|
216
|
+
pose's parent frame, in this pose's units::
|
|
217
|
+
|
|
218
|
+
position = self.position + R_self · other.position
|
|
219
|
+
orientation = R_self · R_other
|
|
220
|
+
|
|
221
|
+
The resulting Euler angles describe an equivalent rotation, but not necessarily
|
|
222
|
+
the same triple one would compute by hand (gimbal lock, angle wrapping).
|
|
223
|
+
Use `is_close` to compare poses.
|
|
224
|
+
"""
|
|
225
|
+
unit = self.position.unit
|
|
226
|
+
r = _matrix_of(self.orientation)
|
|
227
|
+
dx, dy, dz = _rotate(r, other.position.to(unit).as_tuple())
|
|
228
|
+
x, y, z = self.position.as_tuple()
|
|
229
|
+
return Pose(
|
|
230
|
+
Position(x + dx, y + dy, z + dz, unit),
|
|
231
|
+
_orientation_from(_matmul(r, _matrix_of(other.orientation)), self.orientation.unit),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def inverse(self) -> Pose:
|
|
235
|
+
"""Return the pose that undoes this one, so ``p @ p.inverse()`` is the identity."""
|
|
236
|
+
rt = _transpose(_matrix_of(self.orientation))
|
|
237
|
+
x, y, z = _rotate(rt, self.position.as_tuple())
|
|
238
|
+
return Pose(
|
|
239
|
+
Position(-x, -y, -z, self.position.unit),
|
|
240
|
+
_orientation_from(rt, self.orientation.unit),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def transform(self, point: Position) -> Position:
|
|
244
|
+
"""Express ``point`` (given in this pose's frame) in the parent frame."""
|
|
245
|
+
unit = self.position.unit
|
|
246
|
+
dx, dy, dz = _rotate(_matrix_of(self.orientation), point.to(unit).as_tuple())
|
|
247
|
+
x, y, z = self.position.as_tuple()
|
|
248
|
+
return Position(x + dx, y + dy, z + dz, unit)
|
|
249
|
+
|
|
250
|
+
def is_close(self, other: Pose, *, rel_tol: float = 1e-9, abs_tol: float = 1e-9) -> bool:
|
|
251
|
+
"""Approximate equality, regardless of Euler representation."""
|
|
252
|
+
return self.position.is_close(other.position, rel_tol=rel_tol, abs_tol=abs_tol) and (
|
|
253
|
+
self.orientation.is_close(other.orientation, abs_tol=abs_tol)
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def to_ua(self, *, length_unit: Unit, angle_unit: Unit) -> ua.ThreeDFrame:
|
|
257
|
+
"""Convert to a OPC UA ``3DFrame``, expressed in the given units."""
|
|
258
|
+
p, o = self.position.to(length_unit), self.orientation.to(angle_unit)
|
|
259
|
+
return ua.ThreeDFrame(
|
|
260
|
+
CartesianCoordinates=ua.ThreeDCartesianCoordinates(X=p.x, Y=p.y, Z=p.z), # type: ignore
|
|
261
|
+
Orientation=ua.ThreeDOrientation(A=o.a, B=o.b, C=o.c), # type: ignore
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
@classmethod
|
|
265
|
+
def from_ua(cls, frame: ua.ThreeDFrame, *, length_unit: Unit, angle_unit: Unit) -> Pose:
|
|
266
|
+
"""Convert from ``ThreeDFrame`` whose numbers are in the given units."""
|
|
267
|
+
p, o = frame.CartesianCoordinates, frame.Orientation
|
|
268
|
+
return cls(
|
|
269
|
+
position=Position(float(p.X), float(p.Y), float(p.Z), length_unit),
|
|
270
|
+
orientation=Orientation(float(o.A), float(o.B), float(o.C), angle_unit),
|
|
271
|
+
)
|
|
@@ -84,7 +84,8 @@ class SubscriptionManager:
|
|
|
84
84
|
"""Create a new instance.
|
|
85
85
|
|
|
86
86
|
:param ua_client: OPC UA client to use.
|
|
87
|
-
:param publishing_interval_ms:
|
|
87
|
+
:param publishing_interval_ms: Requested publishing interval in milliseconds for OPC UA subscription.
|
|
88
|
+
May be revised by the server.
|
|
88
89
|
:param heartbeat_stale_after: Time in seconds until any subscription should be received.
|
|
89
90
|
:param max_items_per_call: Maximum number of OPC UA monitored items per call. If ``None``, will be read from the
|
|
90
91
|
servers ``MaxMonitoredItemsPerCall``.
|
|
@@ -109,6 +110,12 @@ class SubscriptionManager:
|
|
|
109
110
|
period=self._publishing_interval_ms, handler=self
|
|
110
111
|
)
|
|
111
112
|
self.logger.info("Created subscription!", parameters=self._ua_subscription.parameters)
|
|
113
|
+
actual = self._ua_subscription.parameters.RequestedPublishingInterval
|
|
114
|
+
if actual != self._publishing_interval_ms:
|
|
115
|
+
self.logger.warning(
|
|
116
|
+
"Requested publishing interval was revised!", requested=self._publishing_interval_ms, actual=actual
|
|
117
|
+
)
|
|
118
|
+
self._publishing_interval_ms = actual
|
|
112
119
|
|
|
113
120
|
async def _read_max_items_per_call(self) -> None:
|
|
114
121
|
fallback_value = 100
|
|
@@ -47,7 +47,7 @@ class SkillState(Enum):
|
|
|
47
47
|
RESETTING = (19, "Resetting")
|
|
48
48
|
"""Intermediate state leading to `SkillState.READY` on success or `SkillState.HALTING` on failure."""
|
|
49
49
|
SUSPENDING = (20, "Suspending")
|
|
50
|
-
"""Intermediate state leading to `SkillState.
|
|
50
|
+
"""Intermediate state leading to `SkillState.SUSPENDED` on success or `SkillState.HALTING` on failure."""
|
|
51
51
|
|
|
52
52
|
@property
|
|
53
53
|
def localized_text(self) -> ua.LocalizedText:
|
|
@@ -142,7 +142,7 @@ async def get_type_definition(ua_node: Node, *, default: str = "UNKNOWN") -> str
|
|
|
142
142
|
if len(type_definition_list) > 0:
|
|
143
143
|
return type_definition_list[0].BrowseName.Name
|
|
144
144
|
|
|
145
|
-
_LOGGER.warning("Could not determine type definition!", ua_node
|
|
145
|
+
_LOGGER.warning("Could not determine type definition!", ua_node=ua_node.nodeid.to_string(), default=default)
|
|
146
146
|
return default
|
|
147
147
|
|
|
148
148
|
|
|
@@ -178,7 +178,9 @@ def get_node_id(
|
|
|
178
178
|
return ua.NodeId(Identifier=identifier, NamespaceIndex=ua.Int16(ns_idx))
|
|
179
179
|
|
|
180
180
|
|
|
181
|
-
async def get_child_without_ns(
|
|
181
|
+
async def get_child_without_ns(
|
|
182
|
+
parent: Node, *, browse_name: str | None = None, display_name: str | None = None
|
|
183
|
+
) -> Node:
|
|
182
184
|
"""Return the first child node with given ``browse_name`` or ``display_name`` while ignoring namespaces.
|
|
183
185
|
|
|
184
186
|
For each child, ``BrowseName`` is matched first, ``DisplayName`` afterward. If both are given, a child matching
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 OpenSMI Contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""Tests for Position / Orientation / Pose, including a SciPy cross-check.
|
|
6
|
+
|
|
7
|
+
SciPy is a *test-only* dependency and the independent reference implementation.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import dataclasses
|
|
13
|
+
import math
|
|
14
|
+
from itertools import product
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pytest
|
|
18
|
+
from scipy.spatial.transform import Rotation as R # noqa: N817
|
|
19
|
+
|
|
20
|
+
from opensmi.core import Unit
|
|
21
|
+
from opensmi.core.rsl import Orientation, Pose, Position, _matmul, _matrix_of, _orientation_from, _rotate, _transpose
|
|
22
|
+
|
|
23
|
+
MM, CM, M = Unit.MILLIMETRE, Unit.CENTIMETRE, Unit.METRE
|
|
24
|
+
DEG, RAD = Unit.DEGREE_UNIT_OF_ANGLE, Unit.RADIAN
|
|
25
|
+
|
|
26
|
+
LENGTH_UNITS = [MM, CM, M]
|
|
27
|
+
ANGLE_UNITS = [DEG, RAD]
|
|
28
|
+
SEEDS = list(range(20))
|
|
29
|
+
ATOL = 1e-9
|
|
30
|
+
IDENTITY = (1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# --------------------------------------------------------------------------- helpers
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def rand_orientation(g: np.random.Generator, unit: Unit = DEG, max_pitch: float = 85.0) -> Orientation:
|
|
37
|
+
"""Random orientation, kept away from gimbal lock unless ``max_pitch`` says otherwise."""
|
|
38
|
+
o = Orientation(
|
|
39
|
+
float(g.uniform(-180, 180)),
|
|
40
|
+
float(g.uniform(-max_pitch, max_pitch)),
|
|
41
|
+
float(g.uniform(-180, 180)),
|
|
42
|
+
DEG,
|
|
43
|
+
)
|
|
44
|
+
return o.to(unit)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def rand_position(g: np.random.Generator, unit: Unit = MM) -> Position:
|
|
48
|
+
return Position(*(float(v) for v in g.uniform(-1000, 1000, size=3)), MM).to(unit)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def rand_pose(g: np.random.Generator, length: Unit = MM, angle: Unit = DEG) -> Pose:
|
|
52
|
+
return Pose(rand_position(g, length), rand_orientation(g, angle))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def sp_rot(o: Orientation) -> R:
|
|
56
|
+
return R.from_euler("xyz", o.as_tuple(), degrees=o.unit == DEG)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def sp_pose(p: Pose) -> tuple[R, np.ndarray]:
|
|
60
|
+
return sp_rot(p.orientation), np.array(p.position.to(M).as_tuple())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def assert_matrix_equals(o: Orientation, rot: R, atol: float = 1e-12) -> None:
|
|
64
|
+
np.testing.assert_allclose(_matrix_of(o), rot.as_matrix().ravel(), atol=atol)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def assert_pose_matches(p: Pose, rot: R, t_metres: np.ndarray, atol: float = ATOL) -> None:
|
|
68
|
+
np.testing.assert_allclose(p.position.to(M).as_tuple(), t_metres, atol=atol)
|
|
69
|
+
assert_matrix_equals(p.orientation, rot, atol=atol)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def angle_diff_deg(x: float, y: float) -> float:
|
|
73
|
+
"""Smallest absolute difference between two angles in degrees."""
|
|
74
|
+
return abs((x - y + 180.0) % 360.0 - 180.0)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# --------------------------------------------------------------------------- units
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TestUnits:
|
|
81
|
+
@pytest.mark.parametrize(
|
|
82
|
+
("src", "dst", "factor"),
|
|
83
|
+
[
|
|
84
|
+
(M, MM, 1000.0),
|
|
85
|
+
(M, CM, 100.0),
|
|
86
|
+
(CM, MM, 10.0),
|
|
87
|
+
(MM, M, 0.001),
|
|
88
|
+
(MM, CM, 0.1),
|
|
89
|
+
(CM, M, 0.01),
|
|
90
|
+
],
|
|
91
|
+
)
|
|
92
|
+
def test_position_conversion(self, src: Unit, dst: Unit, factor: float) -> None:
|
|
93
|
+
p = Position(1.0, -2.0, 3.0, src).to(dst)
|
|
94
|
+
assert p.unit == dst
|
|
95
|
+
assert p.as_tuple() == pytest.approx((factor, -2 * factor, 3 * factor))
|
|
96
|
+
|
|
97
|
+
def test_exact_when_scaling_up(self) -> None:
|
|
98
|
+
# The table is expressed relative to the smallest unit, so these are exact.
|
|
99
|
+
assert Position(1.5, 2.5, 3.5, M).to(MM).as_tuple() == (1500.0, 2500.0, 3500.0)
|
|
100
|
+
|
|
101
|
+
def test_orientation_conversion(self) -> None:
|
|
102
|
+
o = Orientation(90.0, -45.0, 180.0, DEG).to(RAD)
|
|
103
|
+
assert o.unit == RAD
|
|
104
|
+
assert o.as_tuple() == pytest.approx((math.pi / 2, -math.pi / 4, math.pi))
|
|
105
|
+
assert o.to(DEG).as_tuple() == pytest.approx((90.0, -45.0, 180.0))
|
|
106
|
+
|
|
107
|
+
@pytest.mark.parametrize("seed", SEEDS)
|
|
108
|
+
def test_position_round_trip(self, seed: int) -> None:
|
|
109
|
+
p = rand_position(np.random.default_rng(seed))
|
|
110
|
+
for unit in LENGTH_UNITS:
|
|
111
|
+
assert p.to(unit).to(MM).is_close(p)
|
|
112
|
+
|
|
113
|
+
@pytest.mark.parametrize("seed", SEEDS)
|
|
114
|
+
def test_orientation_round_trip(self, seed: int) -> None:
|
|
115
|
+
o = rand_orientation(np.random.default_rng(seed))
|
|
116
|
+
assert o.to(RAD).to(DEG).as_tuple() == pytest.approx(o.as_tuple())
|
|
117
|
+
|
|
118
|
+
def test_same_unit_returns_same_object(self) -> None:
|
|
119
|
+
p, o = Position(1, 2, 3, MM), Orientation(1, 2, 3, DEG)
|
|
120
|
+
assert p.to(MM) is p
|
|
121
|
+
assert o.to(DEG) is o
|
|
122
|
+
|
|
123
|
+
def test_unsupported_unit_in_constructor(self) -> None:
|
|
124
|
+
with pytest.raises(ValueError, match="unsupported unit"):
|
|
125
|
+
Position(unit=RAD)
|
|
126
|
+
with pytest.raises(ValueError, match="unsupported unit"):
|
|
127
|
+
Orientation(unit=MM)
|
|
128
|
+
|
|
129
|
+
def test_unsupported_target_unit_raises_every_time(self) -> None:
|
|
130
|
+
# Guards against exceptions being cached (they aren't) or swallowed.
|
|
131
|
+
p, o = Position(1, 2, 3, MM), Orientation(1, 2, 3, DEG)
|
|
132
|
+
for _ in range(3):
|
|
133
|
+
with pytest.raises(ValueError, match="unsupported unit"):
|
|
134
|
+
p.to(RAD)
|
|
135
|
+
with pytest.raises(ValueError, match="unsupported unit"):
|
|
136
|
+
o.to(MM)
|
|
137
|
+
with pytest.raises(ValueError, match="unsupported unit"):
|
|
138
|
+
p.to(Unit.SECOND_UNIT_OF_TIME)
|
|
139
|
+
|
|
140
|
+
def test_defaults(self) -> None:
|
|
141
|
+
assert Position() == Position(0.0, 0.0, 0.0, MM)
|
|
142
|
+
assert Orientation() == Orientation(0.0, 0.0, 0.0, DEG)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# --------------------------------------------------------------------------- matrix
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class TestMatrix:
|
|
149
|
+
def test_identity(self) -> None:
|
|
150
|
+
assert _matrix_of(Orientation()) == IDENTITY
|
|
151
|
+
|
|
152
|
+
@pytest.mark.parametrize(("seed", "unit"), list(product(SEEDS, ANGLE_UNITS)))
|
|
153
|
+
def test_matches_scipy(self, seed: int, unit: Unit) -> None:
|
|
154
|
+
o = rand_orientation(np.random.default_rng(seed), unit, max_pitch=90.0)
|
|
155
|
+
assert_matrix_equals(o, sp_rot(o))
|
|
156
|
+
|
|
157
|
+
@pytest.mark.parametrize("seed", SEEDS)
|
|
158
|
+
def test_is_proper_rotation(self, seed: int) -> None:
|
|
159
|
+
m = _matrix_of(rand_orientation(np.random.default_rng(seed), max_pitch=90.0))
|
|
160
|
+
np.testing.assert_allclose(_matmul(m, _transpose(m)), IDENTITY, atol=1e-12)
|
|
161
|
+
assert np.linalg.det(np.array(m).reshape(3, 3)) == pytest.approx(1.0)
|
|
162
|
+
|
|
163
|
+
@pytest.mark.parametrize(
|
|
164
|
+
("o", "v", "expected"),
|
|
165
|
+
[
|
|
166
|
+
# roll (x): y -> z
|
|
167
|
+
(Orientation(90, 0, 0), (0, 1, 0), (0, 0, 1)),
|
|
168
|
+
# pitch (y): z -> x
|
|
169
|
+
(Orientation(0, 90, 0), (0, 0, 1), (1, 0, 0)),
|
|
170
|
+
# yaw (z): x -> y
|
|
171
|
+
(Orientation(0, 0, 90), (1, 0, 0), (0, 1, 0)),
|
|
172
|
+
],
|
|
173
|
+
)
|
|
174
|
+
def test_single_axis_rotations(self, o: Orientation, v: tuple[float, ...], expected: tuple[float, ...]) -> None:
|
|
175
|
+
assert _rotate(_matrix_of(o), v) == pytest.approx(expected, abs=1e-12) # type: ignore[arg-type]
|
|
176
|
+
|
|
177
|
+
def test_order_is_yaw_then_pitch_then_roll(self) -> None:
|
|
178
|
+
# R = Rz(c) Ry(b) Rx(a): the roll is applied to the vector first.
|
|
179
|
+
o = Orientation(90, 0, 90) # roll 90 then yaw 90
|
|
180
|
+
# y --roll--> z --yaw--> z
|
|
181
|
+
assert _rotate(_matrix_of(o), (0, 1, 0)) == pytest.approx((0, 0, 1), abs=1e-12)
|
|
182
|
+
# x --roll--> x --yaw--> y
|
|
183
|
+
assert _rotate(_matrix_of(o), (1, 0, 0)) == pytest.approx((0, 1, 0), abs=1e-12)
|
|
184
|
+
|
|
185
|
+
def test_rotate_and_transpose_are_inverse(self) -> None:
|
|
186
|
+
m = _matrix_of(Orientation(10, 20, 30))
|
|
187
|
+
v = (1.0, -2.0, 3.0)
|
|
188
|
+
assert _rotate(_transpose(m), _rotate(m, v)) == pytest.approx(v)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# --------------------------------------------------------------------------- euler round trip
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class TestEulerExtraction:
|
|
195
|
+
@pytest.mark.parametrize(("seed", "unit"), list(product(SEEDS, ANGLE_UNITS)))
|
|
196
|
+
def test_round_trip_recovers_angles(self, seed: int, unit: Unit) -> None:
|
|
197
|
+
o = rand_orientation(np.random.default_rng(seed), unit)
|
|
198
|
+
back = _orientation_from(_matrix_of(o), unit)
|
|
199
|
+
assert back.unit == unit
|
|
200
|
+
for x, y in zip(o.to(DEG).as_tuple(), back.to(DEG).as_tuple()):
|
|
201
|
+
assert angle_diff_deg(x, y) == pytest.approx(0.0, abs=1e-9)
|
|
202
|
+
|
|
203
|
+
@pytest.mark.parametrize("seed", SEEDS)
|
|
204
|
+
def test_matches_scipy_as_euler(self, seed: int) -> None:
|
|
205
|
+
o = rand_orientation(np.random.default_rng(seed))
|
|
206
|
+
ours = _orientation_from(_matrix_of(o), DEG).as_tuple()
|
|
207
|
+
theirs = sp_rot(o).as_euler("xyz", degrees=True)
|
|
208
|
+
for x, y in zip(ours, theirs):
|
|
209
|
+
assert angle_diff_deg(x, float(y)) == pytest.approx(0.0, abs=1e-9)
|
|
210
|
+
|
|
211
|
+
@pytest.mark.parametrize("seed", SEEDS)
|
|
212
|
+
def test_output_unit_is_respected(self, seed: int) -> None:
|
|
213
|
+
o = rand_orientation(np.random.default_rng(seed))
|
|
214
|
+
m = _matrix_of(o)
|
|
215
|
+
assert _orientation_from(m, RAD).to(DEG).is_close(_orientation_from(m, DEG))
|
|
216
|
+
|
|
217
|
+
@pytest.mark.parametrize("pitch", [90.0, -90.0])
|
|
218
|
+
@pytest.mark.parametrize(("a", "c"), [(0, 0), (30, 10), (-120, 75), (179, -179)])
|
|
219
|
+
def test_exact_gimbal_lock(self, pitch: float, a: float, c: float) -> None:
|
|
220
|
+
o = Orientation(a, pitch, c)
|
|
221
|
+
back = _orientation_from(_matrix_of(o), DEG)
|
|
222
|
+
# Same rotation, canonical representation: yaw is folded into roll.
|
|
223
|
+
assert back.is_close(o)
|
|
224
|
+
assert back.b == pytest.approx(pitch)
|
|
225
|
+
assert back.c == 0.0
|
|
226
|
+
assert_matrix_equals(back, sp_rot(o))
|
|
227
|
+
|
|
228
|
+
@pytest.mark.parametrize(
|
|
229
|
+
("delta_deg", "tol"),
|
|
230
|
+
[(1e-3, 1e-9), (1e-5, 1e-6), (1e-7, 1e-8), (0.0, 1e-12)],
|
|
231
|
+
)
|
|
232
|
+
@pytest.mark.parametrize("sign", [1, -1])
|
|
233
|
+
def test_near_gimbal_lock(self, delta_deg: float, tol: float, sign: int) -> None:
|
|
234
|
+
# Precision degrades near the singularity; tolerances reflect that.
|
|
235
|
+
o = Orientation(25.0, sign * (90.0 - delta_deg), 40.0)
|
|
236
|
+
back = _orientation_from(_matrix_of(o), DEG)
|
|
237
|
+
assert back.is_close(o, abs_tol=tol)
|
|
238
|
+
|
|
239
|
+
def test_pitch_is_clamped_against_rounding(self) -> None:
|
|
240
|
+
# An entry a hair beyond +-1 must not make asin() raise.
|
|
241
|
+
m = (0.0, 0.0, 1.0, 0.0, 1.0, 0.0, -1.0 - 1e-15, 0.0, 0.0)
|
|
242
|
+
assert _orientation_from(m, DEG).b == pytest.approx(90.0)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# --------------------------------------------------------------------------- is_close
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class TestIsClose:
|
|
249
|
+
def test_position_reflexive_and_unit_aware(self) -> None:
|
|
250
|
+
p = Position(1, 2, 3, M)
|
|
251
|
+
assert p.is_close(p)
|
|
252
|
+
assert p.is_close(Position(1000, 2000, 3000, MM))
|
|
253
|
+
assert Position(1000, 2000, 3000, MM).is_close(p)
|
|
254
|
+
|
|
255
|
+
def test_position_tolerances(self) -> None:
|
|
256
|
+
p = Position(1.0, 1.0, 1.0, MM)
|
|
257
|
+
assert not p.is_close(Position(1.0, 1.0, 1.001, MM))
|
|
258
|
+
assert p.is_close(Position(1.0, 1.0, 1.001, MM), abs_tol=0.01)
|
|
259
|
+
assert p.is_close(Position(1.0, 1.0, 1.001, MM), rel_tol=0.01)
|
|
260
|
+
|
|
261
|
+
def test_position_zero_needs_abs_tol(self) -> None:
|
|
262
|
+
assert Position(0, 0, 0).is_close(Position(1e-12, 0, 0))
|
|
263
|
+
assert not Position(0, 0, 0).is_close(Position(1e-6, 0, 0))
|
|
264
|
+
|
|
265
|
+
@pytest.mark.parametrize(
|
|
266
|
+
("x", "y"),
|
|
267
|
+
[
|
|
268
|
+
(Orientation(0, 0, 359), Orientation(0, 0, -1)), # wrapping on yaw
|
|
269
|
+
(Orientation(360, 0, 0), Orientation(0, 0, 0)), # full turn on roll
|
|
270
|
+
(Orientation(180, 0, 0), Orientation(-180, 0, 0)),
|
|
271
|
+
(Orientation(30, 90, 10), Orientation(20, 90, 0)), # gimbal lock, +90
|
|
272
|
+
(Orientation(30, -90, 10), Orientation(40, -90, 0)), # gimbal lock, -90
|
|
273
|
+
(Orientation(0, 0, 90, DEG), Orientation(0, 0, math.pi / 2, RAD)), # mixed units
|
|
274
|
+
# the same rotation written two ways: (a, b, c) == (a+180, 180-b, c+180)
|
|
275
|
+
(Orientation(10, 20, 30), Orientation(190, 160, 210)),
|
|
276
|
+
],
|
|
277
|
+
)
|
|
278
|
+
def test_orientation_equivalent_rotations(self, x: Orientation, y: Orientation) -> None:
|
|
279
|
+
assert x.is_close(y)
|
|
280
|
+
assert y.is_close(x)
|
|
281
|
+
|
|
282
|
+
@pytest.mark.parametrize(
|
|
283
|
+
("x", "y"),
|
|
284
|
+
[
|
|
285
|
+
(Orientation(0, 0, 0), Orientation(0, 0, 1)),
|
|
286
|
+
(Orientation(10, 20, 30), Orientation(30, 20, 10)),
|
|
287
|
+
(Orientation(0, 0, 90), Orientation(0, 0, math.pi / 2, DEG)), # unit mix-up
|
|
288
|
+
],
|
|
289
|
+
)
|
|
290
|
+
def test_orientation_different_rotations(self, x: Orientation, y: Orientation) -> None:
|
|
291
|
+
assert not x.is_close(y)
|
|
292
|
+
|
|
293
|
+
@pytest.mark.parametrize("seed", SEEDS)
|
|
294
|
+
def test_orientation_matches_scipy_equivalence(self, seed: int) -> None:
|
|
295
|
+
g = np.random.default_rng(seed)
|
|
296
|
+
x, y = rand_orientation(g), rand_orientation(g)
|
|
297
|
+
sp_equal = bool(np.allclose(sp_rot(x).as_matrix(), sp_rot(y).as_matrix(), atol=1e-9))
|
|
298
|
+
assert x.is_close(y) == sp_equal
|
|
299
|
+
assert x.is_close(x)
|
|
300
|
+
|
|
301
|
+
def test_pose_is_close(self) -> None:
|
|
302
|
+
p = Pose(Position(1, 2, 3, M), Orientation(10, 20, 30))
|
|
303
|
+
assert p.is_close(p)
|
|
304
|
+
assert p.is_close(Pose(Position(1000, 2000, 3000, MM), Orientation(10, 20, 30 + 360)))
|
|
305
|
+
assert not p.is_close(Pose(Position(1, 2, 3.01, M), p.orientation))
|
|
306
|
+
assert not p.is_close(Pose(p.position, Orientation(10, 20, 31)))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# --------------------------------------------------------------------------- accessors
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class TestAccessors:
|
|
313
|
+
def test_as_tuple(self) -> None:
|
|
314
|
+
assert Position(1, 2, 3, CM).as_tuple() == (1, 2, 3)
|
|
315
|
+
assert Orientation(4, 5, 6, RAD).as_tuple() == (4, 5, 6)
|
|
316
|
+
|
|
317
|
+
def test_not_iterable(self) -> None:
|
|
318
|
+
with pytest.raises(TypeError):
|
|
319
|
+
iter(Position()) # type: ignore[call-overload]
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# --------------------------------------------------------------------------- composition
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class TestComposition:
|
|
326
|
+
@pytest.mark.parametrize(
|
|
327
|
+
("seed", "l1", "a1", "l2", "a2"),
|
|
328
|
+
[(s, *u) for s in range(8) for u in product(LENGTH_UNITS, ANGLE_UNITS, LENGTH_UNITS, ANGLE_UNITS)],
|
|
329
|
+
)
|
|
330
|
+
def test_matches_scipy(self, seed: int, l1: Unit, a1: Unit, l2: Unit, a2: Unit) -> None:
|
|
331
|
+
g = np.random.default_rng(seed)
|
|
332
|
+
a, b = rand_pose(g, l1, a1), rand_pose(g, l2, a2)
|
|
333
|
+
(ra, ta), (rb, tb) = sp_pose(a), sp_pose(b)
|
|
334
|
+
|
|
335
|
+
c = a @ b
|
|
336
|
+
|
|
337
|
+
assert c.position.unit == l1
|
|
338
|
+
assert c.orientation.unit == a1
|
|
339
|
+
assert_pose_matches(c, ra * rb, ta + ra.apply(tb))
|
|
340
|
+
|
|
341
|
+
@pytest.mark.parametrize("seed", SEEDS)
|
|
342
|
+
def test_identity_is_neutral(self, seed: int) -> None:
|
|
343
|
+
p = rand_pose(np.random.default_rng(seed))
|
|
344
|
+
assert (Pose() @ p).is_close(p)
|
|
345
|
+
assert (p @ Pose()).is_close(p)
|
|
346
|
+
|
|
347
|
+
@pytest.mark.parametrize("seed", SEEDS)
|
|
348
|
+
def test_associative(self, seed: int) -> None:
|
|
349
|
+
g = np.random.default_rng(seed)
|
|
350
|
+
a, b, c = (rand_pose(g, MM, DEG) for _ in range(3))
|
|
351
|
+
assert ((a @ b) @ c).is_close(a @ (b @ c), abs_tol=1e-8)
|
|
352
|
+
|
|
353
|
+
def test_not_commutative(self) -> None:
|
|
354
|
+
a = Pose(Position(1, 0, 0, M), Orientation(0, 0, 90))
|
|
355
|
+
b = Pose(Position(1, 0, 0, M), Orientation(0, 0, 0))
|
|
356
|
+
assert not (a @ b).is_close(b @ a)
|
|
357
|
+
|
|
358
|
+
def test_rotations_do_not_commute(self) -> None:
|
|
359
|
+
roll = Pose(orientation=Orientation(90, 0, 0))
|
|
360
|
+
yaw = Pose(orientation=Orientation(0, 0, 90))
|
|
361
|
+
assert not (roll @ yaw).is_close(yaw @ roll)
|
|
362
|
+
|
|
363
|
+
def test_known_example_yaw_then_translate(self) -> None:
|
|
364
|
+
# A frame 1 m along x, yawed 90 degrees. A child 1 m along its own x
|
|
365
|
+
# therefore ends up at (1, 1, 0) in the parent frame.
|
|
366
|
+
parent = Pose(Position(1, 0, 0, M), Orientation(0, 0, 90))
|
|
367
|
+
child = Pose(Position(1, 0, 0, M))
|
|
368
|
+
c = parent @ child
|
|
369
|
+
assert c.position.as_tuple() == pytest.approx((1, 1, 0), abs=1e-12)
|
|
370
|
+
assert c.orientation.as_tuple() == pytest.approx((0, 0, 90), abs=1e-9)
|
|
371
|
+
|
|
372
|
+
def test_pure_translations_add(self) -> None:
|
|
373
|
+
c = Pose(Position(1, 2, 3, MM)) @ Pose(Position(10, 20, 30, MM))
|
|
374
|
+
assert c.position.as_tuple() == pytest.approx((11, 22, 33))
|
|
375
|
+
|
|
376
|
+
def test_same_axis_rotations_add_angles(self) -> None:
|
|
377
|
+
c = Pose(orientation=Orientation(0, 0, 30)) @ Pose(orientation=Orientation(0, 0, 40))
|
|
378
|
+
assert c.orientation.as_tuple() == pytest.approx((0, 0, 70), abs=1e-9)
|
|
379
|
+
|
|
380
|
+
def test_composition_into_gimbal_lock(self) -> None:
|
|
381
|
+
a = Pose(orientation=Orientation(0, 45, 0))
|
|
382
|
+
c = a @ a
|
|
383
|
+
assert c.orientation.b == pytest.approx(90.0)
|
|
384
|
+
assert_matrix_equals(c.orientation, sp_rot(a.orientation) * sp_rot(a.orientation), atol=1e-9)
|
|
385
|
+
|
|
386
|
+
def test_does_not_mutate_operands(self) -> None:
|
|
387
|
+
a, b = rand_pose(np.random.default_rng(1)), rand_pose(np.random.default_rng(2))
|
|
388
|
+
a0, b0 = dataclasses.replace(a), dataclasses.replace(b)
|
|
389
|
+
_ = a @ b
|
|
390
|
+
assert (a, b) == (a0, b0)
|
|
391
|
+
|
|
392
|
+
def test_repeated_small_rotations_return_to_start(self) -> None:
|
|
393
|
+
step = Pose(orientation=Orientation(0, 0, 1))
|
|
394
|
+
acc = Pose()
|
|
395
|
+
for _ in range(360):
|
|
396
|
+
acc = acc @ step
|
|
397
|
+
assert acc.is_close(Pose(), abs_tol=1e-9)
|
|
398
|
+
|
|
399
|
+
def test_long_chain_matches_scipy_without_drift(self) -> None:
|
|
400
|
+
g = np.random.default_rng(42)
|
|
401
|
+
acc = Pose()
|
|
402
|
+
rot, t = R.identity(), np.zeros(3)
|
|
403
|
+
for _ in range(500):
|
|
404
|
+
step = rand_pose(g, M, RAD)
|
|
405
|
+
acc = acc @ step
|
|
406
|
+
sr, st = sp_pose(step)
|
|
407
|
+
t, rot = t + rot.apply(st), rot * sr
|
|
408
|
+
assert_pose_matches(acc, rot, t, atol=1e-9)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# --------------------------------------------------------------------------- inverse
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
class TestInverse:
|
|
415
|
+
@pytest.mark.parametrize(
|
|
416
|
+
("seed", "length", "angle"),
|
|
417
|
+
[(s, *u) for s in SEEDS for u in product(LENGTH_UNITS, ANGLE_UNITS)],
|
|
418
|
+
)
|
|
419
|
+
def test_inverse_composes_to_identity(self, seed: int, length: Unit, angle: Unit) -> None:
|
|
420
|
+
p = rand_pose(np.random.default_rng(seed), length, angle)
|
|
421
|
+
assert (p @ p.inverse()).is_close(Pose(Position(unit=length), Orientation(unit=angle)))
|
|
422
|
+
assert (p.inverse() @ p).is_close(Pose(Position(unit=length), Orientation(unit=angle)))
|
|
423
|
+
|
|
424
|
+
@pytest.mark.parametrize("seed", SEEDS)
|
|
425
|
+
def test_matches_scipy(self, seed: int) -> None:
|
|
426
|
+
p = rand_pose(np.random.default_rng(seed))
|
|
427
|
+
rot, t = sp_pose(p)
|
|
428
|
+
inv = rot.inv()
|
|
429
|
+
assert_pose_matches(p.inverse(), inv, -inv.apply(t))
|
|
430
|
+
|
|
431
|
+
@pytest.mark.parametrize("seed", SEEDS)
|
|
432
|
+
def test_involution(self, seed: int) -> None:
|
|
433
|
+
p = rand_pose(np.random.default_rng(seed))
|
|
434
|
+
assert p.inverse().inverse().is_close(p)
|
|
435
|
+
|
|
436
|
+
@pytest.mark.parametrize(("length", "angle"), list(product(LENGTH_UNITS, ANGLE_UNITS)))
|
|
437
|
+
def test_units_are_preserved(self, length: Unit, angle: Unit) -> None:
|
|
438
|
+
inv = rand_pose(np.random.default_rng(0), length, angle).inverse()
|
|
439
|
+
assert inv.position.unit == length
|
|
440
|
+
assert inv.orientation.unit == angle
|
|
441
|
+
|
|
442
|
+
def test_identity_inverse(self) -> None:
|
|
443
|
+
assert Pose().inverse().is_close(Pose())
|
|
444
|
+
|
|
445
|
+
def test_inverse_of_composition(self) -> None:
|
|
446
|
+
g = np.random.default_rng(7)
|
|
447
|
+
a, b = rand_pose(g), rand_pose(g)
|
|
448
|
+
assert (a @ b).inverse().is_close(b.inverse() @ a.inverse(), abs_tol=1e-8)
|
|
449
|
+
|
|
450
|
+
def test_gimbal_lock_pose(self) -> None:
|
|
451
|
+
p = Pose(Position(1, 2, 3, M), Orientation(20, 90, 0))
|
|
452
|
+
assert (p @ p.inverse()).is_close(Pose(Position(unit=M)))
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# --------------------------------------------------------------------------- transform
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
class TestTransform:
|
|
459
|
+
@pytest.mark.parametrize(("seed", "length"), list(product(SEEDS, LENGTH_UNITS)))
|
|
460
|
+
def test_matches_scipy(self, seed: int, length: Unit) -> None:
|
|
461
|
+
g = np.random.default_rng(seed)
|
|
462
|
+
pose = rand_pose(g, length, DEG)
|
|
463
|
+
point = rand_position(g, length)
|
|
464
|
+
rot, t = sp_pose(pose)
|
|
465
|
+
|
|
466
|
+
out = pose.transform(point)
|
|
467
|
+
|
|
468
|
+
assert out.unit == length
|
|
469
|
+
np.testing.assert_allclose(out.to(M).as_tuple(), t + rot.apply(point.to(M).as_tuple()), atol=ATOL)
|
|
470
|
+
|
|
471
|
+
def test_point_in_other_unit_is_converted(self) -> None:
|
|
472
|
+
pose = Pose(Position(1, 0, 0, M), Orientation(0, 0, 90))
|
|
473
|
+
out = pose.transform(Position(1000, 0, 0, MM)) # 1 m in mm
|
|
474
|
+
assert out.unit == M
|
|
475
|
+
assert out.as_tuple() == pytest.approx((1, 1, 0), abs=1e-12)
|
|
476
|
+
|
|
477
|
+
@pytest.mark.parametrize("seed", SEEDS)
|
|
478
|
+
def test_consistent_with_matmul(self, seed: int) -> None:
|
|
479
|
+
g = np.random.default_rng(seed)
|
|
480
|
+
pose, point = rand_pose(g), rand_position(g)
|
|
481
|
+
assert pose.transform(point).is_close((pose @ Pose(point)).position)
|
|
482
|
+
|
|
483
|
+
@pytest.mark.parametrize("seed", SEEDS)
|
|
484
|
+
def test_inverse_undoes_transform(self, seed: int) -> None:
|
|
485
|
+
g = np.random.default_rng(seed)
|
|
486
|
+
pose, point = rand_pose(g), rand_position(g)
|
|
487
|
+
assert pose.inverse().transform(pose.transform(point)).is_close(point, abs_tol=1e-8)
|
|
488
|
+
|
|
489
|
+
def test_identity_orientation_is_pure_translation(self) -> None:
|
|
490
|
+
out = Pose(Position(1, 2, 3, MM)).transform(Position(10, 20, 30, MM))
|
|
491
|
+
assert out.as_tuple() == pytest.approx((11, 22, 33))
|
|
492
|
+
|
|
493
|
+
def test_origin_maps_to_pose_position(self) -> None:
|
|
494
|
+
pose = rand_pose(np.random.default_rng(3))
|
|
495
|
+
assert pose.transform(Position(unit=MM)).is_close(pose.position)
|
|
@@ -129,6 +129,7 @@ def _write_python_file(path: Path, units: dict[str, _UneceUnit], class_name: str
|
|
|
129
129
|
_write_unit(file, unit)
|
|
130
130
|
_LOGGER.info(f"Written {class_name=} {doc=} to {path=}")
|
|
131
131
|
|
|
132
|
+
|
|
132
133
|
def _check(path: Path) -> None:
|
|
133
134
|
subprocess.run(["ruff", "check", "--fix", str(path)], check=True)
|
|
134
135
|
subprocess.run(["ruff", "format", str(path)], check=True)
|
|
@@ -139,6 +140,7 @@ def _check(path: Path) -> None:
|
|
|
139
140
|
# finally, make sure the file is valid python after formatting & linting
|
|
140
141
|
compile(generated_code, path, "exec")
|
|
141
142
|
|
|
143
|
+
|
|
142
144
|
def _main() -> None:
|
|
143
145
|
units = _parse(SRC_PATH)
|
|
144
146
|
|
|
@@ -166,5 +168,6 @@ def _main() -> None:
|
|
|
166
168
|
_write_python_file(DEST_PATH, units=units, class_name="Unit", doc="All units")
|
|
167
169
|
_check(DEST_PATH)
|
|
168
170
|
|
|
171
|
+
|
|
169
172
|
if __name__ == "__main__":
|
|
170
173
|
_main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|