spatialdds-ros 0.3.0__py3-none-any.whl
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.
- spatialdds_idl18/PROVENANCE.md +66 -0
- spatialdds_idl18/__init__.py +9 -0
- spatialdds_idl18/_field_aliases.py +26 -0
- spatialdds_idl18/builtin/__init__.py +11 -0
- spatialdds_idl18/builtin/_types.py +28 -0
- spatialdds_idl18/spatial/__init__.py +33 -0
- spatialdds_idl18/spatial/anchors/__init__.py +20 -0
- spatialdds_idl18/spatial/anchors/_anchors.py +102 -0
- spatialdds_idl18/spatial/argeo/__init__.py +22 -0
- spatialdds_idl18/spatial/argeo/_argeo.py +97 -0
- spatialdds_idl18/spatial/common/__init__.py +27 -0
- spatialdds_idl18/spatial/common/_types.py +104 -0
- spatialdds_idl18/spatial/core/GnssService/__init__.py +17 -0
- spatialdds_idl18/spatial/core/GnssService/_core.py +27 -0
- spatialdds_idl18/spatial/core/__init__.py +40 -0
- spatialdds_idl18/spatial/core/_core.py +338 -0
- spatialdds_idl18/spatial/disco/__init__.py +34 -0
- spatialdds_idl18/spatial/disco/_discovery.py +252 -0
- spatialdds_idl18/spatial/events/__init__.py +29 -0
- spatialdds_idl18/spatial/events/_events.py +164 -0
- spatialdds_idl18/spatial/events/enums/__init__.py +19 -0
- spatialdds_idl18/spatial/events/enums/crossing_direction_enum/__init__.py +11 -0
- spatialdds_idl18/spatial/events/enums/crossing_direction_enum/_events.py +25 -0
- spatialdds_idl18/spatial/events/enums/event_state_enum/__init__.py +11 -0
- spatialdds_idl18/spatial/events/enums/event_state_enum/_events.py +26 -0
- spatialdds_idl18/spatial/events/enums/event_type_enum/__init__.py +11 -0
- spatialdds_idl18/spatial/events/enums/event_type_enum/_events.py +36 -0
- spatialdds_idl18/spatial/events/enums/severity_enum/__init__.py +11 -0
- spatialdds_idl18/spatial/events/enums/severity_enum/_events.py +26 -0
- spatialdds_idl18/spatial/events/enums/zone_kind_enum/__init__.py +11 -0
- spatialdds_idl18/spatial/events/enums/zone_kind_enum/_events.py +31 -0
- spatialdds_idl18/spatial/mapping/__init__.py +30 -0
- spatialdds_idl18/spatial/mapping/_mapping.py +168 -0
- spatialdds_idl18/spatial/mapping/enums/__init__.py +19 -0
- spatialdds_idl18/spatial/mapping/enums/alignment_method/__init__.py +11 -0
- spatialdds_idl18/spatial/mapping/enums/alignment_method/_mapping.py +31 -0
- spatialdds_idl18/spatial/mapping/enums/edge_type/__init__.py +11 -0
- spatialdds_idl18/spatial/mapping/enums/edge_type/_mapping.py +34 -0
- spatialdds_idl18/spatial/mapping/enums/map_event_kind/__init__.py +11 -0
- spatialdds_idl18/spatial/mapping/enums/map_event_kind/_mapping.py +29 -0
- spatialdds_idl18/spatial/mapping/enums/map_kind/__init__.py +11 -0
- spatialdds_idl18/spatial/mapping/enums/map_kind/_mapping.py +32 -0
- spatialdds_idl18/spatial/mapping/enums/map_status/__init__.py +11 -0
- spatialdds_idl18/spatial/mapping/enums/map_status/_mapping.py +27 -0
- spatialdds_idl18/spatial/owm/__init__.py +22 -0
- spatialdds_idl18/spatial/owm/_owm.py +103 -0
- spatialdds_idl18/spatial/semantics/__init__.py +22 -0
- spatialdds_idl18/spatial/semantics/_semantics.py +174 -0
- spatialdds_idl18/spatial/sensing/__init__.py +21 -0
- spatialdds_idl18/spatial/sensing/common/__init__.py +34 -0
- spatialdds_idl18/spatial/sensing/common/_common.py +192 -0
- spatialdds_idl18/spatial/sensing/lidar/__init__.py +32 -0
- spatialdds_idl18/spatial/sensing/lidar/_lidar.py +135 -0
- spatialdds_idl18/spatial/sensing/rad/__init__.py +33 -0
- spatialdds_idl18/spatial/sensing/rad/_rad.py +191 -0
- spatialdds_idl18/spatial/sensing/radio/__init__.py +22 -0
- spatialdds_idl18/spatial/sensing/radio/_radio.py +141 -0
- spatialdds_idl18/spatial/sensing/rf_beam/__init__.py +25 -0
- spatialdds_idl18/spatial/sensing/rf_beam/_rf_beam.py +117 -0
- spatialdds_idl18/spatial/sensing/vision/__init__.py +36 -0
- spatialdds_idl18/spatial/sensing/vision/_vision.py +170 -0
- spatialdds_idl18/spatial/slam_frontend/__init__.py +22 -0
- spatialdds_idl18/spatial/slam_frontend/_slam_frontend.py +144 -0
- spatialdds_idl18/spatial/vio/__init__.py +20 -0
- spatialdds_idl18/spatial/vio/_vio.py +134 -0
- spatialdds_ros/__init__.py +6 -0
- spatialdds_ros/adapter.py +336 -0
- spatialdds_ros/announce.py +231 -0
- spatialdds_ros/config.py +293 -0
- spatialdds_ros/frames.py +219 -0
- spatialdds_ros/kinematic.py +140 -0
- spatialdds_ros/qos.py +101 -0
- spatialdds_ros/recording.py +167 -0
- spatialdds_ros/robot_node.py +287 -0
- spatialdds_ros/standalone.py +221 -0
- spatialdds_ros/tempo.py +114 -0
- spatialdds_ros/topics.py +64 -0
- spatialdds_ros/transport.py +170 -0
- spatialdds_ros-0.3.0.data/data/share/ament_index/resource_index/packages/spatialdds_ros +0 -0
- spatialdds_ros-0.3.0.data/data/share/spatialdds_ros/package.xml +29 -0
- spatialdds_ros-0.3.0.dist-info/METADATA +331 -0
- spatialdds_ros-0.3.0.dist-info/RECORD +87 -0
- spatialdds_ros-0.3.0.dist-info/WHEEL +5 -0
- spatialdds_ros-0.3.0.dist-info/entry_points.txt +3 -0
- spatialdds_ros-0.3.0.dist-info/licenses/LICENSE +21 -0
- spatialdds_ros-0.3.0.dist-info/top_level.txt +2 -0
- spatialdds_ros-0.3.0.dist-info/zip-safe +1 -0
spatialdds_ros/frames.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Frames, and the one piece of arithmetic that fails silently.
|
|
3
|
+
|
|
4
|
+
Two things live here: the `FrameRef` the robot stamps on every pose, and the
|
|
5
|
+
`FrameTransform` that says where that frame sits on the earth — published only
|
|
6
|
+
when an anchor is configured.
|
|
7
|
+
|
|
8
|
+
**The direction of the transform is the thing to get right.** `parent_ref` is
|
|
9
|
+
the *global* frame and `child_ref` the *local* one, with `T_parent_child`
|
|
10
|
+
mapping parent into child. An inverted transform still produces a well-formed
|
|
11
|
+
pose with plausible-looking numbers and places everything near the centre of
|
|
12
|
+
the earth: no exception, no malformed record, nothing a schema check would
|
|
13
|
+
catch. The prototype's `venue_georeference` carries the same warning, and for
|
|
14
|
+
the same reason — the 1.7 announce spells the two ends `from` and `to` with
|
|
15
|
+
`from` being the *local* frame, so anyone mapping by name rather than by role
|
|
16
|
+
inverts it.
|
|
17
|
+
|
|
18
|
+
The ENU↔ECEF projection is written once, here. Two copies would make a drift
|
|
19
|
+
between consumers look like a disagreement about where the venue is.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import math
|
|
24
|
+
import time
|
|
25
|
+
import uuid
|
|
26
|
+
from typing import Optional, Tuple
|
|
27
|
+
|
|
28
|
+
from spatialdds_idl18.builtin import Time
|
|
29
|
+
from spatialdds_idl18.spatial.common import (
|
|
30
|
+
CoordConvention, FrameRef, ScaleStatus)
|
|
31
|
+
from spatialdds_idl18.spatial.core import CovMatrix, FrameTransform, PoseSE3
|
|
32
|
+
|
|
33
|
+
from spatialdds_ros.config import Anchor
|
|
34
|
+
from spatialdds_ros.topics import EARTH_FIXED_FQN
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def now_time() -> Time:
|
|
38
|
+
now = time.time()
|
|
39
|
+
return Time(sec=int(now), nanosec=int((now % 1) * 1e9))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def frame_uuid(fqn: str) -> str:
|
|
43
|
+
"""
|
|
44
|
+
A frame's uuid, derived from its name.
|
|
45
|
+
|
|
46
|
+
UUIDv5 of the fqn, which is how every other participant in this family
|
|
47
|
+
derives it. Load-bearing rather than tidy: the scene, a catalogue and an
|
|
48
|
+
announce all naming the same frame must produce the same uuid, or they
|
|
49
|
+
describe three frames that happen to share a name.
|
|
50
|
+
"""
|
|
51
|
+
return str(uuid.uuid5(uuid.NAMESPACE_URL, fqn))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def robot_frame(fqn: str) -> FrameRef:
|
|
55
|
+
"""
|
|
56
|
+
The frame the robot's pose is expressed in.
|
|
57
|
+
|
|
58
|
+
**The scale quartet is filled, and it is the one number this library
|
|
59
|
+
asserts without measuring it.** REP-103 makes ROS metric, so
|
|
60
|
+
`SCALE_DECLARED` with `meters_per_unit = 1.0` is a true statement about any
|
|
61
|
+
conformant ROS robot rather than a guess — and `SCALE_DECLARED` rather than
|
|
62
|
+
`SCALE_DERIVED` because nobody recovered it from a similarity transform;
|
|
63
|
+
the units were metres to begin with.
|
|
64
|
+
|
|
65
|
+
`has_scale = false` would also mean metric — the spec makes every pre-1.8
|
|
66
|
+
publisher correct by construction — so these four members change nothing a
|
|
67
|
+
consumer computes. They change what it has to assume. They are also
|
|
68
|
+
exactly the four a 1.7 reader truncates away, which is the era boundary
|
|
69
|
+
working as designed rather than a fault.
|
|
70
|
+
"""
|
|
71
|
+
return FrameRef(
|
|
72
|
+
uuid=frame_uuid(fqn),
|
|
73
|
+
fqn=fqn,
|
|
74
|
+
has_coord_convention=True,
|
|
75
|
+
coord_convention=CoordConvention.ENU,
|
|
76
|
+
has_scale=True,
|
|
77
|
+
scale_status=ScaleStatus.SCALE_DECLARED,
|
|
78
|
+
meters_per_unit=1.0,
|
|
79
|
+
display_unit="m",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def earth_fixed_frame() -> FrameRef:
|
|
84
|
+
"""
|
|
85
|
+
The global frame a georeference is relative to.
|
|
86
|
+
|
|
87
|
+
No coordinate convention: ECEF is not one of the conventions
|
|
88
|
+
`CoordConvention` can name, so claiming one would be false. Saying nothing
|
|
89
|
+
is the accurate answer, and `has_coord_convention = False` is how the type
|
|
90
|
+
says it.
|
|
91
|
+
"""
|
|
92
|
+
return FrameRef(
|
|
93
|
+
uuid=frame_uuid(EARTH_FIXED_FQN),
|
|
94
|
+
fqn=EARTH_FIXED_FQN,
|
|
95
|
+
has_coord_convention=False,
|
|
96
|
+
coord_convention=CoordConvention.ENU, # unread while the flag is off
|
|
97
|
+
has_scale=True,
|
|
98
|
+
scale_status=ScaleStatus.SCALE_DECLARED,
|
|
99
|
+
meters_per_unit=1.0,
|
|
100
|
+
display_unit="m",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def enu_to_ecef_pose(lat_deg: float, lon_deg: float,
|
|
105
|
+
height_m: float) -> Tuple[list, list]:
|
|
106
|
+
"""
|
|
107
|
+
A local ENU frame's origin and basis, in earth-fixed metres.
|
|
108
|
+
|
|
109
|
+
Returns ``(t, q)``: the translation is the frame origin's ECEF position and
|
|
110
|
+
the quaternion (x, y, z, w) is the ENU basis at that origin, so the pose
|
|
111
|
+
maps **from** the local frame **into** earth-fixed.
|
|
112
|
+
|
|
113
|
+
WGS84. One implementation, because a difference between two consumers
|
|
114
|
+
should come from their anchors and never from two copies of this drifting
|
|
115
|
+
apart.
|
|
116
|
+
"""
|
|
117
|
+
lat, lon = math.radians(lat_deg), math.radians(lon_deg)
|
|
118
|
+
a, f = 6378137.0, 1.0 / 298.257223563
|
|
119
|
+
e2 = f * (2.0 - f)
|
|
120
|
+
n = a / math.sqrt(1.0 - e2 * math.sin(lat) ** 2)
|
|
121
|
+
t = [(n + height_m) * math.cos(lat) * math.cos(lon),
|
|
122
|
+
(n + height_m) * math.cos(lat) * math.sin(lon),
|
|
123
|
+
(n * (1.0 - e2) + height_m) * math.sin(lat)]
|
|
124
|
+
|
|
125
|
+
# The ENU basis at (lat, lon), as columns: east, north, up.
|
|
126
|
+
sl, cl = math.sin(lon), math.cos(lon)
|
|
127
|
+
sp, cp = math.sin(lat), math.cos(lat)
|
|
128
|
+
east = (-sl, cl, 0.0)
|
|
129
|
+
north = (-sp * cl, -sp * sl, cp)
|
|
130
|
+
up = (cp * cl, cp * sl, sp)
|
|
131
|
+
m = ((east[0], north[0], up[0]),
|
|
132
|
+
(east[1], north[1], up[1]),
|
|
133
|
+
(east[2], north[2], up[2]))
|
|
134
|
+
|
|
135
|
+
# Matrix to quaternion, branching on the trace for numerical conditioning.
|
|
136
|
+
trace = m[0][0] + m[1][1] + m[2][2]
|
|
137
|
+
if trace > 0.0:
|
|
138
|
+
s = math.sqrt(trace + 1.0) * 2.0
|
|
139
|
+
w = 0.25 * s
|
|
140
|
+
x = (m[2][1] - m[1][2]) / s
|
|
141
|
+
y = (m[0][2] - m[2][0]) / s
|
|
142
|
+
z = (m[1][0] - m[0][1]) / s
|
|
143
|
+
elif m[0][0] > m[1][1] and m[0][0] > m[2][2]:
|
|
144
|
+
s = math.sqrt(1.0 + m[0][0] - m[1][1] - m[2][2]) * 2.0
|
|
145
|
+
w = (m[2][1] - m[1][2]) / s
|
|
146
|
+
x = 0.25 * s
|
|
147
|
+
y = (m[0][1] + m[1][0]) / s
|
|
148
|
+
z = (m[0][2] + m[2][0]) / s
|
|
149
|
+
elif m[1][1] > m[2][2]:
|
|
150
|
+
s = math.sqrt(1.0 + m[1][1] - m[0][0] - m[2][2]) * 2.0
|
|
151
|
+
w = (m[0][2] - m[2][0]) / s
|
|
152
|
+
x = (m[0][1] + m[1][0]) / s
|
|
153
|
+
y = 0.25 * s
|
|
154
|
+
z = (m[1][2] + m[2][1]) / s
|
|
155
|
+
else:
|
|
156
|
+
s = math.sqrt(1.0 + m[2][2] - m[0][0] - m[1][1]) * 2.0
|
|
157
|
+
w = (m[1][0] - m[0][1]) / s
|
|
158
|
+
x = (m[0][2] + m[2][0]) / s
|
|
159
|
+
y = (m[1][2] + m[2][1]) / s
|
|
160
|
+
z = 0.25 * s
|
|
161
|
+
return t, [x, y, z, w]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def georeference(frame_id: str, anchor: Anchor) -> FrameTransform:
|
|
165
|
+
"""
|
|
166
|
+
Where `frame_id`'s origin sits on the earth — the robot's own claim.
|
|
167
|
+
|
|
168
|
+
Published only when an anchor is configured; `Config.publishes_georeference`
|
|
169
|
+
is the gate, and its absence is a documented mode rather than a failure.
|
|
170
|
+
|
|
171
|
+
**Direction, stated at the construction site because this is where it goes
|
|
172
|
+
wrong.** `parent_ref` is earth-fixed, the global frame. `child_ref` is the
|
|
173
|
+
local frame. Following the 1.7 announce's `from`/`to` spelling instead
|
|
174
|
+
would invert it and place the frame near the centre of the earth with every
|
|
175
|
+
number still looking plausible.
|
|
176
|
+
|
|
177
|
+
`cov` is a union and has to say which arm it is using. `COV_NONE` is the
|
|
178
|
+
honest answer for an anchor somebody typed into a launch file: there are no
|
|
179
|
+
error bars on it, and there is no implicit absent value to leave it at.
|
|
180
|
+
"""
|
|
181
|
+
t, q = enu_to_ecef_pose(anchor.lat_deg, anchor.lon_deg, anchor.height_m)
|
|
182
|
+
return FrameTransform(
|
|
183
|
+
transform_id=f"xf:{frame_uuid(frame_id)}",
|
|
184
|
+
parent_ref=earth_fixed_frame(),
|
|
185
|
+
child_ref=robot_frame(frame_id),
|
|
186
|
+
T_parent_child=PoseSE3(t=t, q=q),
|
|
187
|
+
stamp=now_time(),
|
|
188
|
+
cov=CovMatrix(none=0),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def yaw_quaternion(yaw: float) -> list:
|
|
193
|
+
"""A rotation about the frame's up axis, as (x, y, z, w)."""
|
|
194
|
+
return [0.0, 0.0, math.sin(yaw / 2.0), math.cos(yaw / 2.0)]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def pose_at(x: float, y: float, z: float, yaw: float) -> PoseSE3:
|
|
198
|
+
return PoseSE3(t=[x, y, z], q=yaw_quaternion(yaw))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def geodetic_of(t) -> Tuple[float, float, float]:
|
|
202
|
+
"""
|
|
203
|
+
ECEF metres back to (lat, lon, height). The inverse of the translation
|
|
204
|
+
above, provided so a test can compose the two and check the round trip —
|
|
205
|
+
an inverted or mis-scaled transform is caught by composing it, not by
|
|
206
|
+
reading it.
|
|
207
|
+
"""
|
|
208
|
+
x, y, z = t[0], t[1], t[2]
|
|
209
|
+
a, f = 6378137.0, 1.0 / 298.257223563
|
|
210
|
+
e2 = f * (2.0 - f)
|
|
211
|
+
lon = math.atan2(y, x)
|
|
212
|
+
p = math.hypot(x, y)
|
|
213
|
+
lat = math.atan2(z, p)
|
|
214
|
+
for _ in range(8):
|
|
215
|
+
n = a / math.sqrt(1.0 - e2 * math.sin(lat) ** 2)
|
|
216
|
+
h = p / math.cos(lat) - n
|
|
217
|
+
lat = math.atan2(z, p * (1.0 - e2 * n / (n + h)))
|
|
218
|
+
n = a / math.sqrt(1.0 - e2 * math.sin(lat) ** 2)
|
|
219
|
+
return math.degrees(lat), math.degrees(lon), p / math.cos(lat) - n
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The no-hardware mode: a robot that drives in straight lines.
|
|
3
|
+
|
|
4
|
+
This is the library's CI story and its try-it story, the same role the
|
|
5
|
+
prototype's `PlazaSim` played. It exists so the whole chain — entity on the
|
|
6
|
+
bus, a command accepted, a refusal reaching the asker — can be exercised with
|
|
7
|
+
no ROS, no nav2 and no robot.
|
|
8
|
+
|
|
9
|
+
**What it deliberately does not carry.** The prototype's sim tested every step
|
|
10
|
+
against a mask built from the venue's declared extents, and refused to enter.
|
|
11
|
+
That coupling does not travel: for a real robot the clearance is nav2's
|
|
12
|
+
footprint and inflation radius, which nav2 already owns, and the prototype's
|
|
13
|
+
own clearance constant is documented as tied to a GLB scaled up for on-screen
|
|
14
|
+
legibility rather than to a robot. The census rules the keep-out job as the
|
|
15
|
+
costmap-filter path, and this source is not it.
|
|
16
|
+
|
|
17
|
+
**What it carries instead, and why.** Optional `bounds`: an axis-aligned box
|
|
18
|
+
this sim will not leave. Default unbounded, so by default nothing is refused.
|
|
19
|
+
Bounds are not a world model and make no claim about a venue — they are a
|
|
20
|
+
property of the sim, the way a treadmill is a property of a test rig. They are
|
|
21
|
+
here because the *shape* of a real late refusal needs reproducing: whether a
|
|
22
|
+
goal is drivable depends on where the robot is when it meets the boundary, so
|
|
23
|
+
the refusal necessarily arrives after the acceptance. A source that could only
|
|
24
|
+
accept would leave that path unexercised in the one mode anybody can run
|
|
25
|
+
without hardware.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import math
|
|
30
|
+
from typing import Optional, Tuple
|
|
31
|
+
|
|
32
|
+
#: Metres per second. Fast enough that a test does not wait, slow enough that
|
|
33
|
+
#: a person watching can see it move.
|
|
34
|
+
DEFAULT_SPEED_MPS = 0.6
|
|
35
|
+
|
|
36
|
+
#: How close counts as arrived.
|
|
37
|
+
ARRIVAL_M = 0.05
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Bounds:
|
|
41
|
+
"""An axis-aligned box the sim will not leave."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, min_x: float, min_y: float,
|
|
44
|
+
max_x: float, max_y: float) -> None:
|
|
45
|
+
if min_x >= max_x or min_y >= max_y:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"bounds are empty: x [{min_x}, {max_x}], y [{min_y}, {max_y}]")
|
|
48
|
+
self.min_x, self.min_y = min_x, min_y
|
|
49
|
+
self.max_x, self.max_y = max_x, max_y
|
|
50
|
+
|
|
51
|
+
def contains(self, x: float, y: float) -> bool:
|
|
52
|
+
return (self.min_x <= x <= self.max_x
|
|
53
|
+
and self.min_y <= y <= self.max_y)
|
|
54
|
+
|
|
55
|
+
def __repr__(self) -> str: # pragma: no cover
|
|
56
|
+
return (f"Bounds(x [{self.min_x}, {self.max_x}], "
|
|
57
|
+
f"y [{self.min_y}, {self.max_y}])")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class KinematicSource:
|
|
61
|
+
"""
|
|
62
|
+
A pose that walks towards a goal in a straight line.
|
|
63
|
+
|
|
64
|
+
No physics, no sensors, no world model. `step` returns the new pose; when
|
|
65
|
+
it has had to give up on a goal it sets `abandoned` to the reason, which
|
|
66
|
+
the caller is expected to pass to `RobotAdapter.refused` so whoever asked
|
|
67
|
+
finds out.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, start: Tuple[float, float] = (0.0, 0.0),
|
|
71
|
+
yaw: float = 0.0, speed: float = DEFAULT_SPEED_MPS,
|
|
72
|
+
bounds: Optional[Bounds] = None) -> None:
|
|
73
|
+
self.x, self.y = start
|
|
74
|
+
self.yaw = yaw
|
|
75
|
+
self.speed = speed
|
|
76
|
+
self.bounds = bounds
|
|
77
|
+
self.goal: Optional[Tuple[float, float]] = None
|
|
78
|
+
#: Set when a goal was dropped, cleared by the caller once reported.
|
|
79
|
+
self.abandoned: Optional[str] = None
|
|
80
|
+
if bounds is not None and not bounds.contains(self.x, self.y):
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"the start position ({self.x}, {self.y}) is outside the "
|
|
83
|
+
f"configured bounds, so this sim would be refusing from its "
|
|
84
|
+
f"first step")
|
|
85
|
+
|
|
86
|
+
def step(self, dt: float) -> Tuple[float, float, float]:
|
|
87
|
+
"""Advance by `dt` seconds. Returns (x, y, yaw)."""
|
|
88
|
+
if self.goal is None:
|
|
89
|
+
# Idle is *still*. A sim that drifted while idle would publish
|
|
90
|
+
# motion nobody asked for, and the fast lane would carry it.
|
|
91
|
+
return self.x, self.y, self.yaw
|
|
92
|
+
|
|
93
|
+
gx, gy = self.goal
|
|
94
|
+
dx, dy = gx - self.x, gy - self.y
|
|
95
|
+
distance = math.hypot(dx, dy)
|
|
96
|
+
if distance <= ARRIVAL_M:
|
|
97
|
+
self.x, self.y, self.goal = gx, gy, None
|
|
98
|
+
return self.x, self.y, self.yaw
|
|
99
|
+
|
|
100
|
+
yaw = math.atan2(dy, dx)
|
|
101
|
+
step = min(self.speed * dt, distance)
|
|
102
|
+
x = self.x + step * math.cos(yaw)
|
|
103
|
+
y = self.y + step * math.sin(yaw)
|
|
104
|
+
|
|
105
|
+
if self.bounds is not None and not self.bounds.contains(x, y):
|
|
106
|
+
# **The reason cannot be known at accept time**, which is the whole
|
|
107
|
+
# point of this branch: it depends on where the robot is when it
|
|
108
|
+
# meets the boundary. So the goal was honestly accepted and is
|
|
109
|
+
# honestly given up on here, with a reason the asker can read.
|
|
110
|
+
self.abandoned = (
|
|
111
|
+
f"goal ({gx:.2f}, {gy:.2f}) abandoned at "
|
|
112
|
+
f"({self.x:.2f}, {self.y:.2f}) — a straight line from here "
|
|
113
|
+
f"leaves this source's configured bounds, and it drives "
|
|
114
|
+
f"straight lines only rather than planning around them. Send "
|
|
115
|
+
f"a waypoint inside the bounds, or run a real planner")
|
|
116
|
+
self.goal = None
|
|
117
|
+
return self.x, self.y, self.yaw
|
|
118
|
+
|
|
119
|
+
self.yaw, self.x, self.y = yaw, x, y
|
|
120
|
+
return self.x, self.y, self.yaw
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class KinematicNavigator:
|
|
124
|
+
"""
|
|
125
|
+
`RobotAdapter`'s navigator, backed by the sim above.
|
|
126
|
+
|
|
127
|
+
`available()` is always `None`: there is nothing for a straight-line sim to
|
|
128
|
+
be unavailable for, and claiming otherwise would make the adapter decline
|
|
129
|
+
goals it can in fact take.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def __init__(self, source: KinematicSource) -> None:
|
|
133
|
+
self._source = source
|
|
134
|
+
|
|
135
|
+
def available(self) -> Optional[str]:
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
def send(self, x: float, y: float) -> str:
|
|
139
|
+
self._source.goal = (x, y)
|
|
140
|
+
return f"the kinematic source is heading for ({x:.2f}, {y:.2f})"
|
spatialdds_ros/qos.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The three QoS profiles this library's lanes use, from spec §3.3.3.
|
|
3
|
+
|
|
4
|
+
**Not configurable, and this is the clearest case in the library.** QoS in DDS
|
|
5
|
+
is a request/offered contract: endpoints match only when the reader's request
|
|
6
|
+
is compatible with the writer's offer, and when they do not match *nothing
|
|
7
|
+
happens*. No error, no warning, no mismatch reported — the reader simply never
|
|
8
|
+
receives, which is indistinguishable from a writer that is not publishing.
|
|
9
|
+
|
|
10
|
+
The prototype has the scar. Deadline is one of those request/offered policies,
|
|
11
|
+
so a reader requesting 33 ms does not match a writer offering none. An
|
|
12
|
+
independently-built probe transcribed the spec's older table, read its
|
|
13
|
+
"Typical" column heading as advisory, omitted the deadlines, and exchanged
|
|
14
|
+
nothing at all; `tests/test_interop.py::DeadlineIsLoadBearing` in
|
|
15
|
+
SpatialDDS-demo pins the behaviour. A dash in the spec's deadline column means
|
|
16
|
+
*no deadline*, which is a different statement from an unstated one.
|
|
17
|
+
|
|
18
|
+
So a QoS knob on this library would be a way for an operator to build a robot
|
|
19
|
+
that is invisible to the scene, with nothing anywhere saying why. There is one
|
|
20
|
+
right answer per lane and the library fills it.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
try: # pragma: no cover - import shape
|
|
28
|
+
from cyclonedds import qos as _qos, util as _util
|
|
29
|
+
HAVE_CYCLONEDDS = True
|
|
30
|
+
except Exception: # pragma: no cover - import shape
|
|
31
|
+
# Importable without CycloneDDS so the profile *table* can be read and
|
|
32
|
+
# asserted on a machine with no DDS build — which is where this library's
|
|
33
|
+
# unit tests run. `to_qos` is the only thing that needs the binding.
|
|
34
|
+
_qos = _util = None # type: ignore
|
|
35
|
+
HAVE_CYCLONEDDS = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class QosProfile:
|
|
40
|
+
"""One row of §3.3.3, transcribed field for field."""
|
|
41
|
+
|
|
42
|
+
name: str
|
|
43
|
+
reliable: bool
|
|
44
|
+
deadline_ms: Optional[int]
|
|
45
|
+
latched: bool = False
|
|
46
|
+
keep_last: Optional[int] = None # None means KEEP_ALL
|
|
47
|
+
note: str = ""
|
|
48
|
+
|
|
49
|
+
def to_qos(self, lifespan_sec: Optional[float] = None):
|
|
50
|
+
"""This row as a CycloneDDS `Qos`."""
|
|
51
|
+
if not HAVE_CYCLONEDDS: # pragma: no cover - import shape
|
|
52
|
+
raise RuntimeError(
|
|
53
|
+
"cyclonedds is not importable, so no QoS can be built. The "
|
|
54
|
+
"profile table itself is readable without it")
|
|
55
|
+
policies = [
|
|
56
|
+
_qos.Policy.Reliability.Reliable(_util.duration(seconds=1))
|
|
57
|
+
if self.reliable else _qos.Policy.Reliability.BestEffort,
|
|
58
|
+
_qos.Policy.Durability.TransientLocal if self.latched
|
|
59
|
+
else _qos.Policy.Durability.Volatile,
|
|
60
|
+
_qos.Policy.History.KeepLast(self.keep_last) if self.keep_last
|
|
61
|
+
else _qos.Policy.History.KeepAll,
|
|
62
|
+
]
|
|
63
|
+
if self.deadline_ms is not None:
|
|
64
|
+
policies.append(
|
|
65
|
+
_qos.Policy.Deadline(
|
|
66
|
+
_util.duration(milliseconds=self.deadline_ms)))
|
|
67
|
+
if lifespan_sec:
|
|
68
|
+
# A payload TTL (an announce's `ttl_sec`) mapped onto DDS Lifespan,
|
|
69
|
+
# so the middleware expires a stale sample rather than every
|
|
70
|
+
# consumer re-deriving staleness.
|
|
71
|
+
policies.append(
|
|
72
|
+
_qos.Policy.Lifespan(_util.duration(seconds=lifespan_sec)))
|
|
73
|
+
return _qos.Qos(*policies)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
#: What exists and what it is. TRANSIENT_LOCAL so a client that connects after
|
|
77
|
+
#: the robot has been running still learns the robot is there — the property
|
|
78
|
+
#: the whole model layer rests on, and the reason a late joiner needs no query.
|
|
79
|
+
MODEL_LATCHED = QosProfile(
|
|
80
|
+
"MODEL_LATCHED", reliable=True, deadline_ms=None, latched=True,
|
|
81
|
+
keep_last=1,
|
|
82
|
+
note="RELIABLE + TRANSIENT_LOCAL + KEEP_LAST(1) per key",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
#: Where it is now. BEST_EFFORT because a pose that missed its moment is worth
|
|
86
|
+
#: less than the next one; VOLATILE because a late joiner should read the
|
|
87
|
+
#: latched record rather than a pose from before it existed.
|
|
88
|
+
MODEL_FAST = QosProfile(
|
|
89
|
+
"MODEL_FAST", reliable=False, deadline_ms=None, latched=False, keep_last=1,
|
|
90
|
+
note="BEST_EFFORT + VOLATILE + KEEP_LAST(1) per key -- the tempo lane",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
#: Requests. RELIABLE so one is not lost in transit, VOLATILE so a restarting
|
|
94
|
+
#: participant does not replay yesterday's goals at a robot that has moved.
|
|
95
|
+
MODEL_COMMAND = QosProfile(
|
|
96
|
+
"MODEL_COMMAND", reliable=True, deadline_ms=None, latched=False,
|
|
97
|
+
keep_last=16,
|
|
98
|
+
note="RELIABLE + VOLATILE + KEEP_LAST(16), unkeyed -- a request, not a claim",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
PROFILES = {p.name: p for p in (MODEL_LATCHED, MODEL_FAST, MODEL_COMMAND)}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Record this library's lanes to a typed SpatialDDS MCAP.
|
|
3
|
+
|
|
4
|
+
The file this writes is the one `spatialdds serve --mcap` reads — the
|
|
5
|
+
unmodified gateway from PyPI, with no adapter in between. That is the point:
|
|
6
|
+
if the gateway can serve it, the bytes on the bus were conformant, and the
|
|
7
|
+
check is the gateway rather than an assertion of mine.
|
|
8
|
+
|
|
9
|
+
**The format is defined by what the gateway reads, not chosen here.** Matching
|
|
10
|
+
the packaged `fountain-anchors.mcap`: one schema per type, named in *colon*
|
|
11
|
+
form (`spatial::owm::Entity`), `encoding="omgidl"`, data being the flattened
|
|
12
|
+
IDL text; one channel per topic with `message_encoding="cdr"`; and each
|
|
13
|
+
message the sample's own CycloneDDS serialization.
|
|
14
|
+
|
|
15
|
+
That last part is worth stating because it is the load-bearing coincidence,
|
|
16
|
+
and it was verified rather than assumed: `Entity.serialize()` from
|
|
17
|
+
cyclonedds-python decodes with `spatialdds.server.xcdr2.Decoder` against the
|
|
18
|
+
model built from this IDL, 1.8's appended scale members included. Nothing
|
|
19
|
+
re-encodes anything — the bytes recorded are the bytes that were on the wire.
|
|
20
|
+
|
|
21
|
+
Note the contrast with the prototype's `bridges/mcap_bridge`, which writes
|
|
22
|
+
`message_encoding="json"` with `encoding="jsonschema"`. That is a 1.7 lane for
|
|
23
|
+
a different consumer and is not what the gateway reads; a recording in that
|
|
24
|
+
format would be a perfectly good MCAP that `serve --mcap` could not use.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import time
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, Dict, Optional
|
|
31
|
+
|
|
32
|
+
from spatialdds_idl18.spatial.core import FrameTransform
|
|
33
|
+
from spatialdds_idl18.spatial.disco import Announce
|
|
34
|
+
from spatialdds_idl18.spatial.owm import Entity, ModelCommand, ModelPose
|
|
35
|
+
|
|
36
|
+
from spatialdds_ros.announce import ANNOUNCE_TOPIC
|
|
37
|
+
from spatialdds_ros.config import Config
|
|
38
|
+
from spatialdds_ros.topics import Topics
|
|
39
|
+
|
|
40
|
+
#: The IDL closure the four model types need, relative to `idl/v1.8`.
|
|
41
|
+
#: `provisional/owm.idl` is here because `spatial.owm` is provisional and
|
|
42
|
+
#: independently versioned, so the `spatialdds` package does not vendor it —
|
|
43
|
+
#: and should not. This repository does.
|
|
44
|
+
CLOSURE = ["types.idl", "core.idl", "provisional/owm.idl"]
|
|
45
|
+
|
|
46
|
+
#: And the closure for the discovery lane, which `Announce` needs.
|
|
47
|
+
DISCOVERY_CLOSURE = ["types.idl", "core.idl", "discovery.idl"]
|
|
48
|
+
|
|
49
|
+
#: Where the vendored IDL lives, and where it came from. Kept beside the
|
|
50
|
+
#: generated bindings' provenance deliberately: a recording whose schemas came
|
|
51
|
+
#: from a different spec revision than the bindings that produced the bytes
|
|
52
|
+
#: would be two type systems in one file.
|
|
53
|
+
IDL_DIR = Path(__file__).resolve().parent.parent / "idl" / "v1.8"
|
|
54
|
+
SPEC_COMMIT = "9c3e1f2"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def lanes(config: Config) -> Dict[str, tuple]:
|
|
58
|
+
"""
|
|
59
|
+
Topic -> (python class, colon fqn, dotted fqn, closure).
|
|
60
|
+
|
|
61
|
+
The four model lanes plus discovery. Discovery is not scene-scoped — it is
|
|
62
|
+
the one place a participant looks before it knows what scenes exist.
|
|
63
|
+
"""
|
|
64
|
+
names = Topics(config.scene)
|
|
65
|
+
return {
|
|
66
|
+
names.entity: (Entity, "spatial::owm::Entity",
|
|
67
|
+
"spatial.owm.Entity", CLOSURE),
|
|
68
|
+
names.pose: (ModelPose, "spatial::owm::ModelPose",
|
|
69
|
+
"spatial.owm.ModelPose", CLOSURE),
|
|
70
|
+
names.command: (ModelCommand, "spatial::owm::ModelCommand",
|
|
71
|
+
"spatial.owm.ModelCommand", CLOSURE),
|
|
72
|
+
names.frame_transform: (FrameTransform,
|
|
73
|
+
"spatial::core::FrameTransform",
|
|
74
|
+
"spatial.core.FrameTransform", CLOSURE),
|
|
75
|
+
ANNOUNCE_TOPIC: (Announce, "spatial::disco::Announce",
|
|
76
|
+
"spatial.disco.Announce", DISCOVERY_CLOSURE),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Recorder:
|
|
81
|
+
"""
|
|
82
|
+
One MCAP writer, schemas and channels registered on first sight.
|
|
83
|
+
|
|
84
|
+
Lazily, because a lane with nothing on it should not appear in the
|
|
85
|
+
recording: an empty channel tells a reader a lane exists and said nothing,
|
|
86
|
+
which is a claim this recorder is not in a position to make — it only knows
|
|
87
|
+
what it saw.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(self, path: Path, config: Config) -> None:
|
|
91
|
+
from mcap.writer import Writer
|
|
92
|
+
|
|
93
|
+
self.path = Path(path)
|
|
94
|
+
self.config = config
|
|
95
|
+
self._lanes = lanes(config)
|
|
96
|
+
self._handle = self.path.open("wb")
|
|
97
|
+
self._writer = Writer(self._handle)
|
|
98
|
+
self._writer.start()
|
|
99
|
+
self._schema_ids: Dict[str, int] = {}
|
|
100
|
+
self._channel_ids: Dict[str, int] = {}
|
|
101
|
+
self._flat: Dict[str, bytes] = {}
|
|
102
|
+
self.counts: Dict[str, int] = {}
|
|
103
|
+
|
|
104
|
+
# --- schemas and channels --------------------------------------------
|
|
105
|
+
|
|
106
|
+
def _flattened(self, closure_key: str, closure: list) -> bytes:
|
|
107
|
+
if closure_key not in self._flat:
|
|
108
|
+
from spatialdds.server import pin
|
|
109
|
+
self._flat[closure_key] = pin.flatten(IDL_DIR, closure).encode()
|
|
110
|
+
return self._flat[closure_key]
|
|
111
|
+
|
|
112
|
+
def _schema_id(self, colon_fqn: str, closure: list) -> int:
|
|
113
|
+
if colon_fqn not in self._schema_ids:
|
|
114
|
+
closure_key = ",".join(closure)
|
|
115
|
+
self._schema_ids[colon_fqn] = self._writer.register_schema(
|
|
116
|
+
name=colon_fqn, encoding="omgidl",
|
|
117
|
+
data=self._flattened(closure_key, closure))
|
|
118
|
+
return self._schema_ids[colon_fqn]
|
|
119
|
+
|
|
120
|
+
def _channel_id(self, topic: str) -> int:
|
|
121
|
+
if topic not in self._channel_ids:
|
|
122
|
+
_cls, colon_fqn, dotted, closure = self._lanes[topic]
|
|
123
|
+
self._channel_ids[topic] = self._writer.register_channel(
|
|
124
|
+
schema_id=self._schema_id(colon_fqn, closure),
|
|
125
|
+
topic=topic,
|
|
126
|
+
message_encoding="cdr",
|
|
127
|
+
metadata={
|
|
128
|
+
"spatialdds_type": dotted,
|
|
129
|
+
"spatialdds_version": "1.8",
|
|
130
|
+
"spatialdds_idl": f"SpatialDDS-spec {SPEC_COMMIT}",
|
|
131
|
+
})
|
|
132
|
+
self.counts.setdefault(topic, 0)
|
|
133
|
+
return self._channel_ids[topic]
|
|
134
|
+
|
|
135
|
+
# --- writing ----------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def write(self, topic: str, sample: Any,
|
|
138
|
+
log_time_ns: Optional[int] = None) -> None:
|
|
139
|
+
"""
|
|
140
|
+
One sample, as the bytes it was on the wire.
|
|
141
|
+
|
|
142
|
+
`sample.serialize()` and nothing else: re-encoding would make this a
|
|
143
|
+
recording of what the recorder thinks the sample means rather than of
|
|
144
|
+
what was published.
|
|
145
|
+
"""
|
|
146
|
+
if topic not in self._lanes:
|
|
147
|
+
raise KeyError(
|
|
148
|
+
f"{topic!r} is not a lane this recorder knows; expected one of "
|
|
149
|
+
f"{sorted(self._lanes)}")
|
|
150
|
+
now = time.time_ns() if log_time_ns is None else log_time_ns
|
|
151
|
+
self._writer.add_message(
|
|
152
|
+
channel_id=self._channel_id(topic),
|
|
153
|
+
log_time=now, publish_time=now, data=sample.serialize())
|
|
154
|
+
self.counts[topic] = self.counts.get(topic, 0) + 1
|
|
155
|
+
|
|
156
|
+
def close(self) -> Dict[str, int]:
|
|
157
|
+
"""Finish the file and return what was recorded, per lane."""
|
|
158
|
+
self._writer.finish()
|
|
159
|
+
self._handle.close()
|
|
160
|
+
return dict(self.counts)
|
|
161
|
+
|
|
162
|
+
def __enter__(self) -> "Recorder":
|
|
163
|
+
return self
|
|
164
|
+
|
|
165
|
+
def __exit__(self, *exc) -> None:
|
|
166
|
+
if not self._handle.closed:
|
|
167
|
+
self.close()
|