from __future__ import annotations import math from dataclasses import dataclass from typing import Any from .kinematics import KinematicSolveError, build_equations, solve_instance from .models import ( AssemblySnapshot, ComponentManifest, ConcreteRelation, ConcreteTopology, ReducerInstance, StageParameters, ValidationCheck, ) MESH_RELATION_TYPES = {"external_mesh", "internal_mesh"} @dataclass(frozen=True) class MeshDesign: module_mm: float addendum_coeff: float clearance_coeff: float backlash_mm: float @property def nominal_tip_root_clearance_mm(self) -> float: return self.clearance_coeff * self.module_mm @property def circular_pitch_mm(self) -> float: return math.pi * self.module_mm @dataclass(frozen=True) class GearRadii: pitch_mm: float tip_mm: float root_mm: float def _check( checks: list[ValidationCheck], *, code: str, passed: bool, message: str, actual: Any | None = None, expected: Any | None = None, ) -> None: checks.append( ValidationCheck( section="geometry", code=code, passed=bool(passed), message=message, actual=actual, expected=expected, ) ) def _stage_parameters(instance: ReducerInstance, stage_id: str | None) -> StageParameters | None: if stage_id is None or not instance.parameters.stages: return None return next((stage for stage in instance.parameters.stages if stage.stage_id == stage_id), None) def _mesh_design(instance: ReducerInstance, relation: ConcreteRelation) -> MeshDesign: stage = _stage_parameters(instance, relation.stage_id) if stage is not None: return MeshDesign( module_mm=stage.module_mm, addendum_coeff=stage.addendum_coeff, clearance_coeff=stage.clearance_coeff, backlash_mm=stage.backlash_mm, ) p = instance.parameters return MeshDesign( module_mm=p.module_mm, addendum_coeff=p.addendum_coeff, clearance_coeff=p.clearance_coeff, backlash_mm=p.backlash_mm, ) def _origin(snapshot: AssemblySnapshot, component_id: str) -> list[float] | None: placement = snapshot.component_placements.get(component_id, {}) origin = placement.get("origin") if origin is None: component = snapshot.manifest.components.get(component_id) if component is not None: origin = component.center_xyz_mm if origin is None: return None return [float(value) for value in origin] def _xy_distance(a: list[float], b: list[float]) -> float: return math.hypot(a[0] - b[0], a[1] - b[1]) def _xy_angle_deg(origin: list[float]) -> float: return (math.degrees(math.atan2(origin[1], origin[0])) + 360.0) % 360.0 def _placement_angle_deg(snapshot: AssemblySnapshot, component_id: str) -> float | None: placement = snapshot.component_placements.get(component_id, {}) x_axis = placement.get("x_axis") if not x_axis: return None return (math.degrees(math.atan2(float(x_axis[1]), float(x_axis[0]))) + 360.0) % 360.0 def _nearest_mod_error(actual: float, expected: float, period: float) -> float: if period <= 0.0: return float("inf") return abs((actual - expected + period / 2.0) % period - period / 2.0) def _gear_radii(component: ComponentManifest, design: MeshDesign) -> GearRadii | None: if component.pitch_radius_mm is None or component.gear_type is None: return None pitch = float(component.pitch_radius_mm) addendum = design.addendum_coeff * design.module_mm dedendum = (design.addendum_coeff + design.clearance_coeff) * design.module_mm if component.gear_type == "external": return GearRadii( pitch_mm=pitch, tip_mm=pitch + addendum, root_mm=max(0.0, pitch - dedendum), ) if component.gear_type == "internal": return GearRadii( pitch_mm=pitch, tip_mm=max(0.0, pitch - addendum), root_mm=pitch + dedendum, ) return None def _tip_root_clearances( relation: ConcreteRelation, source: ComponentManifest, target: ComponentManifest, design: MeshDesign, center_distance: float, ) -> dict[str, float] | None: source_radii = _gear_radii(source, design) target_radii = _gear_radii(target, design) if source_radii is None or target_radii is None: return None if relation.relation_type == "external_mesh": return { "source_tip_to_target_root_mm": center_distance - source_radii.tip_mm - target_radii.root_mm, "target_tip_to_source_root_mm": center_distance - target_radii.tip_mm - source_radii.root_mm, } if relation.relation_type == "internal_mesh": if source.gear_type == "internal" and target.gear_type == "external": internal_radii = source_radii external_radii = target_radii internal_id = source.component_id external_id = target.component_id elif target.gear_type == "internal" and source.gear_type == "external": internal_radii = target_radii external_radii = source_radii internal_id = target.component_id external_id = source.component_id else: return None return { f"{external_id}_tip_to_{internal_id}_root_mm": ( internal_radii.root_mm - center_distance - external_radii.tip_mm ), f"{internal_id}_tip_to_{external_id}_root_mm": ( internal_radii.tip_mm - center_distance - external_radii.root_mm ), } return None def _component_local_role(component: ComponentManifest) -> str: raw_values = [ component.local_role, component.formula_node, component.component_id, component.role, ] for raw in raw_values: if not raw: continue if raw in {"sun", "ring", "planet", "carrier"}: return raw if raw.startswith("sun"): return "sun" if raw.startswith("ring"): return "ring" if raw.startswith("planet"): return "planet" if raw.endswith("_sun"): return "sun" if raw.endswith("_ring"): return "ring" if raw.endswith("_planet"): return "planet" if component.component_id.startswith("planet_") and component.kind == "gear": return "planet" if "_planet_" in component.component_id and component.kind == "gear": return "planet" return component.role def _planet_mesh_side(component: ComponentManifest) -> str | None: for raw in [component.formula_node, component.local_role, component.component_id]: if not raw: continue if raw.startswith("planet1"): return "1" if raw.startswith("planet2"): return "2" return None def _sun_matches_planet(component: ComponentManifest, planet: ComponentManifest) -> bool: side = _planet_mesh_side(planet) if side is not None: return component.formula_node == f"sun{side}" or component.component_id == f"sun{side}" return _component_local_role(component) == "sun" and component.stage_id == planet.stage_id def _expected_placement_phase_deg( *, component: ComponentManifest, snapshot: AssemblySnapshot, ) -> tuple[float | None, str]: local_role = _component_local_role(component) if local_role in {"sun", "ring"}: return 0.0, "coaxial gear placement phase policy" if local_role != "planet": return None, "not_a_mesh_gear" origin = _origin(snapshot, component.component_id) if origin is None: return None, "missing_placement" if component.tooth_count is None: return None, "missing_tooth_count" sun = next((item for item in snapshot.manifest.components.values() if _sun_matches_planet(item, component)), None) if sun is None or sun.tooth_count is None: return None, "missing_sun_tooth_count" center_angle = _xy_angle_deg(origin) planet_teeth = float(component.tooth_count) sun_teeth = float(sun.tooth_count) expected = (180.0 - 180.0 / planet_teeth) + center_angle * (1.0 + sun_teeth / planet_teeth) return ( expected % 360.0, "formula_phase_policy: 180deg - half_planet_tooth_pitch + center_angle * (1 + z_sun / z_planet)", ) def _validate_radial_clearance( *, relation: ConcreteRelation, source: ComponentManifest | None, target: ComponentManifest | None, snapshot: AssemblySnapshot, design: MeshDesign, checks: list[ValidationCheck], ) -> None: code = f"gear_mesh.{relation.relation_id}.tip_root_clearance" origin_a = _origin(snapshot, relation.source_component_id) origin_b = _origin(snapshot, relation.target_component_id) if source is None or target is None or origin_a is None or origin_b is None: _check( checks, code=code, passed=False, message="mesh tip/root clearance can be computed only when both gear manifests and placements exist", actual="missing_component_or_placement", ) return center_distance = _xy_distance(origin_a, origin_b) clearances = _tip_root_clearances(relation, source, target, design, center_distance) if clearances is None: _check( checks, code=code, passed=False, message="mesh tip/root clearance requires a valid external/external or internal/external gear pair", actual={ "source_gear_type": source.gear_type, "target_gear_type": target.gear_type, "relation_type": relation.relation_type, }, ) return min_clearance = min(clearances.values()) expected = design.nominal_tip_root_clearance_mm tolerance = max(1e-9, design.module_mm * 1e-9) _check( checks, code=code, passed=min_clearance >= expected - tolerance, message="mesh radial tip/root clearance is consistent with module and clearance coefficient", actual={ "center_distance_mm": center_distance, "clearances_mm": clearances, "min_clearance_mm": min_clearance, "module_mm": design.module_mm, "clearance_coeff": design.clearance_coeff, }, expected=f">= {expected} mm", ) def _validate_backlash( *, relation: ConcreteRelation, design: MeshDesign, checks: list[ValidationCheck], ) -> None: max_reasonable = 0.20 * design.circular_pitch_mm _check( checks, code=f"gear_mesh.{relation.relation_id}.backlash_range", passed=0.0 <= design.backlash_mm <= max_reasonable, message="mesh backlash is non-negative and below 20% of circular pitch", actual={ "backlash_mm": design.backlash_mm, "circular_pitch_mm": design.circular_pitch_mm, "backlash_to_circular_pitch": ( design.backlash_mm / design.circular_pitch_mm if design.circular_pitch_mm > 0 else None ), }, expected=f"0 <= backlash_mm <= {max_reasonable} mm", ) def _validate_phase( *, relation: ConcreteRelation, source: ComponentManifest | None, target: ComponentManifest | None, snapshot: AssemblySnapshot, checks: list[ValidationCheck], ) -> None: code = f"gear_mesh.{relation.relation_id}.placement_phase_policy" if source is None or target is None: _check( checks, code=code, passed=False, message="mesh placement phase check requires both gear manifests", actual="missing_component", ) return errors: dict[str, Any] = {} passed = True for component in [source, target]: if component.tooth_count is None: passed = False errors[component.component_id] = "missing_tooth_count" continue actual = _placement_angle_deg(snapshot, component.component_id) expected, source_text = _expected_placement_phase_deg( component=component, snapshot=snapshot, ) if actual is None or expected is None: passed = False errors[component.component_id] = { "actual": actual, "expected": expected, "source": source_text, } continue pitch = 360.0 / float(component.tooth_count) error = _nearest_mod_error(actual, expected, pitch) errors[component.component_id] = { "actual_phase_deg": actual, "expected_phase_deg": expected, "tooth_pitch_deg": pitch, "phase_error_deg_mod_tooth_pitch": error, "source": source_text, } passed = passed and error <= 1e-6 _check( checks, code=code, passed=passed, message="mesh gear component placements follow the deterministic tooth phase policy", actual=errors, expected="phase error <= 1e-6 deg modulo each gear tooth pitch", ) def _phase_policy_value(snapshot: AssemblySnapshot, stage_id: str | None) -> float | None: summary = snapshot.manifest.geometry_summary or {} if stage_id: stage_policy = summary.get("stage_phase_policy") if isinstance(stage_policy, dict): stage_entry = stage_policy.get(stage_id) if isinstance(stage_entry, dict): value = stage_entry.get("internal_ring_phase_correction_deg") if value is not None: return float(value) value = summary.get("internal_ring_phase_correction_deg") if value is not None: return float(value) if snapshot.build_meta: meta_value = snapshot.build_meta.get("internal_ring_phase_correction_deg") if meta_value is not None: return float(meta_value) return None def _ferguson_wolfrom_phase_policy_value( snapshot: AssemblySnapshot, internal: ComponentManifest, ) -> float | None: summary = snapshot.manifest.geometry_summary or {} policy = summary.get("ferguson_wolfrom_phase_policy") if not isinstance(policy, dict): return None entry = policy.get(internal.component_id) or policy.get(internal.formula_node) if not isinstance(entry, dict): return None value = entry.get("internal_ring_phase_correction_deg") return None if value is None else float(value) def _validate_internal_ring_phase_policy( *, relation: ConcreteRelation, source: ComponentManifest | None, target: ComponentManifest | None, snapshot: AssemblySnapshot, checks: list[ValidationCheck], ) -> None: if relation.relation_type != "internal_mesh": return internal = None planet = None for component in [source, target]: if component is not None and component.gear_type == "internal": internal = component if component is not None and _component_local_role(component) == "planet": planet = component code = f"gear_mesh.{relation.relation_id}.internal_ring_half_tooth_phase" if internal is None or internal.tooth_count is None or planet is None or planet.tooth_count is None: _check( checks, code=code, passed=False, message="internal mesh phase check requires internal ring and planet tooth counts", actual="missing_internal_or_planet_gear", ) return actual = _ferguson_wolfrom_phase_policy_value(snapshot, internal) if actual is None: actual = _phase_policy_value(snapshot, relation.stage_id) expected = 180.0 / float(internal.tooth_count) if int(planet.tooth_count) % 2 else 0.0 if actual is None: _check( checks, code=code, passed=False, message="internal ring phase correction is not recorded in manifest geometry summary", actual=None, expected=expected, ) return _check( checks, code=code, passed=abs(actual - expected) <= 1e-9, message="internal ring phase correction follows planet tooth-count parity", actual=actual, expected=expected, ) def _validate_motion_sampling( *, instance: ReducerInstance, topology: ConcreteTopology, checks: list[ValidationCheck], ) -> None: mesh_relations = [ relation for relation in topology.relations if relation.relation_type in MESH_RELATION_TYPES ] if not mesh_relations: return try: solution = solve_instance(instance) equations = build_equations( parameters=instance.parameters, boundary=instance.boundary, topology=topology, ) except KinematicSolveError as exc: _check( checks, code="gear_mesh.motion_sampling.solve", passed=False, message="mesh motion sampling requires a solvable kinematic system", actual=str(exc), ) return equations_by_id = {equation.id: equation for equation in equations} input_speed = solution.speeds[instance.boundary.input] input_angles = [0.0, abs(solution.ratio) / 4.0, abs(solution.ratio) / 2.0, abs(solution.ratio)] for relation in mesh_relations: equation = equations_by_id.get(relation.relation_id) if equation is None: _check( checks, code=f"gear_mesh.{relation.relation_id}.motion_sample_residual", passed=False, message="mesh relation has no generated motion equation", actual=relation.relation_id, ) continue residuals: list[dict[str, float]] = [] for input_angle in input_angles: member_angles = { name: input_angle * speed / input_speed for name, speed in solution.speeds.items() } residual = ( sum( float(coefficient) * member_angles[variable] for variable, coefficient in equation.coefficients.items() ) - equation.rhs ) residuals.append({"input_angle_deg": input_angle, "residual_deg_teeth": residual}) max_abs = max(abs(item["residual_deg_teeth"]) for item in residuals) _check( checks, code=f"gear_mesh.{relation.relation_id}.motion_sample_residual", passed=max_abs <= 1e-9, message="sampled input angles satisfy the mesh rolling equation", actual={ "samples": residuals, "max_abs_residual": max_abs, "equation": equation.expression, }, expected="<= 1e-9", ) def validate_gear_mesh_geometry( instance: ReducerInstance, topology: ConcreteTopology, snapshot: AssemblySnapshot, checks: list[ValidationCheck], ) -> None: mesh_relations = [ relation for relation in topology.relations if relation.relation_type in MESH_RELATION_TYPES ] if not mesh_relations: return for relation in mesh_relations: source = snapshot.manifest.components.get(relation.source_component_id) target = snapshot.manifest.components.get(relation.target_component_id) design = _mesh_design(instance, relation) _validate_radial_clearance( relation=relation, source=source, target=target, snapshot=snapshot, design=design, checks=checks, ) _validate_backlash(relation=relation, design=design, checks=checks) _validate_phase( relation=relation, source=source, target=target, snapshot=snapshot, checks=checks, ) _validate_internal_ring_phase_policy( relation=relation, source=source, target=target, snapshot=snapshot, checks=checks, ) _validate_motion_sampling(instance=instance, topology=topology, checks=checks)