From b668942ec890d837f5c9d13155b2e90cad09c7b0 Mon Sep 17 00:00:00 2001 From: "-T.K.-" Date: Mon, 29 Jun 2026 00:50:59 -0700 Subject: [PATCH] feat(workflow): generate hybrid (EtherCAT+CAN) ros2_control; weld fixed joints Add a "hybrid" ros2_control layout so a robot can drive heterogeneous buses from one controller_manager, and a joint-weld transform so a CAD-real DoF can be retired to a rigid joint without re-exporting from Onshape. - robot_model.weld_joints(): convert joints flagged `"fixed": true` in joint_properties.json to fixed joints (drop axis/limit/dynamics/mimic), called from finalize_urdf so the hub, MJCF (MuJoCo welds it), and the description xacro all see the weld. No-op for robots without the flag. - urdf_to_xacro: `"layout": "hybrid"` emits one block per group (each its own plugin), recognizing `ethercat` joints (CiA402 EcCiA402Drive + per-joint slave_config) and `sito` joints (MIT + can_id), with sim/mock collapsing to one combined MIT block. The default CAN layout is untouched -- existing robots regenerate byte-identical. Verified: full suite 1376 passed / 5 skipped; all lite robots byte-identical after regen; Prime-Description regenerates + xacro-expands (mock/sim/real). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019z5ubj27Me94u163g7E3ec --- robot_assets/workflow/finalize_urdf.py | 1 + robot_assets/workflow/robot_model.py | 22 +++ robot_assets/workflow/urdf_to_xacro.py | 230 +++++++++++++++++++++++++ 3 files changed, 253 insertions(+) diff --git a/robot_assets/workflow/finalize_urdf.py b/robot_assets/workflow/finalize_urdf.py index 2bf3c0c..ab28007 100644 --- a/robot_assets/workflow/finalize_urdf.py +++ b/robot_assets/workflow/finalize_urdf.py @@ -37,6 +37,7 @@ def generate(robot_dir: Path) -> list[Path]: tree = robot_model.parse(hub_urdf) root = tree.getroot() + robot_model.weld_joints(root, joint_properties) robot_model.harmonize_effort(root, joint_properties) robot_model.rewrite_mesh_filenames(root, lambda name: f"../meshes/visual/{name}") ensure_autogen_comment(root, robot_model.autogen_comment(robot)) diff --git a/robot_assets/workflow/robot_model.py b/robot_assets/workflow/robot_model.py index 6529b6c..ac79e13 100644 --- a/robot_assets/workflow/robot_model.py +++ b/robot_assets/workflow/robot_model.py @@ -94,6 +94,28 @@ def harmonize_effort(root: ET.Element, joint_properties: dict) -> None: limit.set("effort", str(config["effort_limit"])) +def weld_joints(root: ET.Element, joint_properties: dict) -> None: + """Convert joints flagged ``"fixed": true`` in joint_properties to fixed joints. + + Drops the movable-only children (```` / ```` / ```` / + ````) so the joint rigidly welds its child to its parent. This retires a + CAD-real DoF (e.g. a locked waist) from the kinematic hub -- so the description + xacro, the MJCF (MuJoCo welds a fixed joint), and URDF<->MJCF parity all see a + rigid weld -- without re-exporting from Onshape. + """ + for joint in root.findall("joint"): + name = joint.get("name") + if name is None or joint.get("type") == "fixed": + continue + config = resolve_properties(name, joint_properties) + if not (config and config.get("fixed")): + continue + joint.set("type", "fixed") + for tag in ("axis", "limit", "dynamics", "mimic"): + for element in joint.findall(tag): + joint.remove(element) + + def rewrite_mesh_filenames(root: ET.Element, rewrite: Callable[[str], str]) -> None: """Rewrite every ````; ``rewrite`` receives the file basename.""" for mesh in root.iter("mesh"): diff --git a/robot_assets/workflow/urdf_to_xacro.py b/robot_assets/workflow/urdf_to_xacro.py index 0e5382b..0deb75f 100644 --- a/robot_assets/workflow/urdf_to_xacro.py +++ b/robot_assets/workflow/urdf_to_xacro.py @@ -207,7 +207,235 @@ def _top_macro(robot: str, name: str) -> str: """ +# --------------------------------------------------------------------------- +# Hybrid layout: heterogeneous backends on one controller_manager +# --------------------------------------------------------------------------- +# Selected by ros2_control.json ``"layout": "hybrid"``. The default CAN layout +# (above) assumes one MIT joint macro + per-CAN-bus blocks sharing one ``real`` +# plugin. The hybrid layout instead emits one block PER GROUP, each +# with its own plugin + hardware params, and recognizes two joint kinds: +# * ethercat : CiA402 servo on an EtherCAT ring -- position/effort + an +# with a per-joint slave_config; no can_id. +# * sito : MIT motor on SocketCAN -- the MIT interface set + can_id/model/ +# direction (read by bar_sito). +# Sim and mock still collapse to one combined MIT block so the shared controllers +# (which claim the MIT surface) run unchanged. The ``args`` from ros2_control.json +# drive the dispatch (master_id, sito_can_interface, backends, ec_control_frequency, +# erob_config_dir) instead of the CAN layout's fixed mode / can_interface_*. + + +def _hybrid_real_args(args: dict) -> list[str]: + """Args threaded to the real macro: everything but the sim/mock switches.""" + return [k for k in args if k not in (SIM_ARG, MOCK_ARG)] + + +def _interface_lines(command: list[str], state: list[str]) -> str: + cmd = "\n".join(f' ' for n in command) + st = "\n".join(f' ' for n in state) + return f"{cmd}\n{st}" + + +def _hybrid_mit_joint_macro(robot: str, command: list[str], state: list[str]) -> str: + return f""" + + +{_interface_lines(command, state)} + + """ + + +def _hybrid_ec_joint_macro(robot: str) -> str: + return f""" + + + + + + + + + + + + + ethercat_generic_plugins/EcCiA402Drive + 0 + ${{position}} + 8 + ${{erob_config_dir}}/${{name}}.yaml + + + """ + + +def _hybrid_sito_joint_macro(robot: str, command: list[str], state: list[str]) -> str: + return f""" + + +{_interface_lines(command, state)} + ${{can_id}} + ${{model}} + ${{direction}} + + """ + + +def _hybrid_all_mit_macro(robot: str, joints: list[dict]) -> str: + calls = "\n".join(f' ' for j in joints) + return f""" + +{calls} + """ + + +def _hybrid_group_joints_macro(robot: str, group: dict, joints: list[dict]) -> str: + if group["kind"] == "ethercat": + calls = "\n".join( + f' ' + for j in joints + ) + return f""" +{calls} + """ + calls = "\n".join( + f' ' + for j in joints + ) + return f""" +{calls} + """ + + +def _hybrid_combined_macro(robot: str, backends: dict) -> str: + return f""" + + + + + {backends["sim"]} + + + {backends["mock"]} + + + + + """ + + +def _hybrid_real_block(robot: str, group: dict) -> str: + hw = "\n".join( + f' {v}' + for k, v in group.get("hardware_params", {}).items() + ) + if group["kind"] == "ethercat": + joints_call = ( + f' ' + ) + else: + joints_call = f' ' + block = f""" + + {group["plugin"]} +{hw} + +{joints_call} + """ + enable = group.get("enable_when") + if not enable: + return block + guard = " or ".join(f"backends == '{v}'" for v in enable) + return f""" +{block} + """ + + +def _hybrid_real_macro(robot: str, cfg: dict) -> str: + params = " ".join(_hybrid_real_args(cfg["args"])) + blocks = "\n".join(_hybrid_real_block(robot, g) for g in cfg["groups"]) + return f""" + +{blocks} + """ + + +def _hybrid_top_macro(robot: str, cfg: dict) -> str: + top_params = " ".join(["name", *cfg["args"].keys()]) + real_pass = "\n".join(f' {a}="${{{a}}}"' for a in _hybrid_real_args(cfg["args"])) + return f""" + + + + + + + + """ + + +def _build_hybrid_ros2_control(robot: str, cfg: dict, limits: dict) -> str: + command = cfg["mit_interfaces"]["command"] + state = cfg["mit_interfaces"]["state"] + joints = cfg["joints"] + joints_by_group: dict[str, list[dict]] = {} + for joint in joints: + joints_by_group.setdefault(joint["group"], []).append(joint) + + parts = [ + _hybrid_mit_joint_macro(robot, command, state), + _hybrid_ec_joint_macro(robot), + _hybrid_sito_joint_macro(robot, command, state), + _hybrid_all_mit_macro(robot, joints), + ] + for group in cfg["groups"]: + parts.append(_hybrid_group_joints_macro(robot, group, joints_by_group.get(group["name"], []))) + parts.append(_hybrid_combined_macro(robot, cfg["backends"])) + parts.append(_hybrid_real_macro(robot, cfg)) + parts.append(_hybrid_top_macro(robot, cfg)) + + body = "\n\n".join(parts) + return ( + f'\n{_banner(robot)}\n' + f'\n\n{body}\n\n\n' + ) + + +def _build_hybrid_assembly(robot: str, cfg: dict, package: str) -> str: + args = cfg.get("args", {}) + arg_decls = "\n".join(f' ' for k, v in args.items()) + name_attr = cfg.get("combined_block_name", f"{robot}_system") + instantiation = "\n".join(f' {a}="$(arg {a})"' for a in args) + return f""" +{_banner(robot)} + +{arg_decls} + + + + + + + + +""" + + def build_ros2_control_xacro(robot: str, ros2_control: dict, limits: dict) -> str: + if ros2_control.get("layout") == "hybrid": + return _build_hybrid_ros2_control(robot, ros2_control, limits) command = ros2_control["interfaces"]["command"] state = ros2_control["interfaces"]["state"] backends = ros2_control["backends"] @@ -242,6 +470,8 @@ def build_ros2_control_xacro(robot: str, ros2_control: dict, limits: dict) -> st def build_assembly_xacro(robot: str, ros2_control: dict | None, package: str = DEFAULT_PACKAGE) -> str: """Top assembly. With a ros2_control spec it wires the hardware macro; without one (model-only robots like the full lite) it just instantiates the model.""" + if ros2_control and ros2_control.get("layout") == "hybrid": + return _build_hybrid_assembly(robot, ros2_control, package) if not ros2_control: return f""" {_banner(robot)}