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
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The robot's presence on the discovery lane.
|
|
3
|
+
|
|
4
|
+
A robot that publishes its pose and accepts goals is discoverable in exactly
|
|
5
|
+
the way a VPS or a catalogue is: something that wants to drive it should be
|
|
6
|
+
able to find it without being told its topic names. The prototype's robot does
|
|
7
|
+
*not* announce — discovery there belongs to the VPS and catalogue servers — so
|
|
8
|
+
this capability is new rather than extracted, and the catalogue server's
|
|
9
|
+
announce is the shape it follows.
|
|
10
|
+
|
|
11
|
+
**On `kind`.** 1.8's `ServiceKind` is VPS, MAPPING, RELOCAL, SEMANTICS,
|
|
12
|
+
STORAGE, CONTENT, ANCHOR_REGISTRY, SENSING, INFRASTRUCTURE, FUSION, OTHER.
|
|
13
|
+
None of them is an agent that reports where it is and accepts commands about
|
|
14
|
+
where to go. `SENSING` is the nearest and it overstates — it reads as a service
|
|
15
|
+
offering sensor feeds, which this is not. So the default is `OTHER`, which is
|
|
16
|
+
accurate, and `kind` is a parameter so an operator whose robot genuinely is one
|
|
17
|
+
of the others can say so.
|
|
18
|
+
|
|
19
|
+
That gap is filed rather than papered over: see `docs/spec-queue.md`. Choosing
|
|
20
|
+
`SENSING` because it is closest would make every robot on the bus advertise a
|
|
21
|
+
capability it does not have, and the next reader would have no way to know the
|
|
22
|
+
enum had been stretched.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Optional
|
|
27
|
+
|
|
28
|
+
# **Resolved at their definitions, not through `spatial.disco`.** That module
|
|
29
|
+
# re-exports most of what an announce needs, and six of the names it re-exports
|
|
30
|
+
# are annotated typedef aliases that report zero members and raise
|
|
31
|
+
# `TypeError: 'ForwardRef' object is not callable` when constructed — Aabb3,
|
|
32
|
+
# FrameRef, Time, PoseSE3, SpatialUri and MetaKV among them. The convenient
|
|
33
|
+
# import is the wrong one. `test/test_types_resolve_at_definitions.py` is the
|
|
34
|
+
# gate; this trap has cost four separate debugging rounds across this
|
|
35
|
+
# programme, so it is enforced rather than remembered.
|
|
36
|
+
from spatialdds_idl18.builtin import Time
|
|
37
|
+
from spatialdds_idl18.spatial.common import FrameRef
|
|
38
|
+
from spatialdds_idl18.spatial.core import Aabb3, PoseSE3
|
|
39
|
+
from spatialdds_idl18.spatial.disco import (
|
|
40
|
+
Announce, Capabilities, CoverageElement, ProfileSupport, ServiceKind,
|
|
41
|
+
TopicMeta, Transform, ValidityWindow)
|
|
42
|
+
|
|
43
|
+
from spatialdds_ros.config import Config
|
|
44
|
+
from spatialdds_ros.frames import (
|
|
45
|
+
earth_fixed_frame, enu_to_ecef_pose, now_time, robot_frame)
|
|
46
|
+
from spatialdds_ros.qos import MODEL_COMMAND, MODEL_FAST, MODEL_LATCHED
|
|
47
|
+
from spatialdds_ros.topics import Topics
|
|
48
|
+
|
|
49
|
+
#: An absent bbox still needs four numbers, because `BBox2D` is a fixed
|
|
50
|
+
#: `double[4]`. Paired with `has_bbox=False`, so nothing reads them.
|
|
51
|
+
_NO_BBOX = [0.0, 0.0, 0.0, 0.0]
|
|
52
|
+
|
|
53
|
+
#: How long an announce stands before a consumer should stop believing it.
|
|
54
|
+
#: Mapped onto DDS Lifespan by the writer, so the middleware expires it rather
|
|
55
|
+
#: than every consumer re-deriving staleness.
|
|
56
|
+
DEFAULT_TTL_SEC = 300
|
|
57
|
+
|
|
58
|
+
#: What this library speaks. Stated as a range because that is what
|
|
59
|
+
#: `ProfileSupport` is for, and narrow because claiming 1.7 would invite a 1.7
|
|
60
|
+
#: client to assume the four members 1.8 appended are absent.
|
|
61
|
+
PROFILES = (("spatial.core", 1, 8, 8), ("spatial.owm", 0, 1, 1))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _topics(config: Config) -> list[TopicMeta]:
|
|
65
|
+
"""
|
|
66
|
+
The lanes this robot reads and writes, with their QoS profiles named.
|
|
67
|
+
|
|
68
|
+
`qos_profile` being advertised is what lets a consumer build a matching
|
|
69
|
+
endpoint without guessing — and the names here are the same ones
|
|
70
|
+
`qos.PROFILES` resolves, so the advertisement is true on the wire rather
|
|
71
|
+
than decorative. `target_rate_hz` is 0.0 where there is no steady rate:
|
|
72
|
+
0 means unspecified, which is a different statement from a slow rate.
|
|
73
|
+
"""
|
|
74
|
+
names = Topics(config.scene)
|
|
75
|
+
return [
|
|
76
|
+
TopicMeta(name=names.entity, type="spatial.owm.Entity", version="v1",
|
|
77
|
+
qos_profile=MODEL_LATCHED.name, target_rate_hz=0.0,
|
|
78
|
+
max_chunk_bytes=0, durability="TRANSIENT_LOCAL"),
|
|
79
|
+
TopicMeta(name=names.pose, type="spatial.owm.ModelPose", version="v1",
|
|
80
|
+
qos_profile=MODEL_FAST.name, target_rate_hz=0.0,
|
|
81
|
+
max_chunk_bytes=0, durability="VOLATILE"),
|
|
82
|
+
TopicMeta(name=names.command, type="spatial.owm.ModelCommand",
|
|
83
|
+
version="v1", qos_profile=MODEL_COMMAND.name,
|
|
84
|
+
target_rate_hz=0.0, max_chunk_bytes=0,
|
|
85
|
+
durability="VOLATILE"),
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _coverage(config: Config) -> list[CoverageElement]:
|
|
90
|
+
"""
|
|
91
|
+
Where this robot is useful.
|
|
92
|
+
|
|
93
|
+
**With no anchor there is no geographic coverage to claim**, and a
|
|
94
|
+
`_global` element would be a loud lie — a robot in one building
|
|
95
|
+
advertising the planet. So the coverage is expressed in the robot's own
|
|
96
|
+
frame instead: a frame-referenced element with no bbox, which says "within
|
|
97
|
+
this frame" and nothing about the earth. That is the same honesty the
|
|
98
|
+
missing `FrameTransform` observes, carried onto the discovery lane.
|
|
99
|
+
|
|
100
|
+
With an anchor, a degenerate bbox at the anchor point: the robot moves, so
|
|
101
|
+
any extent would be a guess about how far, and a point is the one claim
|
|
102
|
+
that is true.
|
|
103
|
+
"""
|
|
104
|
+
if config.anchor is None:
|
|
105
|
+
return [CoverageElement(
|
|
106
|
+
has_crs=False, crs="", has_bbox=False,
|
|
107
|
+
bbox=_NO_BBOX,
|
|
108
|
+
has_aabb=False,
|
|
109
|
+
aabb=Aabb3(min_xyz=[0.0, 0.0, 0.0], max_xyz=[0.0, 0.0, 0.0]),
|
|
110
|
+
_global=False,
|
|
111
|
+
has_frame_ref=True, frame_ref=robot_frame(config.frame_id),
|
|
112
|
+
has_coverage_window=False,
|
|
113
|
+
coverage_window_start=Time(sec=0, nanosec=0),
|
|
114
|
+
coverage_window_end=Time(sec=0, nanosec=0),
|
|
115
|
+
has_circle=False, circle_center=[0.0, 0.0, 0.0],
|
|
116
|
+
circle_radius_m=0.0)]
|
|
117
|
+
a = config.anchor
|
|
118
|
+
return [CoverageElement(
|
|
119
|
+
has_crs=True, crs="EPSG:4326", has_bbox=True,
|
|
120
|
+
# **[west, south, east, north]** — `BBox2D` is `double[4]`, not a
|
|
121
|
+
# struct with named members, and the order is stated only in a comment
|
|
122
|
+
# in the IDL (`discovery.idl:96`). Longitude first. Getting it wrong
|
|
123
|
+
# produces a well-formed box somewhere else entirely, which is why the
|
|
124
|
+
# order is written out here rather than inferred at the call site.
|
|
125
|
+
bbox=[a.lon_deg, a.lat_deg, a.lon_deg, a.lat_deg],
|
|
126
|
+
has_aabb=False,
|
|
127
|
+
aabb=Aabb3(min_xyz=[0.0, 0.0, 0.0], max_xyz=[0.0, 0.0, 0.0]),
|
|
128
|
+
_global=False,
|
|
129
|
+
has_frame_ref=True, frame_ref=robot_frame(config.frame_id),
|
|
130
|
+
has_coverage_window=False,
|
|
131
|
+
coverage_window_start=Time(sec=0, nanosec=0),
|
|
132
|
+
coverage_window_end=Time(sec=0, nanosec=0),
|
|
133
|
+
has_circle=False, circle_center=[0.0, 0.0, 0.0],
|
|
134
|
+
circle_radius_m=0.0)]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _transforms(config: Config) -> list[Transform]:
|
|
138
|
+
"""
|
|
139
|
+
The announce's own copy of the georeference, when there is one.
|
|
140
|
+
|
|
141
|
+
Empty without an anchor, for the reason the frames lane is silent: there is
|
|
142
|
+
nothing true to say. Note the spelling — `_from` is the *local* frame and
|
|
143
|
+
`to` is earth-fixed, which is the opposite of `FrameTransform`'s
|
|
144
|
+
parent/child roles while carrying identical numbers. Mapping by name
|
|
145
|
+
rather than by role inverts it, which is why both ends are written out
|
|
146
|
+
here rather than passed through a helper that could be read either way.
|
|
147
|
+
"""
|
|
148
|
+
if config.anchor is None:
|
|
149
|
+
return []
|
|
150
|
+
t, q = enu_to_ecef_pose(config.anchor.lat_deg, config.anchor.lon_deg,
|
|
151
|
+
config.anchor.height_m)
|
|
152
|
+
return [Transform(
|
|
153
|
+
_from=robot_frame(config.frame_id), # the local frame
|
|
154
|
+
to=earth_fixed_frame(), # the global one
|
|
155
|
+
pose=PoseSE3(t=t, q=q),
|
|
156
|
+
stamp=now_time(),
|
|
157
|
+
has_validity=False,
|
|
158
|
+
# `_from` and `seconds`, not start/end — a window with a duration
|
|
159
|
+
# rather than two instants. Unread while `has_validity` is false; a
|
|
160
|
+
# georeference that expires would say the frame moved, which is not
|
|
161
|
+
# what an anchor from a launch file means.
|
|
162
|
+
validity=ValidityWindow(_from=Time(sec=0, nanosec=0), seconds=0))]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def announce_for(config: Config, kind: str = "OTHER",
|
|
166
|
+
ttl_sec: int = DEFAULT_TTL_SEC) -> Announce:
|
|
167
|
+
"""This robot, as something another participant can discover."""
|
|
168
|
+
return Announce(
|
|
169
|
+
service_id=config.effective_source_id,
|
|
170
|
+
name=config.effective_label,
|
|
171
|
+
kind=getattr(ServiceKind, kind),
|
|
172
|
+
version="1.8",
|
|
173
|
+
org="",
|
|
174
|
+
hints=[],
|
|
175
|
+
caps=Capabilities(
|
|
176
|
+
supported_profiles=[
|
|
177
|
+
ProfileSupport(name=n, major=major, min_minor=lo,
|
|
178
|
+
max_minor=hi)
|
|
179
|
+
for n, major, lo, hi in PROFILES],
|
|
180
|
+
preferred_profiles=["spatial.owm/0.1"],
|
|
181
|
+
features=[]),
|
|
182
|
+
topics=_topics(config),
|
|
183
|
+
coverage=_coverage(config),
|
|
184
|
+
coverage_frame_ref=(earth_fixed_frame() if config.anchor
|
|
185
|
+
else robot_frame(config.frame_id)),
|
|
186
|
+
has_coverage_eval_time=False,
|
|
187
|
+
coverage_eval_time=Time(sec=0, nanosec=0),
|
|
188
|
+
transforms=_transforms(config),
|
|
189
|
+
manifest_uri="",
|
|
190
|
+
auth_hint="",
|
|
191
|
+
stamp=now_time(),
|
|
192
|
+
ttl_sec=ttl_sec,
|
|
193
|
+
coverage_source_ids=[],
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
#: The discovery lane is not scene-scoped — it is the one place a participant
|
|
198
|
+
#: looks before it knows what scenes exist.
|
|
199
|
+
ANNOUNCE_TOPIC = "spatialdds/discovery/announce/v1"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class Announcer:
|
|
203
|
+
"""
|
|
204
|
+
Publishes the announce, and re-stamps it on a cadence.
|
|
205
|
+
|
|
206
|
+
Re-stamped rather than written once: `ttl_sec` is mapped onto DDS Lifespan,
|
|
207
|
+
so a sample that is never refreshed expires and the robot silently stops
|
|
208
|
+
being discoverable while it is still running. The prototype learned the
|
|
209
|
+
same thing — "Re-stamp the announce on refresh, not just the Lifespan".
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
def __init__(self, participant, config: Config, kind: str = "OTHER",
|
|
213
|
+
ttl_sec: int = DEFAULT_TTL_SEC) -> None:
|
|
214
|
+
from spatialdds_ros import transport as tt
|
|
215
|
+
self.config = config
|
|
216
|
+
self.kind = kind
|
|
217
|
+
self.ttl_sec = ttl_sec
|
|
218
|
+
self._writer = tt.make_writer(
|
|
219
|
+
participant, ANNOUNCE_TOPIC, Announce, MODEL_LATCHED.name,
|
|
220
|
+
lifespan_sec=float(ttl_sec))
|
|
221
|
+
|
|
222
|
+
def publish(self) -> str:
|
|
223
|
+
announce = announce_for(self.config, self.kind, self.ttl_sec)
|
|
224
|
+
self._writer.write(announce)
|
|
225
|
+
return (f"announce: {announce.service_id} as {announce.kind.name}, "
|
|
226
|
+
f"{len(announce.topics)} topics, ttl {announce.ttl_sec}s")
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def refresh_interval_s(self) -> float:
|
|
230
|
+
"""Comfortably inside the TTL, so one missed refresh is not an outage."""
|
|
231
|
+
return max(1.0, self.ttl_sec / 3.0)
|
spatialdds_ros/config.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The parameter surface, and what each silence means.
|
|
3
|
+
|
|
4
|
+
Ruled 2026-10-19; `docs/extraction-census.md` §12 is the reasoning and this is
|
|
5
|
+
the implementation of it. The rule the surface is built on is the programme's:
|
|
6
|
+
**conventions fill plumbing and never claims.** So a parameter exists where an
|
|
7
|
+
operator could be wrong *about the world*, and a default exists only where
|
|
8
|
+
being wrong is impossible.
|
|
9
|
+
|
|
10
|
+
Three parameters have no honest default and therefore refuse to start:
|
|
11
|
+
|
|
12
|
+
* `scene` — which world this robot is in. A made-up default puts it somewhere
|
|
13
|
+
nobody is watching, and the failure is an empty bus rather than an error.
|
|
14
|
+
* `entity_id` — which robot it is. A default collides the moment two run, and
|
|
15
|
+
the one-writer-per-key guard would then stop the second with a reason that
|
|
16
|
+
points at the wrong thing.
|
|
17
|
+
* `frame_id` — which frame its pose is expressed in. A pose without its frame
|
|
18
|
+
is not a weak claim, it is a meaningless one.
|
|
19
|
+
|
|
20
|
+
One silence is a legitimate operating mode rather than a misconfiguration:
|
|
21
|
+
|
|
22
|
+
* `anchor` absent — **no `FrameTransform` is published**, said once plainly.
|
|
23
|
+
The adapter is still fully functional on the bus; the scene simply has no
|
|
24
|
+
georeference from it. The alternative, defaulting the anchor to the frame
|
|
25
|
+
origin, would have every unconfigured robot claiming to be in the Gulf of
|
|
26
|
+
Guinea with a pose that looks entirely plausible. A default is not neutral
|
|
27
|
+
when the thing being defaulted is a claim.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import re
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from typing import Optional, Sequence, Tuple
|
|
34
|
+
|
|
35
|
+
#: "mobile robot" — an automatic machine capable of movement in any given
|
|
36
|
+
#: environment, `subclass of` (P279) Q11012 (robot). Carried from the
|
|
37
|
+
#: prototype, where it was verified against the entity data rather than taken
|
|
38
|
+
#: from a search result: several US patents are also labelled "Mobile robot".
|
|
39
|
+
#: A default rather than a fixture — a drone or an AMR may want its own.
|
|
40
|
+
DEFAULT_TYPE_URI = "http://www.wikidata.org/entity/Q4810574"
|
|
41
|
+
|
|
42
|
+
#: nav2's own action name. A parameter because a robot may remap it; this is
|
|
43
|
+
#: what nav2 calls it unremapped.
|
|
44
|
+
DEFAULT_NAV2_ACTION = "navigate_to_pose"
|
|
45
|
+
|
|
46
|
+
#: How a map-frame pose is obtained. `tf` is the default for a reason the
|
|
47
|
+
#: census documents at length: the prototype read `nav_msgs/Odometry` on
|
|
48
|
+
#: `/odom`, which is map-frame *only* in the simulator, where `map -> odom` is
|
|
49
|
+
#: static and identity. On a real robot odom drifts against map, so publishing
|
|
50
|
+
#: it as a georeferenced OBSERVED pose emits a slowly-drifting claim about
|
|
51
|
+
#: where a robot sits on the earth with every number looking plausible.
|
|
52
|
+
POSE_SOURCES = ("tf", "amcl", "odom")
|
|
53
|
+
|
|
54
|
+
#: The IDL's bounds on the two reference sequences
|
|
55
|
+
#: (`sequence[str, 8]` and `sequence[KV, 8]` on `spatial.owm.Entity`).
|
|
56
|
+
#: Named here so a refusal can cite the limit rather than just report a
|
|
57
|
+
#: serialize failure.
|
|
58
|
+
MAX_CONTENT_REFS = 8
|
|
59
|
+
MAX_EXTERNAL_REFS = 8
|
|
60
|
+
|
|
61
|
+
#: One path segment of a SpatialDDS topic name (§3.3.1). Deliberately strict:
|
|
62
|
+
#: a scene name carrying a slash would silently produce a six-segment topic,
|
|
63
|
+
#: which is a name no other participant looks for.
|
|
64
|
+
_SEGMENT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ConfigError(ValueError):
|
|
68
|
+
"""
|
|
69
|
+
Raised for a configuration that cannot be started.
|
|
70
|
+
|
|
71
|
+
Carries *every* problem rather than the first, because a robot operator
|
|
72
|
+
editing a launch file should learn about three missing parameters in one
|
|
73
|
+
pass instead of three.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class Anchor:
|
|
79
|
+
"""
|
|
80
|
+
Where a local frame's origin sits on the earth.
|
|
81
|
+
|
|
82
|
+
Validated rather than taken on trust: these three numbers are the library's
|
|
83
|
+
only statement about the planet, and a transposed pair is not detectable
|
|
84
|
+
downstream — an anchor at (-97.7, 30.3) instead of (30.3, -97.7) is a
|
|
85
|
+
perfectly well-formed point in the South Atlantic.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
lat_deg: float
|
|
89
|
+
lon_deg: float
|
|
90
|
+
height_m: float
|
|
91
|
+
|
|
92
|
+
def __post_init__(self) -> None:
|
|
93
|
+
problems = []
|
|
94
|
+
if not -90.0 <= self.lat_deg <= 90.0:
|
|
95
|
+
problems.append(
|
|
96
|
+
f"lat_deg {self.lat_deg} is outside [-90, 90]; a latitude "
|
|
97
|
+
f"beyond the poles usually means lat and lon were swapped")
|
|
98
|
+
if not -180.0 <= self.lon_deg <= 180.0:
|
|
99
|
+
problems.append(
|
|
100
|
+
f"lon_deg {self.lon_deg} is outside [-180, 180]")
|
|
101
|
+
if problems:
|
|
102
|
+
raise ConfigError("; ".join(problems))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class Config:
|
|
107
|
+
"""
|
|
108
|
+
Everything the adapter needs, validated at construction.
|
|
109
|
+
|
|
110
|
+
Frozen: the parameter surface is read at startup and a value that changed
|
|
111
|
+
underneath a running adapter would mean the entity it has been publishing
|
|
112
|
+
all along was a different claim than the one it is making now.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
scene: str
|
|
116
|
+
entity_id: str
|
|
117
|
+
frame_id: str
|
|
118
|
+
|
|
119
|
+
anchor: Optional[Anchor] = None
|
|
120
|
+
source_id: Optional[str] = None
|
|
121
|
+
domain: int = 0
|
|
122
|
+
ground_z: float = 0.0
|
|
123
|
+
pose_source: str = "tf"
|
|
124
|
+
nav2_action: str = DEFAULT_NAV2_ACTION
|
|
125
|
+
type_uri: str = DEFAULT_TYPE_URI
|
|
126
|
+
label: Optional[str] = None
|
|
127
|
+
|
|
128
|
+
#: What this robot *is*, for a consumer that wants to draw it.
|
|
129
|
+
#:
|
|
130
|
+
#: Added 2026-10-19 because their absence blocked the first real adoption.
|
|
131
|
+
#: SpatialDDS-demo's robot carries `catalog:dcef7c31-…`, and a consumer
|
|
132
|
+
#: reading that reference is what makes it render a robot rather than a
|
|
133
|
+
#: pin: no reference, no asset, no model. An adapter that cannot say what
|
|
134
|
+
#: a robot looks like cannot be a consumer's only publisher of that robot,
|
|
135
|
+
#: and there was no way for a consumer to supply it from outside — the
|
|
136
|
+
#: record simply had nowhere to put it.
|
|
137
|
+
#:
|
|
138
|
+
#: **Empty by default, and empty means empty.** The adapter states what the
|
|
139
|
+
#: operator configured and nothing else; it invents no reference and
|
|
140
|
+
#: guesses no asset. An empty sequence is a published fact — "this robot
|
|
141
|
+
#: names no content" — not an accident of omission, and the gates assert
|
|
142
|
+
#: both directions.
|
|
143
|
+
content_refs: Tuple[str, ...] = ()
|
|
144
|
+
|
|
145
|
+
#: What this robot is *also known as*: wikidata, an asset-management id, a
|
|
146
|
+
#: fleet number. Same rule — only what was configured.
|
|
147
|
+
external_refs: Tuple[Tuple[str, str], ...] = ()
|
|
148
|
+
|
|
149
|
+
def __post_init__(self) -> None:
|
|
150
|
+
# A launch file or a dict will hand these over as lists. The dataclass
|
|
151
|
+
# is frozen, so they are normalised to tuples here rather than stored
|
|
152
|
+
# mutable — a shared mutable default on a frozen "value" is a bug
|
|
153
|
+
# waiting for a second robot.
|
|
154
|
+
object.__setattr__(self, "content_refs",
|
|
155
|
+
tuple(self.content_refs or ()))
|
|
156
|
+
object.__setattr__(self, "external_refs",
|
|
157
|
+
tuple(tuple(pair) for pair in
|
|
158
|
+
(self.external_refs or ())))
|
|
159
|
+
problems: list[str] = []
|
|
160
|
+
|
|
161
|
+
# The three with no honest default. Reported together, and each with
|
|
162
|
+
# what the silence would have meant rather than just "required".
|
|
163
|
+
if not self.scene:
|
|
164
|
+
problems.append(
|
|
165
|
+
"scene is required: it names the world this robot joins, and "
|
|
166
|
+
"there is no default that is not a guess about where it is. "
|
|
167
|
+
"A wrong scene is an empty bus, not an error")
|
|
168
|
+
elif not _SEGMENT.match(self.scene):
|
|
169
|
+
problems.append(
|
|
170
|
+
f"scene {self.scene!r} is not one topic segment (§3.3.1): "
|
|
171
|
+
f"expected letters, digits, dot, dash or underscore. A scene "
|
|
172
|
+
f"containing a slash silently produces a topic name no other "
|
|
173
|
+
f"participant looks for")
|
|
174
|
+
|
|
175
|
+
if not self.entity_id:
|
|
176
|
+
problems.append(
|
|
177
|
+
"entity_id is required: it names which robot this is. A "
|
|
178
|
+
"default collides as soon as a second robot runs, and the "
|
|
179
|
+
"one-writer-per-key guard would then refuse the second for a "
|
|
180
|
+
"reason that points at the wrong thing")
|
|
181
|
+
|
|
182
|
+
if not self.frame_id:
|
|
183
|
+
problems.append(
|
|
184
|
+
"frame_id is required: a pose without the frame it is "
|
|
185
|
+
"expressed in is not a weak claim, it is a meaningless one")
|
|
186
|
+
|
|
187
|
+
if self.pose_source not in POSE_SOURCES:
|
|
188
|
+
problems.append(
|
|
189
|
+
f"pose_source {self.pose_source!r} is not one of "
|
|
190
|
+
f"{', '.join(POSE_SOURCES)}")
|
|
191
|
+
|
|
192
|
+
if self.domain < 0 or self.domain > 232:
|
|
193
|
+
problems.append(
|
|
194
|
+
f"domain {self.domain} is outside the DDS range [0, 232]")
|
|
195
|
+
|
|
196
|
+
# Bounded sequences in the IDL: `content_refs` and `external_refs` are
|
|
197
|
+
# both `sequence[..., 8]`. Checked here rather than left to serialize,
|
|
198
|
+
# which fails late and says nothing about which field or why.
|
|
199
|
+
if len(self.content_refs) > MAX_CONTENT_REFS:
|
|
200
|
+
problems.append(
|
|
201
|
+
f"content_refs has {len(self.content_refs)} entries; the IDL "
|
|
202
|
+
f"bounds it at {MAX_CONTENT_REFS}")
|
|
203
|
+
for ref in self.content_refs:
|
|
204
|
+
if not ref or not str(ref).strip():
|
|
205
|
+
problems.append(
|
|
206
|
+
"a content_ref is empty; an empty reference resolves to "
|
|
207
|
+
"nothing and is not the same as publishing none")
|
|
208
|
+
if len(self.external_refs) > MAX_EXTERNAL_REFS:
|
|
209
|
+
problems.append(
|
|
210
|
+
f"external_refs has {len(self.external_refs)} entries; the IDL "
|
|
211
|
+
f"bounds it at {MAX_EXTERNAL_REFS}")
|
|
212
|
+
for pair in self.external_refs:
|
|
213
|
+
if len(tuple(pair)) != 2 or not all(str(v).strip() for v in pair):
|
|
214
|
+
problems.append(
|
|
215
|
+
f"external_refs entries are (key, value) pairs and both "
|
|
216
|
+
f"halves must be non-empty; got {pair!r}")
|
|
217
|
+
|
|
218
|
+
if problems:
|
|
219
|
+
raise ConfigError(
|
|
220
|
+
"this configuration cannot be started:\n - "
|
|
221
|
+
+ "\n - ".join(problems))
|
|
222
|
+
|
|
223
|
+
# --- derived, never asked for -----------------------------------------
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def effective_source_id(self) -> str:
|
|
227
|
+
"""
|
|
228
|
+
Who is publishing, which is not a claim about the world.
|
|
229
|
+
|
|
230
|
+
Derived from `entity_id` when unset: the publisher's identity follows
|
|
231
|
+
the thing it publishes, and asking an operator for both invites them
|
|
232
|
+
to disagree.
|
|
233
|
+
"""
|
|
234
|
+
if self.source_id:
|
|
235
|
+
return self.source_id
|
|
236
|
+
tail = self.entity_id.split(":")[-1] or self.entity_id
|
|
237
|
+
return f"svc:robot:{tail}"
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def effective_label(self) -> str:
|
|
241
|
+
"""A human-facing name. Cosmetic, so the id's tail will do."""
|
|
242
|
+
return self.label or (self.entity_id.split(":")[-1] or self.entity_id)
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def publishes_georeference(self) -> bool:
|
|
246
|
+
return self.anchor is not None
|
|
247
|
+
|
|
248
|
+
def startup_notes(self) -> list[str]:
|
|
249
|
+
"""
|
|
250
|
+
What to say once, at startup, about the modes this configuration is in.
|
|
251
|
+
|
|
252
|
+
Returned rather than logged so the caller owns the logging and the
|
|
253
|
+
tests can read it. Each line states a *consequence*, not a setting:
|
|
254
|
+
"no anchor configured" leaves a reader to work out what follows, and
|
|
255
|
+
the whole point of the degraded mode being legitimate is that it is
|
|
256
|
+
legible.
|
|
257
|
+
"""
|
|
258
|
+
notes = [
|
|
259
|
+
f"scene {self.scene}, entity {self.entity_id} in frame "
|
|
260
|
+
f"{self.frame_id}, DDS domain {self.domain}",
|
|
261
|
+
]
|
|
262
|
+
if self.anchor is None:
|
|
263
|
+
# **Reworded 2026-10-19.** This used to say "nothing
|
|
264
|
+
# earth-references them", which assumed this adapter is the only
|
|
265
|
+
# thing that could — and in the first real deployment it was not:
|
|
266
|
+
# the venue publishes the scene's own FrameTransform, and running
|
|
267
|
+
# the robot anchorless is correct *because* somebody else owns
|
|
268
|
+
# that claim, which is the opposite of the absence the old wording
|
|
269
|
+
# described. So the note now says what it actually knows: this
|
|
270
|
+
# adapter is not publishing one. Whether anyone else is, it cannot
|
|
271
|
+
# see from here.
|
|
272
|
+
notes.append(
|
|
273
|
+
"no anchor configured, so this adapter publishes no "
|
|
274
|
+
"FrameTransform: its entity and poses are on the bus and "
|
|
275
|
+
"complete, and it makes no claim about where that frame sits "
|
|
276
|
+
"on the earth. Another participant may own that claim — a "
|
|
277
|
+
"venue publishing its own georeference is the ordinary case, "
|
|
278
|
+
"and then this is the correct configuration. If nobody "
|
|
279
|
+
"publishes one, a consumer cannot place this robot on a globe "
|
|
280
|
+
"and should say so rather than assume a position")
|
|
281
|
+
else:
|
|
282
|
+
notes.append(
|
|
283
|
+
f"anchor {self.anchor.lat_deg:.8f}, {self.anchor.lon_deg:.8f}, "
|
|
284
|
+
f"{self.anchor.height_m:.3f} m — publishing {self.frame_id} -> "
|
|
285
|
+
f"earth-fixed")
|
|
286
|
+
if self.pose_source == "odom":
|
|
287
|
+
notes.append(
|
|
288
|
+
"pose_source is odom: odom drifts against map, so this "
|
|
289
|
+
"publishes a pose that will slowly diverge from where the "
|
|
290
|
+
"robot actually is. Correct only where map -> odom is static "
|
|
291
|
+
"and identity, which is true of a simulator and false of a "
|
|
292
|
+
"localizing robot. Prefer pose_source=tf")
|
|
293
|
+
return notes
|