Skip to content

Commit b1b1312

Browse files
authored
Adds a sanity checker for graph & forest (#21818)
1 parent 8535cc8 commit b1b1312

5 files changed

+169
-4
lines changed

multibody/topology/spanning_forest.cc

+2
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,8 @@ bool SpanningForest::BuildForest() {
167167
/* Dole out the q's and v's, depth-first. (Phase 3) */
168168
AssignCoordinates();
169169

170+
DRAKE_ASSERT_VOID(SanityCheckForest());
171+
170172
return dynamics_ok();
171173
}
172174

multibody/topology/spanning_forest.h

+5
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,11 @@ class SpanningForest {
405405
@see LinkJointGraph::GenerateGraphvizString() */
406406
std::string GenerateGraphvizString(std::string_view label) const;
407407

408+
/** (Debugging, Testing) Runs a series of expensive tests to see that the
409+
Graph and Forest are internally consistent and throws if not. Does nothing
410+
if no Forest has been built. */
411+
void SanityCheckForest() const;
412+
408413
private:
409414
friend class LinkJointGraph;
410415
friend class copyable_unique_ptr<SpanningForest>;

multibody/topology/spanning_forest_debug.cc

+134
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,140 @@ namespace drake {
44
namespace multibody {
55
namespace internal {
66

7+
void SpanningForest::SanityCheckForest() const {
8+
// Should always have a LinkJointGraph backpointer even if empty.
9+
DRAKE_THROW_UNLESS(data_.graph != nullptr);
10+
if (mobods().empty()) return;
11+
12+
const Mobod& world_mobod = mobods(MobodIndex(0));
13+
DRAKE_THROW_UNLESS(world_mobod.is_world());
14+
DRAKE_THROW_UNLESS(!world_mobod.is_base_body());
15+
DRAKE_THROW_UNLESS(world_mobod.is_anchored());
16+
DRAKE_THROW_UNLESS(world_mobod.has_massful_follower_link());
17+
DRAKE_THROW_UNLESS(world_mobod.index() == MobodIndex(0));
18+
DRAKE_THROW_UNLESS(world_mobod.link_ordinal() == LinkOrdinal(0));
19+
DRAKE_THROW_UNLESS(!world_mobod.joint_ordinal().is_valid());
20+
DRAKE_THROW_UNLESS(!world_mobod.tree().is_valid());
21+
DRAKE_THROW_UNLESS(world_mobod.level() == 0);
22+
DRAKE_THROW_UNLESS(!world_mobod.inboard().is_valid());
23+
DRAKE_THROW_UNLESS(ssize(world_mobod.outboards()) == ssize(trees()));
24+
25+
/* WeldedMobods groups and LinkComposites are different but in either case
26+
there is always a World group or composite, it must come first, and the World
27+
Link or Mobod must be listed first in that group or composite. */
28+
DRAKE_THROW_UNLESS(world_mobod.welded_mobods_group().has_value());
29+
DRAKE_THROW_UNLESS(world_mobod.welded_mobods_group() == WeldedMobodsIndex(0));
30+
DRAKE_THROW_UNLESS(welded_mobods()[0][0] == MobodIndex(0));
31+
32+
const LinkJointGraph::Link& world_link = links(LinkOrdinal(0));
33+
DRAKE_THROW_UNLESS(world_link.mobod_index() == 0);
34+
DRAKE_THROW_UNLESS(world_link.composite().has_value());
35+
DRAKE_THROW_UNLESS(world_link.composite() == LinkCompositeIndex(0));
36+
DRAKE_THROW_UNLESS(graph().link_composites()[0].links[0] == LinkIndex(0));
37+
38+
for (MobodIndex index(1); index < ssize(mobods()); ++index) {
39+
const Mobod& mobod = mobods(index);
40+
DRAKE_THROW_UNLESS(mobod.index() == index);
41+
DRAKE_THROW_UNLESS(links(mobod.link_ordinal()).mobod_index() == index);
42+
DRAKE_THROW_UNLESS(joints(mobod.joint_ordinal()).mobod_index() == index);
43+
44+
// The mobod's Tree must include the mobod.
45+
const Tree& tree = trees(mobod.tree());
46+
DRAKE_THROW_UNLESS(tree.base_mobod() <= mobod.index() &&
47+
mobod.index() <= tree.last_mobod());
48+
49+
// If the mobod is part of a WeldedMobods group, that group must include it!
50+
if (mobod.welded_mobods_group().has_value()) {
51+
const auto& welded_group = welded_mobods()[*mobod.welded_mobods_group()];
52+
DRAKE_THROW_UNLESS(std::find(welded_group.begin(), welded_group.end(),
53+
index) != welded_group.end());
54+
}
55+
}
56+
57+
/* Check that Links alleged to be in a LinkComposite agree that they are,
58+
and that massless LinkComposites are composed only of massless Links. */
59+
for (LinkCompositeIndex composite_index(0);
60+
composite_index < ssize(graph().link_composites()); ++composite_index) {
61+
const LinkJointGraph::LinkComposite& composite =
62+
graph().link_composites(composite_index);
63+
bool all_links_are_massless = true;
64+
for (const LinkIndex& link_index : composite.links) {
65+
const LinkJointGraph::Link& link = link_by_index(link_index);
66+
DRAKE_THROW_UNLESS(link.composite() == composite_index);
67+
if (!link.is_massless()) all_links_are_massless = false;
68+
}
69+
DRAKE_THROW_UNLESS(composite.is_massless == all_links_are_massless);
70+
}
71+
72+
/* Check that all the Links and Joints have Mobods that point back to them. */
73+
for (LinkOrdinal link_ordinal{0}; link_ordinal < ssize(links());
74+
++link_ordinal) {
75+
const Link& link = links(link_ordinal);
76+
DRAKE_THROW_UNLESS(link.ordinal() == link_ordinal);
77+
DRAKE_THROW_UNLESS(&link_by_index(link.index()) == &link);
78+
DRAKE_THROW_UNLESS(mobods(link.mobod_index()).HasFollower(link_ordinal));
79+
}
80+
for (JointOrdinal joint_ordinal{0}; joint_ordinal < ssize(joints());
81+
++joint_ordinal) {
82+
const Joint& joint = joints(joint_ordinal);
83+
DRAKE_THROW_UNLESS(joint.ordinal() == joint_ordinal);
84+
DRAKE_THROW_UNLESS(&joint_by_index(joint.index()) == &joint);
85+
if (!joint.mobod_index().is_valid()) continue; // Not modeled
86+
DRAKE_THROW_UNLESS(mobods(joint.mobod_index()).joint_ordinal() ==
87+
joint_ordinal);
88+
}
89+
90+
/* Check tree height and make sure the tree's mobods agree they are part
91+
of the tree. Make sure the forest height is the max tree height. */
92+
int forest_height = 1; // World alone has a height of 1.
93+
for (const auto& tree : trees()) {
94+
DRAKE_THROW_UNLESS(tree.forest_ == this); // Check backpointer.
95+
DRAKE_THROW_UNLESS(mobods(tree.base_mobod()).level() == 1);
96+
DRAKE_THROW_UNLESS(tree.last_mobod() >= tree.base_mobod());
97+
int computed_height = 0;
98+
for (auto& mobod : tree) {
99+
DRAKE_THROW_UNLESS(mobod.tree() == tree.index());
100+
computed_height = std::max(computed_height, mobod.level());
101+
}
102+
DRAKE_THROW_UNLESS(tree.height() == computed_height);
103+
forest_height = std::max(forest_height, tree.height() + 1);
104+
}
105+
DRAKE_THROW_UNLESS(height() == forest_height);
106+
107+
/* Check each WeldedMobods group to make sure:
108+
- the mobods it contains agree that they are contained in that group
109+
- mobods welded to World know they are "anchored"
110+
- except for the World group, the first entry in each group is a
111+
Mobod that has a non-weld inboard joint, and all the others are Welds.
112+
- the first entry in each WeldedMobods group should map to the Link (the
113+
"active link") that is listed first in its LinkComposite. */
114+
for (WeldedMobodsIndex welded_mobods_index(0);
115+
welded_mobods_index < ssize(welded_mobods()); ++welded_mobods_index) {
116+
const std::vector<MobodIndex> welded_mobods_group =
117+
welded_mobods()[welded_mobods_index];
118+
for (MobodIndex mobod_index : welded_mobods_group) {
119+
const Mobod& mobod = mobods(mobod_index);
120+
const bool is_active_mobod = (mobod_index == welded_mobods_group[0]);
121+
const bool should_be_weld = // World or an interior body.
122+
welded_mobods_index == 0 || !is_active_mobod;
123+
DRAKE_THROW_UNLESS(mobod.is_weld() == should_be_weld);
124+
DRAKE_THROW_UNLESS(mobod.welded_mobods_group() == welded_mobods_index);
125+
const bool should_be_anchored = (welded_mobods_index == 0);
126+
DRAKE_THROW_UNLESS(mobod.is_anchored() == should_be_anchored);
127+
128+
if (is_active_mobod) {
129+
const LinkOrdinal active_link_ordinal = mobod.link_ordinal();
130+
const Link& active_link = links(active_link_ordinal);
131+
const std::optional<LinkCompositeIndex> link_composite =
132+
active_link.composite();
133+
DRAKE_THROW_UNLESS(link_composite.has_value());
134+
DRAKE_THROW_UNLESS(graph().link_composites(*link_composite).links[0] ==
135+
active_link.index());
136+
}
137+
}
138+
}
139+
}
140+
7141
// TODO(sherm1) Consider accommodation for colorblind people. Avoid red/green
8142
// and/or use line styles. Update the legend.
9143

multibody/topology/test/link_joint_graph_test.cc

+3-3
Original file line numberDiff line numberDiff line change
@@ -353,9 +353,9 @@ that the wrapping worked correctly.
353353
354354
Here is the graph:
355355
356-
{9}
357-
^
358-
|
356+
{9} {l} Link l
357+
^ -> revolute joint
358+
| => weld joint
359359
{1} -> {2} => {3} => {8}
360360
World
361361
{0} {4} => {5} -> {6}

multibody/topology/test/spanning_forest_test.cc

+25-1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ GTEST_TEST(SpanningForest, WorldOnlyTest) {
5757
EXPECT_TRUE(graph.BuildForest());
5858
EXPECT_TRUE(graph.forest_is_valid());
5959
EXPECT_TRUE(forest.is_valid());
60+
EXPECT_NO_THROW(forest.SanityCheckForest());
6061
EXPECT_EQ(&forest.graph(), &graph);
6162
const SpanningForest::Mobod& world = forest.mobods(MobodIndex(0));
6263
EXPECT_EQ(&world, &forest.world_mobod());
@@ -293,6 +294,7 @@ GTEST_TEST(SpanningForest, MultipleBranchesDefaultOptions) {
293294

294295
// Build with default options.
295296
EXPECT_TRUE(graph.BuildForest());
297+
EXPECT_NO_THROW(forest.SanityCheckForest());
296298

297299
EXPECT_EQ(forest.options(), ForestBuildingOptions::kDefault);
298300
EXPECT_EQ(forest.options(left_instance), ForestBuildingOptions::kDefault);
@@ -678,6 +680,7 @@ GTEST_TEST(SpanningForest, SerialChainAndMore) {
678680
ForestBuildingOptions::kStatic);
679681
EXPECT_TRUE(graph.BuildForest());
680682
const SpanningForest& forest = graph.forest();
683+
EXPECT_NO_THROW(forest.SanityCheckForest());
681684
EXPECT_TRUE(graph.forest_is_valid());
682685

683686
// Verify that ChangeJointType() rejects an attempt to change a static link's
@@ -817,6 +820,7 @@ GTEST_TEST(SpanningForest, SerialChainAndMore) {
817820
ForestBuildingOptions::kMergeLinkComposites |
818821
ForestBuildingOptions::kStatic);
819822
EXPECT_TRUE(graph.BuildForest());
823+
EXPECT_NO_THROW(forest.SanityCheckForest());
820824

821825
// The graph shouldn't change from SpanningForest 1, but the forest will.
822826
EXPECT_EQ(ssize(graph.joints()) - graph.num_user_joints(), 4);
@@ -861,6 +865,7 @@ GTEST_TEST(SpanningForest, SerialChainAndMore) {
861865
graph.ChangeJointFlags(joint_10_11_index, JointFlags::kMustBeModeled);
862866
// Built the forest with same options as used for 2a.
863867
EXPECT_TRUE(graph.BuildForest());
868+
EXPECT_NO_THROW(forest.SanityCheckForest());
864869

865870
EXPECT_EQ(ssize(forest.mobods()), 9);
866871
EXPECT_EQ(ssize(forest.trees()), 3);
@@ -895,6 +900,7 @@ GTEST_TEST(SpanningForest, SerialChainAndMore) {
895900
ForestBuildingOptions::kMergeLinkComposites |
896901
ForestBuildingOptions::kStatic);
897902
EXPECT_TRUE(graph.BuildForest());
903+
EXPECT_NO_THROW(forest.SanityCheckForest());
898904

899905
EXPECT_EQ(ssize(forest.mobods()), 6);
900906
EXPECT_EQ(ssize(forest.trees()), 1);
@@ -1056,6 +1062,7 @@ GTEST_TEST(SpanningForest, WeldedSubgraphs) {
10561062

10571063
EXPECT_TRUE(graph.BuildForest());
10581064
const SpanningForest& forest = graph.forest();
1065+
EXPECT_NO_THROW(forest.SanityCheckForest());
10591066

10601067
EXPECT_EQ(graph.num_user_links(), 14); // Same as before building.
10611068
EXPECT_EQ(graph.num_user_joints(), 14);
@@ -1223,6 +1230,7 @@ GTEST_TEST(SpanningForest, WeldedSubgraphs) {
12231230
graph.SetGlobalForestBuildingOptions(
12241231
ForestBuildingOptions::kMergeLinkComposites);
12251232
EXPECT_TRUE(graph.BuildForest());
1233+
EXPECT_NO_THROW(forest.SanityCheckForest());
12261234

12271235
EXPECT_EQ(ssize(graph.links()), 15); // Only one added shadow.
12281236
EXPECT_EQ(ssize(graph.link_composites()), 3);
@@ -1376,6 +1384,7 @@ GTEST_TEST(SpanningForest, MasslessLinksChangeLoopBreaking) {
13761384

13771385
EXPECT_TRUE(graph.BuildForest()); // Using default options.
13781386
const SpanningForest& forest = graph.forest();
1387+
EXPECT_NO_THROW(forest.SanityCheckForest());
13791388

13801389
EXPECT_EQ(ssize(graph.links()), 8); // Added a shadow.
13811390
EXPECT_EQ(ssize(graph.joints()), 7);
@@ -1392,6 +1401,7 @@ GTEST_TEST(SpanningForest, MasslessLinksChangeLoopBreaking) {
13921401
// (Tests Case 2 in ExtendTreesOneLevel())
13931402
graph.ChangeLinkFlags(LinkIndex(3), LinkFlags::kMassless);
13941403
EXPECT_TRUE(graph.BuildForest());
1404+
EXPECT_NO_THROW(forest.SanityCheckForest());
13951405

13961406
// Check that links not in a composite still respond correctly.
13971407
EXPECT_TRUE(graph.link_and_its_composite_are_massless(LinkOrdinal(3)));
@@ -1411,6 +1421,7 @@ GTEST_TEST(SpanningForest, MasslessLinksChangeLoopBreaking) {
14111421
// (Tests Case 3 in ExtendTreesOneLevel())
14121422
graph.ChangeLinkFlags(LinkIndex(4), LinkFlags::kMassless);
14131423
EXPECT_TRUE(graph.BuildForest());
1424+
EXPECT_NO_THROW(forest.SanityCheckForest());
14141425

14151426
EXPECT_EQ(ssize(graph.links()), 8);
14161427
EXPECT_EQ(ssize(graph.joints()), 7);
@@ -1478,6 +1489,7 @@ GTEST_TEST(SpanningForest, MasslessBodiesShareSplitLink) {
14781489

14791490
EXPECT_TRUE(graph.BuildForest()); // Using default options.
14801491
const SpanningForest& forest = graph.forest();
1492+
EXPECT_NO_THROW(forest.SanityCheckForest());
14811493

14821494
EXPECT_EQ(ssize(graph.links()), 5); // After modeling.
14831495
EXPECT_EQ(graph.num_user_links(), 4);
@@ -1566,6 +1578,7 @@ GTEST_TEST(SpanningForest, DoubleLoop) {
15661578

15671579
EXPECT_TRUE(graph.BuildForest()); // Using default options.
15681580
const SpanningForest& forest = graph.forest();
1581+
EXPECT_NO_THROW(forest.SanityCheckForest());
15691582

15701583
EXPECT_EQ(ssize(graph.links()), 10); // After modeling.
15711584
EXPECT_EQ(ssize(graph.joints()), 9);
@@ -1668,6 +1681,7 @@ GTEST_TEST(SpanningForest, WorldCompositeComesFirst) {
16681681

16691682
EXPECT_TRUE(graph.BuildForest()); // Using default options.
16701683
const SpanningForest& forest = graph.forest();
1684+
EXPECT_NO_THROW(forest.SanityCheckForest());
16711685

16721686
EXPECT_EQ(ssize(graph.links()), 5);
16731687
EXPECT_EQ(ssize(forest.mobods()), 5); // Because we're not merging.
@@ -1697,7 +1711,7 @@ GTEST_TEST(SpanningForest, WorldCompositeComesFirst) {
16971711
graph.SetGlobalForestBuildingOptions(
16981712
ForestBuildingOptions::kMergeLinkComposites);
16991713
EXPECT_TRUE(graph.BuildForest());
1700-
1714+
EXPECT_NO_THROW(forest.SanityCheckForest());
17011715
EXPECT_EQ(ssize(forest.mobods()), 3); // Because we're merging.
17021716
EXPECT_EQ(forest.mobods(MobodIndex(0)).follower_link_ordinals(),
17031717
(std::vector<LinkOrdinal>{LinkOrdinal(0), LinkOrdinal(3)}));
@@ -1984,6 +1998,7 @@ GTEST_TEST(SpanningForest, LoopWithComposites) {
19841998
ForestBuildingOptions::kMergeLinkComposites);
19851999
EXPECT_TRUE(graph.BuildForest());
19862000
const SpanningForest& forest = graph.forest();
2001+
EXPECT_NO_THROW(forest.SanityCheckForest());
19872002

19882003
// After modeling
19892004
EXPECT_EQ(ssize(graph.links()), 12); // split one, added shadow
@@ -2022,6 +2037,7 @@ GTEST_TEST(SpanningForest, LoopWithComposites) {
20222037
EXPECT_EQ(ssize(graph_copy.links()), 12);
20232038
EXPECT_TRUE(graph_copy.forest_is_valid());
20242039
const SpanningForest& copy_model = graph_copy.forest();
2040+
EXPECT_NO_THROW(copy_model.SanityCheckForest());
20252041
EXPECT_NE(&copy_model, &forest);
20262042
EXPECT_EQ(&copy_model.graph(), &graph_copy); // backpointer
20272043

@@ -2030,30 +2046,35 @@ GTEST_TEST(SpanningForest, LoopWithComposites) {
20302046
EXPECT_EQ(ssize(graph_assign.links()), 12);
20312047
EXPECT_TRUE(graph_assign.forest_is_valid());
20322048
EXPECT_NE(&graph_assign.forest(), &forest);
2049+
EXPECT_NO_THROW(graph_assign.forest().SanityCheckForest());
20332050
EXPECT_EQ(&graph_assign.forest().graph(), &graph_assign);
20342051

20352052
LinkJointGraph graph_move(std::move(graph));
20362053
EXPECT_EQ(ssize(graph_move.links()), 12);
20372054
EXPECT_EQ(ssize(graph.links()), 1); // Just world now.
20382055
EXPECT_EQ(&graph_move.forest(), &forest);
2056+
EXPECT_NO_THROW(graph_move.forest().SanityCheckForest());
20392057
EXPECT_EQ(&graph_move.forest().graph(), &graph_move);
20402058
// graph is now default-constructed so still has a forest
20412059
EXPECT_NE(&graph.forest(), &forest);
20422060
EXPECT_FALSE(graph.forest_is_valid());
20432061
EXPECT_EQ(&graph.forest().graph(), &graph);
2062+
EXPECT_NO_THROW(graph.forest().SanityCheckForest()); // Empty but OK.
20442063

20452064
LinkJointGraph graph_move_assign;
20462065
graph_move_assign = std::move(graph_copy);
20472066
EXPECT_EQ(ssize(graph_move_assign.links()), 12);
20482067
EXPECT_TRUE(graph_move_assign.forest_is_valid());
20492068
EXPECT_EQ(&graph_move_assign.forest(), &copy_model);
2069+
EXPECT_NO_THROW(graph_move_assign.forest().SanityCheckForest());
20502070
EXPECT_EQ(&graph_move_assign.forest().graph(), &graph_move_assign);
20512071
// graph_copy is now default-constructed. Should have world and a
20522072
// new (empty) forest.
20532073
EXPECT_EQ(ssize(graph_copy.links()), 1);
20542074
EXPECT_NE(&graph_copy.forest(), &copy_model);
20552075
EXPECT_FALSE(graph_copy.forest_is_valid());
20562076
EXPECT_EQ(&graph_copy.forest().graph(), &graph_copy);
2077+
EXPECT_NO_THROW(graph_copy.forest().SanityCheckForest()); // Empty but OK.
20572078
}
20582079

20592080
/* Make sure massless, merged composites are working correctly. They are
@@ -2129,6 +2150,7 @@ GTEST_TEST(SpanningForest, MasslessMergedComposites) {
21292150
EXPECT_EQ(ssize(graph.loop_constraints()), 0);
21302151

21312152
EXPECT_TRUE(graph.BuildForest());
2153+
EXPECT_NO_THROW(forest.SanityCheckForest());
21322154

21332155
// After modeling
21342156
EXPECT_EQ(ssize(graph.links()), 10); // added shadow 3s {9}
@@ -2169,6 +2191,7 @@ GTEST_TEST(SpanningForest, MasslessMergedComposites) {
21692191
height of 3. */
21702192
graph.ChangeJointType(JointIndex(6), "revolute");
21712193
EXPECT_TRUE(graph.BuildForest());
2194+
EXPECT_NO_THROW(forest.SanityCheckForest());
21722195

21732196
// The links are massless and so is their composite.
21742197
for (LinkOrdinal link_ordinal(4); link_ordinal <= 6; ++link_ordinal)
@@ -2195,6 +2218,7 @@ GTEST_TEST(SpanningForest, MasslessMergedComposites) {
21952218
graph.ChangeLinkFlags(LinkIndex(7), LinkFlags::kMassless);
21962219
graph.ChangeLinkFlags(LinkIndex(8), LinkFlags::kMassless);
21972220
EXPECT_TRUE(graph.BuildForest());
2221+
EXPECT_NO_THROW(forest.SanityCheckForest());
21982222
const auto& newer_shadow_link = graph.link_by_index(LinkIndex(9));
21992223
EXPECT_TRUE(newer_shadow_link.is_shadow());
22002224
EXPECT_EQ(newer_shadow_link.name(), "link3$1");

0 commit comments

Comments
 (0)