spatialdds-ros 0.3.0__tar.gz → 0.4.0__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 (109) hide show
  1. {spatialdds_ros-0.3.0/spatialdds_ros.egg-info → spatialdds_ros-0.4.0}/PKG-INFO +144 -8
  2. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/README.md +143 -7
  3. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/package.xml +3 -1
  4. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/setup.py +1 -1
  5. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_ros/__init__.py +1 -1
  6. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_ros/adapter.py +50 -7
  7. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_ros/announce.py +19 -0
  8. spatialdds_ros-0.4.0/spatialdds_ros/awareness.py +421 -0
  9. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_ros/config.py +73 -0
  10. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_ros/frames.py +80 -0
  11. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_ros/qos.py +15 -1
  12. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_ros/robot_node.py +143 -4
  13. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_ros/standalone.py +8 -0
  14. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_ros/transport.py +1 -1
  15. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0/spatialdds_ros.egg-info}/PKG-INFO +144 -8
  16. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_ros.egg-info/SOURCES.txt +3 -0
  17. spatialdds_ros-0.4.0/test/test_acceptance_two_domains.py +337 -0
  18. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/test/test_adapter.py +68 -0
  19. spatialdds_ros-0.4.0/test/test_awareness.py +456 -0
  20. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/test/test_frames.py +79 -0
  21. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/test/test_readme.py +56 -1
  22. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/test/test_transport.py +93 -5
  23. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/LICENSE +0 -0
  24. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/resource/spatialdds_ros +0 -0
  25. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/setup.cfg +0 -0
  26. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/PROVENANCE.md +0 -0
  27. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/__init__.py +0 -0
  28. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/_field_aliases.py +0 -0
  29. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/builtin/__init__.py +0 -0
  30. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/builtin/_types.py +0 -0
  31. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/__init__.py +0 -0
  32. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/anchors/__init__.py +0 -0
  33. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/anchors/_anchors.py +0 -0
  34. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/argeo/__init__.py +0 -0
  35. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/argeo/_argeo.py +0 -0
  36. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/common/__init__.py +0 -0
  37. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/common/_types.py +0 -0
  38. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/core/GnssService/__init__.py +0 -0
  39. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/core/GnssService/_core.py +0 -0
  40. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/core/__init__.py +0 -0
  41. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/core/_core.py +0 -0
  42. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/disco/__init__.py +0 -0
  43. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/disco/_discovery.py +0 -0
  44. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/events/__init__.py +0 -0
  45. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/events/_events.py +0 -0
  46. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/events/enums/__init__.py +0 -0
  47. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/events/enums/crossing_direction_enum/__init__.py +0 -0
  48. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/events/enums/crossing_direction_enum/_events.py +0 -0
  49. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/events/enums/event_state_enum/__init__.py +0 -0
  50. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/events/enums/event_state_enum/_events.py +0 -0
  51. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/events/enums/event_type_enum/__init__.py +0 -0
  52. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/events/enums/event_type_enum/_events.py +0 -0
  53. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/events/enums/severity_enum/__init__.py +0 -0
  54. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/events/enums/severity_enum/_events.py +0 -0
  55. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/events/enums/zone_kind_enum/__init__.py +0 -0
  56. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/events/enums/zone_kind_enum/_events.py +0 -0
  57. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/mapping/__init__.py +0 -0
  58. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/mapping/_mapping.py +0 -0
  59. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/mapping/enums/__init__.py +0 -0
  60. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/mapping/enums/alignment_method/__init__.py +0 -0
  61. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/mapping/enums/alignment_method/_mapping.py +0 -0
  62. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/mapping/enums/edge_type/__init__.py +0 -0
  63. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/mapping/enums/edge_type/_mapping.py +0 -0
  64. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/mapping/enums/map_event_kind/__init__.py +0 -0
  65. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/mapping/enums/map_event_kind/_mapping.py +0 -0
  66. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/mapping/enums/map_kind/__init__.py +0 -0
  67. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/mapping/enums/map_kind/_mapping.py +0 -0
  68. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/mapping/enums/map_status/__init__.py +0 -0
  69. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/mapping/enums/map_status/_mapping.py +0 -0
  70. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/owm/__init__.py +0 -0
  71. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/owm/_owm.py +0 -0
  72. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/semantics/__init__.py +0 -0
  73. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/semantics/_semantics.py +0 -0
  74. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/sensing/__init__.py +0 -0
  75. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/sensing/common/__init__.py +0 -0
  76. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/sensing/common/_common.py +0 -0
  77. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/sensing/lidar/__init__.py +0 -0
  78. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/sensing/lidar/_lidar.py +0 -0
  79. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/sensing/rad/__init__.py +0 -0
  80. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/sensing/rad/_rad.py +0 -0
  81. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/sensing/radio/__init__.py +0 -0
  82. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/sensing/radio/_radio.py +0 -0
  83. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/sensing/rf_beam/__init__.py +0 -0
  84. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/sensing/rf_beam/_rf_beam.py +0 -0
  85. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/sensing/vision/__init__.py +0 -0
  86. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/sensing/vision/_vision.py +0 -0
  87. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/slam_frontend/__init__.py +0 -0
  88. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/slam_frontend/_slam_frontend.py +0 -0
  89. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/vio/__init__.py +0 -0
  90. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_idl18/spatial/vio/_vio.py +0 -0
  91. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_ros/kinematic.py +0 -0
  92. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_ros/recording.py +0 -0
  93. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_ros/tempo.py +0 -0
  94. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_ros/topics.py +0 -0
  95. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_ros.egg-info/dependency_links.txt +0 -0
  96. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_ros.egg-info/entry_points.txt +0 -0
  97. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_ros.egg-info/requires.txt +0 -0
  98. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_ros.egg-info/top_level.txt +0 -0
  99. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/spatialdds_ros.egg-info/zip-safe +0 -0
  100. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/test/test_acceptance_bare_scene.py +0 -0
  101. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/test/test_acceptance_mcap_gateway.py +0 -0
  102. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/test/test_acceptance_web_to_robot.py +0 -0
  103. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/test/test_config.py +0 -0
  104. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/test/test_generated_types.py +0 -0
  105. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/test/test_kinematic.py +0 -0
  106. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/test/test_no_ros_needed.py +0 -0
  107. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/test/test_packaging.py +0 -0
  108. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/test/test_qos_and_tempo.py +0 -0
  109. {spatialdds_ros-0.3.0 → spatialdds_ros-0.4.0}/test/test_types_resolve_at_definitions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spatialdds-ros
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: A ROS 2 robot as a SpatialDDS participant
5
5
  Home-page: https://github.com/OpenArCloud/spatialdds-ros
6
6
  Author: Open AR Cloud
@@ -48,8 +48,11 @@ Dynamic: summary
48
48
 
49
49
  Make a ROS 2 robot a SpatialDDS participant: its pose published as a
50
50
  `spatial.owm` entity on scene-scoped topics, `goto` commands consumed from
51
- `ModelCommand` and dispatched to nav2, and its presence announced — all on the
52
- same DDS bus ROS already runs on.
51
+ `ModelCommand` and dispatched to nav2, its presence announced, and — when
52
+ asked — the scene's other moving entities put into its own costmap. All on the
53
+ same DDS bus ROS already runs on: one pip install makes a robot seen,
54
+ steerable and aware, including of robots in other ROS domains it shares no
55
+ middleware with.
53
56
 
54
57
  **A type adapter on a shared bus, not a protocol gateway.** Nothing is
55
58
  relayed. ROS and SpatialDDS meet on the wire because both are DDS, so this is
@@ -76,7 +79,7 @@ The split runs through the whole package:
76
79
 
77
80
  Only `spatialdds_ros.robot_node` requires a ROS 2 environment. It imports
78
81
  `rclpy` inside its entry point rather than at module scope, which is what
79
- lets the other twelve modules — and the entire test suite — run on a machine
82
+ lets the other thirteen modules — and the entire test suite — run on a machine
80
83
  that has never had ROS installed.
81
84
 
82
85
  **That claim is a gate, not a promise.**
@@ -143,6 +146,117 @@ static and identity, which is a simulator and not a localizing robot.
143
146
  deferred** — there is no released binary package; install from source or with
144
147
  pip.
145
148
 
149
+ ## A robot that sees the scene
150
+
151
+ ```sh
152
+ ros2 run spatialdds_ros robot_node --ros-args \
153
+ -p scene:=bare-scene \
154
+ -p entity_id:=ent:robot:b \
155
+ -p frame_id:=map/bare-scene \
156
+ -p awareness:=true \
157
+ -p awareness_range_m:=8.0
158
+ ```
159
+
160
+ The node then reads the scene's other moving entities off the bus and
161
+ publishes them where nav2 can plan around them: `~/peers/cloud` marks them,
162
+ `~/peers/clearing` is a ring at `awareness_range_m` that clears whatever has
163
+ gone, and `~/peers/markers` shows an operator what is being avoided. nav2
164
+ needs one obstacle layer reading both clouds:
165
+
166
+ ```yaml
167
+ peers_layer:
168
+ plugin: "nav2_costmap_2d::ObstacleLayer"
169
+ enabled: true
170
+ observation_sources: peers clearing
171
+ peers:
172
+ topic: /robot_b/peers/cloud
173
+ data_type: PointCloud2
174
+ marking: true
175
+ clearing: false
176
+ obstacle_max_range: 8.0
177
+ raytrace_max_range: 8.5
178
+ min_obstacle_height: 0.0
179
+ max_obstacle_height: 2.0
180
+ clearing:
181
+ topic: /robot_b/peers/clearing
182
+ data_type: PointCloud2
183
+ marking: false
184
+ clearing: true
185
+ obstacle_max_range: 8.0
186
+ raytrace_max_range: 8.5
187
+ min_obstacle_height: 0.0
188
+ max_obstacle_height: 2.0
189
+ ```
190
+
191
+ Add `peers_layer` to the costmap's `plugins`, before the inflation layer, in
192
+ every costmap that should avoid peers — the global one, for the planner to
193
+ route around them. The topics carry the node's name (`robot_b` here).
194
+
195
+ **Set `awareness_range_m` to your costmap's obstacle range or less** — both
196
+ `obstacle_max_range` and `raytrace_max_range` above. The node refuses to start
197
+ with awareness on and no range, because the right value lives in your nav2
198
+ YAML, which it cannot read, and a guess beyond your raytrace range draws marks
199
+ nothing will ever clear.
200
+
201
+ **Why two clouds.** A marking cloud that empties raytraces nothing, so the
202
+ last place a peer was seen stays lethal for good. Measured, with the ring
203
+ beside it and without, in
204
+ [`docs/awareness-census.md`](https://github.com/OpenArCloud/spatialdds-ros/blob/main/docs/awareness-census.md)
205
+ §1.
206
+
207
+ ### What is drawn, and when it stops
208
+
209
+ - **Moving things only:** `FAST`, `ACTIVE`, with a pose, within range, and of
210
+ an `awareness_types` URI if you set any. A declared region is not a peer;
211
+ the keep-out filter is the mechanism for those.
212
+ - **Never this robot.** Its own record does reach its own reader, so this is
213
+ a filter that does work, on its own `entity_id`.
214
+ - **Its own size, or `peer_radius_m`** (0.3 m) for a peer that publishes no
215
+ extent — which includes every robot this library publishes.
216
+ - **Dropped when it goes quiet, and remembered.** A peer is drawn while its
217
+ poses keep arriving, within a window learned from its own cadence — at
218
+ least one second, three times its interval, measured on this robot's clock.
219
+ The record is kept, so the next pose brings it back with no rediscovery. An
220
+ `UNOBSERVED`, retired or disposed record, or a writer that has gone, drops
221
+ it at once.
222
+ - **Never at a guessed position.** A peer in another frame is placed through
223
+ both frames' georeferences; with either one missing it is skipped, and the
224
+ log says which.
225
+
226
+ ### Two robots, two ROS domains, one bus
227
+
228
+ The walkthrough is the acceptance test. Robot A runs in `ROS_DOMAIN_ID` 10,
229
+ localized by an AMCL that converges at (3, 0) and then says nothing; robot B
230
+ runs in 20 at the origin with nav2's planner; the SpatialDDS bus is DDS domain
231
+ 30. Nothing else connects them.
232
+
233
+ ```sh
234
+ docker build -t spatialdds-ros-nav2 -f test/ros/Dockerfile .
235
+ python3 -m pytest test/test_acceptance_two_domains.py -q -s
236
+ ```
237
+
238
+ ```
239
+ A absent: path 6.00 m, max |y| 0.000; cloud empty
240
+ A present: cloud 109 points near (3, 0), cost 100; path 6.30 m, max |y| 0.84
241
+ A still for 20 s: drawn in all 31 samples
242
+ quiet but latched: not drawing ent:robot:a: UNOBSERVED: no pose from svc:robot:a for 1.6s
243
+ killed: cost at A back to 0 within 2.8s (probe overhead included)
244
+ gateway served and validated 2 entities: ['ent:robot:a', 'ent:robot:b']
245
+ ```
246
+
247
+ B's plan to (6, 0) bends around A, and straightens again once A is gone. Both
248
+ ROS graphs are listed from one container, and neither holds a node or topic of
249
+ the other: what separates them is the domain id, not the network.
250
+
251
+ **What it does not do.** A peer beyond the range is not drawn. A mark left
252
+ behind when this robot drives away from it stays until the robot comes back
253
+ within range, which is how nav2 treats anything a sensor saw. A peer whose
254
+ publisher sends a pose only when it moves will drop out when it stops — every
255
+ `spatialdds_ros` robot sends at least `pose_floor_hz` for exactly this reason,
256
+ but the wire gives a consumer no way to know what a foreign one promises
257
+ ([`docs/spec-queue.md`](https://github.com/OpenArCloud/spatialdds-ros/blob/main/docs/spec-queue.md)
258
+ §3).
259
+
146
260
  ## Configuration
147
261
 
148
262
  Three parameters have no default and the node refuses to start without them,
@@ -156,7 +270,9 @@ because every possible default would be a false statement about the world:
156
270
 
157
271
  Everything else has one: `domain` 0, `ground_z` 0.0, `pose_source` tf,
158
272
  `nav2_action` navigate_to_pose, `source_id` derived from `entity_id`,
159
- `content_refs` and `external_refs` empty.
273
+ `content_refs` and `external_refs` empty, `pose_floor_hz` 2.0, `awareness`
274
+ off, `peer_radius_m` 0.3, `awareness_types` empty. One more has none, and is
275
+ needed only with awareness on: `awareness_range_m`, for the reason above.
160
276
 
161
277
  ### What the robot *is*, and what it is *also known as*
162
278
 
@@ -244,8 +360,20 @@ not neutral when the thing being defaulted is a claim.
244
360
  The adapter publishes `basis = OBSERVED` and offers no parameter to change it:
245
361
  a self-localizing robot is reporting where it has worked out that it is, which
246
362
  is a sighting rather than an assertion. It claims no extent — a robot locates
247
- itself, it does not legislate a region, and an extent would be read as
248
- keep-out by anything applying the `DECLARED` rule.
363
+ itself, and its footprint is not a region anybody should plan by. (Until 0.4.0
364
+ this said an extent would be read as keep-out by the `DECLARED` rule; the
365
+ robot is `OBSERVED`, so that rule excludes it before it looks at extent. The
366
+ conclusion stands; the reason was wrong. Publishing a true footprint is a
367
+ follow-up.)
368
+
369
+ **Its pose goes out at least `pose_floor_hz` (2 Hz), moving or not.** A
370
+ consumer stops drawing a peer it has not heard from within that peer's
371
+ cadence, and some localizers fall silent when the robot stops — AMCL updates
372
+ on motion — so without a floor a stationary robot would vanish from its
373
+ peers' costmaps while standing in their way. In `pose_source=amcl` a live
374
+ AMCL publisher counts as holding the estimate; a robot whose localizer has
375
+ actually gone still goes `UNOBSERVED` on time. Anything at or below 1 Hz is
376
+ refused, as slower than a consumer's shortest window.
249
377
 
250
378
  Three things it refuses to do quietly:
251
379
 
@@ -307,6 +435,13 @@ This adapter still does not *act* on disposal: it owns one key, never disposes
307
435
  it, and reports going quiet as `UNOBSERVED` — a weaker statement than "this
308
436
  robot is gone". Not acting on an event is not the same as not receiving one.
309
437
 
438
+ **And nothing it writes is disposed for it.** CycloneDDS's default writer QoS
439
+ disposes a writer's instances when the writer goes, so until 0.4.0 a robot
440
+ that crashed reached every reader as `NOT_ALIVE_DISPOSED` — a deliberate
441
+ removal, by §2.14's reading — ten seconds after the crash. Every writer here
442
+ now turns that off, and the same events arrive as `NOT_ALIVE_NO_WRITERS`:
443
+ liveliness lost, which is what happened.
444
+
310
445
  ## Tests
311
446
 
312
447
  ```sh
@@ -321,8 +456,9 @@ Everything runs with no ROS installed — that is a gate
321
456
  | document | what is in it |
322
457
  |---|---|
323
458
  | [`docs/extraction-census.md`](https://github.com/OpenArCloud/spatialdds-ros/blob/main/docs/extraction-census.md) | what became a parameter, what the library fills, and why |
459
+ | [`docs/awareness-census.md`](https://github.com/OpenArCloud/spatialdds-ros/blob/main/docs/awareness-census.md) | what becomes an obstacle, when it stops, in which frame — measured, and ruled |
324
460
  | [`docs/failability.md`](https://github.com/OpenArCloud/spatialdds-ros/blob/main/docs/failability.md) | the two claims broken deliberately, with the failures |
325
- | [`docs/spec-queue.md`](https://github.com/OpenArCloud/spatialdds-ros/blob/main/docs/spec-queue.md) | two things for the specification rather than worked around |
461
+ | [`docs/spec-queue.md`](https://github.com/OpenArCloud/spatialdds-ros/blob/main/docs/spec-queue.md) | things for the specification rather than worked around |
326
462
  | [`spatialdds_idl18/PROVENANCE.md`](https://github.com/OpenArCloud/spatialdds-ros/blob/main/spatialdds_idl18/PROVENANCE.md) | where the vendored 1.8 bindings came from |
327
463
  | [`docs/RELEASING.md`](https://github.com/OpenArCloud/spatialdds-ros/blob/main/docs/RELEASING.md) | how a version reaches PyPI, and how the artifact is checked against the commit |
328
464
 
@@ -2,8 +2,11 @@
2
2
 
3
3
  Make a ROS 2 robot a SpatialDDS participant: its pose published as a
4
4
  `spatial.owm` entity on scene-scoped topics, `goto` commands consumed from
5
- `ModelCommand` and dispatched to nav2, and its presence announced — all on the
6
- same DDS bus ROS already runs on.
5
+ `ModelCommand` and dispatched to nav2, its presence announced, and — when
6
+ asked — the scene's other moving entities put into its own costmap. All on the
7
+ same DDS bus ROS already runs on: one pip install makes a robot seen,
8
+ steerable and aware, including of robots in other ROS domains it shares no
9
+ middleware with.
7
10
 
8
11
  **A type adapter on a shared bus, not a protocol gateway.** Nothing is
9
12
  relayed. ROS and SpatialDDS meet on the wire because both are DDS, so this is
@@ -30,7 +33,7 @@ The split runs through the whole package:
30
33
 
31
34
  Only `spatialdds_ros.robot_node` requires a ROS 2 environment. It imports
32
35
  `rclpy` inside its entry point rather than at module scope, which is what
33
- lets the other twelve modules — and the entire test suite — run on a machine
36
+ lets the other thirteen modules — and the entire test suite — run on a machine
34
37
  that has never had ROS installed.
35
38
 
36
39
  **That claim is a gate, not a promise.**
@@ -97,6 +100,117 @@ static and identity, which is a simulator and not a localizing robot.
97
100
  deferred** — there is no released binary package; install from source or with
98
101
  pip.
99
102
 
103
+ ## A robot that sees the scene
104
+
105
+ ```sh
106
+ ros2 run spatialdds_ros robot_node --ros-args \
107
+ -p scene:=bare-scene \
108
+ -p entity_id:=ent:robot:b \
109
+ -p frame_id:=map/bare-scene \
110
+ -p awareness:=true \
111
+ -p awareness_range_m:=8.0
112
+ ```
113
+
114
+ The node then reads the scene's other moving entities off the bus and
115
+ publishes them where nav2 can plan around them: `~/peers/cloud` marks them,
116
+ `~/peers/clearing` is a ring at `awareness_range_m` that clears whatever has
117
+ gone, and `~/peers/markers` shows an operator what is being avoided. nav2
118
+ needs one obstacle layer reading both clouds:
119
+
120
+ ```yaml
121
+ peers_layer:
122
+ plugin: "nav2_costmap_2d::ObstacleLayer"
123
+ enabled: true
124
+ observation_sources: peers clearing
125
+ peers:
126
+ topic: /robot_b/peers/cloud
127
+ data_type: PointCloud2
128
+ marking: true
129
+ clearing: false
130
+ obstacle_max_range: 8.0
131
+ raytrace_max_range: 8.5
132
+ min_obstacle_height: 0.0
133
+ max_obstacle_height: 2.0
134
+ clearing:
135
+ topic: /robot_b/peers/clearing
136
+ data_type: PointCloud2
137
+ marking: false
138
+ clearing: true
139
+ obstacle_max_range: 8.0
140
+ raytrace_max_range: 8.5
141
+ min_obstacle_height: 0.0
142
+ max_obstacle_height: 2.0
143
+ ```
144
+
145
+ Add `peers_layer` to the costmap's `plugins`, before the inflation layer, in
146
+ every costmap that should avoid peers — the global one, for the planner to
147
+ route around them. The topics carry the node's name (`robot_b` here).
148
+
149
+ **Set `awareness_range_m` to your costmap's obstacle range or less** — both
150
+ `obstacle_max_range` and `raytrace_max_range` above. The node refuses to start
151
+ with awareness on and no range, because the right value lives in your nav2
152
+ YAML, which it cannot read, and a guess beyond your raytrace range draws marks
153
+ nothing will ever clear.
154
+
155
+ **Why two clouds.** A marking cloud that empties raytraces nothing, so the
156
+ last place a peer was seen stays lethal for good. Measured, with the ring
157
+ beside it and without, in
158
+ [`docs/awareness-census.md`](https://github.com/OpenArCloud/spatialdds-ros/blob/main/docs/awareness-census.md)
159
+ §1.
160
+
161
+ ### What is drawn, and when it stops
162
+
163
+ - **Moving things only:** `FAST`, `ACTIVE`, with a pose, within range, and of
164
+ an `awareness_types` URI if you set any. A declared region is not a peer;
165
+ the keep-out filter is the mechanism for those.
166
+ - **Never this robot.** Its own record does reach its own reader, so this is
167
+ a filter that does work, on its own `entity_id`.
168
+ - **Its own size, or `peer_radius_m`** (0.3 m) for a peer that publishes no
169
+ extent — which includes every robot this library publishes.
170
+ - **Dropped when it goes quiet, and remembered.** A peer is drawn while its
171
+ poses keep arriving, within a window learned from its own cadence — at
172
+ least one second, three times its interval, measured on this robot's clock.
173
+ The record is kept, so the next pose brings it back with no rediscovery. An
174
+ `UNOBSERVED`, retired or disposed record, or a writer that has gone, drops
175
+ it at once.
176
+ - **Never at a guessed position.** A peer in another frame is placed through
177
+ both frames' georeferences; with either one missing it is skipped, and the
178
+ log says which.
179
+
180
+ ### Two robots, two ROS domains, one bus
181
+
182
+ The walkthrough is the acceptance test. Robot A runs in `ROS_DOMAIN_ID` 10,
183
+ localized by an AMCL that converges at (3, 0) and then says nothing; robot B
184
+ runs in 20 at the origin with nav2's planner; the SpatialDDS bus is DDS domain
185
+ 30. Nothing else connects them.
186
+
187
+ ```sh
188
+ docker build -t spatialdds-ros-nav2 -f test/ros/Dockerfile .
189
+ python3 -m pytest test/test_acceptance_two_domains.py -q -s
190
+ ```
191
+
192
+ ```
193
+ A absent: path 6.00 m, max |y| 0.000; cloud empty
194
+ A present: cloud 109 points near (3, 0), cost 100; path 6.30 m, max |y| 0.84
195
+ A still for 20 s: drawn in all 31 samples
196
+ quiet but latched: not drawing ent:robot:a: UNOBSERVED: no pose from svc:robot:a for 1.6s
197
+ killed: cost at A back to 0 within 2.8s (probe overhead included)
198
+ gateway served and validated 2 entities: ['ent:robot:a', 'ent:robot:b']
199
+ ```
200
+
201
+ B's plan to (6, 0) bends around A, and straightens again once A is gone. Both
202
+ ROS graphs are listed from one container, and neither holds a node or topic of
203
+ the other: what separates them is the domain id, not the network.
204
+
205
+ **What it does not do.** A peer beyond the range is not drawn. A mark left
206
+ behind when this robot drives away from it stays until the robot comes back
207
+ within range, which is how nav2 treats anything a sensor saw. A peer whose
208
+ publisher sends a pose only when it moves will drop out when it stops — every
209
+ `spatialdds_ros` robot sends at least `pose_floor_hz` for exactly this reason,
210
+ but the wire gives a consumer no way to know what a foreign one promises
211
+ ([`docs/spec-queue.md`](https://github.com/OpenArCloud/spatialdds-ros/blob/main/docs/spec-queue.md)
212
+ §3).
213
+
100
214
  ## Configuration
101
215
 
102
216
  Three parameters have no default and the node refuses to start without them,
@@ -110,7 +224,9 @@ because every possible default would be a false statement about the world:
110
224
 
111
225
  Everything else has one: `domain` 0, `ground_z` 0.0, `pose_source` tf,
112
226
  `nav2_action` navigate_to_pose, `source_id` derived from `entity_id`,
113
- `content_refs` and `external_refs` empty.
227
+ `content_refs` and `external_refs` empty, `pose_floor_hz` 2.0, `awareness`
228
+ off, `peer_radius_m` 0.3, `awareness_types` empty. One more has none, and is
229
+ needed only with awareness on: `awareness_range_m`, for the reason above.
114
230
 
115
231
  ### What the robot *is*, and what it is *also known as*
116
232
 
@@ -198,8 +314,20 @@ not neutral when the thing being defaulted is a claim.
198
314
  The adapter publishes `basis = OBSERVED` and offers no parameter to change it:
199
315
  a self-localizing robot is reporting where it has worked out that it is, which
200
316
  is a sighting rather than an assertion. It claims no extent — a robot locates
201
- itself, it does not legislate a region, and an extent would be read as
202
- keep-out by anything applying the `DECLARED` rule.
317
+ itself, and its footprint is not a region anybody should plan by. (Until 0.4.0
318
+ this said an extent would be read as keep-out by the `DECLARED` rule; the
319
+ robot is `OBSERVED`, so that rule excludes it before it looks at extent. The
320
+ conclusion stands; the reason was wrong. Publishing a true footprint is a
321
+ follow-up.)
322
+
323
+ **Its pose goes out at least `pose_floor_hz` (2 Hz), moving or not.** A
324
+ consumer stops drawing a peer it has not heard from within that peer's
325
+ cadence, and some localizers fall silent when the robot stops — AMCL updates
326
+ on motion — so without a floor a stationary robot would vanish from its
327
+ peers' costmaps while standing in their way. In `pose_source=amcl` a live
328
+ AMCL publisher counts as holding the estimate; a robot whose localizer has
329
+ actually gone still goes `UNOBSERVED` on time. Anything at or below 1 Hz is
330
+ refused, as slower than a consumer's shortest window.
203
331
 
204
332
  Three things it refuses to do quietly:
205
333
 
@@ -261,6 +389,13 @@ This adapter still does not *act* on disposal: it owns one key, never disposes
261
389
  it, and reports going quiet as `UNOBSERVED` — a weaker statement than "this
262
390
  robot is gone". Not acting on an event is not the same as not receiving one.
263
391
 
392
+ **And nothing it writes is disposed for it.** CycloneDDS's default writer QoS
393
+ disposes a writer's instances when the writer goes, so until 0.4.0 a robot
394
+ that crashed reached every reader as `NOT_ALIVE_DISPOSED` — a deliberate
395
+ removal, by §2.14's reading — ten seconds after the crash. Every writer here
396
+ now turns that off, and the same events arrive as `NOT_ALIVE_NO_WRITERS`:
397
+ liveliness lost, which is what happened.
398
+
264
399
  ## Tests
265
400
 
266
401
  ```sh
@@ -275,8 +410,9 @@ Everything runs with no ROS installed — that is a gate
275
410
  | document | what is in it |
276
411
  |---|---|
277
412
  | [`docs/extraction-census.md`](https://github.com/OpenArCloud/spatialdds-ros/blob/main/docs/extraction-census.md) | what became a parameter, what the library fills, and why |
413
+ | [`docs/awareness-census.md`](https://github.com/OpenArCloud/spatialdds-ros/blob/main/docs/awareness-census.md) | what becomes an obstacle, when it stops, in which frame — measured, and ruled |
278
414
  | [`docs/failability.md`](https://github.com/OpenArCloud/spatialdds-ros/blob/main/docs/failability.md) | the two claims broken deliberately, with the failures |
279
- | [`docs/spec-queue.md`](https://github.com/OpenArCloud/spatialdds-ros/blob/main/docs/spec-queue.md) | two things for the specification rather than worked around |
415
+ | [`docs/spec-queue.md`](https://github.com/OpenArCloud/spatialdds-ros/blob/main/docs/spec-queue.md) | things for the specification rather than worked around |
280
416
  | [`spatialdds_idl18/PROVENANCE.md`](https://github.com/OpenArCloud/spatialdds-ros/blob/main/spatialdds_idl18/PROVENANCE.md) | where the vendored 1.8 bindings came from |
281
417
  | [`docs/RELEASING.md`](https://github.com/OpenArCloud/spatialdds-ros/blob/main/docs/RELEASING.md) | how a version reaches PyPI, and how the artifact is checked against the commit |
282
418
 
@@ -2,7 +2,7 @@
2
2
  <?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://relaxng.org/ns/structure/1.0"?>
3
3
  <package format="3">
4
4
  <name>spatialdds_ros</name>
5
- <version>0.3.0</version>
5
+ <version>0.4.0</version>
6
6
  <description>
7
7
  Makes a ROS 2 robot a SpatialDDS participant: its pose published as a
8
8
  spatial.owm entity on scene-scoped topics, goto commands consumed from
@@ -20,6 +20,8 @@
20
20
  <exec_depend>nav_msgs</exec_depend>
21
21
  <exec_depend>nav2_msgs</exec_depend>
22
22
  <exec_depend>tf2_ros</exec_depend>
23
+ <exec_depend>sensor_msgs</exec_depend>
24
+ <exec_depend>visualization_msgs</exec_depend>
23
25
 
24
26
  <test_depend>python3-pytest</test_depend>
25
27
 
@@ -42,7 +42,7 @@ LONG_DESCRIPTION = (HERE / "README.md").read_text(encoding="utf-8")
42
42
 
43
43
  setup(
44
44
  name="spatialdds-ros",
45
- version="0.3.0",
45
+ version="0.4.0",
46
46
  # `spatialdds_idl18` ships alongside: the generated bindings annotate
47
47
  # nested members with their own fully-qualified names, so the package has
48
48
  # to be importable as `spatialdds_idl18` and cannot be nested inside this
@@ -3,4 +3,4 @@
3
3
  See docs/extraction-census.md for what is configurable and why.
4
4
  """
5
5
 
6
- __version__ = "0.3.0"
6
+ __version__ = "0.4.0"
@@ -30,6 +30,7 @@ statement:
30
30
  """
31
31
  from __future__ import annotations
32
32
 
33
+ import math
33
34
  import time
34
35
  from typing import List, Optional, Protocol, Tuple
35
36
 
@@ -90,6 +91,7 @@ class RobotAdapter:
90
91
  self._goal: Optional[Tuple[float, float]] = None
91
92
  self._silent = False
92
93
  self._last_seen = 0.0
94
+ self._last_pose_write = 0.0
93
95
  self._navigator = navigator
94
96
  # **The last thing that happened to a goal, held until something
95
97
  # changes it.** A refusal published once and then superseded by the
@@ -120,9 +122,12 @@ class RobotAdapter:
120
122
  frame_ref=robot_frame(self.config.frame_id),
121
123
  has_pose=True,
122
124
  pose=pose,
123
- # A robot locates itself; it does not legislate a region. An
124
- # extent here would be read as keep-out by anything applying the
125
- # DECLARED rule, which is not what a robot's footprint means.
125
+ # A robot locates itself; its footprint is not a region anybody
126
+ # should plan by. (This once said an extent would be read as
127
+ # keep-out by the DECLARED rule; the robot is OBSERVED, and that
128
+ # rule excludes it on basis first — awareness census §2.) A true
129
+ # footprint, for consumers that draw peers by size, is a filed
130
+ # follow-up rather than something to guess here.
126
131
  has_extent=False,
127
132
  extent=Aabb3(min_xyz=[0.0, 0.0, 0.0], max_xyz=[0.0, 0.0, 0.0]),
128
133
  properties=properties,
@@ -173,10 +178,7 @@ class RobotAdapter:
173
178
  self._last_seen = now
174
179
  self._silent = False
175
180
 
176
- self._poses.write(ModelPose(
177
- entity_id=self.config.entity_id, pose=pose,
178
- source_id=self.config.effective_source_id,
179
- stamp=self._entity.stamp))
181
+ self._write_pose(pose, now, self._entity.stamp)
180
182
 
181
183
  # The expensive record goes on the cadence — and unconditionally when
182
184
  # the robot first appears or comes back, because those are changes of
@@ -265,6 +267,29 @@ class RobotAdapter:
265
267
  notes.append(note)
266
268
  return notes
267
269
 
270
+ def _write_pose(self, pose: PoseSE3, now: float, stamp=None) -> None:
271
+ # A floor republish is stamped when it is sent: it says where the
272
+ # robot is as of now, which is true for as long as the robot is not
273
+ # silent — and a silent robot gets no floor.
274
+ self._poses.write(ModelPose(
275
+ entity_id=self.config.entity_id, pose=pose,
276
+ source_id=self.config.effective_source_id,
277
+ stamp=stamp if stamp is not None else now_time()))
278
+ self._last_pose_write = now
279
+
280
+ def holding(self, now: Optional[float] = None) -> None:
281
+ """
282
+ The pose source is alive and its estimate has not changed.
283
+
284
+ For a source that only speaks when the robot moves — AMCL publishes
285
+ on filter updates — silence is not evidence of anything going wrong,
286
+ so the caller says what it knows: the localizer is still running.
287
+ That keeps the robot ACTIVE without inventing a new observation; the
288
+ floor in `tick` then keeps it fresh on the bus.
289
+ """
290
+ if self._entity is not None and not self._silent:
291
+ self._last_seen = time.time() if now is None else now
292
+
268
293
  def tick(self, now: Optional[float] = None) -> Optional[str]:
269
294
  """
270
295
  Housekeeping between poses: flush the latch when the robot stops
@@ -286,6 +311,14 @@ class RobotAdapter:
286
311
  self._tempo.forget(self.config.entity_id)
287
312
  return f"unobserved after {quiet_for:.1f}s of silence"
288
313
 
314
+ # The floor: the last pose again, freshly stamped, when nothing newer
315
+ # has gone out for a floor interval. Republishing is not a new
316
+ # sighting, so it touches neither the silence clock nor the latch
317
+ # cadence — a robot gone quiet still goes UNOBSERVED on time.
318
+ if (not self._silent
319
+ and now - self._last_pose_write >= 1.0 / self.config.pose_floor_hz):
320
+ self._write_pose(self._entity.pose, now)
321
+
289
322
  if not self._silent and self._tempo.idle(now):
290
323
  self._entities.write(self._entity)
291
324
  return "latch flushed — it stopped moving"
@@ -301,6 +334,16 @@ class RobotAdapter:
301
334
  def clear_goal(self) -> None:
302
335
  self._goal = None
303
336
 
337
+ @property
338
+ def pose(self) -> Optional[Tuple[float, float, float]]:
339
+ """The last published (x, y, yaw) in this robot's frame, or `None`."""
340
+ if self._entity is None:
341
+ return None
342
+ q = self._entity.pose.q
343
+ yaw = math.atan2(2.0 * (q[3] * q[2] + q[0] * q[1]),
344
+ 1.0 - 2.0 * (q[1] * q[1] + q[2] * q[2]))
345
+ return self._entity.pose.t[0], self._entity.pose.t[1], yaw
346
+
304
347
  @property
305
348
  def state(self) -> str:
306
349
  return "UNOBSERVED" if self._silent else "ACTIVE"
@@ -218,13 +218,32 @@ class Announcer:
218
218
  self._writer = tt.make_writer(
219
219
  participant, ANNOUNCE_TOPIC, Announce, MODEL_LATCHED.name,
220
220
  lifespan_sec=float(ttl_sec))
221
+ self._last: Optional[Announce] = None
221
222
 
222
223
  def publish(self) -> str:
223
224
  announce = announce_for(self.config, self.kind, self.ttl_sec)
224
225
  self._writer.write(announce)
226
+ self._last = announce
225
227
  return (f"announce: {announce.service_id} as {announce.kind.name}, "
226
228
  f"{len(announce.topics)} topics, ttl {announce.ttl_sec}s")
227
229
 
230
+ def depart(self) -> Optional[str]:
231
+ """
232
+ Leave the discovery lane on purpose: dispose the announce.
233
+
234
+ §3.3's Announce lifecycle: "A node leaving the bus gracefully MUST
235
+ dispose its Announce instance". Until 0.4.0 this happened only by
236
+ accident — CycloneDDS disposed every instance of a deleted writer —
237
+ and that same default made a *crash* look like a departure. With it
238
+ off (`qos.py`), a graceful exit has to say so, and this is where. An
239
+ ungraceful one is left to the ttl, which is the spec's backstop.
240
+ `Depart` (a SHOULD, for non-DDS transports) is not published.
241
+ """
242
+ if self._last is None:
243
+ return None
244
+ self._writer.dispose(self._last)
245
+ return f"departed: disposed the announce for {self._last.service_id}"
246
+
228
247
  @property
229
248
  def refresh_interval_s(self) -> float:
230
249
  """Comfortably inside the TTL, so one missed refresh is not an outage."""