Files
reducers/backend/src/physical_validators/base.py
T

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)