Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 15 additions & 13 deletions blueboat_description/config/blueboat.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,7 @@
# composed Gazebo model AND the ros_gz bridge config. To try a config without
# rebuilding, pass config_file:=<your.yaml> to the launches.
#
# The default BlueBoat needs nothing here: every slot of every part fills
# itself with its default (the chassis fits its two outboards, the flag and
# the Ping2 integration kit; the kit's bracket fits the Ping). The entries
# below are how to change that. See bluerobotics_parts/urdf/assembly.xacro
# for the full rules and the site (vehicles/blueboat/configuration) for the slots
# and the catalog.
# The BlueBoat is exactly what parts: lists, because slot_defaults is false: a slot with no entry stays empty, including the slots of fitted parts (the Ping2 kit's bracket brings no Ping unless the Ping is listed too). With slot_defaults true, or left out, every unlisted slot fills with its default instead: the chassis fits its two propellers, the flag and the Ping2 integration kit, and the kit's bracket fits the Ping. See bluerobotics_parts/urdf/assembly.xacro for the full rules and the site (vehicles/blueboat/configuration) for the slots and the catalog.
#
# Topic namespace: sensor topics default to /<topic_namespace>/<name>/...
topic_namespace: blueboat
Expand All @@ -20,7 +15,10 @@ topic_namespace: blueboat
# since graded buoyancy reads only boxes and spheres (docs: design/buoyancy).
base: {type: blueboat_chassis, name: base_link}

# parts: overrides and additions. A slot entry picks what goes in a slot
# slot_defaults: false fits only the slots listed under parts; true fills every unlisted slot with its default part.
slot_defaults: false

# parts: what is fitted. A slot entry picks what goes in a slot
# (slot: the slot name, of: the instance carrying it, default base_link;
# type: an accepted part type, or none to leave it empty). An entry without
# slot: is a free placement: type, name, xyz/rpy relative to parent (default
Expand All @@ -33,13 +31,17 @@ base: {type: blueboat_chassis, name: base_link}
# which is then used as given (and shared by every instance).
# bridge: dict of NATIVE ros_gz_bridge keys merged into that part's
# generated bridge entries
parts: []
# - {slot: ping_mount, type: none} # no Ping2 kit
# - {slot: motor_port, type: t200_prop_ccw} # the other propeller option
# - {slot: motor_stbd, type: t200_prop_cw}
# - {slot: mast, type: blueboat_antenna_mast} # slots with no default
parts:
- {slot: prop_port, type: m200_weedless_prop_ccw}
- {slot: prop_stbd, type: m200_weedless_prop_cw}
- {type: ping_singlebeam, name: ping, xyz: "0 -0.259 0", rpy: "0 0 0"} # the Ping where the Ping2 kit puts it, without the bracket
# - {slot: flag, type: blueboat_flag}
# The Ping2 kit instead of the free placed Ping above (remove that entry first):
# - {slot: ping_mount, type: blueboat_ping_singlebeam_mount} # Ping2 integration kit bracket
# - {slot: ping, of: ping_mount, type: ping_singlebeam} # the Ping in it; topic: sonar renames its topics
# - {slot: mast, type: blueboat_antenna_mast}
# - {slot: payload, type: blueboat_payload_bracket}
# - {slot: ping, of: ping_mount, type: ping_singlebeam, topic: sonar} # rename the Ping topics
# T200 propellers instead of the M200s: type: t200_prop_ccw / t200_prop_cw on the two prop_ slots above
# - {type: basestation_antenna, name: radio, xyz: "-0.35 0 0.5", rpy: "0 0 0"}
# - {type: omniscan_450_sidescan, name: sidescan, xyz: "0.2 0.22 -0.03", rpy: "0 0 0"}
# - {type: surveyor_multibeam, name: multibeam, xyz: "0.35 0 -0.08", rpy: "0 0 0"}
Expand Down
71 changes: 38 additions & 33 deletions blueboat_description/test/test_description.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,6 @@

WATER_DENSITY = 1025.0
HULL = yaml.safe_load(DEFAULT_CONFIG)['hull_displacement']
# What the chassis slots fill with when the config is silent.
DEFAULT_PARTS = {'base_link': 'blueboat_chassis',
'motor_port': 'm200_weedless_prop_ccw',
'motor_stbd': 'm200_weedless_prop_cw',
'flag': 'blueboat_flag',
'ping_mount': 'blueboat_ping_singlebeam_mount',
'ping': 'ping_singlebeam'}
# The Ping2 transducer face sits this far below the part origin (delivered mesh).
PING_FACE_BELOW_ORIGIN = 0.044

Expand All @@ -58,7 +51,13 @@ def catalog():


def make_config(parts=(), slots=(), hull=True):
"""Vehicle config yaml: chassis base, overrides/additions, hull displacement."""
"""
Vehicle config yaml: chassis base, overrides/additions, hull displacement.

The tests' own vehicle, independent of the shipped config: no
slot_defaults key, so every slot fills with its default (the M200 props,
the flag, the Ping2 kit and its Ping) unless an entry says otherwise.
"""
cfg = {'base': {'type': 'blueboat_chassis', 'name': 'base_link',
'collision': False},
'parts': [dict(p) for p in parts]}
Expand Down Expand Up @@ -142,46 +141,46 @@ def position_in_base(root, link):
return pos


def test_default_config_yields_the_default_parts():
"""With no parts configured, the slots fill with their defaults."""
root = generate_urdf(DEFAULT_CONFIG)
assert manifest(root) == DEFAULT_PARTS
def test_props_spin_and_the_chassis_keeps_contact():
"""Propellers turn on continuous joints; contact stays the chassis's own."""
cfg = yaml.safe_load(make_config())
cfg['base']['collision'] = True
root = generate_urdf(yaml.safe_dump(cfg, sort_keys=False))
assert 'hull_displacement' not in link_names(root), 'displacement belongs to the Gazebo side'
base = next(li for li in root.findall('link') if li.get('name') == 'base_link')
assert base.findall('collision'), 'the chassis keeps its delivered contact geometry'
assert set(DEFAULT_PARTS) <= link_names(root)
motors = {j.get('name'): j for j in root.findall('joint')
if j.get('name') in ('motor_port_joint', 'motor_stbd_joint')}
assert set(motors) == {'motor_port_joint', 'motor_stbd_joint'}
if j.get('name') in ('prop_port_joint', 'prop_stbd_joint')}
assert set(motors) == {'prop_port_joint', 'prop_stbd_joint'}
for joint in motors.values():
assert joint.get('type') == 'continuous'
assert joint.find('axis') is not None


def test_manifest_names_are_links():
"""Every manifest instance exists as a link (and vice versa for parts)."""
root = generate_urdf(DEFAULT_CONFIG)
root = generate_urdf(make_config())
links = link_names(root)
for name in manifest(root):
assert name in links


def test_slot_chains_through_the_parent_part():
"""Slot occupants hang off the slot frame of the instance carrying it."""
table = joints_by_child(generate_urdf(DEFAULT_CONFIG))
table = joints_by_child(generate_urdf(make_config()))
assert table['ping'][0] == 'ping_mount_ping'
assert table['ping_mount_ping'][0] == 'ping_mount'
assert table['ping_mount'][0] == 'base_link_ping_mount'
assert table['base_link_ping_mount'][0] == 'base_link'
assert table['motor_port'][0] == 'base_link_motor_port'
assert table['prop_port'][0] == 'base_link_prop_port'
assert table['ping_beam'][0] == 'ping', 'the Ping declares its beam frame'


def test_slot_override_picks_another_option():
"""A slot entry swaps the occupant for another accepted type."""
root = generate_urdf(make_config([{'slot': 'motor_port', 'type': 't200_prop_ccw'}]))
assert manifest(root)['motor_port'] == 't200_prop_ccw'
assert manifest(root)['motor_stbd'] == 'm200_weedless_prop_cw'
root = generate_urdf(make_config([{'slot': 'prop_port', 'type': 't200_prop_ccw'}]))
assert manifest(root)['prop_port'] == 't200_prop_ccw'
assert manifest(root)['prop_stbd'] == 'm200_weedless_prop_cw'


def test_slot_none_leaves_it_empty():
Expand Down Expand Up @@ -221,7 +220,7 @@ def test_manifest_records_every_slot_visited():
root = generate_urdf(make_config(
slots=[{'of': 'ping_mount', 'name': 'side', 'xyz': '0 0.05 0'}]))
visited = assembly.slots(root)
assert {('base_link', 'motor_port'), ('base_link', 'ping_mount'),
assert {('base_link', 'prop_port'), ('base_link', 'ping_mount'),
('ping_mount', 'ping'), ('ping_mount', 'side')} <= visited


Expand Down Expand Up @@ -257,7 +256,7 @@ def test_check_catches_what_the_expansion_cannot():

def test_slot_rejects_parts_that_do_not_fit():
"""A type outside the slot's accepts list fails the expansion."""
expect_failure(make_config([{'slot': 'motor_port', 'type': 'blueboat_flag'}]),
expect_failure(make_config([{'slot': 'prop_port', 'type': 'blueboat_flag'}]),
'does not fit')


Expand Down Expand Up @@ -311,7 +310,7 @@ def test_catalog_completeness():

def test_mesh_assets_resolve():
"""Every mesh the URDF references ships in bluerobotics_parts."""
root = generate_urdf(DEFAULT_CONFIG)
root = generate_urdf(make_config())
meshes = list(root.iter('mesh'))
assert meshes, 'the parts based model must reference the delivered meshes'
prefix = 'package://bluerobotics_parts/'
Expand All @@ -334,20 +333,26 @@ def test_check_urdf_accepts_generated():
f'--- stdout ---\n{out.stdout}\n--- stderr ---\n{out.stderr}')


def test_echosounder_mounted_below_waterline():
@pytest.mark.parametrize('config', ['kit', 'shipped'])
def test_echosounder_mounted_below_waterline(config):
"""
The default Ping transducer is submerged at rest.
A fitted Ping transducer is submerged at rest.

The gpu_lidar returns seabed ranges from anywhere, so a transducer in the
air fails silently; lock its face below the static waterline computed from
mass and waterplane area.
mass and waterplane area. Checked on the Ping2 kit (the slot default) and
on whatever the shipped config fits, if it fits a Ping at all.
"""
root = generate_urdf(DEFAULT_CONFIG)
face_z = position_in_base(root, 'ping')[2] - PING_FACE_BELOW_ORIGIN
assert position_in_base(root, 'ping_beam')[2] == pytest.approx(face_z, abs=1e-6)
root = generate_urdf(make_config() if config == 'kit' else DEFAULT_CONFIG)
pings = [name for name, ptype in manifest(root).items() if ptype == 'ping_singlebeam']
if not pings:
pytest.skip(f'the {config} config fits no Ping')
area = 2 * HULL['length'] * HULL['width']
draft = total_mass(root) / (WATER_DENSITY * area)
waterline_z = HULL['z'] - HULL['height'] / 2 + draft
assert face_z < waterline_z - 0.02, (
f'ping transducer face at z {face_z:.3f} is not below the static '
f'waterline {waterline_z:.3f}')
for ping in pings:
face_z = position_in_base(root, ping)[2] - PING_FACE_BELOW_ORIGIN
assert position_in_base(root, f'{ping}_beam')[2] == pytest.approx(face_z, abs=1e-6)
assert face_z < waterline_z - 0.02, (
f'{ping} transducer face at z {face_z:.3f} is not below the static '
f'waterline {waterline_z:.3f}')
3 changes: 2 additions & 1 deletion blueboat_description/urdf/blueboat.urdf.xacro
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<!-- Chassis as the root; its slots (and theirs) fill with their defaults,
the config overrides and adds. -->
<xacro:assemble base="${cfg['base']}" parts="${cfg.get('parts') or []}"
slots="${cfg.get('slots') or []}"/>
slots="${cfg.get('slots') or []}"
defaults="${cfg.get('slot_defaults', True)}"/>

</robot>
7 changes: 4 additions & 3 deletions blueboat_gazebo/model.sdf.xacro
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,8 @@
<!-- Thrusters and sensors: one per propeller / sensor part the
assembly resolves to. No manifest: that lives in the merged URDF. -->
<xacro:assemble base="${cfg['base']}" parts="${cfg.get('parts') or []}"
slots="${cfg.get('slots') or []}" emit="gz_part" emit_slot="gz_no_slot"
slots="${cfg.get('slots') or []}" defaults="${cfg.get('slot_defaults', True)}"
emit="gz_part" emit_slot="gz_no_slot"
manifest="false"/>

<!-- ===================== ArduPilot SITL =====================
Expand Down Expand Up @@ -390,8 +391,8 @@
the servo band converted to a normalized command. The thruster owns
the newtons, including the asymmetry between ahead and astern.
-->
<xacro:ardupilot_control channel="0" motor="motor_stbd"/>
<xacro:ardupilot_control channel="2" motor="motor_port"/>
<xacro:ardupilot_control channel="0" motor="prop_stbd"/>
<xacro:ardupilot_control channel="2" motor="prop_port"/>
</plugin>

</xacro:if>
Expand Down
10 changes: 5 additions & 5 deletions blueboat_gazebo/test/test_bridge_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
_spec.loader.exec_module(bridge_gen)

ALWAYS = {'/clock', '/blueboat/joint_states'}
PROPS = [('m200_weedless_prop_ccw', 'motor_port'), ('m200_weedless_prop_cw', 'motor_stbd')]
PROPS = [('m200_weedless_prop_ccw', 'prop_port'), ('m200_weedless_prop_cw', 'prop_stbd')]


def entries_for(cfg, instances):
Expand All @@ -44,12 +44,12 @@ def test_only_clock_and_joint_states_without_parts():
def test_propellers_bridge_their_thrust_command():
"""Each propeller instance gets a ROS_TO_GZ thrust topic named after it."""
entries = entries_for({}, PROPS)
assert set(entries) == ALWAYS | {'/blueboat/motor_port/cmd', '/blueboat/motor_stbd/cmd'}
port = entries['/blueboat/motor_port/cmd']
assert set(entries) == ALWAYS | {'/blueboat/prop_port/cmd', '/blueboat/prop_stbd/cmd'}
port = entries['/blueboat/prop_port/cmd']
assert port['direction'] == 'ROS_TO_GZ'
assert port['ros_type_name'] == 'std_msgs/msg/Float64'
# Same name on the gz side: model.sdf.xacro gives the Thruster this <topic>.
assert port['gz_topic_name'] == '/blueboat/motor_port/cmd'
assert port['gz_topic_name'] == '/blueboat/prop_port/cmd'
# A renamed or swapped propeller follows its instance name.
entries = entries_for({}, [('t200_prop_ccw', 'left')])
assert '/blueboat/left/cmd' in entries
Expand Down Expand Up @@ -83,7 +83,7 @@ def test_topic_override_precedence():
entries = entries_for(cfg, [('ping_singlebeam', 'ping')] + PROPS)
ping = entries['/sensors/ping/range']
assert ping['gz_topic_name'] == '/boat_a/ping_raw/range'
assert '/boat_a/motor_port/cmd' in entries
assert '/boat_a/prop_port/cmd' in entries
# A renamed occupant is matched by its name, not the slot.
cfg = {'parts': [{'slot': 'ping', 'of': 'ping_mount', 'type': 'ping_singlebeam',
'name': 'sonar', 'topic': 'echo'}]}
Expand Down
42 changes: 27 additions & 15 deletions blueboat_gazebo/test/test_configure_vehicle.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@ def configure(config_path, out_dir):
return Path(out_dir)


def kit_config(parts=()):
"""
Return the tests' own vehicle.

The shipped hull and namespace, every slot filling with its default, plus
parts. Independent of what the shipped config fits, so these tests follow
the tool's behavior rather than the current product.
"""
cfg = yaml.safe_load(DEFAULT_CONFIG.read_text())
cfg['slot_defaults'] = True
cfg['parts'] = [dict(p) for p in parts]
return cfg


def links(urdf_path):
return {li.get('name') for li in ET.parse(urdf_path).getroot().findall('link')}

Expand Down Expand Up @@ -67,14 +81,15 @@ def test_default_config_reproduces_the_installed_artifacts(tmp_path):
if v.find('geometry/mesh') is not None}
ros_rpys = {v.find('origin').get('rpy') for v in ros_root.iter('visual')
if v.find('geometry/mesh') is not None}
assert ros_rpys == {'0 0 0'} and gz_rpys == {'1.5708 0 0'}
# legacy exports, and profile axes deliveries (axes="gltf" in part_visual)
assert ros_rpys <= {'0 0 0', '0 0 1.5708'}
assert gz_rpys <= {'1.5708 0 0', '1.5708 0 1.5708'}


def test_custom_config_flows_to_every_artifact(tmp_path):
"""A different config changes URDF, model and bridge consistently."""
cfg = yaml.safe_load(DEFAULT_CONFIG.read_text())
cfg['parts'] = [{'slot': 'ping_mount', 'type': 'none'},
{'slot': 'mast', 'type': 'blueboat_antenna_mast'}]
cfg = kit_config([{'slot': 'ping_mount', 'type': 'none'},
{'slot': 'mast', 'type': 'blueboat_antenna_mast'}])
config = tmp_path / 'custom.yaml'
config.write_text(yaml.safe_dump(cfg, sort_keys=False))
out = configure(config, tmp_path / 'v')
Expand All @@ -84,14 +99,13 @@ def test_custom_config_flows_to_every_artifact(tmp_path):
bridge = yaml.safe_load((out / 'ros_gz_bridge.yaml').read_text())
topics = {e['ros_topic_name'] for e in bridge}
assert '/blueboat/ping/range' not in topics
assert '/blueboat/motor_port/cmd' in topics
assert '/blueboat/prop_port/cmd' in topics


def test_drivetrain_follows_the_config(tmp_path):
"""A renamed or removed propeller moves or drops its Thruster and bridge topic."""
cfg = yaml.safe_load(DEFAULT_CONFIG.read_text())
cfg['parts'] = [{'slot': 'motor_port', 'type': 'none'},
{'slot': 'motor_stbd', 'type': 't200_prop_cw', 'name': 'right'}]
cfg = kit_config([{'slot': 'prop_port', 'type': 'none'},
{'slot': 'prop_stbd', 'type': 't200_prop_cw', 'name': 'right'}])
config = tmp_path / 'custom.yaml'
config.write_text(yaml.safe_dump(cfg, sort_keys=False))
out = configure(config, tmp_path / 'v')
Expand All @@ -102,7 +116,7 @@ def test_drivetrain_follows_the_config(tmp_path):
topics = {e['ros_topic_name'] for e in
yaml.safe_load((out / 'ros_gz_bridge.yaml').read_text())}
assert '/blueboat/right/cmd' in topics
assert not any(t.endswith('motor_port/cmd') for t in topics)
assert not any(t.endswith('prop_port/cmd') for t in topics)


def test_base_name_flows_to_the_model(tmp_path):
Expand All @@ -125,8 +139,7 @@ def test_base_name_flows_to_the_model(tmp_path):

def test_mismatched_config_fails_with_the_reason(tmp_path):
"""A slot entry that matches nothing fails the tool (and so the launch)."""
cfg = yaml.safe_load(DEFAULT_CONFIG.read_text())
cfg['parts'] = [{'slot': 'pingg', 'of': 'ping_mount', 'type': 'none'}]
cfg = kit_config([{'slot': 'pingg', 'of': 'ping_mount', 'type': 'none'}])
config = tmp_path / 'custom.yaml'
config.write_text(yaml.safe_dump(cfg, sort_keys=False))
out = subprocess.run([str(TOOL), '--config', str(config), '--out-dir', str(tmp_path / 'v')],
Expand Down Expand Up @@ -171,7 +184,7 @@ def test_name_flows_to_every_artifact(tmp_path):
# The bridge agrees, so gz and ROS sides stay in sync.
bridge = yaml.safe_load((out_dir / 'ros_gz_bridge.yaml').read_text())
gz_topics = {e['gz_topic_name'] for e in bridge}
assert any(t.startswith('/boat_b/motor_port/') for t in gz_topics), gz_topics
assert any(t.startswith('/boat_b/prop_port/') for t in gz_topics), gz_topics
assert not any(t.startswith('/blueboat/') for t in gz_topics), gz_topics


Expand Down Expand Up @@ -204,9 +217,8 @@ def test_two_instances_from_one_config_share_no_topic(tmp_path):
a command for one instance never reaches the other; only the fixed
/clock remains shared.
"""
cfg = yaml.safe_load(DEFAULT_CONFIG.read_text())
cfg['parts'] = [{'slot': 'ping', 'of': 'ping_mount', 'type': 'ping_singlebeam',
'topic': 'sonar'}]
cfg = kit_config([{'slot': 'ping', 'of': 'ping_mount', 'type': 'ping_singlebeam',
'topic': 'sonar'}])
config = tmp_path / 'custom.yaml'
config.write_text(yaml.safe_dump(cfg, sort_keys=False))
topics = {}
Expand Down
8 changes: 4 additions & 4 deletions blueboat_gazebo/test/test_gz_launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,10 @@ def test_model_loaded(sim):

def test_interfaces_advertised(sim):
"""Motor commands, speed feedback and the world clock are advertised."""
needed = ('/blueboat/motor_port/cmd',
'/blueboat/motor_stbd/cmd',
'/blueboat/motor_port/cmd/ang_vel',
'/blueboat/motor_stbd/cmd/ang_vel',
needed = ('/blueboat/prop_port/cmd',
'/blueboat/prop_stbd/cmd',
'/blueboat/prop_port/cmd/ang_vel',
'/blueboat/prop_stbd/cmd/ang_vel',
f'/world/{WORLD_NAME}/clock')
poll_until(
lambda: all(t in gz(sim, 'topic', '-l')[1] for t in needed), 30,
Expand Down
Loading
Loading