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.
Files changed (87) hide show
  1. spatialdds_idl18/PROVENANCE.md +66 -0
  2. spatialdds_idl18/__init__.py +9 -0
  3. spatialdds_idl18/_field_aliases.py +26 -0
  4. spatialdds_idl18/builtin/__init__.py +11 -0
  5. spatialdds_idl18/builtin/_types.py +28 -0
  6. spatialdds_idl18/spatial/__init__.py +33 -0
  7. spatialdds_idl18/spatial/anchors/__init__.py +20 -0
  8. spatialdds_idl18/spatial/anchors/_anchors.py +102 -0
  9. spatialdds_idl18/spatial/argeo/__init__.py +22 -0
  10. spatialdds_idl18/spatial/argeo/_argeo.py +97 -0
  11. spatialdds_idl18/spatial/common/__init__.py +27 -0
  12. spatialdds_idl18/spatial/common/_types.py +104 -0
  13. spatialdds_idl18/spatial/core/GnssService/__init__.py +17 -0
  14. spatialdds_idl18/spatial/core/GnssService/_core.py +27 -0
  15. spatialdds_idl18/spatial/core/__init__.py +40 -0
  16. spatialdds_idl18/spatial/core/_core.py +338 -0
  17. spatialdds_idl18/spatial/disco/__init__.py +34 -0
  18. spatialdds_idl18/spatial/disco/_discovery.py +252 -0
  19. spatialdds_idl18/spatial/events/__init__.py +29 -0
  20. spatialdds_idl18/spatial/events/_events.py +164 -0
  21. spatialdds_idl18/spatial/events/enums/__init__.py +19 -0
  22. spatialdds_idl18/spatial/events/enums/crossing_direction_enum/__init__.py +11 -0
  23. spatialdds_idl18/spatial/events/enums/crossing_direction_enum/_events.py +25 -0
  24. spatialdds_idl18/spatial/events/enums/event_state_enum/__init__.py +11 -0
  25. spatialdds_idl18/spatial/events/enums/event_state_enum/_events.py +26 -0
  26. spatialdds_idl18/spatial/events/enums/event_type_enum/__init__.py +11 -0
  27. spatialdds_idl18/spatial/events/enums/event_type_enum/_events.py +36 -0
  28. spatialdds_idl18/spatial/events/enums/severity_enum/__init__.py +11 -0
  29. spatialdds_idl18/spatial/events/enums/severity_enum/_events.py +26 -0
  30. spatialdds_idl18/spatial/events/enums/zone_kind_enum/__init__.py +11 -0
  31. spatialdds_idl18/spatial/events/enums/zone_kind_enum/_events.py +31 -0
  32. spatialdds_idl18/spatial/mapping/__init__.py +30 -0
  33. spatialdds_idl18/spatial/mapping/_mapping.py +168 -0
  34. spatialdds_idl18/spatial/mapping/enums/__init__.py +19 -0
  35. spatialdds_idl18/spatial/mapping/enums/alignment_method/__init__.py +11 -0
  36. spatialdds_idl18/spatial/mapping/enums/alignment_method/_mapping.py +31 -0
  37. spatialdds_idl18/spatial/mapping/enums/edge_type/__init__.py +11 -0
  38. spatialdds_idl18/spatial/mapping/enums/edge_type/_mapping.py +34 -0
  39. spatialdds_idl18/spatial/mapping/enums/map_event_kind/__init__.py +11 -0
  40. spatialdds_idl18/spatial/mapping/enums/map_event_kind/_mapping.py +29 -0
  41. spatialdds_idl18/spatial/mapping/enums/map_kind/__init__.py +11 -0
  42. spatialdds_idl18/spatial/mapping/enums/map_kind/_mapping.py +32 -0
  43. spatialdds_idl18/spatial/mapping/enums/map_status/__init__.py +11 -0
  44. spatialdds_idl18/spatial/mapping/enums/map_status/_mapping.py +27 -0
  45. spatialdds_idl18/spatial/owm/__init__.py +22 -0
  46. spatialdds_idl18/spatial/owm/_owm.py +103 -0
  47. spatialdds_idl18/spatial/semantics/__init__.py +22 -0
  48. spatialdds_idl18/spatial/semantics/_semantics.py +174 -0
  49. spatialdds_idl18/spatial/sensing/__init__.py +21 -0
  50. spatialdds_idl18/spatial/sensing/common/__init__.py +34 -0
  51. spatialdds_idl18/spatial/sensing/common/_common.py +192 -0
  52. spatialdds_idl18/spatial/sensing/lidar/__init__.py +32 -0
  53. spatialdds_idl18/spatial/sensing/lidar/_lidar.py +135 -0
  54. spatialdds_idl18/spatial/sensing/rad/__init__.py +33 -0
  55. spatialdds_idl18/spatial/sensing/rad/_rad.py +191 -0
  56. spatialdds_idl18/spatial/sensing/radio/__init__.py +22 -0
  57. spatialdds_idl18/spatial/sensing/radio/_radio.py +141 -0
  58. spatialdds_idl18/spatial/sensing/rf_beam/__init__.py +25 -0
  59. spatialdds_idl18/spatial/sensing/rf_beam/_rf_beam.py +117 -0
  60. spatialdds_idl18/spatial/sensing/vision/__init__.py +36 -0
  61. spatialdds_idl18/spatial/sensing/vision/_vision.py +170 -0
  62. spatialdds_idl18/spatial/slam_frontend/__init__.py +22 -0
  63. spatialdds_idl18/spatial/slam_frontend/_slam_frontend.py +144 -0
  64. spatialdds_idl18/spatial/vio/__init__.py +20 -0
  65. spatialdds_idl18/spatial/vio/_vio.py +134 -0
  66. spatialdds_ros/__init__.py +6 -0
  67. spatialdds_ros/adapter.py +336 -0
  68. spatialdds_ros/announce.py +231 -0
  69. spatialdds_ros/config.py +293 -0
  70. spatialdds_ros/frames.py +219 -0
  71. spatialdds_ros/kinematic.py +140 -0
  72. spatialdds_ros/qos.py +101 -0
  73. spatialdds_ros/recording.py +167 -0
  74. spatialdds_ros/robot_node.py +287 -0
  75. spatialdds_ros/standalone.py +221 -0
  76. spatialdds_ros/tempo.py +114 -0
  77. spatialdds_ros/topics.py +64 -0
  78. spatialdds_ros/transport.py +170 -0
  79. spatialdds_ros-0.3.0.data/data/share/ament_index/resource_index/packages/spatialdds_ros +0 -0
  80. spatialdds_ros-0.3.0.data/data/share/spatialdds_ros/package.xml +29 -0
  81. spatialdds_ros-0.3.0.dist-info/METADATA +331 -0
  82. spatialdds_ros-0.3.0.dist-info/RECORD +87 -0
  83. spatialdds_ros-0.3.0.dist-info/WHEEL +5 -0
  84. spatialdds_ros-0.3.0.dist-info/entry_points.txt +3 -0
  85. spatialdds_ros-0.3.0.dist-info/licenses/LICENSE +21 -0
  86. spatialdds_ros-0.3.0.dist-info/top_level.txt +2 -0
  87. spatialdds_ros-0.3.0.dist-info/zip-safe +1 -0
@@ -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()