from __future__ import annotations import math from dataclasses import dataclass from typing import Any, Protocol from ..models import ( AssemblySnapshot, ComponentManifest, PhysicalRelation, ReducerInstance, ValidationCheck, ) @dataclass(frozen=True) class PhysicalValidationContext: instance: ReducerInstance snapshot: AssemblySnapshot def component(self, component_id: str) -> ComponentManifest | None: return self.snapshot.manifest.components.get(component_id) def component_exists(self, component_id: str) -> bool: return ( component_id in self.snapshot.manifest.components and component_id in self.snapshot.components_from_model ) def placement_origin(self, component_id: str) -> list[float] | None: origin = self.snapshot.component_placements.get(component_id, {}).get("origin") if origin is None: component = self.component(component_id) if component is None: return None origin = component.center_xyz_mm return [float(value) for value in origin] class PhysicalRelationValidator(Protocol): relation_type: str def validate( self, context: PhysicalValidationContext, relation: PhysicalRelation, checks: list[ValidationCheck], ) -> None: ... def add_check( checks: list[ValidationCheck], *, section: str, code: str, passed: bool, message: str, actual: Any | None = None, expected: Any | None = None, ) -> None: checks.append( ValidationCheck( section=section, code=code, passed=bool(passed), message=message, actual=actual, expected=expected, ) ) def relation_code(relation: PhysicalRelation, suffix: str) -> str: return f"industrial.{relation.relation_type}.{relation.relation_id}.{suffix}" def xy_distance(a: list[float], b: list[float]) -> float: return math.hypot(a[0] - b[0], a[1] - b[1]) def xy_radius(origin: list[float]) -> float: return math.hypot(origin[0], origin[1]) def planet_bore_diameter_mm(context: PhysicalValidationContext) -> float: derived = context.instance.derived return 2.0 * (max(0.6, min(2.0, derived.planet_pitch_radius_mm * 0.22)) + 0.25) def relation_manifest_constraints( context: PhysicalValidationContext, relation: PhysicalRelation, ) -> list[Any]: pair = {relation.source_component_id, relation.target_component_id} matches = [] for constraint in context.snapshot.manifest.constraints: if constraint.relation_id == relation.relation_id or constraint.constraint_id == relation.relation_id: matches.append(constraint) continue if constraint.relation_type != relation.relation_type: continue if {constraint.component_a, constraint.component_b} == pair: matches.append(constraint) return matches def constraint_id_in_model(context: PhysicalValidationContext, constraint_id: str, relation_type: str) -> bool: if relation_type == "bearing_revolute": return constraint_id in context.snapshot.revolute_constraints if relation_type in {"rigid", "bolted", "press_fit", "slip_fit", "axial_retention", "bearing_support", "coaxial", "keyed"}: return constraint_id in context.snapshot.fixed_constraints return False def add_common_relation_checks( context: PhysicalValidationContext, relation: PhysicalRelation, checks: list[ValidationCheck], *, extra_components: list[str] | None = None, ) -> list[Any]: component_ids = [relation.source_component_id, relation.target_component_id] + list(extra_components or []) missing = [component_id for component_id in component_ids if not context.component_exists(component_id)] add_check( checks, section="assembly", code=relation_code(relation, "component_exists"), passed=not missing, message="all physical relation components exist in manifest and model", actual=missing, expected=[], ) matches = relation_manifest_constraints(context, relation) add_check( checks, section="assembly", code=relation_code(relation, "manifest_relation_exists"), passed=bool(matches), message="physical relation is represented in the assembly manifest", actual=[match.constraint_id for match in matches], expected=relation.relation_id, ) model_or_semantic = any( match.source == "formula_semantic" or match.cad_constraint_kind is None or constraint_id_in_model(context, match.constraint_id, relation.relation_type) for match in matches ) add_check( checks, section="assembly", code=relation_code(relation, "model_constraint_or_semantic_exists"), passed=model_or_semantic, message="physical relation has a real CAD constraint or explicit formula semantic manifest entry", actual=[ { "constraint_id": match.constraint_id, "source": match.source, "cad_constraint_kind": match.cad_constraint_kind, } for match in matches ], expected="simplecadapi constraint or formula_semantic", ) return matches class BasePhysicalRelationValidator: relation_type: str def validate( self, context: PhysicalValidationContext, relation: PhysicalRelation, checks: list[ValidationCheck], ) -> None: add_common_relation_checks(context, relation, checks)