468 lines
19 KiB
Python
468 lines
19 KiB
Python
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"
|
|
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 requirement.motor_placement_policy == "external_flush_input_end"
|
|
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 == "external_flush_input_end"
|
|
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 '<robot name="planetary_reducer_joint_module">' in xml
|
|
assert '<joint name="sun_input_joint" type="continuous">' in xml
|
|
assert '<joint name="carrier_output_joint" type="continuous">' 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()
|