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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions core/src/linkforge_core/composer/naming.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from dataclasses import replace
from typing import TYPE_CHECKING

from ..exceptions import RobotModelError
from ..exceptions import RobotModelError, RobotValidationError, ValidationErrorCode
from ..logging_config import get_logger

if TYPE_CHECKING:
Expand All @@ -35,7 +35,7 @@ def add_link_with_renaming(robot: Robot, link: Link) -> None:
except RobotModelError as e:
original_name = link.name
counter = 1
if "already exists" in str(e).lower():
if isinstance(e, RobotValidationError) and e.code == ValidationErrorCode.DUPLICATE_NAME:
while True:
new_name = f"{original_name}_duplicate_{counter}"
if new_name not in robot._link_index:
Expand Down Expand Up @@ -67,9 +67,7 @@ def add_joint_with_renaming(robot: Robot, joint: Joint, fallback_name: str | Non
robot.add_joint(joint)
except RobotModelError as e:
joint_name = joint.name or fallback_name or "unnamed_joint"
error_msg = str(e).lower()

if "already exists" in error_msg:
if isinstance(e, RobotValidationError) and e.code == ValidationErrorCode.DUPLICATE_NAME:
original_name = joint_name
counter = 1
while True:
Expand All @@ -81,8 +79,10 @@ def add_joint_with_renaming(robot: Robot, joint: Joint, fallback_name: str | Non
logger.warning(f"Renamed duplicate joint '{original_name}' to '{new_name}'")
break
except RobotModelError as inner_e:
inner_msg = str(inner_e).lower()
if "not found" in inner_msg:
if (
isinstance(inner_e, RobotValidationError)
and inner_e.code == ValidationErrorCode.NOT_FOUND
):
logger.warning(f"Skipping duplicate joint '{original_name}': {inner_e}")
break
counter += 1
Expand Down
21 changes: 16 additions & 5 deletions core/src/linkforge_core/composer/robot_assembly.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from dataclasses import dataclass, field

from ..exceptions import RobotValidationError
from ..exceptions import RobotValidationError, ValidationErrorCode
from ..generators.srdf_generator import SRDFGenerator
from ..generators.urdf_generator import URDFGenerator
from ..models.geometry import Transform, Vector3
Expand Down Expand Up @@ -114,7 +114,12 @@ def attach(
"""
# 0. Early validation of attachment point
if not self.robot.get_link(at_link):
raise RobotValidationError("Attach", at_link, "Attachment link not found in assembly")
raise RobotValidationError(
ValidationErrorCode.NOT_FOUND,
f"Attachment link '{at_link}' not found in assembly",
target="Attach",
value=at_link,
)

# 1. Deep copy the component to ensure isolation
sub_robot = component.clone()
Expand All @@ -127,7 +132,12 @@ def attach(
# 3. Identify the root link of the sub-robot
root_link = sub_robot.get_root_link()
if not root_link:
raise RobotValidationError("Attach", component.name, "No root link found in component")
raise RobotValidationError(
ValidationErrorCode.NO_ROOT,
f"No root link found in component '{component.name}'",
target="Attach",
value=component.name,
)

# 4. Merge links
for link in sub_robot.links:
Expand Down Expand Up @@ -371,9 +381,10 @@ def _get_connection_params(self, origin: Transform | None = None) -> tuple[str,
"""Resolve parent, joint name and origin for finalization."""
if self._pending_parent is None or self._pending_joint_name is None:
raise RobotValidationError(
"LinkBuilder",
self._link.name,
ValidationErrorCode.GENERIC_FAILURE,
"connect_to() must be called before finalizing the joint",
target="LinkBuilder",
value=self._link.name,
)

resolved_origin = origin if origin is not None else self._pending_origin
Expand Down
102 changes: 86 additions & 16 deletions core/src/linkforge_core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,39 @@
and generators to provide granular error handling.
"""

from enum import Enum
from pathlib import Path
from typing import Any


class ValidationErrorCode(Enum):
"""Categorized error codes for robot validation failures."""

# Naming and Identity
INVALID_NAME = "invalid_name"
DUPLICATE_NAME = "duplicate_name"
NAME_EMPTY = "name_empty"

# Kinematic Structure
NOT_FOUND = "not_found"
HAS_CYCLE = "has_cycle"
NO_ROOT = "no_root"
MULTIPLE_ROOTS = "multiple_roots"
DISCONNECTED = "disconnected"

# Physics and Values
OUT_OF_RANGE = "out_of_range"
VALUE_EMPTY = "value_empty"
INVALID_VALUE = "invalid_value"
PHYSICS_VIOLATION = "physics_violation"
MATH_ERROR = "math_error"
INERTIA_TRIANGLE_INEQUALITY = "inertia_triangle_inequality"

# Configuration and Misc
MISMATCH = "mismatch"
GENERIC_FAILURE = "generic_failure"


class LinkForgeError(Exception):
"""Base category for all LinkForge-related exceptions."""

Expand Down Expand Up @@ -60,22 +89,51 @@ class RobotPhysicsError(RobotModelError):
"""Exception raised for unphysical properties (e.g. negative mass or volume)."""

def __init__(
self, property_name: str = "unknown", value: Any = None, reason: str | None = None
self,
code: ValidationErrorCode,
message: str,
target: str | None = None,
value: Any = None,
):
msg = f"Invalid physics property '{property_name}': {value}"
if reason:
msg += f" ({reason})"
super().__init__(msg)
self.code = code
self.target = target
self.value = value
self.raw_message = message

full_msg = f"[PHYSICS_{code.name}] {message}"
if target:
full_msg += f" (target: {target})"
if value is not None:
full_msg += f" (value: {value})"

super().__init__(full_msg)


class RobotValidationError(RobotModelError):
"""Exception raised for structural or logic validation failures."""
"""Exception raised for structural or logic validation failures.

def __init__(self, check_name: str = "unknown", value: Any = None, reason: str | None = None):
msg = f"Validation failed [{check_name}]: {value}"
if reason:
msg += f" ({reason})"
super().__init__(msg)
Now structured using ValidationErrorCode for robust error handling.
"""

def __init__(
self,
code: ValidationErrorCode,
message: str,
target: str | None = None,
value: Any = None,
):
self.code = code
self.target = target
self.value = value
self.raw_message = message

full_msg = f"[{code.name}] {message}"
if target:
full_msg += f" (target: {target})"
if value is not None:
full_msg += f" (value: {value})"

super().__init__(full_msg)


class RobotSecurityError(RobotModelError):
Expand All @@ -89,12 +147,24 @@ class RobotMathError(RobotModelError):
"""Exception raised for invalid numerical values (NaN, Inf, or Out of Range)."""

def __init__(
self, value: float | str | None = None, check_name: str = "value", reason: str | None = None
self,
code: ValidationErrorCode,
message: str,
target: str | None = None,
value: Any = None,
):
msg = f"Invalid value '{value}' in {check_name}: must be a finite number"
if reason:
msg += f" ({reason})"
super().__init__(msg)
self.code = code
self.target = target
self.value = value
self.raw_message = message

full_msg = f"[MATH_{code.name}] {message}"
if target:
full_msg += f" (target: {target})"
if value is not None:
full_msg += f" (value: {value})"

super().__init__(full_msg)


class RobotXacroError(RobotParserError):
Expand Down
18 changes: 14 additions & 4 deletions core/src/linkforge_core/models/gazebo.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from dataclasses import dataclass, field

from ..exceptions import RobotValidationError
from ..exceptions import RobotValidationError, ValidationErrorCode


@dataclass(frozen=True)
Expand Down Expand Up @@ -41,7 +41,11 @@ def __post_init__(self) -> None:
"""Validate Gazebo element."""
# If reference is specified, it must be non-empty
if self.reference is not None and not self.reference:
raise RobotValidationError("GazeboReference", self.reference, "cannot be empty string")
raise RobotValidationError(
ValidationErrorCode.NAME_EMPTY,
"Gazebo reference cannot be empty",
target="GazeboReference",
)


@dataclass(frozen=True)
Expand All @@ -59,6 +63,12 @@ class GazeboPlugin:
def __post_init__(self) -> None:
"""Validate plugin configuration."""
if not self.name:
raise RobotValidationError("PluginName", self.name, "cannot be empty")
raise RobotValidationError(
ValidationErrorCode.NAME_EMPTY, "Plugin name cannot be empty", target="PluginName"
)
if not self.filename:
raise RobotValidationError("PluginFilename", self.filename, "cannot be empty")
raise RobotValidationError(
ValidationErrorCode.VALUE_EMPTY,
"Plugin filename cannot be empty",
target="PluginFilename",
)
37 changes: 31 additions & 6 deletions core/src/linkforge_core/models/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from dataclasses import dataclass, field
from enum import Enum

from ..exceptions import RobotPhysicsError
from ..exceptions import RobotPhysicsError, ValidationErrorCode


class GeometryType(Enum):
Expand Down Expand Up @@ -71,7 +71,12 @@ class Box:
def __post_init__(self) -> None:
"""Validate box dimensions."""
if self.size.x <= 0 or self.size.y <= 0 or self.size.z <= 0:
raise RobotPhysicsError("BoxSize", self.size, "dimensions must be positive")
raise RobotPhysicsError(
ValidationErrorCode.PHYSICS_VIOLATION,
"Box dimensions must be positive",
target="BoxSize",
value=self.size,
)

@property
def type(self) -> GeometryType:
Expand All @@ -92,9 +97,19 @@ class Cylinder:
def __post_init__(self) -> None:
"""Validate cylinder dimensions."""
if self.radius <= 0:
raise RobotPhysicsError("CylinderRadius", self.radius, "radius must be positive")
raise RobotPhysicsError(
ValidationErrorCode.PHYSICS_VIOLATION,
"Cylinder radius must be positive",
target="CylinderRadius",
value=self.radius,
)
if self.length <= 0:
raise RobotPhysicsError("CylinderLength", self.length, "length must be positive")
raise RobotPhysicsError(
ValidationErrorCode.PHYSICS_VIOLATION,
"Cylinder length must be positive",
target="CylinderLength",
value=self.length,
)

@property
def type(self) -> GeometryType:
Expand All @@ -116,7 +131,12 @@ class Sphere:
def __post_init__(self) -> None:
"""Validate sphere dimensions."""
if self.radius <= 0:
raise RobotPhysicsError("SphereRadius", self.radius, "radius must be positive")
raise RobotPhysicsError(
ValidationErrorCode.PHYSICS_VIOLATION,
"Sphere radius must be positive",
target="SphereRadius",
value=self.radius,
)

@property
def type(self) -> GeometryType:
Expand All @@ -139,7 +159,12 @@ class Mesh:
def __post_init__(self) -> None:
"""Validate mesh scale."""
if self.scale.x == 0 or self.scale.y == 0 or self.scale.z == 0:
raise RobotPhysicsError("MeshScale", self.scale, "scale components must be non-zero")
raise RobotPhysicsError(
ValidationErrorCode.PHYSICS_VIOLATION,
"Mesh scale components must be non-zero",
target="MeshScale",
value=self.scale,
)

@property
def type(self) -> GeometryType:
Expand Down
18 changes: 14 additions & 4 deletions core/src/linkforge_core/models/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from collections.abc import Iterable
from typing import TYPE_CHECKING

from ..exceptions import RobotValidationError
from ..exceptions import RobotValidationError, ValidationErrorCode

if TYPE_CHECKING:
from .joint import Joint
Expand Down Expand Up @@ -44,11 +44,17 @@ def __init__(self, links: Iterable[Link], joints: Iterable[Joint]) -> None:
for joint in self.joints:
if joint.parent not in self.link_names:
raise RobotValidationError(
"ParentLink", joint.parent, f"referenced by joint '{joint.name}' is unknown"
ValidationErrorCode.NOT_FOUND,
f"Parent link '{joint.parent}' unknown",
target="ParentLink",
value=joint.parent,
)
if joint.child not in self.link_names:
raise RobotValidationError(
"ChildLink", joint.child, f"referenced by joint '{joint.name}' is unknown"
ValidationErrorCode.NOT_FOUND,
f"Child link '{joint.child}' unknown",
target="ChildLink",
value=joint.child,
)

self.adj[joint.parent].append((joint.child, joint.name))
Expand Down Expand Up @@ -152,7 +158,11 @@ def get_topological_order(self) -> list[str]:
RobotValidationError: If a cycle is detected
"""
if self.has_cycle():
raise RobotValidationError("CyclicGraph", None, "Graph contains cycles")
raise RobotValidationError(
ValidationErrorCode.HAS_CYCLE,
"Kinematic graph contains cycles",
target="CyclicGraph",
)

order: list[str] = []
# Implement Kahn's algorithm for topological sorting.
Expand Down
Loading
Loading