Files
reducers/backend/tests/test_topology_and_kinematics.py
T

497 lines
19 KiB
Python

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