181 lines
5.6 KiB
Python
181 lines
5.6 KiB
Python
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)
|