Source code for linkforge.core.validation.checks

"""Modular validation checks for robot models.

This module provides focused validation rules following the Single
Responsibility Principle. Each check is independent and contributes to
a shared :class:`~result.ValidationResult`.

Check Categories:
- **Topology**: Links, joints, and kinematic tree integrity.
- **Physics**: Mass, inertia, and numerical stability.
- **Interfaces**: ros2_control and mimic joint chains.
- **Semantic**: SRDF planning groups and collision filters.
"""

from __future__ import annotations

from abc import ABC, abstractmethod
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any

from ..constants import (
    MIN_MASS_STABILITY_THRESHOLD,
    MIN_REASONABLE_INERTIA,
    MIN_REASONABLE_MASS,
)
from ..exceptions import RobotModelError, RobotValidationError, ValidationErrorCode
from .result import ValidationResult

if TYPE_CHECKING:
    from ..models.link import Link
    from ..models.robot import Robot
    from ..models.srdf import PlanningGroup


[docs] class ValidationCheck(ABC): """Abstract base class for a single, focused validation rule. All concrete checks must implement :meth:`run`. Checks are stateless by design — all output is written into the provided ``ValidationResult``. """
[docs] @abstractmethod def run(self, robot: Robot, result: ValidationResult) -> None: """Apply this check, writing errors and warnings into ``result``. Args: robot: The robot model to validate. result: The shared result object to append errors/warnings to. """ ...
[docs] class HasLinksCheck(ValidationCheck): """Check that the robot has at least one link."""
[docs] def run(self, robot: Robot, result: ValidationResult) -> None: """Check that robot has at least one link.""" if not robot.links: result.add_error( title="No links", message="Robot must have at least one link", code=ValidationErrorCode.VALUE_EMPTY, suggestion="Add a link by marking an object as a robot link in the Link panel", )
[docs] class DuplicateNameCheck(ValidationCheck): """Check for duplicate link and joint names."""
[docs] def run(self, robot: Robot, result: ValidationResult) -> None: """Check for duplicate link and joint names.""" self._check_duplicates( names=[link.name for link in robot.links], kind="link", result=result, ) self._check_duplicates( names=[joint.name for joint in robot.joints], kind="joint", result=result, )
@staticmethod def _check_duplicates(names: list[str], kind: str, result: ValidationResult) -> None: seen: set[str] = set() for name in names: if name in seen: result.add_error( title=f"Duplicate {kind} name", message=( f"{kind.capitalize()} name '{name}' is used by " f"{names.count(name)} {kind}s. Each {kind} must have a unique name" ), affected_objects=[n for n in names if n == name], code=ValidationErrorCode.DUPLICATE_NAME, suggestion=f"Rename duplicate {kind}s to unique names (e.g., '{name}_1', '{name}_2')", ) return # Report once per kind seen.add(name)
[docs] class JointReferenceCheck(ValidationCheck): """Check that all joints reference existing links."""
[docs] def run(self, robot: Robot, result: ValidationResult) -> None: """Check that all joints reference valid links.""" link_names = {link.name for link in robot.links} for joint in robot.joints: if joint.parent not in link_names: result.add_error( title="Missing parent link", message=( f"Joint '{joint.name}' references parent link " f"'{joint.parent}' which does not exist" ), affected_objects=[joint.name], code=ValidationErrorCode.NOT_FOUND, suggestion=f"Create a link named '{joint.parent}' or update the joint's parent reference", ) if joint.child not in link_names: result.add_error( title="Missing child link", message=( f"Joint '{joint.name}' references child link " f"'{joint.child}' which does not exist" ), affected_objects=[joint.name], code=ValidationErrorCode.NOT_FOUND, suggestion=f"Create a link named '{joint.child}' or update the joint's child reference", )
[docs] class TreeStructureCheck(ValidationCheck): """Check kinematic tree integrity: cycles, root link, and connectivity."""
[docs] def run(self, robot: Robot, result: ValidationResult) -> None: """Check kinematic tree structure.""" if not robot.links: return # Already reported by HasLinksCheck self._check_cycles(robot, result) root = self._check_root(robot, result) if root is not None: self._check_connectivity(robot, root, result)
@staticmethod def _check_cycles(robot: Robot, result: ValidationResult) -> None: try: if robot.has_cycle: result.add_error( title="Circular dependency", message=( "Kinematic tree contains a cycle. " "Links must form a tree structure, not a loop." ), code=ValidationErrorCode.HAS_CYCLE, suggestion="Review joint connections to ensure they form a tree (no loops)", ) except RobotModelError as e: has_ref_errors = any( err.title in ("Missing parent link", "Missing child link") for err in result.errors ) if not has_ref_errors: result.add_error( title="Kinematic graph error", message=str(e), code=ValidationErrorCode.INVALID_VALUE, suggestion="Check joint and link consistency", ) @staticmethod def _check_root(robot: Robot, result: ValidationResult) -> Link | None: """Return the root link, or None if it cannot be determined.""" try: return robot.root_link except RobotValidationError as e: if e.code == ValidationErrorCode.NO_ROOT: result.add_error( title="No root link", message=( "No root link found. A robot must have exactly one link " "that is not a child in any joint." ), code=ValidationErrorCode.NO_ROOT, suggestion="Ensure exactly one link has no parent joint (the base/root link)", ) elif e.code == ValidationErrorCode.MULTIPLE_ROOTS: result.add_error( title="Multiple root links", message=str(e), code=ValidationErrorCode.MULTIPLE_ROOTS, suggestion="Ensure only one link has no parent joint. Connect other root links to the tree with joints", ) else: result.add_error( title="Root link error", message=str(e), suggestion="Check the joint connections in your robot tree", ) return None except RobotModelError as e: result.add_error( title="Kinematic error", message=str(e), code=ValidationErrorCode.INVALID_VALUE, ) return None @staticmethod def _check_connectivity(robot: Robot, root: Link, result: ValidationResult) -> None: child_counts: dict[str, int] = {} for joint in robot.joints: child_counts[joint.child] = child_counts.get(joint.child, 0) + 1 for link in robot.links: count = child_counts.get(link.name, 0) if count > 1: result.add_error( title="Multiple parent joints", message=( f"Link '{link.name}' has {count} parent joints (should have exactly 1)" ), affected_objects=[link.name], code=ValidationErrorCode.MULTIPLE_ROOTS, suggestion="Remove extra joints. Each link can only have one parent", ) elif count == 0 and link.name != root.name: result.add_error( title="Disconnected link", message=f"Link '{link.name}' is not connected to the kinematic tree", affected_objects=[link.name], code=ValidationErrorCode.MULTIPLE_ROOTS, suggestion="Add a joint to connect this link to the kinematic tree", )
[docs] class MassPropertiesCheck(ValidationCheck): """Check for mass and inertia issues (warnings)."""
[docs] def run(self, robot: Robot, result: ValidationResult) -> None: """Check for mass property health.""" for link in robot.links: # Mass Checks if link.mass < MIN_REASONABLE_MASS: result.add_error( title="Critical low mass", message=( f"Link '{link.name}' has near-zero mass ({link.mass:.9f} kg). " "This will crash most physics solvers." ), affected_objects=[link.name], code=ValidationErrorCode.PHYSICS_VIOLATION, suggestion=f"Increase mass to at least {MIN_REASONABLE_MASS} kg", ) elif link.mass < MIN_MASS_STABILITY_THRESHOLD: result.add_warning( title="Very low mass", message=f"Link '{link.name}' has low mass ({link.mass:.6f} kg).", affected_objects=[link.name], code=ValidationErrorCode.INVALID_VALUE, suggestion="Consider providing a more realistic mass for better simulation stability", ) # Inertia Checks if link.inertial is None: result.add_warning( title="Missing inertia", message=f"Link '{link.name}' has no inertia tensor defined", affected_objects=[link.name], code=ValidationErrorCode.NOT_FOUND, suggestion="Add an inertial element or use automatic inertia calculation", ) else: tensor = link.inertial.inertia if any(v < MIN_REASONABLE_INERTIA for v in [tensor.ixx, tensor.iyy, tensor.izz]): result.add_error( title="Critical low inertia", message=( f"Link '{link.name}' has near-zero inertia diagonals. " "This will lead to numerical instability." ), affected_objects=[link.name], code=ValidationErrorCode.PHYSICS_VIOLATION, suggestion=f"Increase inertia diagonals to at least {MIN_REASONABLE_INERTIA}", )
[docs] class GeometryCheck(ValidationCheck): """Check for missing visual and collision geometry (warnings)."""
[docs] def run(self, robot: Robot, result: ValidationResult) -> None: """Check for geometry warnings.""" for link in robot.links: if not link.visuals: result.add_warning( title="No visual geometry", message=f"Link '{link.name}' has no visual geometry", affected_objects=[link.name], code=ValidationErrorCode.NOT_FOUND, suggestion="Add visual geometry for better visualization in simulators", ) if not link.collisions: result.add_warning( title="No collision geometry", message=f"Link '{link.name}' has no collision geometry", affected_objects=[link.name], code=ValidationErrorCode.NOT_FOUND, suggestion="Add collision geometry for physics simulation", )
[docs] class Ros2ControlCheck(ValidationCheck): """Check that ros2_control joints reference existing robot joints."""
[docs] def run(self, robot: Robot, result: ValidationResult) -> None: """Check ros2_control joint existence.""" if not robot.ros2_controls: return joint_names = {joint.name for joint in robot.joints} for control in robot.ros2_controls: for rc_joint in control.joints: if rc_joint.name not in joint_names: result.add_error( title="Invalid ros2_control joint", message=( f"ros2_control joint '{rc_joint.name}' " "does not exist in the kinematic tree" ), affected_objects=[rc_joint.name], code=ValidationErrorCode.NOT_FOUND, suggestion="Ensure joint name in control matches a robot joint", )
[docs] class MimicChainCheck(ValidationCheck): """Check for invalid or circular mimic joint configurations."""
[docs] def run(self, robot: Robot, result: ValidationResult) -> None: """Check for invalid mimic joint configurations.""" joint_names = {joint.name for joint in robot.joints} joint_map = {joint.name: joint for joint in robot.joints} for joint in robot.joints: if joint.mimic is None: continue visited: set[str] = {joint.name} current: str | None = joint.mimic.joint while True: if current not in joint_names: result.add_error( title="Invalid mimic target", message=(f"Joint '{joint.name}' mimics non-existent joint '{current}'"), affected_objects=[joint.name], code=ValidationErrorCode.NOT_FOUND, suggestion=(f"Ensure joint '{current}' exists or update mimic reference"), ) break if current in visited: chain = " -> ".join(visited) + f" -> {current}" result.add_error( title="Circular mimic dependency", message=f"Circular mimic dependency detected: {chain}", affected_objects=list(visited), code=ValidationErrorCode.HAS_CYCLE, suggestion="Break the circular mimic chain by changing mimic targets", ) break visited.add(current) next_joint = joint_map[current] if next_joint.mimic is None: break current = next_joint.mimic.joint
class SemanticCheck(ValidationCheck): """Check for semantic robot description (SRDF) invariants.""" def run(self, robot: Robot, result: ValidationResult) -> None: """Check SRDF invariants.""" if not robot.semantic: return semantic = robot.semantic group_names = {g.name for g in semantic.groups} link_names = {link.name for link in robot.links} joint_names = {joint.name for joint in robot.joints} # Check planning groups for group in semantic.groups: # Check links for link_name in group.links: if link_name not in link_names: result.add_error( title="Invalid planning group link", message=f"Group '{group.name}' references non-existent link '{link_name}'", affected_objects=[group.name], code=ValidationErrorCode.NOT_FOUND, ) # Check joints for joint_name in group.joints: if joint_name not in joint_names: result.add_error( title="Invalid planning group joint", message=f"Group '{group.name}' references non-existent joint '{joint_name}'", affected_objects=[group.name], code=ValidationErrorCode.NOT_FOUND, ) # Check subgroups and cycles self._check_subgroup_cycles(group, semantic.groups, result) # Check group states for state in semantic.group_states: if state.group not in group_names: result.add_error( title="Invalid group state reference", message=f"State '{state.name}' references non-existent group '{state.group}'", affected_objects=[state.name], code=ValidationErrorCode.NOT_FOUND, ) # Check end effectors for ee in semantic.end_effectors: if ee.group not in group_names: result.add_error( title="Invalid end effector group", message=f"End effector '{ee.name}' references non-existent group '{ee.group}'", affected_objects=[ee.name], code=ValidationErrorCode.NOT_FOUND, ) if ee.parent_link not in link_names: result.add_error( title="Invalid end effector parent link", message=f"End effector '{ee.name}' references non-existent parent link '{ee.parent_link}'", affected_objects=[ee.name], code=ValidationErrorCode.NOT_FOUND, ) if ee.parent_group and ee.parent_group not in group_names: result.add_error( title="Invalid end effector parent group", message=f"End effector '{ee.name}' references non-existent parent group '{ee.parent_group}'", affected_objects=[ee.name], code=ValidationErrorCode.NOT_FOUND, ) # Check passive joints for pj in semantic.passive_joints: if pj.name not in joint_names: result.add_error( title="Invalid passive joint", message=f"Passive joint '{pj.name}' does not exist", affected_objects=[pj.name], code=ValidationErrorCode.NOT_FOUND, ) def _check_subgroup_cycles( self, group: PlanningGroup, all_groups: Sequence[PlanningGroup], result: ValidationResult ) -> None: """Check for circular subgroup dependencies.""" group_map = {g.name for g in all_groups} group_obj_map = {g.name: g for g in all_groups} def _dfs(current_name: str, path: list[str]) -> bool: current_group = group_obj_map.get(current_name) if not current_group: return False for sg_name in current_group.subgroups: if sg_name not in group_map: result.add_error( title="Invalid subgroup reference", message=f"Group '{current_name}' references non-existent subgroup '{sg_name}'", affected_objects=[current_name], code=ValidationErrorCode.NOT_FOUND, ) continue if sg_name in path: cycle = " -> ".join(path[path.index(sg_name) :]) + f" -> {sg_name}" result.add_error( title="Circular subgroup dependency", message=f"Circular subgroup dependency detected: {cycle}", affected_objects=path[path.index(sg_name) :], code=ValidationErrorCode.HAS_CYCLE, ) return True if _dfs(sg_name, path + [sg_name]): return True return False _dfs(group.name, [group.name]) class SemanticConsistencyCheck(ValidationCheck): """Cross-layer consistency check between URDF kinematics, SRDF semantics, and ros2_control. The existing :class:`SemanticCheck` validates *intra-SRDF* references (e.g., a group referencing a missing link). This check validates *inter-layer* contradictions: cases where two layers are individually valid but contradict each other when composed. Rules: 1. **GroupState joint range**: a named pose must not exceed the kinematic joint limits it was designed for. 2. **Chain reachability**: a ``Chain.base_link → tip_link`` path must be traversable in the kinematic graph. 3. **EndEffector parent link in group**: the ``EndEffector.parent_link`` must be a direct member of the referenced planning group. 4. **Passive vs. command interface contradiction**: a joint declared as ``passive`` in SRDF must not also have a ``command_interface`` in ros2_control (you cannot command what is passive). 5. **Collision pair link existence**: links referenced in ``disabled_collisions`` / ``enabled_collisions`` must exist in the robot. """ def run(self, robot: Robot, result: ValidationResult) -> None: """Run all cross-layer consistency rules.""" if not robot.links: return # Nothing to validate without links semantic = robot.semantic if ( not semantic.groups and not semantic.disabled_collisions and not semantic.enabled_collisions ): return # No semantic data at all joint_map = {j.name: j for j in robot.joints} link_names = {link.name for link in robot.links} self._check_group_state_joint_ranges(semantic, joint_map, result) self._check_chain_reachability(robot, semantic, result) self._check_end_effector_parent_in_group(semantic, result) self._check_passive_command_contradiction(semantic, robot, result) self._check_collision_pair_link_existence(semantic, link_names, result) @staticmethod def _check_group_state_joint_ranges( semantic: Any, joint_map: dict[str, Any], result: ValidationResult, ) -> None: """Rule 1: GroupState joint values must be within kinematic joint limits.""" for state in semantic.group_states: for joint_name, values in state.joint_values.items(): joint = joint_map.get(joint_name) if joint is None or joint.limits is None: continue # Missing joint reported elsewhere; no limits means unconstrained limits = joint.limits for val in values: if not isinstance(val, (int, float)): continue if not (limits.lower <= float(val) <= limits.upper): result.add_error( title="GroupState joint value out of range", message=( f"State '{state.name}' sets joint '{joint_name}' to {val:.4f}, " f"which is outside its limits " f"[{limits.lower:.4f}, {limits.upper:.4f}]" ), affected_objects=[state.name, joint_name], code=ValidationErrorCode.INVALID_VALUE, suggestion=( f"Adjust the joint value for '{joint_name}' in state " f"'{state.name}' to be within [{limits.lower:.4f}, " f"{limits.upper:.4f}]" ), ) @staticmethod def _check_chain_reachability( robot: Robot, semantic: Any, result: ValidationResult, ) -> None: """Rule 2: Chain base_link → tip_link path must exist in the kinematic graph.""" # Build a child -> parent map for upward traversal child_to_parent: dict[str, str] = {j.child: j.parent for j in robot.joints} for group in semantic.groups: for chain in group.chains: # Walk from tip_link upward until we hit base_link or the tree root visited: set[str] = set() current: str | None = chain.tip_link found = False while current is not None: if current == chain.base_link: found = True break if current in visited: break # Cycle guard (cycle detected elsewhere) visited.add(current) current = child_to_parent.get(current) if not found: result.add_error( title="Unreachable kinematic chain", message=( f"Group '{group.name}' defines a chain from " f"'{chain.base_link}' to '{chain.tip_link}', " f"but no such path exists in the kinematic tree" ), affected_objects=[group.name], code=ValidationErrorCode.NOT_FOUND, suggestion=( f"Ensure '{chain.tip_link}' is a descendant of " f"'{chain.base_link}' in the kinematic tree" ), ) @staticmethod def _check_end_effector_parent_in_group( semantic: Any, result: ValidationResult, ) -> None: """Rule 3: EndEffector.parent_link must be a member of its referenced group.""" group_link_map: dict[str, set[str]] = {g.name: set(g.links) for g in semantic.groups} for ee in semantic.end_effectors: group_links = group_link_map.get(ee.group) if group_links is None: continue # Missing group reported by SemanticCheck if group_links and ee.parent_link not in group_links: result.add_warning( title="End effector parent link not in group", message=( f"End effector '{ee.name}' references parent_link '{ee.parent_link}', " f"which is not an explicit link member of group '{ee.group}'" ), affected_objects=[ee.name], code=ValidationErrorCode.INVALID_VALUE, suggestion=( f"Add '{ee.parent_link}' to the links of group '{ee.group}', " f"or verify the end effector configuration is correct" ), ) @staticmethod def _check_passive_command_contradiction( semantic: Any, robot: Robot, result: ValidationResult, ) -> None: """Rule 4: A joint declared passive in SRDF must not have a ros2_control command_interface. Commanding a passive joint is a physical contradiction: passive joints are not actuated by definition, so a command_interface on one would silently produce no motion while consuming controller resources. """ passive_joint_names = {pj.name for pj in semantic.passive_joints} if not passive_joint_names: return for control in robot.ros2_controls: for ctrl_joint in control.joints: if ctrl_joint.name not in passive_joint_names: continue if ctrl_joint.command_interfaces: result.add_error( title="Passive joint has command interface", message=( f"Joint '{ctrl_joint.name}' is declared passive in SRDF " f"but has command_interface(s) in ros2_control '{control.name}': " f"{list(ctrl_joint.command_interfaces)}. " f"Passive joints cannot be commanded." ), affected_objects=[ctrl_joint.name], code=ValidationErrorCode.INVALID_VALUE, suggestion=( f"Remove the command_interface from '{ctrl_joint.name}' in " f"ros2_control, or remove it from the SRDF passive joints list" ), ) @staticmethod def _check_collision_pair_link_existence( semantic: Any, link_names: set[str], result: ValidationResult, ) -> None: """Rule 5: Links referenced in collision rules must exist in the robot.""" for collection_name, pairs in [ ("disabled_collisions", semantic.disabled_collisions), ("enabled_collisions", semantic.enabled_collisions), ]: for pair in pairs: for link_ref in (pair.link1, pair.link2): if link_ref not in link_names: result.add_error( title="Collision rule references missing link", message=( f"{collection_name}: link '{link_ref}' does not exist " f"in the robot (referenced in pair with '{pair.link1}' " f"and '{pair.link2}')" ), affected_objects=[link_ref], code=ValidationErrorCode.NOT_FOUND, suggestion=( f"Add a link named '{link_ref}' to the robot, " f"or remove it from the {collection_name} list" ), ) __all__ = [ "ValidationCheck", "HasLinksCheck", "DuplicateNameCheck", "JointReferenceCheck", "TreeStructureCheck", "MassPropertiesCheck", "GeometryCheck", "Ros2ControlCheck", "MimicChainCheck", "SemanticCheck", "SemanticConsistencyCheck", ]