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