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.
Files changed (131) hide show
  1. ate/__init__.py +6 -0
  2. ate/__main__.py +16 -0
  3. ate/auth/__init__.py +1 -0
  4. ate/auth/device_flow.py +141 -0
  5. ate/auth/token_store.py +96 -0
  6. ate/behaviors/__init__.py +100 -0
  7. ate/behaviors/approach.py +399 -0
  8. ate/behaviors/common.py +686 -0
  9. ate/behaviors/tree.py +454 -0
  10. ate/cli.py +855 -3995
  11. ate/client.py +90 -0
  12. ate/commands/__init__.py +168 -0
  13. ate/commands/auth.py +389 -0
  14. ate/commands/bridge.py +448 -0
  15. ate/commands/data.py +185 -0
  16. ate/commands/deps.py +111 -0
  17. ate/commands/generate.py +384 -0
  18. ate/commands/memory.py +907 -0
  19. ate/commands/parts.py +166 -0
  20. ate/commands/primitive.py +399 -0
  21. ate/commands/protocol.py +288 -0
  22. ate/commands/recording.py +524 -0
  23. ate/commands/repo.py +154 -0
  24. ate/commands/simulation.py +291 -0
  25. ate/commands/skill.py +303 -0
  26. ate/commands/skills.py +487 -0
  27. ate/commands/team.py +147 -0
  28. ate/commands/workflow.py +271 -0
  29. ate/detection/__init__.py +38 -0
  30. ate/detection/base.py +142 -0
  31. ate/detection/color_detector.py +399 -0
  32. ate/detection/trash_detector.py +322 -0
  33. ate/drivers/__init__.py +39 -0
  34. ate/drivers/ble_transport.py +405 -0
  35. ate/drivers/mechdog.py +942 -0
  36. ate/drivers/wifi_camera.py +477 -0
  37. ate/interfaces/__init__.py +187 -0
  38. ate/interfaces/base.py +273 -0
  39. ate/interfaces/body.py +267 -0
  40. ate/interfaces/detection.py +282 -0
  41. ate/interfaces/locomotion.py +422 -0
  42. ate/interfaces/manipulation.py +408 -0
  43. ate/interfaces/navigation.py +389 -0
  44. ate/interfaces/perception.py +362 -0
  45. ate/interfaces/sensors.py +247 -0
  46. ate/interfaces/types.py +371 -0
  47. ate/llm_proxy.py +239 -0
  48. ate/mcp_server.py +387 -0
  49. ate/memory/__init__.py +35 -0
  50. ate/memory/cloud.py +244 -0
  51. ate/memory/context.py +269 -0
  52. ate/memory/embeddings.py +184 -0
  53. ate/memory/export.py +26 -0
  54. ate/memory/merge.py +146 -0
  55. ate/memory/migrate/__init__.py +34 -0
  56. ate/memory/migrate/base.py +89 -0
  57. ate/memory/migrate/pipeline.py +189 -0
  58. ate/memory/migrate/sources/__init__.py +13 -0
  59. ate/memory/migrate/sources/chroma.py +170 -0
  60. ate/memory/migrate/sources/pinecone.py +120 -0
  61. ate/memory/migrate/sources/qdrant.py +110 -0
  62. ate/memory/migrate/sources/weaviate.py +160 -0
  63. ate/memory/reranker.py +353 -0
  64. ate/memory/search.py +26 -0
  65. ate/memory/store.py +548 -0
  66. ate/recording/__init__.py +83 -0
  67. ate/recording/demonstration.py +378 -0
  68. ate/recording/session.py +415 -0
  69. ate/recording/upload.py +304 -0
  70. ate/recording/visual.py +416 -0
  71. ate/recording/wrapper.py +95 -0
  72. ate/robot/__init__.py +221 -0
  73. ate/robot/agentic_servo.py +856 -0
  74. ate/robot/behaviors.py +493 -0
  75. ate/robot/ble_capture.py +1000 -0
  76. ate/robot/ble_enumerate.py +506 -0
  77. ate/robot/calibration.py +668 -0
  78. ate/robot/calibration_state.py +388 -0
  79. ate/robot/commands.py +3735 -0
  80. ate/robot/direction_calibration.py +554 -0
  81. ate/robot/discovery.py +441 -0
  82. ate/robot/introspection.py +330 -0
  83. ate/robot/llm_system_id.py +654 -0
  84. ate/robot/locomotion_calibration.py +508 -0
  85. ate/robot/manager.py +270 -0
  86. ate/robot/marker_generator.py +611 -0
  87. ate/robot/perception.py +502 -0
  88. ate/robot/primitives.py +614 -0
  89. ate/robot/profiles.py +281 -0
  90. ate/robot/registry.py +322 -0
  91. ate/robot/servo_mapper.py +1153 -0
  92. ate/robot/skill_upload.py +675 -0
  93. ate/robot/target_calibration.py +500 -0
  94. ate/robot/teach.py +515 -0
  95. ate/robot/types.py +242 -0
  96. ate/robot/visual_labeler.py +1048 -0
  97. ate/robot/visual_servo_loop.py +494 -0
  98. ate/robot/visual_servoing.py +570 -0
  99. ate/robot/visual_system_id.py +906 -0
  100. ate/transports/__init__.py +121 -0
  101. ate/transports/base.py +394 -0
  102. ate/transports/ble.py +405 -0
  103. ate/transports/hybrid.py +444 -0
  104. ate/transports/serial.py +345 -0
  105. ate/urdf/__init__.py +30 -0
  106. ate/urdf/capture.py +582 -0
  107. ate/urdf/cloud.py +491 -0
  108. ate/urdf/collision.py +271 -0
  109. ate/urdf/commands.py +708 -0
  110. ate/urdf/depth.py +360 -0
  111. ate/urdf/inertial.py +312 -0
  112. ate/urdf/kinematics.py +330 -0
  113. ate/urdf/lifting.py +415 -0
  114. ate/urdf/meshing.py +300 -0
  115. ate/urdf/models/__init__.py +110 -0
  116. ate/urdf/models/depth_anything.py +253 -0
  117. ate/urdf/models/sam2.py +324 -0
  118. ate/urdf/motion_analysis.py +396 -0
  119. ate/urdf/pipeline.py +468 -0
  120. ate/urdf/scale.py +256 -0
  121. ate/urdf/scan_session.py +411 -0
  122. ate/urdf/segmentation.py +299 -0
  123. ate/urdf/synthesis.py +319 -0
  124. ate/urdf/topology.py +336 -0
  125. ate/urdf/validation.py +371 -0
  126. {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/METADATA +9 -1
  127. foodforthought_cli-0.3.0.dist-info/RECORD +166 -0
  128. {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/WHEEL +1 -1
  129. foodforthought_cli-0.2.7.dist-info/RECORD +0 -44
  130. {foodforthought_cli-0.2.7.dist-info → foodforthought_cli-0.3.0.dist-info}/entry_points.txt +0 -0
  131. {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
+ ]