Split project into frontend and backend

This commit is contained in:
2026-08-13 16:24:36 +08:00
commit f051146486
274 changed files with 953932 additions and 0 deletions
+302
View File
@@ -0,0 +1,302 @@
from __future__ import annotations
import importlib
from pathlib import Path
import pytest
from src.assembly_reader import read_assembly_snapshot
from src.cad.ocp_interference import detect_interferences
from src.cad.simplecad_generator import generate_simplecad
from src.parameter_solver import load_requirement, solve_parameters
from src.topology import load_template
from src.validators import build_validation_report
ROOT = Path(__file__).resolve().parents[1]
REQ = ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_ratio_7p5.json"
TEMPLATE = ROOT / "src" / "configurations" / "simple_2k_h" / "topology.template.json"
def _simplecad_or_skip():
scad = pytest.importorskip("simplecadapi")
required = ["add_gear_constraint_rassembly", "add_belt_constraint_rassembly"]
missing = [name for name in required if not hasattr(scad, name)]
if missing:
pytest.skip("SimpleCADAPI lacks required gear/belt assembly constraints: " + ", ".join(missing))
required_gear = [
"make_spur_gear_rsolid",
"make_spur_ring_gear_rsolid",
"make_helical_gear_rsolid",
"make_helical_ring_gear_rsolid",
]
missing_gear = [name for name in required_gear if not hasattr(scad.std.gear, name)]
if missing_gear:
pytest.skip("SimpleCADAPI lacks required gear APIs: " + ", ".join(missing_gear))
return scad
def test_simplecad_generator_creates_step_model_session(tmp_path: Path) -> None:
_simplecad_or_skip()
instance = solve_parameters(requirement=load_requirement(REQ), template=load_template(TEMPLATE))[0]
manifest = generate_simplecad(instance, tmp_path)
for key in ["step", "model", "session", "manifest", "build_meta"]:
path = Path(manifest.exported_files[key])
assert path.exists()
assert path.stat().st_size > 0
snapshot = read_assembly_snapshot(
model_path=Path(manifest.exported_files["model"]),
manifest_path=Path(manifest.exported_files["manifest"]),
build_meta_path=Path(manifest.exported_files["build_meta"]),
)
assert {"sun", "ring", "carrier", "housing"} <= set(snapshot.components_from_model)
assert len(snapshot.gear_constraints) == instance.parameters.planet_count
assert len(snapshot.internal_mesh_constraints) == instance.parameters.planet_count
report = build_validation_report(instance, snapshot)
assert any(check.code == "ocp_boolean_interference" for check in report.checks)
assert report.overall_passed
def test_generated_validation_uses_topology_relations(tmp_path: Path) -> None:
_simplecad_or_skip()
instance = solve_parameters(requirement=load_requirement(REQ), template=load_template(TEMPLATE))[0]
manifest = generate_simplecad(instance, tmp_path)
snapshot = read_assembly_snapshot(
model_path=Path(manifest.exported_files["model"]),
manifest_path=Path(manifest.exported_files["manifest"]),
build_meta_path=Path(manifest.exported_files["build_meta"]),
)
report = build_validation_report(instance, snapshot)
codes = [check.code for check in report.checks]
assert any(code.startswith("external_mesh.") for code in codes)
assert any(code.startswith("internal_mesh.") for code in codes)
assert any(code.startswith("revolute_joint.") for code in codes)
assert any(code.startswith("coaxial.") for code in codes)
assert report.overall_passed
def test_ocp_interference_validator_detects_overlap(tmp_path: Path) -> None:
_simplecad_or_skip()
instance = solve_parameters(requirement=load_requirement(REQ), template=load_template(TEMPLATE))[0]
manifest = generate_simplecad(instance, tmp_path)
sun_step = Path(manifest.components["sun"].assembled_step_path)
metrics, results = detect_interferences(
{"sun_a": sun_step, "sun_b": sun_step},
volume_tolerance_mm3=1e-6,
)
assert len(metrics) == 2
assert results[0].interfering
assert results[0].common_volume_mm3 > 0
@pytest.mark.parametrize(
"requirement_name",
[
"simple_2kh_boundary_a_spur.json",
"simple_2kh_boundary_b_spur.json",
"simple_2kh_boundary_c_spur.json",
],
)
def test_spur_boundary_variants_create_and_validate(
tmp_path: Path,
requirement_name: str,
) -> None:
_simplecad_or_skip()
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / requirement_name)
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
manifest = generate_simplecad(instance, tmp_path)
snapshot = read_assembly_snapshot(
model_path=Path(manifest.exported_files["model"]),
manifest_path=Path(manifest.exported_files["manifest"]),
build_meta_path=Path(manifest.exported_files["build_meta"]),
)
report = build_validation_report(instance, snapshot)
assert report.overall_passed
def test_helical_generator_creates_real_outputs(tmp_path: Path) -> None:
_simplecad_or_skip()
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_ratio_3_helical.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
manifest = generate_simplecad(instance, tmp_path)
for key in ["step", "model", "session", "manifest", "build_meta"]:
path = Path(manifest.exported_files[key])
assert path.exists()
assert path.stat().st_size > 0
snapshot = read_assembly_snapshot(
model_path=Path(manifest.exported_files["model"]),
manifest_path=Path(manifest.exported_files["manifest"]),
build_meta_path=Path(manifest.exported_files["build_meta"]),
)
report = build_validation_report(instance, snapshot)
if not report.overall_passed:
assert any(
check.code == "ocp_boolean_interference" and not check.passed
for check in report.checks
)
def test_industrial_simple_2kh_spur_creates_complete_core_and_validates(tmp_path: Path) -> None:
_simplecad_or_skip()
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_industrial_ratio_7p5_spur.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
manifest = generate_simplecad(instance, tmp_path)
for key in ["step", "model", "session", "manifest", "build_meta", "industrial_parameters", "assembly_topology"]:
path = Path(manifest.exported_files[key])
assert path.exists()
assert path.stat().st_size > 0
snapshot = read_assembly_snapshot(
model_path=Path(manifest.exported_files["model"]),
manifest_path=Path(manifest.exported_files["manifest"]),
build_meta_path=Path(manifest.exported_files["build_meta"]),
)
expected = {
"carrier",
"carrier_front_plate",
"output_shaft",
"sun_input_shaft",
"sun_input_key",
"output_bearing_1",
"output_bearing_2",
"front_cover",
"rear_cover",
"seal_input",
"seal_output",
}
for index in range(1, instance.parameters.planet_count + 1):
expected.update({f"planet_pin_{index}", f"planet_bearing_{index}", f"planet_spacer_{index}"})
assert expected <= set(snapshot.components_from_model)
assert "input_bearing" not in set(snapshot.components_from_model)
assert manifest.components["planet_bearing_1"].details["bearing_type"] == "radial_ball_bearing"
assert manifest.components["planet_bearing_1"].details["ball_count"] >= 6
assert manifest.components["output_bearing_1"].details["bearing_type"] == "radial_ball_bearing"
report = build_validation_report(instance, snapshot)
assert any(check.code == "industrial_all_physical_components_exist" for check in report.checks)
assert any(check.code == "industrial_component_steps_exist" for check in report.checks)
check_codes = {check.code for check in report.checks}
for relation in instance.assembly_topology.relations:
assert f"industrial.{relation.relation_type}.{relation.relation_id}.component_exists" in check_codes
assert report.overall_passed
def test_industrial_simple_2kh_helical_creates_complete_core_and_validates(tmp_path: Path) -> None:
_simplecad_or_skip()
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_industrial_ratio_7p5_helical.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
manifest = generate_simplecad(instance, tmp_path)
for key in ["step", "model", "session", "manifest", "build_meta", "industrial_parameters", "assembly_topology"]:
path = Path(manifest.exported_files[key])
assert path.exists()
assert path.stat().st_size > 0
assert manifest.components["sun"].tooth_form == "helical"
assert manifest.components["sun"].helix_hand == "right"
assert manifest.components["planet_1"].helix_hand == "left"
assert manifest.components["ring"].helix_hand == "left"
snapshot = read_assembly_snapshot(
model_path=Path(manifest.exported_files["model"]),
manifest_path=Path(manifest.exported_files["manifest"]),
build_meta_path=Path(manifest.exported_files["build_meta"]),
)
report = build_validation_report(instance, snapshot)
codes = {check.code for check in report.checks}
assert any(code.startswith("gear_mesh.") for code in codes)
assert any(code.startswith("industrial.keyed.") for code in codes)
assert any(code.endswith(".internal_ring_half_tooth_phase") for code in codes)
assert "input_bearing" not in set(snapshot.components_from_model)
assert manifest.components["planet_bearing_1"].details["bearing_type"] == "radial_ball_bearing"
assert report.overall_passed
def test_cascade_spur_creates_and_validates(tmp_path: Path) -> None:
_simplecad_or_skip()
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h_cascade" / "simple_2kh_cascade_ratio_9_spur.json")
template = load_template(ROOT / "src" / "configurations" / "simple_2k_h_cascade" / "topology.template.json")
instance = solve_parameters(requirement=requirement, template=template)[0]
manifest = generate_simplecad(instance, tmp_path)
interstage_components = {
"interstage_shaft",
"interstage_coupler",
"interstage_bearing_1",
"interstage_bearing_2",
"interstage_support_1",
"interstage_support_2",
}
assert interstage_components <= set(manifest.components)
assert manifest.components["interstage_bearing_1"].details["bearing_type"] == "radial_ball_bearing"
assert manifest.components["interstage_bearing_1"].details["ball_count"] >= 6
assert {
"s1_carrier_rigid_to_interstage_shaft",
"interstage_shaft_rigid_to_s2_sun",
"interstage_shaft_supported_by_housing",
} <= {constraint.constraint_id for constraint in manifest.constraints}
snapshot = read_assembly_snapshot(
model_path=Path(manifest.exported_files["model"]),
manifest_path=Path(manifest.exported_files["manifest"]),
build_meta_path=Path(manifest.exported_files["build_meta"]),
)
report = build_validation_report(instance, snapshot)
codes = {check.code: check for check in report.checks}
assert interstage_components <= set(snapshot.components_from_model)
assert "s1_carrier_rigid_to_s2_sun" not in snapshot.fixed_constraints
assert codes["cascade_interstage_components_exist"].passed
assert codes["cascade_interstage_constraints_exist"].passed
assert codes["cascade_topology_rigid_realized_by_physical_chain"].passed
assert codes["cascade_interstage_bearings_are_ball_bearings"].passed
assert codes["cascade_interstage_dimension_stack"].passed
assert codes["cascade_interstage_component_steps_exist"].passed
assert report.overall_passed
assert manifest.components["s1_sun"].stage_id == "s1"
assert manifest.components["s2_sun"].stage_id == "s2"
assert instance.derived.axial_stack["stage_offsets_mm"] == {"s1": 0.0, "s2": 10.0}
missing_shaft_snapshot = snapshot.model_copy(
update={
"components_from_model": [
component_id
for component_id in snapshot.components_from_model
if component_id != "interstage_shaft"
]
}
)
missing_shaft_report = build_validation_report(instance, missing_shaft_snapshot)
assert not missing_shaft_report.overall_passed
assert any(
check.code == "cascade_interstage_components_exist"
and not check.passed
and "interstage_shaft" in check.actual["missing_from_model"]
for check in missing_shaft_report.checks
)
def test_cascade_helical_creates_real_outputs(tmp_path: Path) -> None:
_simplecad_or_skip()
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h_cascade" / "simple_2kh_cascade_ratio_9_helical.json")
template = load_template(ROOT / "src" / "configurations" / "simple_2k_h_cascade" / "topology.template.json")
instance = solve_parameters(requirement=requirement, template=template)[0]
manifest = generate_simplecad(instance, tmp_path)
for key in ["step", "model", "session", "manifest", "build_meta"]:
path = Path(manifest.exported_files[key])
assert path.exists()
assert path.stat().st_size > 0
snapshot = read_assembly_snapshot(
model_path=Path(manifest.exported_files["model"]),
manifest_path=Path(manifest.exported_files["manifest"]),
build_meta_path=Path(manifest.exported_files["build_meta"]),
)
report = build_validation_report(instance, snapshot)
if not report.overall_passed:
assert any(
check.code == "ocp_boolean_interference" and not check.passed
for check in report.checks
)
@@ -0,0 +1,86 @@
from __future__ import annotations
from pathlib import Path
from src.configurations.registry import CONFIGURATIONS, load_configuration_template
from src.placement.registry import PLACEMENT_SOLVERS
from src.relation_validators import VALIDATOR_REGISTRY
ROOT = Path(__file__).resolve().parents[1]
def test_configuration_development_standard_docs_exist() -> None:
assert (ROOT / "docs" / "configuration-development-standard.md").exists()
assert (ROOT / "docs" / "topology-json-authoring-guide.md").exists()
assert (ROOT / "skill" / "references" / "configuration-development-standard.md").exists()
assert (ROOT / "skill" / "references" / "topology-json-authoring-guide.md").exists()
skill_text = (ROOT / "skill" / "SKILL.md").read_text(encoding="utf-8")
assert "references/configuration-development-standard.md" in skill_text
assert "references/topology-json-authoring-guide.md" in skill_text
def test_registered_configurations_have_standard_files() -> None:
for family, spec in CONFIGURATIONS.items():
assert spec.package_dir.exists()
assert (spec.package_dir / "family.py").exists()
assert (spec.package_dir / "topology.template.json").exists()
assert (spec.package_dir / "placement_rules.py").exists()
assert load_configuration_template(family).family == family
def test_registered_configurations_have_requirements_and_placement_solvers() -> None:
for family in CONFIGURATIONS:
requirement_dir = ROOT / "input" / "requirements" / "reducers" / family
assert requirement_dir.exists()
assert list(requirement_dir.glob("*.json"))
assert family in PLACEMENT_SOLVERS
def test_all_topology_relation_types_have_validators() -> None:
for family in CONFIGURATIONS:
template = load_configuration_template(family)
for relation in template.relations:
assert relation.relation_type in VALIDATOR_REGISTRY
def test_configuration_standard_uses_flat_src_layout() -> None:
docs = [
ROOT / "docs" / "configuration-development-standard.md",
ROOT / "docs" / "topology-json-authoring-guide.md",
ROOT / "skill" / "references" / "configuration-development-standard.md",
ROOT / "skill" / "references" / "topology-json-authoring-guide.md",
]
for path in docs:
text = path.read_text(encoding="utf-8")
assert "src/configurations/" in text
if "configuration-development-standard" in path.name:
assert "src/placement/" in text
assert "src/planetary_reducer" not in text
def test_topology_json_authoring_guide_covers_schema_fields() -> None:
text = (ROOT / "docs" / "topology-json-authoring-guide.md").read_text(encoding="utf-8")
for field in [
"template_id",
"family",
"description",
"nodes",
"relations",
"id",
"kind",
"role",
"gear_type",
"repeat",
"formula_ref",
"stage_id",
"local_role",
"source",
"target",
"relation_type",
"carrier_ref",
"mesh_id",
]:
assert field in text
for relation_type in VALIDATOR_REGISTRY:
assert relation_type in text
+430
View File
@@ -0,0 +1,430 @@
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.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"
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:
housing_step = _generated_housing_step(tmp_path)
payload = {
"module_type": "planetary_joint_module",
"motor_step": str(MOTOR_STEP.resolve()),
"housing_step": str(housing_step.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.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 instance.boundary.input == "s1_sun"
assert instance.boundary.output == "s2_carrier"
assert instance.boundary.fixed_members == ["s1_ring", "s2_ring"]
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_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()
@@ -0,0 +1,108 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import pytest
from src.cad.parametric_parts import (
PART_CAPABILITIES,
export_parametric_part,
get_parametric_part_capability,
list_parametric_part_capability_manifests,
make_parametric_part,
validate_part_parameters,
)
EXPECTED_SIMPLECADAPI_PARTS = {
"spur_gear",
"straight_bevel_gear",
"helical_gear",
"herringbone_gear",
"spur_ring_gear",
"helical_ring_gear",
"herringbone_ring_gear",
"cycloidal_disc",
"spur_rack",
"helical_rack",
"herringbone_rack",
"ball_bearing",
"roller_chain_sprocket",
"bolt",
"nut",
"n_hole_flange",
"naca_propeller_blade",
"threaded_rod",
}
def test_catalog_covers_all_simplecadapi_formula_factories() -> None:
assert EXPECTED_SIMPLECADAPI_PARTS <= set(PART_CAPABILITIES)
manifests = list_parametric_part_capability_manifests()
assert len(manifests) >= len(EXPECTED_SIMPLECADAPI_PARTS)
by_id = {item["part_id"]: item for item in manifests}
assert by_id["spur_gear"]["api_path"] == "simplecadapi.std.gear.make_spur_gear_rsolid"
assert by_id["ball_bearing"]["result_kind"] == "assembly"
assert by_id["roller_chain_sprocket"]["available"] is True
def test_simplecadapi_is_vendored_under_project_src() -> None:
import simplecadapi
assert "/src/simplecadapi/" in str(Path(simplecadapi.__file__).as_posix()) + "/"
assert hasattr(simplecadapi.std.fastener, "make_bolt_rsolid")
assert hasattr(simplecadapi.std.bearing, "make_ball_bearing_rassembly")
assert hasattr(simplecadapi.std.chain, "make_roller_chain_sprocket_rsolid")
def test_validate_part_parameters_uses_underlying_signature() -> None:
params = validate_part_parameters(
"roller_chain_sprocket",
{
"n_teeth": 12,
"chain_pitch": 6.35,
"roller_diameter": 3.3,
"sprocket_thickness": 3.0,
},
)
assert params["bore_radius"] == 0.0
assert params["roller_clearance"] == 0.15
with pytest.raises(Exception, match="invalid_parametric_part_parameters"):
validate_part_parameters("roller_chain_sprocket", {"n_teeth": 12})
def test_dependency_report_marks_sketch_solver_backed_gears() -> None:
capability = get_parametric_part_capability("spur_gear")
assert "py_slvs" in capability.required_packages
if importlib.util.find_spec("py_slvs") is None:
assert capability.missing_packages() == ("py_slvs",)
def test_make_and_export_available_parametric_sprocket(tmp_path: Path) -> None:
shape = make_parametric_part(
"roller_chain_sprocket",
{
"n_teeth": 12,
"chain_pitch": 6.35,
"roller_diameter": 3.3,
"sprocket_thickness": 3.0,
"bore_radius": 1.2,
},
)
assert shape.get_volume() > 0
manifest = export_parametric_part(
"roller_chain_sprocket",
{
"n_teeth": 12,
"chain_pitch": 6.35,
"roller_diameter": 3.3,
"sprocket_thickness": 3.0,
"bore_radius": 1.2,
},
tmp_path,
)
assert Path(manifest["exported_files"]["step"]).exists()
assert Path(manifest["exported_files"]["model"]).exists()
assert manifest["geometry_summary"]["volume_mm3"] > 0
@@ -0,0 +1,144 @@
from __future__ import annotations
import math
from pathlib import Path
import pytest
from src.cli import main
from src.configurations.registry import CONFIGURATIONS, load_configuration_template
from src.parameter_solver import load_requirement, solve_parameters
from src.placement import solve_reducer_placements
from src.topology import load_template
ROOT = Path(__file__).resolve().parents[1]
SIMPLE_REQ = ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_ratio_7p5.json"
INDUSTRIAL_REQ = ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_industrial_ratio_7p5_spur.json"
CASCADE_REQ = ROOT / "input" / "requirements" / "reducers" / "simple_2k_h_cascade" / "simple_2kh_cascade_ratio_9_spur.json"
FERGUSON_WOLFROM_REQ = ROOT / "input" / "requirements" / "reducers" / "ferguson_wolfrom" / "ferguson_wolfrom_ratio_531p25_spur.json"
SIMPLE_TEMPLATE = ROOT / "src" / "configurations" / "simple_2k_h" / "topology.template.json"
CASCADE_TEMPLATE = ROOT / "src" / "configurations" / "simple_2k_h_cascade" / "topology.template.json"
FERGUSON_WOLFROM_TEMPLATE = ROOT / "src" / "configurations" / "ferguson_wolfrom" / "topology.template.json"
def test_configuration_registry_loads_all_families() -> None:
assert {"simple_2k_h", "simple_2k_h_cascade", "ferguson_wolfrom"} <= set(CONFIGURATIONS)
assert load_configuration_template("simple_2k_h").family == "simple_2k_h"
assert load_configuration_template("simple_2k_h_cascade").family == "simple_2k_h_cascade"
assert load_configuration_template("ferguson_wolfrom").family == "ferguson_wolfrom"
def test_all_requirements_live_under_input() -> None:
requirement_files = sorted((ROOT / "input" / "requirements").rglob("*.json"))
assert requirement_files
assert all("input/requirements" in path.as_posix() for path in requirement_files)
def test_cli_rejects_input_outside_input_dir(tmp_path: Path) -> None:
bad_input = tmp_path / "bad.json"
bad_input.write_text("{}", encoding="utf-8")
output = ROOT / "output" / "runs" / "bad_input_rejected"
with pytest.raises(ValueError, match="input_must_be_under_input"):
main(["solve", "--input", str(bad_input), "--output", str(output)])
def test_cli_rejects_output_outside_output_dir(tmp_path: Path) -> None:
with pytest.raises(ValueError, match="output_must_be_under_output"):
main(["solve", "--input", str(SIMPLE_REQ), "--output", str(tmp_path / "outside")])
def test_simple_2kh_placement_solver_planet_positions() -> None:
instance = solve_parameters(requirement=load_requirement(SIMPLE_REQ), template=load_template(SIMPLE_TEMPLATE))[0]
plan = solve_reducer_placements(instance)
for index in range(instance.parameters.planet_count):
pose = plan.require_pose(f"planet_{index + 1}")
radius = math.hypot(pose.center_xyz_mm[0], pose.center_xyz_mm[1])
assert abs(radius - instance.derived.planet_orbit_radius_mm) <= 1e-9
expected_angle = 360.0 * index / instance.parameters.planet_count
actual_angle = (math.degrees(math.atan2(pose.center_xyz_mm[1], pose.center_xyz_mm[0])) + 360.0) % 360.0
assert abs(((actual_angle - expected_angle + 180.0) % 360.0) - 180.0) <= 1e-9
def test_industrial_placement_solver_places_pin_bearing_spacer_coaxially() -> None:
instance = solve_parameters(requirement=load_requirement(INDUSTRIAL_REQ), template=load_template(SIMPLE_TEMPLATE))[0]
plan = solve_reducer_placements(instance)
for index in range(1, instance.parameters.planet_count + 1):
centers = [
plan.require_pose(f"planet_{index}").center_xyz_mm,
plan.require_pose(f"planet_pin_{index}").center_xyz_mm,
plan.require_pose(f"planet_bearing_{index}").center_xyz_mm,
plan.require_pose(f"planet_spacer_{index}").center_xyz_mm,
]
assert len({(round(center[0], 9), round(center[1], 9)) for center in centers}) == 1
def test_bolt_circle_positions_are_uniform() -> None:
instance = solve_parameters(requirement=load_requirement(INDUSTRIAL_REQ), template=load_template(SIMPLE_TEMPLATE))[0]
plan = solve_reducer_placements(instance)
params = instance.industrial_parameters
assert params is not None
radii = []
for index in range(1, params.bolt_count + 1):
pose = plan.require_pose(f"housing_bolt_{index}")
radii.append(math.hypot(pose.center_xyz_mm[0], pose.center_xyz_mm[1]))
assert max(radii) - min(radii) <= 1e-9
assert abs(radii[0] - params.bolt_circle_diameter_mm / 2.0) <= 1e-9
def test_cascade_placement_solver_stage_offsets() -> None:
instance = solve_parameters(requirement=load_requirement(CASCADE_REQ), template=load_template(CASCADE_TEMPLATE))[0]
plan = solve_reducer_placements(instance)
assert plan.frames["s1_frame"].translation_mm[2] == 0.0
assert plan.frames["s2_frame"].translation_mm[2] == instance.derived.axial_stack["stage_offsets_mm"]["s2"]
assert plan.require_pose("s2_sun").center_xyz_mm[2] > plan.require_pose("s1_sun").center_xyz_mm[2]
def test_ferguson_wolfrom_placement_solver_planet_sets_are_on_expected_orbits() -> None:
instance = solve_parameters(
requirement=load_requirement(FERGUSON_WOLFROM_REQ),
template=load_template(FERGUSON_WOLFROM_TEMPLATE),
)[0]
plan = solve_reducer_placements(instance)
planet1 = plan.require_pose("planet1_1")
planet2 = plan.require_pose("planet2_1")
teeth = instance.parameters.gear_tooth_counts
expected_orbit_1 = instance.parameters.module_mm * (teeth["sun1"] + teeth["planet1"]) / 2.0
expected_orbit_2 = instance.parameters.module_mm * (teeth["sun2"] + teeth["planet2"]) / 2.0
assert abs(math.hypot(planet1.center_xyz_mm[0], planet1.center_xyz_mm[1]) - expected_orbit_1) <= 1e-9
assert abs(math.hypot(planet2.center_xyz_mm[0], planet2.center_xyz_mm[1]) - expected_orbit_2) <= 1e-9
assert planet2.center_xyz_mm[2] > planet1.center_xyz_mm[2]
def test_ferguson_wolfrom_places_input_shaft_at_left_planet_pin() -> None:
instance = solve_parameters(
requirement=load_requirement(FERGUSON_WOLFROM_REQ),
template=load_template(FERGUSON_WOLFROM_TEMPLATE),
)[0]
plan = solve_reducer_placements(instance)
planet = plan.require_pose("planet1_1")
shaft = plan.require_pose("planet1_input_shaft")
static_carrier = plan.require_pose("static_carrier")
assert shaft.center_xyz_mm[:2] == planet.center_xyz_mm[:2]
assert shaft.center_xyz_mm[2] < planet.center_xyz_mm[2]
assert shaft.center_xyz_mm[2] < static_carrier.center_xyz_mm[2] + instance.parameters.face_width_mm
assert shaft.details["supported_by"] == "static_planet_pin_1"
def test_ferguson_wolfrom_output_carrier_is_phase_offset_60_degrees() -> None:
instance = solve_parameters(
requirement=load_requirement(FERGUSON_WOLFROM_REQ),
template=load_template(FERGUSON_WOLFROM_TEMPLATE),
)[0]
plan = solve_reducer_placements(instance)
planet1 = plan.require_pose("planet1_1")
planet2 = plan.require_pose("planet2_1")
output_carrier = plan.require_pose("output_carrier")
angle1 = math.degrees(math.atan2(planet1.center_xyz_mm[1], planet1.center_xyz_mm[0]))
angle2 = math.degrees(math.atan2(planet2.center_xyz_mm[1], planet2.center_xyz_mm[0]))
assert abs(angle1) <= 1e-9
assert abs(angle2 - 60.0) <= 1e-9
assert output_carrier.rotation_axis_angle_deg[3] == 60.0
@@ -0,0 +1,496 @@
from __future__ import annotations
from pathlib import Path
import pytest
from src.cascade_physical import (
cascade_interstage_component_ids,
compute_cascade_interstage_design,
)
from src.kinematics import KinematicSolveError, solve_instance, solve_simple_2kh, solve_topology
from src.cad.gear_factory import gear_spec_for_component
from src.models import BoundaryCondition, ReducerParameters
from src.parameter_solver import ParameterSolveError
from src.parameter_constraints import CandidateContext, default_constraint_pipeline
from src.parameter_solver import load_requirement, solve_parameters
from src.topology import load_template, validate_topology
from src.topology_runtime import expand_template
ROOT = Path(__file__).resolve().parents[1]
REQ = ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_ratio_7p5.json"
REQ_INDUSTRIAL = ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_industrial_ratio_7p5_spur.json"
REQ_CASCADE = ROOT / "input" / "requirements" / "reducers" / "simple_2k_h_cascade" / "simple_2kh_cascade_ratio_9_spur.json"
REQ_FERGUSON_WOLFROM = ROOT / "input" / "requirements" / "reducers" / "ferguson_wolfrom" / "ferguson_wolfrom_ratio_531p25_spur.json"
TEMPLATE = ROOT / "src" / "configurations" / "simple_2k_h" / "topology.template.json"
TEMPLATE_CASCADE = ROOT / "src" / "configurations" / "simple_2k_h_cascade" / "topology.template.json"
TEMPLATE_FERGUSON_WOLFROM = ROOT / "src" / "configurations" / "ferguson_wolfrom" / "topology.template.json"
def _params(
*,
z_sun: int = 36,
z_planet: int = 18,
z_ring: int = 72,
planet_count: int = 3,
tooth_form: str = "spur",
helix_angle_deg: float = 0.0,
) -> ReducerParameters:
return ReducerParameters(
z_sun=z_sun,
z_planet=z_planet,
z_ring=z_ring,
planet_count=planet_count,
module_mm=0.5,
pressure_angle_deg=20.0,
face_width_mm=8.0,
backlash_mm=0.03,
addendum_coeff=1.0,
clearance_coeff=0.25,
ring_rim_thickness_mm=2.5,
tooth_form=tooth_form,
helix_angle_deg=helix_angle_deg,
)
def test_topology_simple_2kh_graph_valid() -> None:
requirement = load_requirement(REQ)
template = load_template(TEMPLATE)
assert validate_topology(template, requirement) == []
def test_topology_runtime_expands_planets() -> None:
topology = expand_template(load_template(TEMPLATE), planet_count=3)
assert {node.component_id for node in topology.nodes if node.template_node_id == "planet"} == {
"planet_1",
"planet_2",
"planet_3",
}
assert sum(1 for rel in topology.relations if rel.relation_type == "external_mesh") == 3
assert sum(1 for rel in topology.relations if rel.relation_type == "internal_mesh") == 3
assert sum(1 for rel in topology.relations if rel.relation_type == "revolute_joint") == 3
def test_parameter_solver_finds_ratio_7p5_without_hardcoded_answer() -> None:
requirement = load_requirement(REQ)
template = load_template(TEMPLATE)
instance, report = solve_parameters(requirement=requirement, template=template)
assert abs(instance.derived.ratio - 7.5) <= requirement.ratio_tolerance
assert report.evaluated_count > report.accepted_count > 0
assert instance.source == "computed_by_parameter_solver"
assert instance.parameters.z_ring == instance.parameters.z_sun + 2 * instance.parameters.z_planet
def test_industrial_parameter_solver_builds_assembly_topology() -> None:
requirement = load_requirement(REQ_INDUSTRIAL)
instance, report = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))
assert instance.detail_level == "industrial_core"
assert abs(instance.derived.ratio - 7.5) <= requirement.ratio_tolerance
assert instance.industrial_parameters is not None
assert instance.assembly_topology is not None
component_ids = {component.component_id for component in instance.assembly_topology.components}
assert {"carrier", "carrier_front_plate", "output_shaft", "sun_input_shaft", "sun_input_key"} <= component_ids
assert "input_bearing" not in component_ids
for index in range(1, instance.parameters.planet_count + 1):
assert {f"planet_pin_{index}", f"planet_bearing_{index}", f"planet_spacer_{index}"} <= component_ids
assert report.accepted_count > 0
def test_industrial_core_rejects_non_default_boundary_for_now() -> None:
requirement = load_requirement(REQ_INDUSTRIAL).model_copy(update={"fixed": "carrier", "output": "ring"})
with pytest.raises(ParameterSolveError, match="industrial_core currently supports only sun input"):
solve_parameters(requirement=requirement, template=load_template(TEMPLATE))
def test_parameter_constraints_rule_codes() -> None:
requirement = load_requirement(REQ)
template = load_template(TEMPLATE)
pipeline = default_constraint_pipeline()
geometry = {
"center_distance_mm": 11.25,
"ring_outer_diameter_mm": 45.25,
"planet_neighbor_clearance_mm": 1.98,
}
bad_closure = CandidateContext(
requirement=requirement,
template=template,
z_sun=20,
z_planet=55,
z_ring=131,
planet_count=3,
module_mm=0.5,
geometry=geometry,
)
assert pipeline.first_failure(bad_closure, include_ratio=False).code == "simple_2kh_tooth_closure"
bad_spacing = CandidateContext(
requirement=requirement,
template=template,
z_sun=20,
z_planet=55,
z_ring=130,
planet_count=4,
module_mm=0.5,
geometry=geometry,
)
assert pipeline.first_failure(bad_spacing, include_ratio=False).code == "equal_spacing_assembly_condition"
bad_ratio = CandidateContext(
requirement=requirement,
template=template,
z_sun=20,
z_planet=55,
z_ring=130,
planet_count=3,
module_mm=0.5,
geometry=geometry,
solution_ratio=6.0,
)
assert pipeline.first_failure(bad_ratio, include_ratio=True).code == "ratio_tolerance"
def test_parameter_solver_rejects_bad_planet_spacing() -> None:
requirement = load_requirement(REQ).model_copy(
update={
"planet_count_candidates": [50],
"constraints": load_requirement(REQ).constraints.model_copy(
update={"min_planet_neighbor_clearance_mm": 5.0}
),
}
)
template = load_template(TEMPLATE)
with pytest.raises(Exception, match="no parameter candidate"):
solve_parameters(requirement=requirement, template=template)
def test_kinematics_fixed_ring_sun_input_carrier_output() -> None:
parameters = ReducerParameters(
z_sun=20,
z_planet=55,
z_ring=130,
planet_count=3,
module_mm=0.75,
pressure_angle_deg=20.0,
face_width_mm=8.0,
backlash_mm=0.03,
addendum_coeff=1.0,
clearance_coeff=0.25,
ring_rim_thickness_mm=2.5,
tooth_form="spur",
)
solution = solve_simple_2kh(
run_id="test",
parameters=parameters,
boundary=BoundaryCondition(input="sun", fixed="ring", output="carrier"),
)
assert abs(solution.ratio - 7.5) < 1e-12
assert solution.direction == "same"
assert abs(solution.residual_max_abs) < 1e-12
def test_graph_kinematics_fixed_ring_sun_input_carrier_output() -> None:
parameters = ReducerParameters(
z_sun=20,
z_planet=55,
z_ring=130,
planet_count=3,
module_mm=0.75,
pressure_angle_deg=20.0,
face_width_mm=8.0,
backlash_mm=0.03,
addendum_coeff=1.0,
clearance_coeff=0.25,
ring_rim_thickness_mm=2.5,
tooth_form="spur",
)
solution = solve_topology(
run_id="test",
parameters=parameters,
boundary=BoundaryCondition(input="sun", fixed="ring", output="carrier"),
topology=expand_template(load_template(TEMPLATE), planet_count=3),
)
assert abs(solution.ratio - 7.5) < 1e-12
assert solution.direction == "same"
assert abs(solution.residual_max_abs) < 1e-12
def test_kinematics_fixed_sun_ring_input_carrier_output() -> None:
parameters = ReducerParameters(
z_sun=20,
z_planet=30,
z_ring=80,
planet_count=4,
module_mm=1.0,
pressure_angle_deg=20.0,
face_width_mm=8.0,
backlash_mm=0.03,
addendum_coeff=1.0,
clearance_coeff=0.25,
ring_rim_thickness_mm=2.5,
tooth_form="spur",
)
solution = solve_simple_2kh(
run_id="test",
parameters=parameters,
boundary=BoundaryCondition(input="ring", fixed="sun", output="carrier"),
)
assert abs(solution.ratio - 1.25) < 1e-12
assert solution.direction == "same"
def test_graph_kinematics_fixed_sun_ring_input_carrier_output() -> None:
parameters = ReducerParameters(
z_sun=20,
z_planet=30,
z_ring=80,
planet_count=4,
module_mm=1.0,
pressure_angle_deg=20.0,
face_width_mm=8.0,
backlash_mm=0.03,
addendum_coeff=1.0,
clearance_coeff=0.25,
ring_rim_thickness_mm=2.5,
tooth_form="spur",
)
solution = solve_topology(
run_id="test",
parameters=parameters,
boundary=BoundaryCondition(input="ring", fixed="sun", output="carrier"),
topology=expand_template(load_template(TEMPLATE), planet_count=4),
)
assert abs(solution.ratio - 1.25) < 1e-12
assert solution.direction == "same"
def test_kinematics_singular_when_no_fixed_member() -> None:
parameters = ReducerParameters(
z_sun=20,
z_planet=55,
z_ring=130,
planet_count=3,
module_mm=0.75,
pressure_angle_deg=20.0,
face_width_mm=8.0,
backlash_mm=0.03,
addendum_coeff=1.0,
clearance_coeff=0.25,
ring_rim_thickness_mm=2.5,
tooth_form="spur",
)
with pytest.raises(KinematicSolveError):
solve_simple_2kh(
run_id="test",
parameters=parameters,
boundary=BoundaryCondition(input="sun", fixed=None, output="carrier"),
)
def test_graph_kinematics_singular_when_no_fixed_member() -> None:
parameters = ReducerParameters(
z_sun=20,
z_planet=55,
z_ring=130,
planet_count=3,
module_mm=0.75,
pressure_angle_deg=20.0,
face_width_mm=8.0,
backlash_mm=0.03,
addendum_coeff=1.0,
clearance_coeff=0.25,
ring_rim_thickness_mm=2.5,
tooth_form="spur",
)
with pytest.raises(KinematicSolveError):
solve_topology(
run_id="test",
parameters=parameters,
boundary=BoundaryCondition(input="sun", fixed=None, output="carrier"),
topology=expand_template(load_template(TEMPLATE), planet_count=3),
)
def test_boundary_a_ring_input_sun_fixed_carrier_output() -> None:
solution = solve_simple_2kh(
run_id="test",
parameters=_params(),
boundary=BoundaryCondition(input="ring", fixed="sun", output="carrier"),
)
assert abs(solution.ratio - 1.5) < 1e-12
assert abs(solution.speeds["sun"]) < 1e-12
assert solution.direction == "same"
assert solution.speed_mode == "speed_reducer"
def test_boundary_b_carrier_input_ring_fixed_sun_output() -> None:
solution = solve_simple_2kh(
run_id="test",
parameters=_params(),
boundary=BoundaryCondition(input="carrier", fixed="ring", output="sun"),
)
assert abs(solution.ratio - (1.0 / 3.0)) < 1e-12
assert abs(solution.speeds["ring"]) < 1e-12
assert solution.direction == "same"
assert solution.speed_mode == "speed_increaser"
def test_boundary_c_sun_input_carrier_fixed_ring_output() -> None:
solution = solve_simple_2kh(
run_id="test",
parameters=_params(),
boundary=BoundaryCondition(input="sun", fixed="carrier", output="ring"),
)
assert abs(abs(solution.ratio) - 2.0) < 1e-12
assert abs(solution.speeds["carrier"]) < 1e-12
assert solution.direction == "opposite"
assert solution.speed_mode == "speed_reducer"
def test_spur_requires_zero_helix() -> None:
with pytest.raises(ValueError, match="spur gears require helix_angle_deg=0"):
_params(tooth_form="spur", helix_angle_deg=15.0)
def test_helical_defaults_and_signs() -> None:
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_ratio_3_helical.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
assert instance.parameters.tooth_form == "helical"
assert instance.parameters.helix_angle_deg == 15.0
assert gear_spec_for_component(instance, "sun").helix_hand == "right"
assert gear_spec_for_component(instance, "planet").helix_hand == "left"
assert gear_spec_for_component(instance, "ring").helix_hand == "left"
def test_herringbone_is_explicitly_unsupported_for_now() -> None:
requirement = load_requirement(REQ).model_copy(update={"tooth_form": "herringbone"})
with pytest.raises(ParameterSolveError, match="unsupported_tooth_form"):
solve_parameters(requirement=requirement, template=load_template(TEMPLATE))
def test_cascade_requirement_loads() -> None:
requirement = load_requirement(REQ_CASCADE)
assert requirement.topology_family == "simple_2k_h_cascade"
assert [stage.stage_id for stage in requirement.stages] == ["s1", "s2"]
def test_cascade_parameter_solver_finds_ratio_9_without_hardcoding() -> None:
requirement = load_requirement(REQ_CASCADE)
instance, report = solve_parameters(requirement=requirement, template=load_template(TEMPLATE_CASCADE))
assert instance.topology_family == "simple_2k_h_cascade"
assert abs(instance.derived.ratio - 9.0) <= requirement.ratio_tolerance
assert report.stage_candidate_counts == {"s1": 1, "s2": 1}
assert report.pair_evaluated_count == 1
assert report.selected_total_solution["ratio"] == 9.0
def test_cascade_topology_expands_two_stages_and_rigid_coupling() -> None:
requirement = load_requirement(REQ_CASCADE)
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE_CASCADE))[0]
component_ids = {node.component_id for node in instance.topology.nodes}
assert {"s1_sun", "s1_ring", "s1_carrier", "s2_sun", "s2_ring", "s2_carrier"} <= component_ids
assert {"s1_planet_1", "s1_planet_2", "s1_planet_3"} <= component_ids
assert {"s2_planet_1", "s2_planet_2", "s2_planet_3"} <= component_ids
assert any(
relation.relation_type == "rigid"
and relation.source_component_id == "s1_carrier"
and relation.target_component_id == "s2_sun"
for relation in instance.topology.relations
)
def test_cascade_interstage_physical_design_is_rule_computed() -> None:
requirement = load_requirement(REQ_CASCADE)
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE_CASCADE))[0]
design = compute_cascade_interstage_design(instance)
assert {
"interstage_shaft",
"interstage_coupler",
"interstage_bearing_1",
"interstage_bearing_2",
"interstage_support_1",
"interstage_support_2",
} == cascade_interstage_component_ids(instance)
assert design.source_component_id == "s1_carrier"
assert design.target_component_id == "s2_sun"
assert design.shaft_diameter_mm < design.s2_sun_bore_diameter_mm
assert design.shaft_diameter_mm < design.bearing_inner_diameter_mm
assert design.bearing_inner_diameter_mm < design.bearing_outer_diameter_mm
assert design.bearing_outer_diameter_mm < design.support_inner_diameter_mm
assert design.support_outer_diameter_mm < design.housing_inner_diameter_mm
assert design.bearing_ball_count >= 6
def test_cascade_kinematics_ratio_9_fixed_members_and_rigid_speed() -> None:
requirement = load_requirement(REQ_CASCADE)
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE_CASCADE))[0]
solution = solve_instance(instance)
assert abs(solution.ratio - 9.0) < 1e-12
assert abs(solution.speeds["s1_ring"]) < 1e-12
assert abs(solution.speeds["s2_ring"]) < 1e-12
assert abs(solution.speeds["s1_carrier"] - solution.speeds["s2_sun"]) < 1e-12
def test_ferguson_wolfrom_requirement_and_topology_are_valid() -> None:
requirement = load_requirement(REQ_FERGUSON_WOLFROM)
template = load_template(TEMPLATE_FERGUSON_WOLFROM)
assert requirement.topology_family == "ferguson_wolfrom"
assert validate_topology(template, requirement) == []
assert requirement.input == "planet1"
assert requirement.fixed == "static_carrier"
assert requirement.output == "output_carrier"
def test_ferguson_wolfrom_topology_expands_paradox_structure() -> None:
topology = expand_template(load_template(TEMPLATE_FERGUSON_WOLFROM), planet_count=3)
component_ids = {node.component_id for node in topology.nodes}
assert {"sun1", "sun2", "ring1", "ring2", "static_carrier", "output_carrier"} <= component_ids
assert {"planet1_1", "planet1_2", "planet1_3", "planet2_1", "planet2_2", "planet2_3"} <= component_ids
assert sum(1 for rel in topology.relations if rel.relation_type == "external_mesh") == 6
assert sum(1 for rel in topology.relations if rel.relation_type == "internal_mesh") == 6
assert any(
rel.relation_type == "rigid"
and rel.source_component_id == "sun1"
and rel.target_component_id == "sun2"
for rel in topology.relations
)
assert any(
rel.relation_type == "rigid"
and rel.source_component_id == "ring1"
and rel.target_component_id == "ring2"
for rel in topology.relations
)
assert any(
rel.relation_type == "fixed"
and rel.source_component_id == "static_carrier"
and rel.target_component_id == "housing"
for rel in topology.relations
)
def test_ferguson_wolfrom_parameter_solver_solves_ratio_531p25() -> None:
requirement = load_requirement(REQ_FERGUSON_WOLFROM)
instance, report = solve_parameters(
requirement=requirement,
template=load_template(TEMPLATE_FERGUSON_WOLFROM),
)
solution = solve_instance(instance)
assert report.evaluated_count == 1
assert report.accepted_count == 1
assert abs(instance.derived.ratio - 531.25) <= requirement.ratio_tolerance
assert abs(solution.ratio - 531.25) <= requirement.ratio_tolerance
assert solution.direction == "same"
assert solution.speed_mode == "speed_reducer"
assert abs(solution.speeds["static_carrier"]) <= 1e-12
assert abs(solution.speeds["sun1"] - solution.speeds["sun2"]) <= 1e-12
assert abs(solution.speeds["ring1"] - solution.speeds["ring2"]) <= 1e-12
assert abs(solution.speeds["output_carrier"] - (1.0 / 531.25)) <= 1e-12
+584
View File
@@ -0,0 +1,584 @@
from __future__ import annotations
import math
from pathlib import Path
from src.models import (
AssemblyManifest,
AssemblySnapshot,
ComponentManifest,
ConstraintManifest,
PhysicalRelation,
ReducerInstance,
sha256_json,
)
from src.cad.gear_factory import gear_spec_for_component
from src.cad.simplecad_generator import _component_manifests
from src.cad.simplecad_parts import role_for_component
from src.parameter_solver import load_requirement, solve_parameters
from src.placement import solve_reducer_placements
from src.physical_validators import PHYSICAL_VALIDATOR_REGISTRY
from src.topology import load_template
from src.validators import build_validation_report
ROOT = Path(__file__).resolve().parents[1]
REQ = ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_ratio_7p5.json"
TEMPLATE = ROOT / "src" / "configurations" / "simple_2k_h" / "topology.template.json"
def _instance() -> ReducerInstance:
return solve_parameters(requirement=load_requirement(REQ), template=load_template(TEMPLATE))[0]
def _snapshot(
instance: ReducerInstance,
*,
missing_component: bool = False,
missing_industrial_component: bool = False,
missing_physical_component: str | None = None,
missing_external_mesh: bool = False,
bad_helical_external_hand: bool = False,
planet_phase_offset_deg: float = 0.0,
) -> AssemblySnapshot:
p = instance.parameters
d = instance.derived
z_mid = p.face_width_mm / 2.0
component_ids = ["housing", "ring", "sun", "carrier"] + [
f"planet_{index + 1}" for index in range(p.planet_count)
]
components = {}
for cid in component_ids:
is_planet = cid.startswith("planet_")
if cid == "sun":
gear_type = "external"
tooth_count = p.z_sun
pitch_radius = d.sun_pitch_radius_mm
spec = gear_spec_for_component(instance, "sun")
elif cid == "ring":
gear_type = "internal"
tooth_count = p.z_ring
pitch_radius = d.ring_pitch_radius_mm
spec = gear_spec_for_component(instance, "ring")
elif is_planet:
gear_type = "external"
tooth_count = p.z_planet
pitch_radius = d.planet_pitch_radius_mm
spec = gear_spec_for_component(instance, "planet")
else:
gear_type = None
tooth_count = None
pitch_radius = None
spec = None
helix_hand = spec.helix_hand if spec else None
if bad_helical_external_hand and is_planet:
helix_hand = gear_spec_for_component(instance, "sun").helix_hand
components[cid] = ComponentManifest(
component_id=cid,
formula_node="planet" if is_planet else cid,
role="planet" if is_planet else role_for_component(instance, cid),
kind="gear" if cid in {"sun", "ring"} or is_planet else cid,
gear_type=gear_type,
tooth_count=tooth_count,
tooth_form=p.tooth_form if gear_type else None,
helix_angle_deg=spec.helix_angle_deg if spec else None,
helix_hand=helix_hand,
module_mm=p.module_mm if gear_type else None,
pressure_angle_deg=p.pressure_angle_deg if gear_type else None,
face_width_mm=p.face_width_mm if gear_type else None,
pitch_radius_mm=pitch_radius,
center_xyz_mm=(0.0, 0.0, z_mid),
tags=[f"formula.run_id.{instance.run_id}"],
)
fixed_member = instance.boundary.fixed
constraints = [
ConstraintManifest(
constraint_id=f"housing_fixed_to_{fixed_member}",
relation_type="fixed",
component_a=fixed_member,
component_b="housing",
source="simplecadapi_constraint",
cad_constraint_kind="fixed",
)
]
for member in ["sun", "ring", "carrier"]:
if member == fixed_member:
continue
constraints.append(
ConstraintManifest(
constraint_id=f"{member}_{role_for_component(instance, member)}_revolute",
relation_type="revolute_joint",
component_a=fixed_member,
component_b=member,
source="simplecadapi_constraint",
cad_constraint_kind="revolute",
)
)
identity_axis = {
"x_axis": [1.0, 0.0, 0.0],
"y_axis": [0.0, 1.0, 0.0],
"z_axis": [0.0, 0.0, 1.0],
}
placements = {
"housing": {"origin": [0.0, 0.0, 0.0], **identity_axis},
"ring": {"origin": [0.0, 0.0, 0.0], **identity_axis},
"sun": {"origin": [0.0, 0.0, 0.0], **identity_axis},
"carrier": {"origin": [0.0, 0.0, 0.0], **identity_axis},
}
for index in range(p.planet_count):
cid = f"planet_{index + 1}"
angle = 2.0 * math.pi * index / p.planet_count
phase = math.radians(
180.0
- 180.0 / p.z_planet
+ math.degrees(angle) * (1.0 + p.z_sun / p.z_planet)
+ (planet_phase_offset_deg if index == 0 else 0.0)
)
placements[cid] = {
"origin": [
instance.derived.planet_orbit_radius_mm * math.cos(angle),
instance.derived.planet_orbit_radius_mm * math.sin(angle),
0.0,
],
"x_axis": [math.cos(phase), math.sin(phase), 0.0],
"y_axis": [-math.sin(phase), math.cos(phase), 0.0],
"z_axis": [0.0, 0.0, 1.0],
}
constraints.extend(
[
ConstraintManifest(
constraint_id=f"{cid}_pin_revolute",
relation_type="revolute_joint",
component_a="carrier",
component_b=cid,
source="simplecadapi_constraint",
cad_constraint_kind="revolute",
),
ConstraintManifest(
constraint_id=f"sun_to_{cid}_external_mesh",
relation_type="external_mesh",
component_a="sun",
component_b=cid,
source="simplecadapi_constraint",
cad_constraint_kind="gear",
),
ConstraintManifest(
constraint_id=f"ring_to_{cid}_internal_mesh",
relation_type="internal_mesh",
component_a="ring",
component_b=cid,
source="simplecadapi_constraint",
cad_constraint_kind="belt",
),
]
)
if instance.assembly_topology is not None:
for physical in instance.assembly_topology.components:
if physical.component_id in components:
continue
center = (0.0, 0.0, z_mid)
if physical.component_id.startswith(("planet_pin_", "planet_bearing_", "planet_spacer_")):
index = int(physical.component_id.rsplit("_", 1)[1]) - 1
angle = 2.0 * math.pi * index / p.planet_count
center = (
instance.derived.planet_orbit_radius_mm * math.cos(angle),
instance.derived.planet_orbit_radius_mm * math.sin(angle),
z_mid,
)
elif physical.component_id == "sun_input_key" and instance.industrial_parameters:
center = (
instance.industrial_parameters.input_shaft_diameter_mm / 2.0
+ instance.industrial_parameters.sun_key_height_mm / 2.0
+ instance.industrial_parameters.bearing_seat_clearance_mm,
0.0,
z_mid,
)
details = {}
if physical.kind == "bearing" and instance.industrial_parameters:
params = instance.industrial_parameters
if physical.component_id.startswith("planet_bearing_"):
details = {
"bearing_type": "radial_ball_bearing",
"ball_count": params.planet_bearing_ball_count,
"ball_diameter_mm": params.bearing_ball_diameter_mm,
}
elif physical.component_id.startswith("output_bearing_"):
details = {
"bearing_type": "radial_ball_bearing",
"ball_count": params.output_bearing_ball_count,
"ball_diameter_mm": params.bearing_ball_diameter_mm,
}
elif physical.component_id == "sun_input_key" and instance.industrial_parameters:
details = {
"coupling_type": "parallel_key",
"width_mm": instance.industrial_parameters.sun_key_width_mm,
"height_mm": instance.industrial_parameters.sun_key_height_mm,
"length_mm": instance.industrial_parameters.sun_key_length_mm,
}
components[physical.component_id] = ComponentManifest(
component_id=physical.component_id,
formula_node=physical.formula_ref,
role=physical.role,
kind=physical.kind,
center_xyz_mm=center,
tags=[f"formula.run_id.{instance.run_id}", f"formula.ref.{physical.formula_ref}"],
details=details,
)
placements[physical.component_id] = {"origin": list(center), **identity_axis}
for relation in instance.assembly_topology.relations:
constraints.append(
ConstraintManifest(
constraint_id=relation.relation_id,
relation_type=relation.relation_type,
component_a=relation.source_component_id,
component_b=relation.target_component_id,
source="formula_semantic",
relation_id=relation.relation_id,
)
)
if missing_external_mesh:
constraints = [
constraint
for constraint in constraints
if constraint.constraint_id != "sun_to_planet_1_external_mesh"
]
all_component_ids = list(components)
found_components = component_ids[1:] if missing_component else all_component_ids
if missing_industrial_component:
found_components = [cid for cid in found_components if cid != "planet_bearing_1"]
if missing_physical_component is not None:
found_components = [cid for cid in found_components if cid != missing_physical_component]
manifest = AssemblyManifest(
run_id=instance.run_id,
formula_instance_hash=sha256_json(instance.model_dump(mode="json")),
generated_at="test",
generator="test",
components=components,
constraints=constraints,
exported_files={},
geometry_summary={
"preview_volume": 1.0,
"preview_solids": 1,
"model_graph_nodes": 1,
"internal_ring_phase_correction_deg": 180.0 / p.z_ring if p.z_planet % 2 else 0.0,
"planet_phase_formula": "180 - 180 / z_planet + planet_center_angle_deg * (1 + z_sun / z_planet)",
},
)
return AssemblySnapshot(
run_id=instance.run_id,
formula_instance_hash=manifest.formula_instance_hash,
model_path="/tmp/test.model.json",
manifest_path="/tmp/manifest.json",
build_meta_path=None,
components_from_model=found_components,
constraints_from_model=[],
component_placements=placements,
grounded_components=[fixed_member] if fixed_member else [],
gear_constraints=[
f"sun_to_planet_{index + 1}_external_mesh" for index in range(p.planet_count)
if not (missing_external_mesh and index == 0)
],
internal_mesh_constraints=[
f"ring_to_planet_{index + 1}_internal_mesh" for index in range(p.planet_count)
],
revolute_constraints=[
constraint.constraint_id
for constraint in constraints
if constraint.relation_type == "revolute_joint"
],
fixed_constraints=[f"housing_fixed_to_{fixed_member}"],
manifest=manifest,
build_meta={"run_id": instance.run_id, "preview_volume": 1.0},
model_graph_node_count=1,
exported_files={},
read_at="test",
)
def test_validation_report_fails_on_missing_component() -> None:
instance = _instance()
report = build_validation_report(instance, _snapshot(instance, missing_component=True))
assert not report.overall_passed
assert any(check.code == "all_formula_components_exist" and not check.passed for check in report.checks)
def test_relation_validators_generate_expected_checks() -> None:
instance = _instance()
report = build_validation_report(instance, _snapshot(instance))
relation_checks = [
check
for check in report.checks
if check.code.startswith(("external_mesh.", "internal_mesh.", "revolute_joint.", "fixed.", "coaxial."))
]
assert relation_checks
assert all(check.passed for check in relation_checks)
def test_validation_fails_when_topology_relation_missing_in_snapshot() -> None:
instance = _instance()
report = build_validation_report(instance, _snapshot(instance, missing_external_mesh=True))
assert not report.overall_passed
assert any(
check.code.startswith("external_mesh.")
and check.code.endswith(".cad_constraint")
and not check.passed
for check in report.checks
)
def test_validation_report_fails_on_wrong_ratio() -> None:
instance = _instance()
bad = instance.model_copy(
update={"derived": instance.derived.model_copy(update={"ratio": instance.derived.ratio + 1.0})}
)
report = build_validation_report(bad, _snapshot(instance))
assert not report.overall_passed
assert any(check.code == "ratio_matches_instance" and not check.passed for check in report.checks)
def test_manifest_roles_follow_boundary() -> None:
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_boundary_a_spur.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
components = _component_manifests(instance, placement_plan=solve_reducer_placements(instance))
assert components["ring"].role == "input"
assert components["sun"].role == "fixed"
assert components["carrier"].role == "output"
assert "formula.role.input" in components["ring"].tags
assert "formula.role.fixed" in components["sun"].tags
assert "formula.role.output" in components["carrier"].tags
def test_industrial_validation_requires_complete_carrier_assembly() -> None:
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_industrial_ratio_7p5_spur.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
report = build_validation_report(instance, _snapshot(instance))
codes = {check.code: check for check in report.checks}
assert codes["industrial_parameters_present"].passed
assert codes["assembly_topology_present"].passed
assert codes["industrial_required_components_declared"].passed
assert codes["industrial_planet_bearing_relation_count"].passed
assert codes["industrial_required_relations_declared"].passed
assert any(code.startswith("industrial.bearing_revolute.") for code in codes)
assert any(code.startswith("industrial.keyed.") for code in codes)
assert any(code.startswith("industrial.press_fit.") for code in codes)
assert any(code.startswith("industrial.bolted.") for code in codes)
def test_physical_validator_registry_contains_required_types() -> None:
assert {
"bearing_revolute",
"bearing_support",
"bolted",
"press_fit",
"slip_fit",
"axial_retention",
"clearance",
"rigid",
"coaxial",
"keyed",
} <= set(PHYSICAL_VALIDATOR_REGISTRY)
def test_industrial_relations_generate_per_relation_checks() -> None:
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_industrial_ratio_7p5_spur.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
report = build_validation_report(instance, _snapshot(instance))
relation_ids = {relation.relation_id for relation in instance.assembly_topology.relations}
check_codes = {check.code for check in report.checks}
for relation in instance.assembly_topology.relations:
assert f"industrial.{relation.relation_type}.{relation.relation_id}.component_exists" in check_codes
assert relation_ids
def test_industrial_validation_fails_when_planet_bearing_missing() -> None:
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_industrial_ratio_7p5_spur.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
report = build_validation_report(instance, _snapshot(instance, missing_industrial_component=True))
assert any(
check.code == "industrial_all_physical_components_exist"
and not check.passed
and "planet_bearing_1" in check.actual["missing_from_model"]
for check in report.checks
)
def test_missing_planet_pin_fails_bearing_revolute() -> None:
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_industrial_ratio_7p5_spur.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
report = build_validation_report(instance, _snapshot(instance, missing_physical_component="planet_pin_1"))
assert any(
check.code == "industrial.bearing_revolute.planet_1_bearing_revolute_on_planet_pin_1.component_exists"
and not check.passed
and "planet_pin_1" in check.actual
for check in report.checks
)
def test_missing_output_shaft_fails_rigid_output_relation() -> None:
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_industrial_ratio_7p5_spur.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
report = build_validation_report(instance, _snapshot(instance, missing_physical_component="output_shaft"))
assert any(
check.code == "industrial.rigid.carrier_body_rigid_to_output_shaft.component_exists"
and not check.passed
and "output_shaft" in check.actual
for check in report.checks
)
def test_missing_sun_input_key_fails_keyed_relation() -> None:
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_industrial_ratio_7p5_spur.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
report = build_validation_report(instance, _snapshot(instance, missing_physical_component="sun_input_key"))
assert any(
check.code == "industrial.keyed.sun_input_shaft_keyed_to_sun.component_exists"
and not check.passed
and "sun_input_key" in check.actual
for check in report.checks
)
def test_missing_ring_mount_relation_fails_static() -> None:
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_industrial_ratio_7p5_spur.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
topology = instance.assembly_topology.model_copy(
update={
"relations": [
relation
for relation in instance.assembly_topology.relations
if relation.relation_id != "ring_bolted_to_housing"
]
}
)
bad = instance.model_copy(update={"assembly_topology": topology})
report = build_validation_report(bad, _snapshot(bad))
assert any(
check.code == "industrial_required_relations_declared"
and not check.passed
and "ring_bolted_to_housing" in check.expected
for check in report.checks
)
def test_bad_bearing_diameter_order_fails_slip_fit() -> None:
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_industrial_ratio_7p5_spur.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
bad_params = instance.industrial_parameters.model_copy(
update={"planet_bearing_outer_diameter_mm": 999.0}
)
bad = instance.model_copy(update={"industrial_parameters": bad_params})
report = build_validation_report(bad, _snapshot(bad))
assert any(
check.code == "industrial.slip_fit.planet_bearing_1_slip_fit_in_planet_1.geometry_clearance_or_fit"
and not check.passed
for check in report.checks
)
def test_bad_pin_diameter_fails_press_fit_or_bearing_revolute() -> None:
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_industrial_ratio_7p5_spur.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
bad_params = instance.industrial_parameters.model_copy(
update={"planet_pin_diameter_mm": 999.0}
)
bad = instance.model_copy(update={"industrial_parameters": bad_params})
report = build_validation_report(bad, _snapshot(bad))
assert any(
check.code.endswith(".geometry_clearance_or_fit")
and check.code.startswith(
(
"industrial.press_fit.planet_pin_1",
"industrial.bearing_revolute.planet_1",
)
)
and not check.passed
for check in report.checks
)
def test_unknown_physical_relation_type_fails_cleanly() -> None:
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_industrial_ratio_7p5_spur.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
topology = instance.assembly_topology.model_copy(
update={
"relations": instance.assembly_topology.relations
+ [
PhysicalRelation(
relation_id="mystery_relation",
relation_type="mystery_relation",
source_component_id="sun",
target_component_id="housing",
formula_ref="test.unknown",
)
]
}
)
bad = instance.model_copy(update={"assembly_topology": topology})
report = build_validation_report(bad, _snapshot(bad))
assert any(
check.code == "unsupported_physical_relation_type.mystery_relation"
and not check.passed
for check in report.checks
)
def test_helical_mesh_hand_rules() -> None:
requirement = load_requirement(ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_ratio_3_helical.json")
instance = solve_parameters(requirement=requirement, template=load_template(TEMPLATE))[0]
report = build_validation_report(instance, _snapshot(instance))
mesh_checks = [
check
for check in report.checks
if check.code.startswith(("external_mesh.", "internal_mesh."))
and check.code.endswith(".gear_compatibility")
]
assert mesh_checks
assert all(check.passed for check in mesh_checks)
bad_report = build_validation_report(instance, _snapshot(instance, bad_helical_external_hand=True))
assert any(
check.code.startswith("external_mesh.")
and check.code.endswith(".gear_compatibility")
and not check.passed
for check in bad_report.checks
)
def test_gear_mesh_strengthened_checks_are_generated() -> None:
instance = _instance()
report = build_validation_report(instance, _snapshot(instance))
gear_mesh_checks = [
check
for check in report.checks
if check.code.startswith("gear_mesh.")
]
assert gear_mesh_checks
assert any(check.code.endswith(".tip_root_clearance") for check in gear_mesh_checks)
assert any(check.code.endswith(".backlash_range") for check in gear_mesh_checks)
assert any(check.code.endswith(".placement_phase_policy") for check in gear_mesh_checks)
assert any(check.code.endswith(".internal_ring_half_tooth_phase") for check in gear_mesh_checks)
assert any(check.code.endswith(".motion_sample_residual") for check in gear_mesh_checks)
assert all(check.passed for check in gear_mesh_checks)
def test_gear_mesh_phase_error_fails_validation() -> None:
instance = _instance()
report = build_validation_report(instance, _snapshot(instance, planet_phase_offset_deg=2.0))
assert any(
check.code.startswith("gear_mesh.sun_planet_external_mesh_sun_to_planet_1.")
and check.code.endswith(".placement_phase_policy")
and not check.passed
for check in report.checks
)