foodforthought-cli 0.2.7__py3-none-any.whl → 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.
- ate/__init__.py +6 -0
- ate/__main__.py +16 -0
- ate/auth/__init__.py +1 -0
- ate/auth/device_flow.py +141 -0
- ate/auth/token_store.py +96 -0
- ate/behaviors/__init__.py +100 -0
- ate/behaviors/approach.py +399 -0
- ate/behaviors/common.py +686 -0
- ate/behaviors/tree.py +454 -0
- ate/cli.py +855 -3995
- ate/client.py +90 -0
- ate/commands/__init__.py +168 -0
- ate/commands/auth.py +389 -0
- ate/commands/bridge.py +448 -0
- ate/commands/data.py +185 -0
- ate/commands/deps.py +111 -0
- ate/commands/generate.py +384 -0
- ate/commands/memory.py +907 -0
- ate/commands/parts.py +166 -0
- ate/commands/primitive.py +399 -0
- ate/commands/protocol.py +288 -0
- ate/commands/recording.py +524 -0
- ate/commands/repo.py +154 -0
- ate/commands/simulation.py +291 -0
- ate/commands/skill.py +303 -0
- ate/commands/skills.py +487 -0
- ate/commands/team.py +147 -0
- ate/commands/workflow.py +271 -0
- ate/detection/__init__.py +38 -0
- ate/detection/base.py +142 -0
- ate/detection/color_detector.py +399 -0
- ate/detection/trash_detector.py +322 -0
- ate/drivers/__init__.py +39 -0
- ate/drivers/ble_transport.py +405 -0
- ate/drivers/mechdog.py +942 -0
- ate/drivers/wifi_camera.py +477 -0
- ate/interfaces/__init__.py +187 -0
- ate/interfaces/base.py +273 -0
- ate/interfaces/body.py +267 -0
- ate/interfaces/detection.py +282 -0
- ate/interfaces/locomotion.py +422 -0
- ate/interfaces/manipulation.py +408 -0
- ate/interfaces/navigation.py +389 -0
- ate/interfaces/perception.py +362 -0
- ate/interfaces/sensors.py +247 -0
- ate/interfaces/types.py +371 -0
- ate/llm_proxy.py +239 -0
- ate/mcp_server.py +387 -0
- ate/memory/__init__.py +35 -0
- ate/memory/cloud.py +244 -0
- ate/memory/context.py +269 -0
- ate/memory/embeddings.py +184 -0
- ate/memory/export.py +26 -0
- ate/memory/merge.py +146 -0
- ate/memory/migrate/__init__.py +34 -0
- ate/memory/migrate/base.py +89 -0
- ate/memory/migrate/pipeline.py +189 -0
- ate/memory/migrate/sources/__init__.py +13 -0
- ate/memory/migrate/sources/chroma.py +170 -0
- ate/memory/migrate/sources/pinecone.py +120 -0
- ate/memory/migrate/sources/qdrant.py +110 -0
- ate/memory/migrate/sources/weaviate.py +160 -0
- ate/memory/reranker.py +353 -0
- ate/memory/search.py +26 -0
- ate/memory/store.py +548 -0
- ate/recording/__init__.py +83 -0
- ate/recording/demonstration.py +378 -0
- ate/recording/session.py +415 -0
- ate/recording/upload.py +304 -0
- ate/recording/visual.py +416 -0
- ate/recording/wrapper.py +95 -0
- ate/robot/__init__.py +221 -0
- ate/robot/agentic_servo.py +856 -0
- ate/robot/behaviors.py +493 -0
- ate/robot/ble_capture.py +1000 -0
- ate/robot/ble_enumerate.py +506 -0
- ate/robot/calibration.py +668 -0
- ate/robot/calibration_state.py +388 -0
- ate/robot/commands.py +3735 -0
- ate/robot/direction_calibration.py +554 -0
- ate/robot/discovery.py +441 -0
- ate/robot/introspection.py +330 -0
- ate/robot/llm_system_id.py +654 -0
- ate/robot/locomotion_calibration.py +508 -0
- ate/robot/manager.py +270 -0
- ate/robot/marker_generator.py +611 -0
- ate/robot/perception.py +502 -0
- ate/robot/primitives.py +614 -0
- ate/robot/profiles.py +281 -0
- ate/robot/registry.py +322 -0
- ate/robot/servo_mapper.py +1153 -0
- ate/robot/skill_upload.py +675 -0
- ate/robot/target_calibration.py +500 -0
- ate/robot/teach.py +515 -0
- ate/robot/types.py +242 -0
- ate/robot/visual_labeler.py +1048 -0
- ate/robot/visual_servo_loop.py +494 -0
- ate/robot/visual_servoing.py +570 -0
- ate/robot/visual_system_id.py +906 -0
- ate/transports/__init__.py +121 -0
- ate/transports/base.py +394 -0
- ate/transports/ble.py +405 -0
- ate/transports/hybrid.py +444 -0
- ate/transports/serial.py +345 -0
- ate/urdf/__init__.py +30 -0
- ate/urdf/capture.py +582 -0
- ate/urdf/cloud.py +491 -0
- ate/urdf/collision.py +271 -0
- ate/urdf/commands.py +708 -0
- ate/urdf/depth.py +360 -0
- ate/urdf/inertial.py +312 -0
- ate/urdf/kinematics.py +330 -0
- ate/urdf/lifting.py +415 -0
- ate/urdf/meshing.py +300 -0
- ate/urdf/models/__init__.py +110 -0
- ate/urdf/models/depth_anything.py +253 -0
- ate/urdf/models/sam2.py +324 -0
- ate/urdf/motion_analysis.py +396 -0
- ate/urdf/pipeline.py +468 -0
- ate/urdf/scale.py +256 -0
- ate/urdf/scan_session.py +411 -0
- ate/urdf/segmentation.py +299 -0
- ate/urdf/synthesis.py +319 -0
- ate/urdf/topology.py +336 -0
- ate/urdf/validation.py +371 -0
- {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/METADATA +9 -1
- foodforthought_cli-0.3.0.dist-info/RECORD +166 -0
- {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/WHEEL +1 -1
- foodforthought_cli-0.2.7.dist-info/RECORD +0 -44
- {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/entry_points.txt +0 -0
- {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/top_level.txt +0 -0
ate/urdf/topology.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kinematic topology discovery for URDF generation.
|
|
3
|
+
|
|
4
|
+
This module handles discovering the parent-child relationships
|
|
5
|
+
between robot links based on their motion patterns:
|
|
6
|
+
- Links that move together are likely connected
|
|
7
|
+
- The link with least motion is likely the base
|
|
8
|
+
- Parent-child relationships form a tree structure
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Dict, List, Optional, Tuple, Set
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import numpy as np
|
|
19
|
+
NUMPY_AVAILABLE = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
NUMPY_AVAILABLE = False
|
|
22
|
+
np = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TopologyError(Exception):
|
|
26
|
+
"""Error during topology discovery."""
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class LinkNode:
|
|
32
|
+
"""Node in the kinematic tree."""
|
|
33
|
+
name: str
|
|
34
|
+
parent: Optional[str]
|
|
35
|
+
children: List[str]
|
|
36
|
+
is_fixed: bool = False # Base link
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class KinematicTree:
|
|
41
|
+
"""Kinematic tree structure."""
|
|
42
|
+
nodes: Dict[str, LinkNode]
|
|
43
|
+
root: str
|
|
44
|
+
|
|
45
|
+
def get_parent(self, link_name: str) -> Optional[str]:
|
|
46
|
+
"""Get parent of a link."""
|
|
47
|
+
if link_name in self.nodes:
|
|
48
|
+
return self.nodes[link_name].parent
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
def get_children(self, link_name: str) -> List[str]:
|
|
52
|
+
"""Get children of a link."""
|
|
53
|
+
if link_name in self.nodes:
|
|
54
|
+
return self.nodes[link_name].children
|
|
55
|
+
return []
|
|
56
|
+
|
|
57
|
+
def get_chain_to_root(self, link_name: str) -> List[str]:
|
|
58
|
+
"""Get chain of links from given link to root."""
|
|
59
|
+
chain = []
|
|
60
|
+
current = link_name
|
|
61
|
+
while current is not None:
|
|
62
|
+
chain.append(current)
|
|
63
|
+
current = self.get_parent(current)
|
|
64
|
+
return chain
|
|
65
|
+
|
|
66
|
+
def get_ordered_links(self) -> List[str]:
|
|
67
|
+
"""Get links in depth-first order from root."""
|
|
68
|
+
ordered = []
|
|
69
|
+
visited = set()
|
|
70
|
+
|
|
71
|
+
def dfs(node_name: str):
|
|
72
|
+
if node_name in visited:
|
|
73
|
+
return
|
|
74
|
+
visited.add(node_name)
|
|
75
|
+
ordered.append(node_name)
|
|
76
|
+
for child in self.get_children(node_name):
|
|
77
|
+
dfs(child)
|
|
78
|
+
|
|
79
|
+
dfs(self.root)
|
|
80
|
+
return ordered
|
|
81
|
+
|
|
82
|
+
def validate(self) -> bool:
|
|
83
|
+
"""Validate tree structure."""
|
|
84
|
+
# Check that all nodes are reachable from root
|
|
85
|
+
ordered = self.get_ordered_links()
|
|
86
|
+
return len(ordered) == len(self.nodes)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def compute_motion_correlation(
|
|
90
|
+
trajectory1: "LinkTrajectory",
|
|
91
|
+
trajectory2: "LinkTrajectory",
|
|
92
|
+
) -> float:
|
|
93
|
+
"""
|
|
94
|
+
Compute motion correlation between two link trajectories.
|
|
95
|
+
|
|
96
|
+
High correlation indicates the links move together (likely connected).
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
trajectory1: First link trajectory
|
|
100
|
+
trajectory2: Second link trajectory
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Correlation coefficient [0, 1]
|
|
104
|
+
"""
|
|
105
|
+
if not NUMPY_AVAILABLE:
|
|
106
|
+
raise TopologyError("NumPy not available")
|
|
107
|
+
|
|
108
|
+
from .motion_analysis import LinkTrajectory
|
|
109
|
+
|
|
110
|
+
# Find common frames
|
|
111
|
+
frames1 = set(trajectory1.frame_indices)
|
|
112
|
+
frames2 = set(trajectory2.frame_indices)
|
|
113
|
+
common = sorted(frames1 & frames2)
|
|
114
|
+
|
|
115
|
+
if len(common) < 3:
|
|
116
|
+
return 0.0
|
|
117
|
+
|
|
118
|
+
# Get velocities at common frames
|
|
119
|
+
vel1 = trajectory1.get_velocity()
|
|
120
|
+
vel2 = trajectory2.get_velocity()
|
|
121
|
+
|
|
122
|
+
# Align to common frames
|
|
123
|
+
idx1 = [trajectory1.frame_indices.index(f) for f in common[:-1]]
|
|
124
|
+
idx2 = [trajectory2.frame_indices.index(f) for f in common[:-1]]
|
|
125
|
+
|
|
126
|
+
v1 = vel1[idx1] if len(vel1) > 0 else np.zeros((len(common)-1, 3))
|
|
127
|
+
v2 = vel2[idx2] if len(vel2) > 0 else np.zeros((len(common)-1, 3))
|
|
128
|
+
|
|
129
|
+
# Compute correlation of velocity magnitudes
|
|
130
|
+
mag1 = np.linalg.norm(v1, axis=1)
|
|
131
|
+
mag2 = np.linalg.norm(v2, axis=1)
|
|
132
|
+
|
|
133
|
+
if np.std(mag1) < 1e-6 or np.std(mag2) < 1e-6:
|
|
134
|
+
return 0.5 # No motion variance
|
|
135
|
+
|
|
136
|
+
corr = np.corrcoef(mag1, mag2)[0, 1]
|
|
137
|
+
return float(np.clip(corr, 0, 1))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def identify_base_link(
|
|
141
|
+
trajectories: Dict[str, "LinkTrajectory"],
|
|
142
|
+
fixed_hints: Optional[Set[str]] = None,
|
|
143
|
+
) -> str:
|
|
144
|
+
"""
|
|
145
|
+
Identify the base (fixed) link of the robot.
|
|
146
|
+
|
|
147
|
+
The base link typically has:
|
|
148
|
+
- Least total motion
|
|
149
|
+
- Or is explicitly marked as fixed by the user
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
trajectories: Dict of link trajectories
|
|
153
|
+
fixed_hints: Optional set of link names marked as fixed by user
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Name of the base link
|
|
157
|
+
"""
|
|
158
|
+
if not NUMPY_AVAILABLE:
|
|
159
|
+
raise TopologyError("NumPy not available")
|
|
160
|
+
|
|
161
|
+
# If user marked a link as fixed, use that
|
|
162
|
+
if fixed_hints:
|
|
163
|
+
for name in fixed_hints:
|
|
164
|
+
if name in trajectories:
|
|
165
|
+
logger.info(f"Using user-marked base link: {name}")
|
|
166
|
+
return name
|
|
167
|
+
|
|
168
|
+
# Otherwise, find link with least motion
|
|
169
|
+
motion_scores = {}
|
|
170
|
+
for name, traj in trajectories.items():
|
|
171
|
+
motion_scores[name] = traj.get_total_displacement()
|
|
172
|
+
|
|
173
|
+
base = min(motion_scores, key=motion_scores.get)
|
|
174
|
+
logger.info(f"Identified base link: {base} (displacement: {motion_scores[base]:.4f}m)")
|
|
175
|
+
|
|
176
|
+
return base
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def build_kinematic_tree(
|
|
180
|
+
trajectories: Dict[str, "LinkTrajectory"],
|
|
181
|
+
fixed_hints: Optional[Set[str]] = None,
|
|
182
|
+
adjacency_hints: Optional[Dict[str, str]] = None,
|
|
183
|
+
) -> KinematicTree:
|
|
184
|
+
"""
|
|
185
|
+
Build kinematic tree from link trajectories.
|
|
186
|
+
|
|
187
|
+
Uses motion correlation to determine parent-child relationships:
|
|
188
|
+
- Base link has no parent
|
|
189
|
+
- Each other link's parent is the most correlated link that has less motion
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
trajectories: Dict of link trajectories
|
|
193
|
+
fixed_hints: Links marked as fixed/base by user
|
|
194
|
+
adjacency_hints: Optional dict of link -> parent hints
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
KinematicTree structure
|
|
198
|
+
"""
|
|
199
|
+
if not NUMPY_AVAILABLE:
|
|
200
|
+
raise TopologyError("NumPy not available")
|
|
201
|
+
|
|
202
|
+
link_names = list(trajectories.keys())
|
|
203
|
+
n_links = len(link_names)
|
|
204
|
+
|
|
205
|
+
if n_links == 0:
|
|
206
|
+
raise TopologyError("No links to build tree from")
|
|
207
|
+
|
|
208
|
+
# Identify base
|
|
209
|
+
base_name = identify_base_link(trajectories, fixed_hints)
|
|
210
|
+
|
|
211
|
+
# Compute pairwise correlations
|
|
212
|
+
correlations = np.zeros((n_links, n_links))
|
|
213
|
+
for i, name1 in enumerate(link_names):
|
|
214
|
+
for j, name2 in enumerate(link_names):
|
|
215
|
+
if i != j:
|
|
216
|
+
correlations[i, j] = compute_motion_correlation(
|
|
217
|
+
trajectories[name1],
|
|
218
|
+
trajectories[name2],
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Compute motion magnitudes
|
|
222
|
+
motion_magnitudes = {
|
|
223
|
+
name: traj.get_total_displacement()
|
|
224
|
+
for name, traj in trajectories.items()
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
# Build tree using Prim's algorithm variant
|
|
228
|
+
# Start from base, add links in order of decreasing correlation
|
|
229
|
+
nodes = {}
|
|
230
|
+
added = {base_name}
|
|
231
|
+
nodes[base_name] = LinkNode(
|
|
232
|
+
name=base_name,
|
|
233
|
+
parent=None,
|
|
234
|
+
children=[],
|
|
235
|
+
is_fixed=True,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
while len(added) < n_links:
|
|
239
|
+
best_child = None
|
|
240
|
+
best_parent = None
|
|
241
|
+
best_score = -1
|
|
242
|
+
|
|
243
|
+
for parent_name in added:
|
|
244
|
+
parent_idx = link_names.index(parent_name)
|
|
245
|
+
|
|
246
|
+
for child_idx, child_name in enumerate(link_names):
|
|
247
|
+
if child_name in added:
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
# Use adjacency hints if available
|
|
251
|
+
if adjacency_hints and child_name in adjacency_hints:
|
|
252
|
+
if adjacency_hints[child_name] == parent_name:
|
|
253
|
+
best_child = child_name
|
|
254
|
+
best_parent = parent_name
|
|
255
|
+
best_score = 1.0
|
|
256
|
+
break
|
|
257
|
+
|
|
258
|
+
# Score = correlation * preference for closer motion magnitude
|
|
259
|
+
corr = correlations[parent_idx, child_idx]
|
|
260
|
+
parent_motion = motion_magnitudes[parent_name]
|
|
261
|
+
child_motion = motion_magnitudes[child_name]
|
|
262
|
+
|
|
263
|
+
# Prefer parent with less motion than child
|
|
264
|
+
if parent_motion < child_motion:
|
|
265
|
+
score = corr * 1.1
|
|
266
|
+
else:
|
|
267
|
+
score = corr * 0.9
|
|
268
|
+
|
|
269
|
+
if score > best_score:
|
|
270
|
+
best_score = score
|
|
271
|
+
best_child = child_name
|
|
272
|
+
best_parent = parent_name
|
|
273
|
+
|
|
274
|
+
if best_score >= 1.0: # Hint matched
|
|
275
|
+
break
|
|
276
|
+
|
|
277
|
+
if best_child is None:
|
|
278
|
+
# Fallback: add remaining links to base
|
|
279
|
+
for name in link_names:
|
|
280
|
+
if name not in added:
|
|
281
|
+
best_child = name
|
|
282
|
+
best_parent = base_name
|
|
283
|
+
break
|
|
284
|
+
|
|
285
|
+
# Add child node
|
|
286
|
+
nodes[best_child] = LinkNode(
|
|
287
|
+
name=best_child,
|
|
288
|
+
parent=best_parent,
|
|
289
|
+
children=[],
|
|
290
|
+
is_fixed=False,
|
|
291
|
+
)
|
|
292
|
+
nodes[best_parent].children.append(best_child)
|
|
293
|
+
added.add(best_child)
|
|
294
|
+
|
|
295
|
+
logger.debug(f"Added {best_child} as child of {best_parent} (score: {best_score:.3f})")
|
|
296
|
+
|
|
297
|
+
tree = KinematicTree(nodes=nodes, root=base_name)
|
|
298
|
+
|
|
299
|
+
if not tree.validate():
|
|
300
|
+
raise TopologyError("Built tree is invalid (disconnected nodes)")
|
|
301
|
+
|
|
302
|
+
logger.info(f"Built kinematic tree: {tree.get_ordered_links()}")
|
|
303
|
+
return tree
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def infer_joint_names(tree: KinematicTree) -> Dict[Tuple[str, str], str]:
|
|
307
|
+
"""
|
|
308
|
+
Generate joint names from parent-child pairs.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
tree: KinematicTree structure
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
Dict mapping (parent, child) -> joint_name
|
|
315
|
+
"""
|
|
316
|
+
joint_names = {}
|
|
317
|
+
|
|
318
|
+
for link_name, node in tree.nodes.items():
|
|
319
|
+
if node.parent is not None:
|
|
320
|
+
parent = node.parent
|
|
321
|
+
child = link_name
|
|
322
|
+
joint_name = f"{parent}_to_{child}_joint"
|
|
323
|
+
joint_names[(parent, child)] = joint_name
|
|
324
|
+
|
|
325
|
+
return joint_names
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
__all__ = [
|
|
329
|
+
"TopologyError",
|
|
330
|
+
"LinkNode",
|
|
331
|
+
"KinematicTree",
|
|
332
|
+
"compute_motion_correlation",
|
|
333
|
+
"identify_base_link",
|
|
334
|
+
"build_kinematic_tree",
|
|
335
|
+
"infer_joint_names",
|
|
336
|
+
]
|
ate/urdf/validation.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""
|
|
2
|
+
URDF validation for generated robot descriptions.
|
|
3
|
+
|
|
4
|
+
This module provides validation checks for URDF files:
|
|
5
|
+
- XML syntax validation
|
|
6
|
+
- Structural validation (links, joints, tree structure)
|
|
7
|
+
- Physics validation (mass, inertia positivity)
|
|
8
|
+
- Mesh file existence checks
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Dict, List, Optional, Tuple
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import xml.etree.ElementTree as ET
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ValidationError(Exception):
|
|
21
|
+
"""Error during URDF validation."""
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ValidationResult:
|
|
27
|
+
"""Result of URDF validation."""
|
|
28
|
+
valid: bool
|
|
29
|
+
errors: List[str]
|
|
30
|
+
warnings: List[str]
|
|
31
|
+
info: Dict
|
|
32
|
+
|
|
33
|
+
def __bool__(self) -> bool:
|
|
34
|
+
return self.valid
|
|
35
|
+
|
|
36
|
+
def summary(self) -> str:
|
|
37
|
+
lines = []
|
|
38
|
+
if self.valid:
|
|
39
|
+
lines.append("URDF is valid")
|
|
40
|
+
else:
|
|
41
|
+
lines.append("URDF has errors")
|
|
42
|
+
|
|
43
|
+
if self.errors:
|
|
44
|
+
lines.append(f"\nErrors ({len(self.errors)}):")
|
|
45
|
+
for err in self.errors:
|
|
46
|
+
lines.append(f" - {err}")
|
|
47
|
+
|
|
48
|
+
if self.warnings:
|
|
49
|
+
lines.append(f"\nWarnings ({len(self.warnings)}):")
|
|
50
|
+
for warn in self.warnings:
|
|
51
|
+
lines.append(f" - {warn}")
|
|
52
|
+
|
|
53
|
+
lines.append(f"\nInfo:")
|
|
54
|
+
for key, value in self.info.items():
|
|
55
|
+
lines.append(f" {key}: {value}")
|
|
56
|
+
|
|
57
|
+
return "\n".join(lines)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def validate_xml_syntax(urdf_content: str) -> Tuple[bool, Optional[str]]:
|
|
61
|
+
"""
|
|
62
|
+
Validate XML syntax.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
urdf_content: URDF XML string
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Tuple of (valid, error_message)
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
ET.fromstring(urdf_content)
|
|
72
|
+
return True, None
|
|
73
|
+
except ET.ParseError as e:
|
|
74
|
+
return False, f"XML parse error: {e}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def validate_structure(root: ET.Element) -> Tuple[List[str], List[str]]:
|
|
78
|
+
"""
|
|
79
|
+
Validate URDF structure (links, joints, tree).
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
root: Root XML element
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Tuple of (errors, warnings)
|
|
86
|
+
"""
|
|
87
|
+
errors = []
|
|
88
|
+
warnings = []
|
|
89
|
+
|
|
90
|
+
# Check root element
|
|
91
|
+
if root.tag != "robot":
|
|
92
|
+
errors.append(f"Root element should be 'robot', got '{root.tag}'")
|
|
93
|
+
return errors, warnings
|
|
94
|
+
|
|
95
|
+
if "name" not in root.attrib:
|
|
96
|
+
warnings.append("Robot element has no 'name' attribute")
|
|
97
|
+
|
|
98
|
+
# Collect links and joints
|
|
99
|
+
links = {}
|
|
100
|
+
joints = []
|
|
101
|
+
|
|
102
|
+
for link in root.findall("link"):
|
|
103
|
+
name = link.get("name")
|
|
104
|
+
if not name:
|
|
105
|
+
errors.append("Link element missing 'name' attribute")
|
|
106
|
+
continue
|
|
107
|
+
if name in links:
|
|
108
|
+
errors.append(f"Duplicate link name: {name}")
|
|
109
|
+
links[name] = link
|
|
110
|
+
|
|
111
|
+
for joint in root.findall("joint"):
|
|
112
|
+
name = joint.get("name")
|
|
113
|
+
jtype = joint.get("type")
|
|
114
|
+
parent = joint.find("parent")
|
|
115
|
+
child = joint.find("child")
|
|
116
|
+
|
|
117
|
+
if not name:
|
|
118
|
+
errors.append("Joint element missing 'name' attribute")
|
|
119
|
+
continue
|
|
120
|
+
if not jtype:
|
|
121
|
+
errors.append(f"Joint '{name}' missing 'type' attribute")
|
|
122
|
+
|
|
123
|
+
if parent is None:
|
|
124
|
+
errors.append(f"Joint '{name}' missing 'parent' element")
|
|
125
|
+
elif parent.get("link") not in links:
|
|
126
|
+
errors.append(f"Joint '{name}' references unknown parent link: {parent.get('link')}")
|
|
127
|
+
|
|
128
|
+
if child is None:
|
|
129
|
+
errors.append(f"Joint '{name}' missing 'child' element")
|
|
130
|
+
elif child.get("link") not in links:
|
|
131
|
+
errors.append(f"Joint '{name}' references unknown child link: {child.get('link')}")
|
|
132
|
+
|
|
133
|
+
joints.append({
|
|
134
|
+
"name": name,
|
|
135
|
+
"type": jtype,
|
|
136
|
+
"parent": parent.get("link") if parent is not None else None,
|
|
137
|
+
"child": child.get("link") if child is not None else None,
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
# Check for valid joint types
|
|
141
|
+
VALID_JOINT_TYPES = ["revolute", "prismatic", "continuous", "fixed", "floating", "planar"]
|
|
142
|
+
for joint in joints:
|
|
143
|
+
if joint["type"] and joint["type"] not in VALID_JOINT_TYPES:
|
|
144
|
+
errors.append(f"Joint '{joint['name']}' has invalid type: {joint['type']}")
|
|
145
|
+
|
|
146
|
+
# Check for tree structure (no cycles, single root)
|
|
147
|
+
children = set()
|
|
148
|
+
for joint in joints:
|
|
149
|
+
if joint["child"]:
|
|
150
|
+
if joint["child"] in children:
|
|
151
|
+
errors.append(f"Link '{joint['child']}' has multiple parent joints")
|
|
152
|
+
children.add(joint["child"])
|
|
153
|
+
|
|
154
|
+
roots = set(links.keys()) - children
|
|
155
|
+
if len(roots) == 0:
|
|
156
|
+
errors.append("No root link found (cycle detected)")
|
|
157
|
+
elif len(roots) > 1:
|
|
158
|
+
warnings.append(f"Multiple root links found: {roots}")
|
|
159
|
+
|
|
160
|
+
# Check for required elements
|
|
161
|
+
for link_name, link in links.items():
|
|
162
|
+
inertial = link.find("inertial")
|
|
163
|
+
if inertial is None:
|
|
164
|
+
warnings.append(f"Link '{link_name}' has no inertial element")
|
|
165
|
+
else:
|
|
166
|
+
mass = inertial.find("mass")
|
|
167
|
+
if mass is None:
|
|
168
|
+
errors.append(f"Link '{link_name}' inertial missing mass")
|
|
169
|
+
|
|
170
|
+
return errors, warnings
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def validate_physics(root: ET.Element) -> Tuple[List[str], List[str]]:
|
|
174
|
+
"""
|
|
175
|
+
Validate physics properties (mass, inertia).
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
root: Root XML element
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Tuple of (errors, warnings)
|
|
182
|
+
"""
|
|
183
|
+
errors = []
|
|
184
|
+
warnings = []
|
|
185
|
+
|
|
186
|
+
for link in root.findall("link"):
|
|
187
|
+
name = link.get("name", "unnamed")
|
|
188
|
+
inertial = link.find("inertial")
|
|
189
|
+
|
|
190
|
+
if inertial is None:
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
# Check mass
|
|
194
|
+
mass_elem = inertial.find("mass")
|
|
195
|
+
if mass_elem is not None:
|
|
196
|
+
try:
|
|
197
|
+
mass = float(mass_elem.get("value", "0"))
|
|
198
|
+
if mass <= 0:
|
|
199
|
+
warnings.append(f"Link '{name}' has non-positive mass: {mass}")
|
|
200
|
+
elif mass < 0.001:
|
|
201
|
+
warnings.append(f"Link '{name}' has very small mass: {mass}kg")
|
|
202
|
+
except ValueError:
|
|
203
|
+
errors.append(f"Link '{name}' has invalid mass value")
|
|
204
|
+
|
|
205
|
+
# Check inertia
|
|
206
|
+
inertia = inertial.find("inertia")
|
|
207
|
+
if inertia is not None:
|
|
208
|
+
try:
|
|
209
|
+
ixx = float(inertia.get("ixx", "0"))
|
|
210
|
+
iyy = float(inertia.get("iyy", "0"))
|
|
211
|
+
izz = float(inertia.get("izz", "0"))
|
|
212
|
+
|
|
213
|
+
# Diagonal elements must be positive
|
|
214
|
+
if ixx <= 0 or iyy <= 0 or izz <= 0:
|
|
215
|
+
warnings.append(
|
|
216
|
+
f"Link '{name}' has non-positive inertia diagonal: "
|
|
217
|
+
f"ixx={ixx}, iyy={iyy}, izz={izz}"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# Triangle inequality for principal moments
|
|
221
|
+
if ixx + iyy < izz or ixx + izz < iyy or iyy + izz < ixx:
|
|
222
|
+
warnings.append(f"Link '{name}' inertia violates triangle inequality")
|
|
223
|
+
|
|
224
|
+
except ValueError:
|
|
225
|
+
errors.append(f"Link '{name}' has invalid inertia values")
|
|
226
|
+
|
|
227
|
+
return errors, warnings
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def validate_meshes(
|
|
231
|
+
root: ET.Element,
|
|
232
|
+
base_path: Path,
|
|
233
|
+
) -> Tuple[List[str], List[str]]:
|
|
234
|
+
"""
|
|
235
|
+
Validate mesh file existence.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
root: Root XML element
|
|
239
|
+
base_path: Base path for resolving mesh paths
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
Tuple of (errors, warnings)
|
|
243
|
+
"""
|
|
244
|
+
errors = []
|
|
245
|
+
warnings = []
|
|
246
|
+
|
|
247
|
+
for link in root.findall("link"):
|
|
248
|
+
name = link.get("name", "unnamed")
|
|
249
|
+
|
|
250
|
+
for geom_type in ["visual", "collision"]:
|
|
251
|
+
geom = link.find(geom_type)
|
|
252
|
+
if geom is None:
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
mesh = geom.find("geometry/mesh")
|
|
256
|
+
if mesh is not None:
|
|
257
|
+
filename = mesh.get("filename")
|
|
258
|
+
if filename:
|
|
259
|
+
mesh_path = base_path / filename
|
|
260
|
+
if not mesh_path.exists():
|
|
261
|
+
warnings.append(
|
|
262
|
+
f"Link '{name}' {geom_type} mesh not found: {filename}"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
return errors, warnings
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def validate_urdf(
|
|
269
|
+
urdf_path: Path,
|
|
270
|
+
check_meshes: bool = True,
|
|
271
|
+
) -> ValidationResult:
|
|
272
|
+
"""
|
|
273
|
+
Validate a URDF file.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
urdf_path: Path to URDF file
|
|
277
|
+
check_meshes: Whether to check mesh file existence
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
ValidationResult with errors/warnings
|
|
281
|
+
"""
|
|
282
|
+
errors = []
|
|
283
|
+
warnings = []
|
|
284
|
+
info = {"path": str(urdf_path)}
|
|
285
|
+
|
|
286
|
+
# Read file
|
|
287
|
+
try:
|
|
288
|
+
with open(urdf_path, 'r') as f:
|
|
289
|
+
content = f.read()
|
|
290
|
+
except Exception as e:
|
|
291
|
+
return ValidationResult(
|
|
292
|
+
valid=False,
|
|
293
|
+
errors=[f"Failed to read file: {e}"],
|
|
294
|
+
warnings=[],
|
|
295
|
+
info=info,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
# Validate XML
|
|
299
|
+
xml_valid, xml_error = validate_xml_syntax(content)
|
|
300
|
+
if not xml_valid:
|
|
301
|
+
return ValidationResult(
|
|
302
|
+
valid=False,
|
|
303
|
+
errors=[xml_error],
|
|
304
|
+
warnings=[],
|
|
305
|
+
info=info,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# Parse XML
|
|
309
|
+
root = ET.fromstring(content)
|
|
310
|
+
info["robot_name"] = root.get("name", "unnamed")
|
|
311
|
+
|
|
312
|
+
# Validate structure
|
|
313
|
+
struct_errors, struct_warnings = validate_structure(root)
|
|
314
|
+
errors.extend(struct_errors)
|
|
315
|
+
warnings.extend(struct_warnings)
|
|
316
|
+
|
|
317
|
+
# Validate physics
|
|
318
|
+
physics_errors, physics_warnings = validate_physics(root)
|
|
319
|
+
errors.extend(physics_errors)
|
|
320
|
+
warnings.extend(physics_warnings)
|
|
321
|
+
|
|
322
|
+
# Validate meshes
|
|
323
|
+
if check_meshes:
|
|
324
|
+
base_path = urdf_path.parent
|
|
325
|
+
mesh_errors, mesh_warnings = validate_meshes(root, base_path)
|
|
326
|
+
errors.extend(mesh_errors)
|
|
327
|
+
warnings.extend(mesh_warnings)
|
|
328
|
+
|
|
329
|
+
# Gather info
|
|
330
|
+
info["links"] = len(root.findall("link"))
|
|
331
|
+
info["joints"] = len(root.findall("joint"))
|
|
332
|
+
|
|
333
|
+
return ValidationResult(
|
|
334
|
+
valid=(len(errors) == 0),
|
|
335
|
+
errors=errors,
|
|
336
|
+
warnings=warnings,
|
|
337
|
+
info=info,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def run_validation(session: "ScanSession") -> ValidationResult:
|
|
342
|
+
"""
|
|
343
|
+
Run validation on session's generated URDF.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
session: ScanSession with generated URDF
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
ValidationResult
|
|
350
|
+
"""
|
|
351
|
+
if not session.has_urdf():
|
|
352
|
+
return ValidationResult(
|
|
353
|
+
valid=False,
|
|
354
|
+
errors=["No URDF file generated yet"],
|
|
355
|
+
warnings=[],
|
|
356
|
+
info={},
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
return validate_urdf(session.urdf_path, check_meshes=True)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
__all__ = [
|
|
363
|
+
"ValidationError",
|
|
364
|
+
"ValidationResult",
|
|
365
|
+
"validate_xml_syntax",
|
|
366
|
+
"validate_structure",
|
|
367
|
+
"validate_physics",
|
|
368
|
+
"validate_meshes",
|
|
369
|
+
"validate_urdf",
|
|
370
|
+
"run_validation",
|
|
371
|
+
]
|