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