609 lines
20 KiB
Python
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)
|