Skip to main content

spatialdds_ros

Make a ROS 2 robot a SpatialDDS participant: its pose published as a spatial.owm entity on scene-scoped topics, goto commands consumed from ModelCommand and dispatched to nav2, and its presence announced — all on the same DDS bus ROS already runs on.

A type adapter on a shared bus, not a protocol gateway. Nothing is relayed. ROS and SpatialDDS meet on the wire because both are DDS, so this is one process holding both stacks, publishing the model as model and reading ROS as ROS.

Installing, and what you are not installing

pip install spatialdds-ros

That gets cyclonedds and nothing else. No ROS. The library puts a robot on a SpatialDDS bus, reads commands off it and drives a navigator, and none of that needs a ROS install — so rclpy is not a dependency, and the absence is deliberate rather than an omission waiting to be tidied up.

The split runs through the whole package:

dependency declared in what it is for
cyclonedds install_requires the bus. Everything here needs it
rclpy, tf2_ros, nav2_msgs package.xml the ROS node alone, resolved by rosdep, never by pip

Only spatialdds_ros.robot_node requires a ROS 2 environment. It imports rclpy inside its entry point rather than at module scope, which is what lets the other twelve modules — and the entire test suite — run on a machine that has never had ROS installed.

That claim is a gate, not a promise. test/test_no_ros_needed.py walks every module reachable from import spatialdds_ros and fails on a module-scope import rclpy anywhere in it. By source inspection, so it catches the violation on a ROS workstation where the import would have succeeded — a test that only checked "the import worked" would pass there and fail in CI. If the split ever stops being real, that is where it is reported.

A robot on the bus, with no robot

No ROS, no nav2, no hardware:

pip install -e .
python3 -m spatialdds_ros.standalone \
    --scene bare-scene \
    --entity-id ent:robot:r1 \
    --frame-id map/bare-scene
robot: scene bare-scene, entity ent:robot:r1 in frame map/bare-scene, DDS domain 0
robot: no anchor configured, so this adapter publishes no FrameTransform: its
       entity and poses are on the bus and complete, and it makes no claim
       about where that frame sits on the earth. Another participant may own
       that claim — a venue publishing its own georeference is the ordinary
       case, and then this is the correct configuration. If nobody publishes
       one, a consumer cannot place this robot on a globe and should say so
       rather than assume a position
robot: announce: svc:robot:r1 as OTHER, 3 topics, ttl 300s

It is now on three lanes, and discoverable on a fourth:

lane topic QoS
what exists spatialdds/bare-scene/world/entity/v1 MODEL_LATCHED
where it is spatialdds/bare-scene/world/model_pose/v1 MODEL_FAST
what to do spatialdds/bare-scene/world/model_command/v1 MODEL_COMMAND
who it is spatialdds/discovery/announce/v1 MODEL_LATCHED

Add --bounds -10,-10,10,10 and it will refuse a goal that leaves the box, so the refusal path can be exercised without hardware.

On a real robot

ros2 run spatialdds_ros robot_node --ros-args \
    -p scene:=bare-scene \
    -p entity_id:=ent:robot:r1 \
    -p frame_id:=map/bare-scene

The pose input defaults to the TF chain map -> base_link. That default matters: it is what nav2 itself consumes, it works whatever the localizer is, and it assumes no topic name. pose_source:=amcl and pose_source:=odom are available, and odom logs its own consequence — odom drifts against map, so publishing it as a georeferenced pose is correct only where map -> odom is static and identity, which is a simulator and not a localizing robot.

ROS 2 Humble and Jazzy are the named targets. The ROS buildfarm is deferred — there is no released binary package; install from source or with pip.

Configuration

Three parameters have no default and the node refuses to start without them, because every possible default would be a false statement about the world:

parameter why there is no default
scene which world this robot is in. A wrong scene is an empty bus, not an error
entity_id which robot it is. A default collides the moment a second one runs
frame_id which frame its pose is in. A pose without its frame is meaningless

Everything else has one: domain 0, ground_z 0.0, pose_source tf, nav2_action navigate_to_pose, source_id derived from entity_id, content_refs and external_refs empty.

What the robot is, and what it is also known as

python3 -m spatialdds_ros.standalone \
    --scene bare-scene --entity-id ent:robot:r1 --frame-id map/bare-scene \
    --content-ref catalog:dcef7c31-1d5f-54aa-9344-899912f9d34a \
    --external-ref wikidata=Q4810574

content_refs is how a consumer finds out what to draw. A client that reads a catalog: reference can resolve the model behind it; without one it has nothing to resolve and will render a marker. That is not a bug in the client — it is the honest rendering of a robot that has not said what it looks like.

external_refs is what it is also known as: a wikidata id, an asset-management number, a fleet number.

Both are empty unless configured, and empty is a statement. The adapter publishes what the operator told it and nothing else — it invents no reference and guesses no asset, because a reference nobody authorised is a claim on nobody's authority. An empty sequence says "this robot names no content"; it is not an accident of omission, and test/test_adapter.py asserts both directions.

On a ROS robot the same two are string-array parameters, with external_refs as key=value entries:

ros2 run spatialdds_ros robot_node --ros-args \
    -p scene:=bare-scene -p entity_id:=ent:robot:r1 \
    -p frame_id:=map/bare-scene \
    -p content_refs:='[catalog:dcef7c31-1d5f-54aa-9344-899912f9d34a]' \
    -p external_refs:='[wikidata=Q4810574]'

Property keys

The adapter publishes two entity properties, and these names are the convention rather than this library's private spelling — a consumer should be able to read any spatialdds_ros robot without diffing two records to discover what they are called:

key meaning
label the human-facing name, from label or the entity_id's tail
nav.status what became of the last goal, held until a new one is accepted

Unprefixed on purpose. An earlier consumer had published demo.label and demo.nav_status, which scoped a general fact to one deployment; a second consumer reading that record would have had to know whose demo it was.

The anchor, and what its absence means for a consumer

anchor_lat_deg, anchor_lon_deg and anchor_height_m say where the robot's frame sits on the earth. Set all three and the node publishes a FrameTransform from that frame to earth-fixed. Set none and it publishes none — a supported mode, not a misconfiguration. Set one or two and it refuses to start, because a partial anchor is not a weaker claim about where the frame is, it is an unusable one.

What a reader of the scene sees in that mode. The robot's entity and its poses are present, complete and moving, and this adapter makes no claim about where that frame sits on the earth.

Whether anybody else does is not something it can see. A venue publishing its own georeference is the ordinary case, and then anchorless is the correct configuration — the scene keeps the single claim and the robot does not contradict it. That is how SpatialDDS-demo runs. If nobody publishes one, a client that wants to place the robot on a globe cannot, and should say so rather than assume a position.

The note said "nothing earth-references them" until 2026-10-19, which assumed this adapter was the only thing that could. In the first real deployment it was not, and anchorless was correct because somebody else owned the claim — the opposite of the absence the wording described.

The degradation is deliberate either way. The alternative, defaulting the anchor to the frame origin, would have every unconfigured robot claiming to be in the Gulf of Guinea with a pose that looks entirely plausible. A default is not neutral when the thing being defaulted is a claim.

What it claims, and what it will not

The adapter publishes basis = OBSERVED and offers no parameter to change it: a self-localizing robot is reporting where it has worked out that it is, which is a sighting rather than an assertion. It claims no extent — a robot locates itself, it does not legislate a region, and an extent would be read as keep-out by anything applying the DECLARED rule.

Three things it refuses to do quietly:

  • It does not dispose its entity when the feed goes quiet. Disposal says the robot is gone; silence only licenses "this is where it was last seen, not where it is". The record is republished UNOBSERVED with a reason and the last pose kept.
  • It does not accept a goal nothing will act on. No pose publishing, or nav2 not accepting goals, is declined with the reason — a declined goal must not look like an ignored one.
  • It does not let an accepted goal fail silently. Whether a goal is drivable can depend on where the robot is when it meets a boundary, so the refusal arrives after the acceptance, on the latched record, and stays there until a new goal is accepted.

QoS and topic names are not configurable. Both are interop contracts whose failure mode is silence: a mismatched QoS means endpoints that never meet with nothing reporting it, and a hand-written topic name means a robot publishing where nobody is listening.

Sharing a participant with another SpatialDDS library

The §3.3.3 profiles go on the endpoints. The Topic objects this library creates carry default QoS, and that is deliberate rather than an omission.

A topic name is unique within a participant, and its QoS has to agree among every live Topic object for that name — in either direction, with the refusal (DDS_RETCODE_INCONSISTENT_POLICY) landing on whichever call came second. Putting the profile on the topic therefore made topic QoS a contract every other participant in the process had to match, and nothing in this API said so. Version 0.2.0 did that, and SpatialDDS-demo was refused an entity reader on a lane this adapter had already opened — both conventions legal DDS, neither legal beside the other.

So this library now asks for the least it can: the default, which is what any participant that passes no QoS gets, including every ROS and DDS example. Nothing leaves the wire — matching is a reader/writer negotiation over endpoint QoS, and a topic's QoS enters it only as a default for endpoints that ask for one, which these never do.

If you run another SpatialDDS stack in the same process and it creates its topics with an explicit QoS, it is the one imposing the contract, and the two cannot share that name. test/test_transport.py::TwoConventionsOnOneTopic pins all of it, including a library writer exchanging real samples with a reader built the other convention's way.

Reads see the lifecycle

transport.take_samples returns payloads; transport.take_with_state returns them alongside instance state for a caller that wants the lifecycle. Both matter because CycloneDDS's take() does not return payloads only — it also yields InvalidSample notifications for a dispose or a departed writer, and a read that assumes otherwise raises on the first peer that joins a lane and leaves. Until 0.2.1 this library made that assumption in one place, which reached two call sites, and the first real deployment found it within a minute. docs/failability.md §3 has the tracebacks.

This adapter still does not act on disposal: it owns one key, never disposes it, and reports going quiet as UNOBSERVED — a weaker statement than "this robot is gone". Not acting on an event is not the same as not receiving one.

Tests

python3 -m pytest test/ -q

Everything runs with no ROS installed — that is a gate (test/test_no_ros_needed.py), not a habit, since one module-scope import rclpy would take the whole suite offline. The DDS-backed tests need cyclonedds and skip with a reason without it.

document what is in it
docs/extraction-census.md what became a parameter, what the library fills, and why
docs/failability.md the two claims broken deliberately, with the failures
docs/spec-queue.md two things for the specification rather than worked around
spatialdds_idl18/PROVENANCE.md where the vendored 1.8 bindings came from
docs/RELEASING.md how a version reaches PyPI, and how the artifact is checked against the commit

Licence

MIT. See LICENSE.

Metadata

Release files for spatialdds-ros 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for spatialdds-ros 0.3.0
File Size Uploaded Provenance
spatialdds_ros-0.3.0.tar.gz 113.2 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for spatialdds-ros 0.3.0
File Interpreter ABI Platform
spatialdds_ros-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 213.2 kB

Release files / spatialdds_ros-0.3.0.tar.gz

Download URL spatialdds_ros-0.3.0.tar.gz
Size 113.2 kB
Tags Source
SHA-256 checksum
How to use checksums
0c9676774019a4e0eb0e8f974cf4c4eafcc1f2195b15fa69e050f3e3c9e376ab
BLAKE2b-256 checksum
How to use checksums
98ea2beab93e6cc391ce6ceadffe084f966963f99b005935a11f0bc231c9c168
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.

Transparency log

Release files / spatialdds_ros-0.3.0-py3-none-any.whl

Download URL spatialdds_ros-0.3.0-py3-none-any.whl
Size 100.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
c03ee5e46b2029fecbe9477105707eb0ad6f94a13245447b0fa87517bf325fba
BLAKE2b-256 checksum
How to use checksums
88797d9b99f853df30827ab0ac79fd6809a5047e24316b0944abca5617faf084
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.

Transparency log

Security for spatialdds-ros 0.3.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for spatialdds_ros-0.3.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page