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,134 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generated by Eclipse Cyclone DDS idlc Python Backend
|
|
3
|
+
Cyclone DDS IDL version: v11.0.1
|
|
4
|
+
Module: spatial.vio
|
|
5
|
+
IDL file: vio.idl
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import auto
|
|
11
|
+
from typing import TYPE_CHECKING, Optional
|
|
12
|
+
|
|
13
|
+
import cyclonedds.idl as idl
|
|
14
|
+
import cyclonedds.idl.annotations as annotate
|
|
15
|
+
import cyclonedds.idl.types as types
|
|
16
|
+
|
|
17
|
+
# root module import for resolving types
|
|
18
|
+
# generate_types.py: bind the root name the dotted
|
|
19
|
+
# annotations below already use, so get_type_hints works
|
|
20
|
+
import spatialdds_idl18
|
|
21
|
+
from spatialdds_idl18 import spatial
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
import builtin
|
|
25
|
+
import spatial.common
|
|
26
|
+
import spatial.core
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
MODULE_ID = "spatial.vio/1.8"
|
|
30
|
+
Time = types.typedef['spatialdds_idl18.spatial.vio.Time', 'spatialdds_idl18.builtin.Time']
|
|
31
|
+
FrameRef = types.typedef['spatialdds_idl18.spatial.vio.FrameRef', 'spatialdds_idl18.spatial.common.FrameRef']
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
@annotate.appendable
|
|
35
|
+
@annotate.autoid("sequential")
|
|
36
|
+
class ImuInfo(idl.IdlStruct, typename="spatial.vio.ImuInfo"):
|
|
37
|
+
imu_id: str
|
|
38
|
+
annotate.key("imu_id")
|
|
39
|
+
frame_ref: 'spatialdds_idl18.spatial.vio.FrameRef'
|
|
40
|
+
accel_noise_density: types.float64
|
|
41
|
+
gyro_noise_density: types.float64
|
|
42
|
+
accel_random_walk: types.float64
|
|
43
|
+
gyro_random_walk: types.float64
|
|
44
|
+
stamp: 'spatialdds_idl18.spatial.vio.Time'
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
@annotate.appendable
|
|
49
|
+
@annotate.autoid("sequential")
|
|
50
|
+
class ImuSample(idl.IdlStruct, typename="spatial.vio.ImuSample"):
|
|
51
|
+
imu_id: str
|
|
52
|
+
annotate.key("imu_id")
|
|
53
|
+
accel: 'spatialdds_idl18.spatial.common.Vec3'
|
|
54
|
+
gyro: 'spatialdds_idl18.spatial.common.Vec3'
|
|
55
|
+
stamp: 'spatialdds_idl18.spatial.vio.Time'
|
|
56
|
+
source_id: str
|
|
57
|
+
seq: types.uint64
|
|
58
|
+
has_accel_cov: bool
|
|
59
|
+
accel_cov: 'spatialdds_idl18.spatial.core.CovMatrix'
|
|
60
|
+
has_gyro_cov: bool
|
|
61
|
+
gyro_cov: 'spatialdds_idl18.spatial.core.CovMatrix'
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
@annotate.appendable
|
|
66
|
+
@annotate.autoid("sequential")
|
|
67
|
+
class MagnetometerSample(idl.IdlStruct, typename="spatial.vio.MagnetometerSample"):
|
|
68
|
+
mag_id: str
|
|
69
|
+
annotate.key("mag_id")
|
|
70
|
+
mag: 'spatialdds_idl18.spatial.common.Vec3'
|
|
71
|
+
stamp: 'spatialdds_idl18.spatial.vio.Time'
|
|
72
|
+
frame_ref: 'spatialdds_idl18.spatial.vio.FrameRef'
|
|
73
|
+
source_id: str
|
|
74
|
+
seq: types.uint64
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
@annotate.appendable
|
|
79
|
+
@annotate.autoid("sequential")
|
|
80
|
+
class SensorFusionSample(idl.IdlStruct, typename="spatial.vio.SensorFusionSample"):
|
|
81
|
+
fusion_id: str
|
|
82
|
+
annotate.key("fusion_id")
|
|
83
|
+
accel: 'spatialdds_idl18.spatial.common.Vec3'
|
|
84
|
+
gyro: 'spatialdds_idl18.spatial.common.Vec3'
|
|
85
|
+
mag: 'spatialdds_idl18.spatial.common.Vec3'
|
|
86
|
+
stamp: 'spatialdds_idl18.spatial.vio.Time'
|
|
87
|
+
frame_ref: 'spatialdds_idl18.spatial.vio.FrameRef'
|
|
88
|
+
source_id: str
|
|
89
|
+
seq: types.uint64
|
|
90
|
+
|
|
91
|
+
@annotate.final
|
|
92
|
+
class FusionMode(idl.IdlEnum, typename="spatial.vio.FusionMode", default="ORIENTATION_3DOF"):
|
|
93
|
+
ORIENTATION_3DOF = 0
|
|
94
|
+
ORIENTATION_6DOF = 1
|
|
95
|
+
POSE_6DOF = 2
|
|
96
|
+
@annotate.final
|
|
97
|
+
class FusionSourceKind(idl.IdlEnum, typename="spatial.vio.FusionSourceKind", default="EKF"):
|
|
98
|
+
EKF = 0
|
|
99
|
+
AHRS = 1
|
|
100
|
+
VIO_FUSED = 2
|
|
101
|
+
IMU_ONLY = 3
|
|
102
|
+
MAG_AIDED = 4
|
|
103
|
+
AR_PLATFORM = 5
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
@annotate.appendable
|
|
107
|
+
@annotate.autoid("sequential")
|
|
108
|
+
class FusedState(idl.IdlStruct, typename="spatial.vio.FusedState"):
|
|
109
|
+
fusion_id: str
|
|
110
|
+
annotate.key("fusion_id")
|
|
111
|
+
mode: 'spatialdds_idl18.spatial.vio.FusionMode'
|
|
112
|
+
source_kind: 'spatialdds_idl18.spatial.vio.FusionSourceKind'
|
|
113
|
+
q: 'spatialdds_idl18.spatial.common.QuaternionXYZW'
|
|
114
|
+
has_position: bool
|
|
115
|
+
t: 'spatialdds_idl18.spatial.common.Vec3'
|
|
116
|
+
has_gravity: bool
|
|
117
|
+
gravity: 'spatialdds_idl18.spatial.common.Vec3'
|
|
118
|
+
has_lin_accel: bool
|
|
119
|
+
lin_accel: 'spatialdds_idl18.spatial.common.Vec3'
|
|
120
|
+
has_gyro_bias: bool
|
|
121
|
+
gyro_bias: 'spatialdds_idl18.spatial.common.Vec3'
|
|
122
|
+
has_accel_bias: bool
|
|
123
|
+
accel_bias: 'spatialdds_idl18.spatial.common.Vec3'
|
|
124
|
+
has_cov_orient: bool
|
|
125
|
+
cov_orient: 'spatialdds_idl18.spatial.common.Mat3x3'
|
|
126
|
+
has_cov_pos: bool
|
|
127
|
+
cov_pos: 'spatialdds_idl18.spatial.common.Mat3x3'
|
|
128
|
+
stamp: 'spatialdds_idl18.spatial.vio.Time'
|
|
129
|
+
frame_ref: 'spatialdds_idl18.spatial.vio.FrameRef'
|
|
130
|
+
source_id: str
|
|
131
|
+
seq: types.uint64
|
|
132
|
+
quality: types.float64
|
|
133
|
+
|
|
134
|
+
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The adapter: one robot, published as a `spatial.owm` entity.
|
|
3
|
+
|
|
4
|
+
**No ROS in here.** It is handed poses and handed a navigator. On a robot the
|
|
5
|
+
caller feeds it the map-frame pose and gives it a nav2 action client; in the
|
|
6
|
+
no-hardware mode the caller feeds it a kinematic sim and gives it that sim's
|
|
7
|
+
goal setter. Both call the same methods, which keeps the interesting behaviour
|
|
8
|
+
— cadence, latching, going quiet, declining, refusing — testable with no ROS
|
|
9
|
+
installation and no robot. That is this library's CI story, and the prototype's
|
|
10
|
+
`RobotBridge` is the shape it follows.
|
|
11
|
+
|
|
12
|
+
It owns exactly one key and writes nothing else. The pose-lane rule is **one
|
|
13
|
+
writer per key**, and `already_published` is the one place two processes could
|
|
14
|
+
plausibly break it.
|
|
15
|
+
|
|
16
|
+
Three things it will not do, each because the alternative is a false
|
|
17
|
+
statement:
|
|
18
|
+
|
|
19
|
+
* **It does not dispose its entity when the feed goes quiet.** Disposal says
|
|
20
|
+
the robot is gone; silence only licenses "this is where it was last seen,
|
|
21
|
+
not where it is". So the record is republished `UNOBSERVED` with a reason.
|
|
22
|
+
* **It does not accept a goal it knows nothing will act on.** A `goto` with no
|
|
23
|
+
pose publishing, or with the navigator refusing goals, is declined with the
|
|
24
|
+
reason — because a requester watching a motionless robot cannot tell a
|
|
25
|
+
declined goal from an ignored one.
|
|
26
|
+
* **It does not let an accepted goal fail silently.** Whether a goal is
|
|
27
|
+
drivable can depend on where the robot is when it meets a boundary, so the
|
|
28
|
+
refusal arrives after the acceptance — on the latched record, where whoever
|
|
29
|
+
asked will see it.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import time
|
|
34
|
+
from typing import List, Optional, Protocol, Tuple
|
|
35
|
+
|
|
36
|
+
from cyclonedds.domain import DomainParticipant
|
|
37
|
+
|
|
38
|
+
from spatialdds_idl18.spatial.common import KV
|
|
39
|
+
from spatialdds_idl18.spatial.core import Aabb3, PoseSE3
|
|
40
|
+
from spatialdds_idl18.spatial.owm import (
|
|
41
|
+
Basis, Entity, LifecycleState, ModelCommand, ModelLayer, ModelPose)
|
|
42
|
+
from spatialdds_idl18.spatial.core import FrameTransform
|
|
43
|
+
|
|
44
|
+
from spatialdds_ros import transport as tt
|
|
45
|
+
from spatialdds_ros.config import Config
|
|
46
|
+
from spatialdds_ros.frames import georeference, now_time, robot_frame
|
|
47
|
+
from spatialdds_ros.qos import MODEL_COMMAND, MODEL_FAST, MODEL_LATCHED
|
|
48
|
+
from spatialdds_ros.tempo import Tempo
|
|
49
|
+
from spatialdds_ros.topics import Topics
|
|
50
|
+
|
|
51
|
+
#: The property key a late refusal travels on. `demo.nav_status` in the
|
|
52
|
+
#: prototype; `nav.status` here, because this is no longer a demo's private
|
|
53
|
+
#: note but the library's own documented surface.
|
|
54
|
+
NAV_STATUS_KEY = "nav.status"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Navigator(Protocol):
|
|
58
|
+
"""Whatever actually drives the robot."""
|
|
59
|
+
|
|
60
|
+
def available(self) -> Optional[str]:
|
|
61
|
+
"""`None` when it will accept goals; otherwise one plain reason."""
|
|
62
|
+
|
|
63
|
+
def send(self, x: float, y: float) -> str:
|
|
64
|
+
"""Ask for a goal. Returns what was *accepted*, for the log."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RobotAdapter:
|
|
68
|
+
"""Owns one entity on the scene's lanes."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, participant: DomainParticipant, config: Config,
|
|
71
|
+
navigator: Optional[Navigator] = None) -> None:
|
|
72
|
+
self.config = config
|
|
73
|
+
self.topics = Topics(config.scene)
|
|
74
|
+
self._entities = tt.make_writer(
|
|
75
|
+
participant, self.topics.entity, Entity, MODEL_LATCHED.name)
|
|
76
|
+
self._poses = tt.make_writer(
|
|
77
|
+
participant, self.topics.pose, ModelPose, MODEL_FAST.name)
|
|
78
|
+
self._commands = tt.make_reader(
|
|
79
|
+
participant, self.topics.command, ModelCommand, MODEL_COMMAND.name)
|
|
80
|
+
# Created only when there is something to say. A writer on the frames
|
|
81
|
+
# lane that never writes is indistinguishable, to a reader, from one
|
|
82
|
+
# about to — so with no anchor there is no writer at all.
|
|
83
|
+
self._frames = (
|
|
84
|
+
tt.make_writer(participant, self.topics.frame_transform,
|
|
85
|
+
FrameTransform, MODEL_LATCHED.name)
|
|
86
|
+
if config.publishes_georeference else None)
|
|
87
|
+
|
|
88
|
+
self._tempo = Tempo()
|
|
89
|
+
self._entity: Optional[Entity] = None
|
|
90
|
+
self._goal: Optional[Tuple[float, float]] = None
|
|
91
|
+
self._silent = False
|
|
92
|
+
self._last_seen = 0.0
|
|
93
|
+
self._navigator = navigator
|
|
94
|
+
# **The last thing that happened to a goal, held until something
|
|
95
|
+
# changes it.** A refusal published once and then superseded by the
|
|
96
|
+
# next pose is an *event*, and an event can be missed: a client that
|
|
97
|
+
# polls the latched record a moment later sees a robot sitting still
|
|
98
|
+
# with nothing to explain it, which is the exact silence this whole
|
|
99
|
+
# path exists to end. "What became of the goal I sent" is state, and a
|
|
100
|
+
# latched record is where state belongs. Cleared when a new goal is
|
|
101
|
+
# accepted, because that supersedes the old outcome.
|
|
102
|
+
self._nav_status: Optional[str] = None
|
|
103
|
+
|
|
104
|
+
# --- what it publishes ------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def _record(self, pose: PoseSE3, state: LifecycleState,
|
|
107
|
+
reason: str = "") -> Entity:
|
|
108
|
+
properties = [KV(key="label", value=self.config.effective_label)]
|
|
109
|
+
if self._nav_status:
|
|
110
|
+
properties.append(KV(key=NAV_STATUS_KEY, value=self._nav_status))
|
|
111
|
+
return Entity(
|
|
112
|
+
entity_id=self.config.entity_id,
|
|
113
|
+
# OBSERVED, and not configurable. A self-localizing robot is
|
|
114
|
+
# reporting where it has worked out that it is, from sensors —
|
|
115
|
+
# a sighting rather than an assertion, and not DERIVED because
|
|
116
|
+
# nothing here fuses anyone else's claims.
|
|
117
|
+
basis=Basis.OBSERVED,
|
|
118
|
+
type_uris=[self.config.type_uri],
|
|
119
|
+
layer=ModelLayer.FAST,
|
|
120
|
+
frame_ref=robot_frame(self.config.frame_id),
|
|
121
|
+
has_pose=True,
|
|
122
|
+
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.
|
|
126
|
+
has_extent=False,
|
|
127
|
+
extent=Aabb3(min_xyz=[0.0, 0.0, 0.0], max_xyz=[0.0, 0.0, 0.0]),
|
|
128
|
+
properties=properties,
|
|
129
|
+
# Only what the operator configured. An adapter that invented a
|
|
130
|
+
# reference would be asserting something about the world on
|
|
131
|
+
# nobody's authority; an adapter with nowhere to put one cannot be
|
|
132
|
+
# a consumer's only publisher of its robot, which is what blocked
|
|
133
|
+
# the first adoption. Empty is a published fact, not an omission.
|
|
134
|
+
external_refs=[KV(key=k, value=v)
|
|
135
|
+
for k, v in self.config.external_refs],
|
|
136
|
+
content_refs=list(self.config.content_refs),
|
|
137
|
+
state=state,
|
|
138
|
+
state_reason=reason,
|
|
139
|
+
source_id=self.config.effective_source_id,
|
|
140
|
+
stamp=now_time(),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def publish_georeference(self) -> Optional[str]:
|
|
144
|
+
"""
|
|
145
|
+
Where this robot's frame sits on the earth, if an anchor was given.
|
|
146
|
+
|
|
147
|
+
Returns the line to log, or `None` when there is no anchor — which is
|
|
148
|
+
a documented mode and not a failure. `Config.startup_notes()` is where
|
|
149
|
+
that absence is explained to an operator; this just does not write.
|
|
150
|
+
"""
|
|
151
|
+
if self._frames is None or self.config.anchor is None:
|
|
152
|
+
return None
|
|
153
|
+
transform = georeference(self.config.frame_id, self.config.anchor)
|
|
154
|
+
self._frames.write(transform)
|
|
155
|
+
return (f"georeference: {self.config.frame_id} -> "
|
|
156
|
+
f"{transform.parent_ref.fqn}")
|
|
157
|
+
|
|
158
|
+
def observed(self, x: float, y: float, yaw: float,
|
|
159
|
+
now: Optional[float] = None) -> None:
|
|
160
|
+
"""
|
|
161
|
+
A new map-frame pose from upstream. Fast lane always; latch on cadence.
|
|
162
|
+
|
|
163
|
+
`z` comes from the configured ground height rather than from the
|
|
164
|
+
caller: a planar robot's upstream pose often carries a z of zero in its
|
|
165
|
+
own frame, and the scene's floor is a property of the scene.
|
|
166
|
+
"""
|
|
167
|
+
now = time.time() if now is None else now
|
|
168
|
+
pose = PoseSE3(t=[x, y, self.config.ground_z],
|
|
169
|
+
q=_yaw_quaternion(yaw))
|
|
170
|
+
first = self._entity is None
|
|
171
|
+
was_silent = self._silent
|
|
172
|
+
self._entity = self._record(pose, LifecycleState.ACTIVE)
|
|
173
|
+
self._last_seen = now
|
|
174
|
+
self._silent = False
|
|
175
|
+
|
|
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))
|
|
180
|
+
|
|
181
|
+
# The expensive record goes on the cadence — and unconditionally when
|
|
182
|
+
# the robot first appears or comes back, because those are changes of
|
|
183
|
+
# *state* rather than of position, and a client waiting four more poses
|
|
184
|
+
# to learn the robot is alive again would be told late.
|
|
185
|
+
if (self._tempo.observed(self.config.entity_id, now)
|
|
186
|
+
or first or was_silent):
|
|
187
|
+
self._entities.write(self._entity)
|
|
188
|
+
|
|
189
|
+
def refused(self, why: str) -> None:
|
|
190
|
+
"""
|
|
191
|
+
Republish the latched record carrying a refusal, now.
|
|
192
|
+
|
|
193
|
+
Off the cadence deliberately: this is a change of state, and the point
|
|
194
|
+
is that the client which asked learns promptly rather than up to four
|
|
195
|
+
poses later. It then *stays* on the record until a new goal is
|
|
196
|
+
accepted — see `_nav_status`. A no-op before the first pose: there is
|
|
197
|
+
no record to amend, and announcing a robot by complaining about it is
|
|
198
|
+
not an introduction.
|
|
199
|
+
"""
|
|
200
|
+
if self._entity is None:
|
|
201
|
+
return
|
|
202
|
+
self._nav_status = why
|
|
203
|
+
self._entity = self._record(self._entity.pose, LifecycleState.ACTIVE)
|
|
204
|
+
self._entities.write(self._entity)
|
|
205
|
+
|
|
206
|
+
# --- what it is asked to do -------------------------------------------
|
|
207
|
+
|
|
208
|
+
def handle_command(self, command: ModelCommand) -> Optional[str]:
|
|
209
|
+
"""
|
|
210
|
+
`goto`, and nothing else. A line for the log, or `None` when the
|
|
211
|
+
command was not ours to answer.
|
|
212
|
+
|
|
213
|
+
Silence for somebody else's subject: the lane has several owners and a
|
|
214
|
+
refusal from the wrong one reads as the system disagreeing with itself.
|
|
215
|
+
A malformed command *about this robot* is a different matter and gets
|
|
216
|
+
a reason.
|
|
217
|
+
"""
|
|
218
|
+
if command.subject_id != self.config.entity_id:
|
|
219
|
+
return None
|
|
220
|
+
if command.verb != "goto":
|
|
221
|
+
return None
|
|
222
|
+
if not command.has_pose:
|
|
223
|
+
# A zeroed pose is a well-formed request to drive to the frame
|
|
224
|
+
# origin, so declining is the only reading that cannot be mistaken
|
|
225
|
+
# for obedience.
|
|
226
|
+
return (f"declined: goto for {self.config.entity_id} carried no "
|
|
227
|
+
f"pose")
|
|
228
|
+
if self._entity is None or self._silent:
|
|
229
|
+
return (f"declined: goto for {self.config.entity_id} — no pose "
|
|
230
|
+
f"from {self.config.effective_source_id}, so there is "
|
|
231
|
+
f"nothing to navigate from")
|
|
232
|
+
if self._navigator is not None:
|
|
233
|
+
unavailable = self._navigator.available()
|
|
234
|
+
if unavailable:
|
|
235
|
+
return (f"declined: goto for {self.config.entity_id} — "
|
|
236
|
+
f"{unavailable}")
|
|
237
|
+
|
|
238
|
+
x, y = command.pose.t[0], command.pose.t[1]
|
|
239
|
+
self._goal = (x, y)
|
|
240
|
+
# A new goal supersedes whatever became of the last one, so the record
|
|
241
|
+
# should stop reporting it. Cleared here rather than on arrival, so a
|
|
242
|
+
# *declined* command leaves the previous outcome standing — the
|
|
243
|
+
# requester still needs to see it.
|
|
244
|
+
self._nav_status = None
|
|
245
|
+
if self._navigator is None:
|
|
246
|
+
return (f"accepted goto ({x:.2f}, {y:.2f}) "
|
|
247
|
+
f"(asked by {command.requester_id})")
|
|
248
|
+
# What the navigator accepted, not what was sent. If it rounds, clamps
|
|
249
|
+
# or refuses, that is what belongs in the log.
|
|
250
|
+
outcome = self._navigator.send(x, y)
|
|
251
|
+
return f"{outcome} (asked by {command.requester_id})"
|
|
252
|
+
|
|
253
|
+
def poll_commands(self) -> List[str]:
|
|
254
|
+
"""Drain the command lane. The goal is already set; the caller decides
|
|
255
|
+
what to do with the notes."""
|
|
256
|
+
notes = []
|
|
257
|
+
try:
|
|
258
|
+
batch = tt.take_samples(self._commands)
|
|
259
|
+
except Exception as error: # noqa: BLE001
|
|
260
|
+
# Untrusted traffic, like every other reader here.
|
|
261
|
+
return [f"command lane read failed: {error!r}"]
|
|
262
|
+
for command in batch:
|
|
263
|
+
note = self.handle_command(command)
|
|
264
|
+
if note:
|
|
265
|
+
notes.append(note)
|
|
266
|
+
return notes
|
|
267
|
+
|
|
268
|
+
def tick(self, now: Optional[float] = None) -> Optional[str]:
|
|
269
|
+
"""
|
|
270
|
+
Housekeeping between poses: flush the latch when the robot stops
|
|
271
|
+
moving, and declare it UNOBSERVED when we stop hearing from it at all.
|
|
272
|
+
"""
|
|
273
|
+
now = time.time() if now is None else now
|
|
274
|
+
if self._entity is None:
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
if not self._silent and now - self._last_seen > self.silence_threshold():
|
|
278
|
+
self._silent = True
|
|
279
|
+
quiet_for = now - self._last_seen
|
|
280
|
+
self._entity = self._record(
|
|
281
|
+
self._entity.pose, LifecycleState.UNOBSERVED,
|
|
282
|
+
f"no pose from {self.config.effective_source_id} for "
|
|
283
|
+
f"{quiet_for:.1f}s; this is where it was last seen, not where "
|
|
284
|
+
f"it is")
|
|
285
|
+
self._entities.write(self._entity)
|
|
286
|
+
self._tempo.forget(self.config.entity_id)
|
|
287
|
+
return f"unobserved after {quiet_for:.1f}s of silence"
|
|
288
|
+
|
|
289
|
+
if not self._silent and self._tempo.idle(now):
|
|
290
|
+
self._entities.write(self._entity)
|
|
291
|
+
return "latch flushed — it stopped moving"
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
def silence_threshold(self) -> float:
|
|
295
|
+
return self._tempo.threshold(self.config.entity_id)
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def goal(self) -> Optional[Tuple[float, float]]:
|
|
299
|
+
return self._goal
|
|
300
|
+
|
|
301
|
+
def clear_goal(self) -> None:
|
|
302
|
+
self._goal = None
|
|
303
|
+
|
|
304
|
+
@property
|
|
305
|
+
def state(self) -> str:
|
|
306
|
+
return "UNOBSERVED" if self._silent else "ACTIVE"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _yaw_quaternion(yaw: float) -> list:
|
|
310
|
+
from spatialdds_ros.frames import yaw_quaternion
|
|
311
|
+
return yaw_quaternion(yaw)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def already_published(participant: DomainParticipant, config: Config,
|
|
315
|
+
settle: float = 2.5) -> Optional[str]:
|
|
316
|
+
"""
|
|
317
|
+
Is somebody else already publishing this entity? Returns their source id.
|
|
318
|
+
|
|
319
|
+
One writer per key is the rule the whole model rests on, and two adapters
|
|
320
|
+
on one robot would put it in two places with nothing anywhere reporting a
|
|
321
|
+
conflict — the readers would simply see a robot that teleports. The caller
|
|
322
|
+
is expected to refuse to start.
|
|
323
|
+
|
|
324
|
+
`settle` is how long to wait for a latched record to arrive. TRANSIENT_LOCAL
|
|
325
|
+
means an existing publisher's record is delivered on connect, so this is
|
|
326
|
+
bounded discovery rather than a poll of an unknown duration.
|
|
327
|
+
"""
|
|
328
|
+
reader = tt.make_reader(participant, Topics(config.scene).entity, Entity,
|
|
329
|
+
MODEL_LATCHED.name)
|
|
330
|
+
deadline = time.time() + settle
|
|
331
|
+
while time.time() < deadline:
|
|
332
|
+
for sample in tt.take_samples(reader):
|
|
333
|
+
if sample.entity_id == config.entity_id:
|
|
334
|
+
return sample.source_id
|
|
335
|
+
time.sleep(0.05)
|
|
336
|
+
return None
|