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 )