Source code for linkforge.core.models.gazebo

"""Gazebo simulation models and plugins.

This module provides data structures to represent Gazebo-specific properties,
bridging the gap between standard kinematic URDF and high-fidelity physics
simulation parameters.

Core Components:
- **Elements**: Container for link/joint specific physics (CFM, ERP, materials).
- **Plugins**: Functional extensions for sensors, controllers, and physics.
"""

from __future__ import annotations

from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field, replace
from types import MappingProxyType
from typing import Any

from ..exceptions import RobotValidationError, ValidationErrorCode


[docs] @dataclass(frozen=True, slots=True) class GazeboElement: """Simulation-specific metadata container for Gazebo. Encapsulates parameters that are not part of the standard URDF spec but are required for high-fidelity physics (e.g., DART/ODE parameters) or visual appearance in Gazebo. All mutable inputs (``properties`` dict, ``plugins`` sequence) are converted to immutable structures in ``__post_init__`` to guarantee true deep immutability even though the class is ``frozen=True``. """ reference: str | None = None # Link or joint name (None for robot-level) properties: Mapping[str, str] = field(default_factory=dict) plugins: Sequence[GazeboPlugin] = field(default_factory=tuple) # Common properties for links (platform-specific) material: str | None = None # Gazebo material (e.g., "Gazebo/Red") static: bool | None = None # Common properties for joints (platform-specific) stop_cfm: float | None = None # Constraint force mixing for joint stops (>= 0) stop_erp: float | None = None # Error reduction parameter for joint stops (∈ [0, 1]) provide_feedback: bool | None = None # Enable force-torque feedback implicit_spring_damper: bool | None = None
[docs] def __post_init__(self) -> None: """Validate and deeply immutabilise the Gazebo element.""" # If reference is specified, it must be non-blank if self.reference is not None and not self.reference.strip(): raise RobotValidationError( ValidationErrorCode.NAME_EMPTY, "Gazebo reference cannot be empty or whitespace-only", target="GazeboReference", ) # Deep-immutabilise mutable fields object.__setattr__(self, "plugins", tuple(self.plugins)) object.__setattr__(self, "properties", MappingProxyType(dict(self.properties))) # --- Physics parameter range validation --- # CFM (Constraint Force Mixing) must be non-negative. # A value of 0 means a perfectly rigid constraint; negative values are physically invalid. if self.stop_cfm is not None and self.stop_cfm < 0.0: raise RobotValidationError( ValidationErrorCode.INVALID_VALUE, f"stop_cfm must be >= 0 (got {self.stop_cfm}). " "Negative CFM values are physically invalid for ODE/DART solvers.", target="GazeboStopCFM", value=str(self.stop_cfm), ) # ERP (Error Reduction Parameter) must be in [0, 1]. # 0 = no correction, 1 = full correction per step. if self.stop_erp is not None and not (0.0 <= self.stop_erp <= 1.0): raise RobotValidationError( ValidationErrorCode.INVALID_VALUE, f"stop_erp must be in [0.0, 1.0] (got {self.stop_erp}). " "Values outside this range produce unstable physics simulation.", target="GazeboStopERP", value=str(self.stop_erp), )
[docs] def with_prefix(self, prefix: str) -> GazeboElement: """Create a new gazebo element with prefixed reference and plugins.""" return replace( self, reference=f"{prefix}{self.reference}" if self.reference else None, plugins=tuple(p.with_prefix(prefix) for p in self.plugins), )
[docs] def __deepcopy__(self, memo: dict[int, Any]) -> GazeboElement: """Deep copy: Since this object is deeply immutable, return self.""" return self
[docs] @dataclass(frozen=True, slots=True) class GazeboPlugin: """Gazebo plugin specification for functional extensions. ``parameters`` is stored as an immutable :class:`~types.MappingProxyType` regardless of what is passed at construction time, ensuring that the ``frozen=True`` contract is not silently violated by callers mutating the dict. """ name: str filename: str parameters: Mapping[str, str] = field(default_factory=dict) raw_xml: str | None = field( default=None, compare=False ) # Store raw XML content for round-trip fidelity
[docs] def __post_init__(self) -> None: """Validate and deeply immutabilise plugin configuration.""" if not self.name.strip(): raise RobotValidationError( ValidationErrorCode.NAME_EMPTY, "Plugin name cannot be empty or whitespace-only", target="PluginName", ) if not self.filename.strip(): raise RobotValidationError( ValidationErrorCode.VALUE_EMPTY, "Plugin filename cannot be empty or whitespace-only", target="PluginFilename", ) object.__setattr__(self, "parameters", MappingProxyType(dict(self.parameters)))
[docs] def with_prefix(self, prefix: str) -> GazeboPlugin: """Create a new plugin with a prefixed name.""" return replace(self, name=f"{prefix}{self.name}")
[docs] def __deepcopy__(self, memo: dict[int, Any]) -> GazeboPlugin: """Deep copy: Since this object is deeply immutable, return self.""" return self