Files
reducers/backend/src/gear_mesh_validation.py
T

609 lines
20 KiB
Python

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)