from __future__ import annotations import json from pathlib import Path import pytest from src.joint_module.auto_design import ( compute_auto_envelope, export_generated_housing, _patch_cadquery_visual_imports, ) from src.joint_module.datum_inference import infer_step_datum_axis from src.joint_module.generator import load_joint_requirement, run_joint_module from src.joint_module.models import ( ComponentPlacement, DatumAxis, JointComponentManifest, JointModuleManifest, StepShapeMetrics, ) from src.joint_module.step_geometry import ( apply_transform, detect_interferences_from_placed_shapes, ) from src.joint_module.validators import build_joint_module_validation_report from src.joint_module.urdf_exporter import ( build_urdf_xml, compute_urdf_motion_map, export_urdf, ) from src.joint_module.reducer_adapters import ( JointAdapterError, SUPPORTED_JOINT_FAMILIES, get_reducer_joint_adapter, ) from src.joint_module.reference_assembly import infer_reference_placements from src.kinematics import solve_instance from src.models import ValidationReport, read_json from src.parameter_solver import load_requirement, solve_parameters from src.topology import load_template ROOT = Path(__file__).resolve().parents[1] WORKSPACE = ROOT.parent MOTOR_STEP = ROOT / "input" / "assets" / "motors" / "3500_motor_part2.step" HOUSING_SOURCE = ROOT / "input" / "assets" / "housings" / "gear_housing.py" JOINT_REQ = ROOT / "input" / "requirements" / "joint_modules" / "3500_motor_housing.json" REDUCER_REQ = ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_industrial_ratio_7p5_spur.json" TEMPLATE = ROOT / "src" / "configurations" / "simple_2k_h" / "topology.template.json" CASCADE_JOINT_REQ = ROOT / "input" / "requirements" / "joint_modules" / "3500_motor_housing_cascade.json" CASCADE_REDUCER_REQ = ROOT / "input" / "requirements" / "reducers" / "simple_2k_h_cascade" / "simple_2kh_cascade_ratio_9_spur.json" STANDARD_MOTOR_STEP = ROOT / "input" / "assets" / "motors" / "3500_motor_part2_standard.step" ACTUATOR_HOUSING_STEP = ROOT / "input" / "assets" / "housings" / "actuator_v2_knee_abd_main_case_2.step" CHIP_STEP = ROOT / "input" / "assets" / "chip" / "chip.step" REFERENCE_ASSEMBLY_STEP = ROOT / "input" / "assets" / "references" / "actuator_v2_knee_abd_reference.step" FIXED_PLATFORM_PROFILE = ROOT / "input" / "assets" / "platforms" / "actuator_v2_fixed_platform.json" SPLIT_MOTOR_DIR = ROOT / "input" / "assets" / "motors" / "3500_split" FERGUSON_REDUCER_REQ = ROOT / "input" / "requirements" / "reducers" / "ferguson_wolfrom" / "ferguson_wolfrom_ratio_531p25_spur.json" CASCADE_TEMPLATE = ROOT / "src" / "configurations" / "simple_2k_h_cascade" / "topology.template.json" FERGUSON_TEMPLATE = ROOT / "src" / "configurations" / "ferguson_wolfrom" / "topology.template.json" def _simplecad_or_skip(): scad = pytest.importorskip("simplecadapi") if not hasattr(scad.std.gear, "make_spur_gear_rsolid"): pytest.skip("SimpleCADAPI lacks spur gear API") return scad def _ocp_or_skip() -> None: pytest.importorskip("OCP") def _cadquery_or_skip() -> None: _patch_cadquery_visual_imports() try: __import__("cadquery") except ModuleNotFoundError: pytest.skip("cadquery is not installed") except ImportError as exc: pytest.skip(f"cadquery import failed: {exc}") def _generated_housing_step(tmp_path: Path) -> Path: _cadquery_or_skip() envelope, evidence, module = compute_auto_envelope( motor_step=MOTOR_STEP, housing_source=HOUSING_SOURCE, radial_clearance_mm=14.0, reducer_axial_allowance_mm=42.0, reducer_radial_clearance_mm=8.0, ) assert envelope.motor_radial_diameter_mm > 0.0 path = tmp_path / "generated_housing.step" export_generated_housing( housing_module=module, scale_payload=evidence["housing_scale"], out_path=path, ) return path def _runtime_joint_requirement(tmp_path: Path) -> Path: payload = { "module_type": "planetary_joint_module", "platform_profile": str(FIXED_PLATFORM_PROFILE.resolve()), "reducer_requirement": str(REDUCER_REQ.resolve()), "placement_policy": "infer_then_write_editable_json", "motor_to_reducer_relation": "motor_output_to_sun_keyed_input", "housing_relation": "ring_fixed_to_housing", "joint_output": "carrier_output_shaft", "output_extension_mm": 8.0, "coupling_clearance_mm": 1.0, "input_shaft_insertion_depth_mm": 4.0, "motor_output_side": "axis_max", "axis_angle_tolerance_deg": 0.5, "axis_distance_tolerance_mm": 0.25, "volume_tolerance_mm3": 0.000001, } path = tmp_path / "joint_requirement.json" path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") return path def _metrics(component_id: str, axis: tuple[float, float, float]) -> StepShapeMetrics: datum = DatumAxis( origin_mm=(0.0, 0.0, 0.0), direction_xyz=axis, confidence=0.95, method="test_axis", ) return StepShapeMetrics( component_id=component_id, step_path=f"/tmp/{component_id}.step", valid=True, volume_mm3=1.0, bbox=(-1.0, -1.0, -1.0, 1.0, 1.0, 1.0), solid_count=1, face_count=1, plane_face_count=0, cylinder_face_count=1, inferred_axis=datum, ) def _component(component_id: str, *, source_type: str, axis: tuple[float, float, float]) -> JointComponentManifest: return JointComponentManifest( component_id=component_id, role=component_id, source_type=source_type, source_step_path=f"/tmp/{component_id}.step", placement=ComponentPlacement(), raw_metrics=_metrics(component_id, axis), placed_metrics=_metrics(component_id, axis), ) def _fake_joint_manifest(*, motor_axis: tuple[float, float, float] = (0.0, 0.0, 1.0)) -> JointModuleManifest: components = { "motor": _component("motor", source_type="external_step", axis=motor_axis), "housing": _component("housing", source_type="external_step", axis=(0.0, 0.0, 1.0)), "sun": _component("sun", source_type="generated_reducer", axis=(0.0, 0.0, 1.0)), "ring": _component("ring", source_type="generated_reducer", axis=(0.0, 0.0, 1.0)), "carrier": _component("carrier", source_type="generated_reducer", axis=(0.0, 0.0, 1.0)), "output_shaft": _component("output_shaft", source_type="generated_reducer", axis=(0.0, 0.0, 1.0)), } return JointModuleManifest( run_id="joint_test", reducer_run_id="reducer_test", generated_at="test", requirement_hash="hash", reducer_output_dir="/tmp/reducer", reducer_validation_passed=True, components=components, relations=[ { "relation_id": "motor_output_to_sun_keyed_input", "relation_type": "keyed_input_coupling", "source_component_id": "motor", "target_component_id": "sun", "formula_ref": "test", }, { "relation_id": "ring_fixed_to_external_housing", "relation_type": "fixed", "source_component_id": "ring", "target_component_id": "housing", "formula_ref": "test", }, { "relation_id": "carrier_output_shaft_is_joint_output", "relation_type": "rigid_output", "source_component_id": "carrier", "target_component_id": "output_shaft", "formula_ref": "test", }, ], placements={"motor": ComponentPlacement(), "housing": ComponentPlacement(), "reducer": ComponentPlacement()}, exported_files={}, geometry_summary={}, ) def _reducer_instance_and_solution(): instance = solve_parameters(requirement=load_requirement(REDUCER_REQ), template=load_template(TEMPLATE))[0] return instance, solve_instance(instance) def _passing_reducer_report(run_id: str) -> ValidationReport: return ValidationReport( run_id=run_id, overall_passed=True, generated_at="test", checks=[], summary={"total": 0, "passed": 0, "failed": 0}, ) def test_joint_requirement_loads() -> None: requirement = load_joint_requirement(JOINT_REQ) assert requirement.module_type == "planetary_joint_module" assert requirement.platform_id == "actuator_v2_fixed_platform" assert Path(requirement.motor_step) == STANDARD_MOTOR_STEP assert Path(requirement.housing_step) == ACTUATOR_HOUSING_STEP assert requirement.chip_step is None assert Path(requirement.reference_assembly_step or "") == REFERENCE_ASSEMBLY_STEP assert Path(requirement.motor_reference_assembly_step or "") == SPLIT_MOTOR_DIR / "3500_motor_reference.step" assert Path(requirement.motor_stator_housing_step or "") == SPLIT_MOTOR_DIR / "3500-定子外壳.STEP" assert Path(requirement.motor_rotor_housing_step or "") == SPLIT_MOTOR_DIR / "3500-转子外壳.STEP" assert Path(requirement.motor_output_shaft_step or "") == SPLIT_MOTOR_DIR / "输出轴.STEP" assert Path(requirement.motor_windings_step or "") == SPLIT_MOTOR_DIR / "线圈.STEP" assert requirement.motor_placement_policy == "reference_pose" assert requirement.motor_to_reducer_relation == "motor_output_to_sun_keyed_input" assert requirement.housing_relation == "ring_fixed_to_housing" def test_supported_reducer_families_have_joint_adapters() -> None: assert SUPPORTED_JOINT_FAMILIES == {"simple_2k_h", "simple_2k_h_cascade"} assert get_reducer_joint_adapter("simple_2k_h").family == "simple_2k_h" assert get_reducer_joint_adapter("simple_2k_h_cascade").family == "simple_2k_h_cascade" def test_ferguson_wolfrom_is_not_supported_as_joint_module() -> None: with pytest.raises(JointAdapterError, match="eccentric_input_not_compatible"): get_reducer_joint_adapter("ferguson_wolfrom") def test_cascade_joint_requirement_loads_and_ports_resolve() -> None: requirement = load_joint_requirement(CASCADE_JOINT_REQ) instance = solve_parameters( requirement=load_requirement(CASCADE_REDUCER_REQ), template=load_template(CASCADE_TEMPLATE), )[0] assert requirement.joint_output == "s2_carrier_output" assert requirement.platform_id == "actuator_v2_fixed_platform" assert requirement.chip_step is None assert Path(requirement.reference_assembly_step or "") == REFERENCE_ASSEMBLY_STEP assert requirement.motor_placement_policy == "reference_pose" assert instance.boundary.input == "s1_sun" assert instance.boundary.output == "s2_carrier" assert instance.boundary.fixed_members == ["s1_ring", "s2_ring"] def test_reference_assembly_recovers_motor_placement() -> None: _ocp_or_skip() placements = infer_reference_placements( reference_step=REFERENCE_ASSEMBLY_STEP, component_steps={ "housing": ACTUATOR_HOUSING_STEP, "motor": STANDARD_MOTOR_STEP, }, ) assert placements["housing"].translation_mm == (0.0, 0.0, 0.0) assert placements["motor"].translation_mm[0] == pytest.approx(2.0, abs=1e-6) assert placements["motor"].source == "inferred_from_reference_assembly_occurrence" def test_joint_requirements_reference_supported_reducer_families() -> None: for requirement_path in (ROOT / "input" / "requirements" / "joint_modules").glob("*.json"): requirement = load_joint_requirement(requirement_path) reducer_requirement_path = (requirement_path.parent / requirement.reducer_requirement).resolve() reducer_requirement = load_requirement(reducer_requirement_path) assert reducer_requirement.topology_family in SUPPORTED_JOINT_FAMILIES def test_all_joint_requirements_use_the_fixed_platform_profile() -> None: platform_ids = { load_joint_requirement(requirement_path).platform_id for requirement_path in (ROOT / "input" / "requirements" / "joint_modules").glob("*.json") } assert platform_ids == {"actuator_v2_fixed_platform"} def test_ferguson_reducer_ports_remain_available_for_reducer_exports() -> None: instance = solve_parameters( requirement=load_requirement(FERGUSON_REDUCER_REQ), template=load_template(FERGUSON_TEMPLATE), )[0] assert instance.boundary.input == "planet1" assert instance.boundary.fixed_members == ["static_carrier"] assert instance.boundary.output == "output_carrier" def test_external_step_component_schema() -> None: component = _component("motor", source_type="external_step", axis=(0.0, 0.0, 1.0)) assert component.source_type == "external_step" assert component.placed_metrics.valid assert component.placed_metrics.inferred_axis.direction_xyz == (0.0, 0.0, 1.0) def test_joint_kinematics_maps_motor_to_sun() -> None: requirement = load_joint_requirement(JOINT_REQ) instance, solution = _reducer_instance_and_solution() report = build_joint_module_validation_report( requirement=requirement, manifest=_fake_joint_manifest(), reducer_instance=instance, reducer_solution=solution, reducer_validation_report=_passing_reducer_report(instance.run_id), interference_findings=[], ) assert any(check.code == "motor_speed_maps_to_sun_speed" and check.passed for check in report.checks) assert any(check.code == "ring_fixed_to_housing_speed_zero" and check.passed for check in report.checks) assert any(check.code == "carrier_maps_to_joint_output_ratio" and check.passed for check in report.checks) def test_urdf_motion_map_uses_solved_transmission_ratio() -> None: instance, _ = _reducer_instance_and_solution() motion = compute_urdf_motion_map(reducer_instance=instance) assert abs(float(motion["ratio"]) - 7.5) < 1e-12 assert abs(float(motion["carrier_joint_multiplier"]) - (1.0 / 7.5)) < 1e-12 expected_planet_relative = -0.18181818181818182 - (1.0 / 7.5) assert abs(float(motion["planet_spin_joint_multiplier"]) - expected_planet_relative) < 1e-12 def test_urdf_xml_contains_official_mimic_transmission_elements() -> None: instance, _ = _reducer_instance_and_solution() xml, motion = build_urdf_xml(manifest=_fake_joint_manifest(), reducer_instance=instance) assert '' in xml assert '' in xml assert '' in xml assert 'mimic joint="sun_input_joint"' in xml assert f'multiplier="{float(motion["carrier_joint_multiplier"]):.12g}"' in xml def test_joint_validation_fails_missing_motor_step_component() -> None: requirement = load_joint_requirement(JOINT_REQ) instance, solution = _reducer_instance_and_solution() manifest = _fake_joint_manifest() manifest = manifest.model_copy( update={"components": {key: value for key, value in manifest.components.items() if key != "motor"}} ) report = build_joint_module_validation_report( requirement=requirement, manifest=manifest, reducer_instance=instance, reducer_solution=solution, reducer_validation_report=_passing_reducer_report(instance.run_id), interference_findings=[], ) assert any(check.code == "joint_required_components_present" and not check.passed for check in report.checks) assert any(check.code == "external_components_are_real_step_sources" and not check.passed for check in report.checks) def test_joint_validation_fails_axis_misalignment() -> None: requirement = load_joint_requirement(JOINT_REQ) instance, solution = _reducer_instance_and_solution() report = build_joint_module_validation_report( requirement=requirement, manifest=_fake_joint_manifest(motor_axis=(1.0, 0.0, 0.0)), reducer_instance=instance, reducer_solution=solution, reducer_validation_report=_passing_reducer_report(instance.run_id), interference_findings=[], ) assert any(check.code == "motor_axis_coaxial_with_reducer_input" and not check.passed for check in report.checks) def test_motor_and_housing_steps_are_valid(tmp_path: Path) -> None: _ocp_or_skip() motor = infer_step_datum_axis( component_id="motor", step_path=MOTOR_STEP, role="motor", ) housing_step = _generated_housing_step(tmp_path) housing = infer_step_datum_axis( component_id="housing", step_path=housing_step, role="housing", ) assert motor.valid and motor.volume_mm3 > 0.0 assert housing.valid and housing.volume_mm3 > 0.0 assert motor.inferred_axis.direction_xyz == (0.0, 1.0, 0.0) assert housing.inferred_axis.direction_xyz == (0.0, 0.0, 1.0) def test_joint_interference_detection_uses_placements() -> None: _ocp_or_skip() from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox dummy_step = MOTOR_STEP shape = BRepPrimAPI_MakeBox(2.0, 2.0, 2.0).Shape() shapes = { "motor_a": apply_transform(shape, ComponentPlacement()), "motor_b": apply_transform(shape, ComponentPlacement()), } metrics, results = detect_interferences_from_placed_shapes( shapes, step_paths={"motor_a": dummy_step, "motor_b": dummy_step}, volume_tolerance_mm3=1e-6, ) assert len(metrics) == 2 assert results[0].interfering assert results[0].common_volume_mm3 > 0.0 def test_joint_run_creates_reducer_and_joint_step(tmp_path: Path) -> None: _simplecad_or_skip() _ocp_or_skip() requirement_path = _runtime_joint_requirement(tmp_path) manifest = run_joint_module(requirement_path=requirement_path, out_dir=tmp_path / "joint") for key in ["joint_step", "joint_manifest", "joint_validation_report", "joint_interference_report"]: path = Path(manifest.exported_files[key]) assert path.exists() assert path.stat().st_size > 0 reducer_dir = Path(manifest.exported_files["reducer_dir"]) assert (reducer_dir / "formula" / "formula_instance.json").exists() assert (reducer_dir / "validation" / "validation_report.json").exists() validation = read_json(Path(manifest.exported_files["joint_validation_report"])) assert any(check["code"] == "joint_ocp_boolean_interference" for check in validation["checks"]) insertion_checks = [ check for check in validation["checks"] if check["code"] == "sun_input_shaft_inserted_into_motor_output_bore" ] assert insertion_checks assert insertion_checks[0]["passed"] assert insertion_checks[0]["actual"]["insertion_depth_mm"] >= 4.0 def test_export_urdf_creates_official_urdf_package_with_mimic_joints(tmp_path: Path) -> None: _simplecad_or_skip() _ocp_or_skip() joint_out = tmp_path / "joint" requirement_path = _runtime_joint_requirement(tmp_path) manifest = run_joint_module(requirement_path=requirement_path, out_dir=joint_out) urdf_manifest = export_urdf( joint_manifest_path=Path(manifest.exported_files["joint_manifest"]), reducer_instance_path=joint_out / "reducer" / "formula" / "formula_instance.json", out_dir=tmp_path / "urdf", linear_deflection_mm=0.5, ) urdf_path = Path(urdf_manifest.urdf_path) validation_path = Path(urdf_manifest.validation_report_path) assert urdf_path.exists() and urdf_path.stat().st_size > 0 assert Path(urdf_manifest.package_xml_path).exists() assert validation_path.exists() validation = read_json(validation_path) assert validation["overall_passed"] xml = urdf_path.read_text(encoding="utf-8") assert 'name="sun_input_joint" type="continuous"' in xml assert 'name="carrier_output_joint" type="continuous"' in xml assert 'mimic joint="sun_input_joint"' in xml assert abs(float(urdf_manifest.transmission_summary["ratio"]) - 7.5) < 1e-12 assert abs(float(urdf_manifest.transmission_summary["carrier_joint_multiplier"]) - (1.0 / 7.5)) < 1e-12 assert "sun" in urdf_manifest.mesh_files assert Path(urdf_manifest.mesh_files["sun"]).exists()