497 lines
19 KiB
Python
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
|