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.
Files changed (47) hide show
  1. opensmi_core-0.6/.github/workflows/checks.yml +72 -0
  2. {opensmi_core-0.5 → opensmi_core-0.6}/.github/workflows/python-publish.yml +4 -1
  3. {opensmi_core-0.5 → opensmi_core-0.6}/PKG-INFO +1 -1
  4. {opensmi_core-0.5 → opensmi_core-0.6}/pyproject.toml +11 -5
  5. {opensmi_core-0.5 → opensmi_core-0.6}/src/OpenSMI_Core.egg-info/PKG-INFO +1 -1
  6. {opensmi_core-0.5 → opensmi_core-0.6}/src/OpenSMI_Core.egg-info/SOURCES.txt +3 -0
  7. {opensmi_core-0.5 → opensmi_core-0.6}/src/OpenSMI_Core.egg-info/scm_file_list.json +3 -0
  8. opensmi_core-0.6/src/OpenSMI_Core.egg-info/scm_version.json +8 -0
  9. {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/base_ua_object.py +1 -3
  10. opensmi_core-0.6/src/opensmi/core/rsl.py +271 -0
  11. {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/subscription_manager.py +8 -1
  12. {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/ua_enums.py +1 -1
  13. {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/ua_node_util.py +4 -2
  14. opensmi_core-0.6/tests/test_rsl.py +495 -0
  15. {opensmi_core-0.5 → opensmi_core-0.6}/tools/auto_generate_units.py +3 -0
  16. opensmi_core-0.5/src/OpenSMI_Core.egg-info/scm_version.json +0 -8
  17. {opensmi_core-0.5 → opensmi_core-0.6}/.github/workflows/api-docs.yml +0 -0
  18. {opensmi_core-0.5 → opensmi_core-0.6}/.gitignore +0 -0
  19. {opensmi_core-0.5 → opensmi_core-0.6}/.gitlab-ci.yml +0 -0
  20. {opensmi_core-0.5 → opensmi_core-0.6}/LICENSE +0 -0
  21. {opensmi_core-0.5 → opensmi_core-0.6}/LICENSES/MIT.txt +0 -0
  22. {opensmi_core-0.5 → opensmi_core-0.6}/README.md +0 -0
  23. {opensmi_core-0.5 → opensmi_core-0.6}/setup.cfg +0 -0
  24. {opensmi_core-0.5 → opensmi_core-0.6}/src/OpenSMI_Core.egg-info/dependency_links.txt +0 -0
  25. {opensmi_core-0.5 → opensmi_core-0.6}/src/OpenSMI_Core.egg-info/requires.txt +0 -0
  26. {opensmi_core-0.5 → opensmi_core-0.6}/src/OpenSMI_Core.egg-info/top_level.txt +0 -0
  27. {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/__init__.py +0 -0
  28. {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/async_task_mixin.py +0 -0
  29. {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/base_server.py +0 -0
  30. {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/config.py +0 -0
  31. {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/enums.py +0 -0
  32. {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/errors.py +0 -0
  33. {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/frozen.py +0 -0
  34. {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/hook.py +0 -0
  35. {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/lifecycle_mixin.py +0 -0
  36. {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/logging.py +0 -0
  37. {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/meta.py +0 -0
  38. {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/mixin_helpers.py +0 -0
  39. {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/protocols.py +0 -0
  40. {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/repr_mixin.py +0 -0
  41. {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/signal.py +0 -0
  42. {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/ua.py +0 -0
  43. {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/unit_all.py +0 -0
  44. {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/unit_base.py +0 -0
  45. {opensmi_core-0.5 → opensmi_core-0.6}/src/opensmi/core/unset.py +0 -0
  46. {opensmi_core-0.5 → opensmi_core-0.6}/tests/test_signal.py +0 -0
  47. {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.5
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" # to load UNECE Excel to autogenerate units
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" = [ # auto-generated
137
- "E501", # line too long
138
- "RUF001", # String contains ambiguous ~
139
- "CPY", # copyright
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.5
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"
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.6",
3
+ "distance": 0,
4
+ "node": "g242780f6570d8852355c9ab5fe3335ccbba3e1e9",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-11-02"
8
+ }
@@ -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: Publishing interval in milliseconds for OPC UA subscription
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.SUSPENDING` on success or `SkillState.HALTING` on failure."""
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 = ua_node.nodeid.to_string(), default=default)
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(parent: Node, *, browse_name: str | None = None, display_name: str | None = None) -> Node:
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()
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.5",
3
- "distance": 0,
4
- "node": "gddccea703057c24b9ad0c2411b5d680aed029421",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-10-15"
8
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes