592 lines
20 KiB
Python
592 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Literal
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
|
|
|
|
FORMULA_VERSION = "0.1.0"
|
|
SOLVER_VERSION = "parameter_solver_v0.1.0"
|
|
MIN_GEAR_TOOTH_COUNT_EXCLUSIVE = 17
|
|
|
|
|
|
def utc_now_iso() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def canonical_json(data: Any) -> str:
|
|
return json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
|
|
|
|
def sha256_json(data: Any) -> str:
|
|
return hashlib.sha256(canonical_json(data).encode("utf-8")).hexdigest()
|
|
|
|
|
|
def read_json(path: Path) -> Any:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def write_json(path: Path, data: Any) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
|
|
|
|
def write_model_json(path: Path, model: BaseModel) -> None:
|
|
write_json(path, model.model_dump(mode="json"))
|
|
|
|
|
|
class StrictModel(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
|
|
class RequirementConstraints(StrictModel):
|
|
max_outer_diameter_mm: float = Field(gt=0)
|
|
min_planet_neighbor_clearance_mm: float = Field(ge=0)
|
|
min_sun_teeth: int = Field(ge=1)
|
|
max_sun_teeth: int = Field(ge=1)
|
|
min_planet_teeth: int = Field(default=8, ge=1)
|
|
max_planet_teeth: int = Field(default=160, ge=1)
|
|
|
|
|
|
TopologyFamily = Literal["simple_2k_h", "simple_2k_h_cascade", "ferguson_wolfrom"]
|
|
MotionMember = str
|
|
ToothForm = Literal["spur", "herringbone", "helical"]
|
|
SupportedCadToothForm = Literal["spur", "helical"]
|
|
DetailLevel = Literal["kinematic_demo", "industrial_core"]
|
|
SIMPLE_2KH_MOTION_MEMBERS = {"sun", "ring", "carrier"}
|
|
|
|
|
|
class StageRequirement(StrictModel):
|
|
stage_id: str
|
|
input: MotionMember = "sun"
|
|
fixed: MotionMember | None = "ring"
|
|
output: MotionMember = "carrier"
|
|
planet_count_candidates: list[int] | None = None
|
|
module_candidates_mm: list[float] | None = None
|
|
pressure_angle_deg: float | None = Field(default=None, gt=0, le=45)
|
|
face_width_mm: float | None = Field(default=None, gt=0)
|
|
backlash_mm: float | None = Field(default=None, ge=0)
|
|
addendum_coeff: float | None = Field(default=None, gt=0)
|
|
clearance_coeff: float | None = Field(default=None, ge=0)
|
|
ring_rim_thickness_mm: float | None = Field(default=None, gt=0)
|
|
tooth_form: ToothForm | None = None
|
|
helix_angle_deg: float | None = Field(default=None, gt=0, le=35)
|
|
helix_hand: Literal["right", "left"] | None = None
|
|
constraints: RequirementConstraints | None = None
|
|
|
|
@model_validator(mode="after")
|
|
def validate_stage_boundary(self) -> "StageRequirement":
|
|
if self.input == self.output:
|
|
raise ValueError("stage input and output must be different")
|
|
if self.fixed in {self.input, self.output}:
|
|
raise ValueError("stage fixed member cannot also be input or output")
|
|
return self
|
|
|
|
|
|
class CascadeRequirement(StrictModel):
|
|
coupling_policy: Literal["stage_output_to_next_stage_input"] = "stage_output_to_next_stage_input"
|
|
stage_gap_mm: float = Field(default=2.0, ge=0)
|
|
|
|
|
|
class ReducerRequirement(StrictModel):
|
|
target_ratio: float = Field(gt=0)
|
|
ratio_tolerance: float = Field(gt=0)
|
|
topology_family: TopologyFamily = "simple_2k_h"
|
|
detail_level: DetailLevel = "kinematic_demo"
|
|
input: MotionMember = "sun"
|
|
fixed: MotionMember | None = "ring"
|
|
output: MotionMember = "carrier"
|
|
tooth_form: Literal["spur", "herringbone", "helical"] = "spur"
|
|
helix_angle_deg: float | None = Field(default=None, gt=0, le=35)
|
|
helix_hand: Literal["right", "left"] = "right"
|
|
planet_count_candidates: list[int] = Field(default_factory=list)
|
|
module_candidates_mm: list[float] = Field(default_factory=list)
|
|
pressure_angle_deg: float = Field(default=20.0, gt=0, le=45)
|
|
face_width_mm: float = Field(default=8.0, gt=0)
|
|
backlash_mm: float = Field(default=0.03, ge=0)
|
|
addendum_coeff: float = Field(default=1.0, gt=0)
|
|
clearance_coeff: float = Field(default=0.25, ge=0)
|
|
ring_rim_thickness_mm: float = Field(default=2.5, gt=0)
|
|
constraints: RequirementConstraints | None = None
|
|
gear_tooth_counts: dict[str, int] = Field(default_factory=dict)
|
|
stages: list[StageRequirement] | None = None
|
|
cascade: CascadeRequirement | None = None
|
|
|
|
@model_validator(mode="after")
|
|
def validate_requirement_family(self) -> "ReducerRequirement":
|
|
if self.topology_family == "simple_2k_h":
|
|
for member in [self.input, self.output, self.fixed]:
|
|
if member is not None and member not in SIMPLE_2KH_MOTION_MEMBERS:
|
|
raise ValueError(f"simple_2k_h boundary member must be one of {sorted(SIMPLE_2KH_MOTION_MEMBERS)}")
|
|
if not self.planet_count_candidates:
|
|
raise ValueError("simple_2k_h requires planet_count_candidates")
|
|
if not self.module_candidates_mm:
|
|
raise ValueError("simple_2k_h requires module_candidates_mm")
|
|
if self.constraints is None:
|
|
raise ValueError("simple_2k_h requires constraints")
|
|
if self.input == self.output:
|
|
raise ValueError("input and output must be different")
|
|
if self.fixed in {self.input, self.output}:
|
|
raise ValueError("fixed member cannot also be input or output")
|
|
return self
|
|
|
|
if self.topology_family == "ferguson_wolfrom":
|
|
required_gears = {"sun1", "sun2", "planet1", "planet2", "ring1", "ring2"}
|
|
missing_gears = sorted(required_gears - set(self.gear_tooth_counts))
|
|
if missing_gears:
|
|
raise ValueError(f"ferguson_wolfrom requires gear_tooth_counts for {missing_gears}")
|
|
low_gears = {
|
|
gear: teeth
|
|
for gear, teeth in self.gear_tooth_counts.items()
|
|
if gear in required_gears and teeth <= MIN_GEAR_TOOTH_COUNT_EXCLUSIVE
|
|
}
|
|
if low_gears:
|
|
raise ValueError(
|
|
"ferguson_wolfrom gear teeth must be greater than "
|
|
f"{MIN_GEAR_TOOTH_COUNT_EXCLUSIVE}: {low_gears}"
|
|
)
|
|
if not self.planet_count_candidates:
|
|
raise ValueError("ferguson_wolfrom requires planet_count_candidates")
|
|
if self.detail_level != "kinematic_demo":
|
|
raise ValueError("ferguson_wolfrom currently supports only kinematic_demo")
|
|
if self.input == self.output:
|
|
raise ValueError("input and output must be different")
|
|
if self.fixed in {self.input, self.output}:
|
|
raise ValueError("fixed member cannot also be input or output")
|
|
return self
|
|
|
|
if self.stages is None or len(self.stages) != 2:
|
|
raise ValueError("simple_2k_h_cascade requires exactly two stages")
|
|
stage_ids = [stage.stage_id for stage in self.stages]
|
|
if stage_ids != ["s1", "s2"]:
|
|
raise ValueError("simple_2k_h_cascade stage ids must be ['s1', 's2']")
|
|
if self.cascade is None:
|
|
self.cascade = CascadeRequirement()
|
|
if self.constraints is None and any(stage.constraints is None for stage in self.stages):
|
|
raise ValueError(
|
|
"simple_2k_h_cascade requires top-level constraints when a stage omits constraints"
|
|
)
|
|
return self
|
|
|
|
|
|
class TemplateNode(StrictModel):
|
|
id: str
|
|
kind: Literal["gear", "carrier", "housing", "axis"]
|
|
role: str
|
|
gear_type: Literal["external", "internal"] | None
|
|
repeat: Literal["single", "planet_count"]
|
|
formula_ref: str
|
|
stage_id: str | None = None
|
|
local_role: str | None = None
|
|
|
|
|
|
class TemplateRelation(StrictModel):
|
|
id: str
|
|
source: str
|
|
target: str
|
|
relation_type: Literal[
|
|
"external_mesh",
|
|
"internal_mesh",
|
|
"revolute_joint",
|
|
"coaxial",
|
|
"fixed",
|
|
"rigid",
|
|
]
|
|
carrier_ref: str | None = None
|
|
mesh_id: str | None = None
|
|
formula_ref: str
|
|
stage_id: str | None = None
|
|
|
|
|
|
class TopologyTemplate(StrictModel):
|
|
template_id: str
|
|
family: TopologyFamily
|
|
description: str
|
|
nodes: list[TemplateNode]
|
|
relations: list[TemplateRelation]
|
|
|
|
|
|
class ConcreteNode(StrictModel):
|
|
component_id: str
|
|
template_node_id: str
|
|
kind: Literal["gear", "carrier", "housing", "axis"]
|
|
role: str
|
|
gear_type: Literal["external", "internal"] | None
|
|
repeat_index: int | None = None
|
|
stage_id: str | None = None
|
|
local_role: str | None = None
|
|
|
|
|
|
class ConcreteRelation(StrictModel):
|
|
relation_id: str
|
|
template_relation_id: str
|
|
relation_type: Literal[
|
|
"external_mesh",
|
|
"internal_mesh",
|
|
"revolute_joint",
|
|
"coaxial",
|
|
"fixed",
|
|
"rigid",
|
|
]
|
|
source_component_id: str
|
|
target_component_id: str
|
|
source_template_node: str
|
|
target_template_node: str
|
|
carrier_component_id: str | None = None
|
|
mesh_id: str | None = None
|
|
stage_id: str | None = None
|
|
|
|
|
|
class ConcreteTopology(StrictModel):
|
|
template_id: str
|
|
family: TopologyFamily
|
|
nodes: list[ConcreteNode]
|
|
relations: list[ConcreteRelation]
|
|
|
|
|
|
class BoundaryCondition(StrictModel):
|
|
input: str
|
|
fixed: str | None
|
|
output: str
|
|
fixed_members: list[str] = Field(default_factory=list)
|
|
|
|
@model_validator(mode="after")
|
|
def normalize_fixed_members(self) -> "BoundaryCondition":
|
|
if self.fixed and not self.fixed_members:
|
|
self.fixed_members = [self.fixed]
|
|
if self.fixed is None and len(self.fixed_members) == 1:
|
|
self.fixed = self.fixed_members[0]
|
|
return self
|
|
|
|
|
|
class IndustrialDesignParameters(StrictModel):
|
|
planet_pin_diameter_mm: float = Field(gt=0)
|
|
planet_bearing_inner_diameter_mm: float = Field(gt=0)
|
|
planet_bearing_outer_diameter_mm: float = Field(gt=0)
|
|
planet_bearing_width_mm: float = Field(gt=0)
|
|
planet_spacer_thickness_mm: float = Field(gt=0)
|
|
carrier_plate_thickness_mm: float = Field(gt=0)
|
|
carrier_front_plate_thickness_mm: float = Field(gt=0)
|
|
carrier_pin_fit_length_mm: float = Field(gt=0)
|
|
input_shaft_diameter_mm: float = Field(gt=0)
|
|
output_shaft_diameter_mm: float = Field(gt=0)
|
|
output_bearing_inner_diameter_mm: float = Field(gt=0)
|
|
output_bearing_outer_diameter_mm: float = Field(gt=0)
|
|
housing_wall_thickness_mm: float = Field(gt=0)
|
|
cover_thickness_mm: float = Field(gt=0)
|
|
bearing_seat_clearance_mm: float = Field(ge=0)
|
|
axial_clearance_mm: float = Field(ge=0)
|
|
radial_clearance_mm: float = Field(ge=0)
|
|
bolt_circle_diameter_mm: float = Field(gt=0)
|
|
bolt_count: int = Field(gt=0)
|
|
bolt_diameter_mm: float = Field(gt=0)
|
|
sun_key_width_mm: float = Field(gt=0)
|
|
sun_key_height_mm: float = Field(gt=0)
|
|
sun_key_length_mm: float = Field(gt=0)
|
|
bearing_ball_diameter_mm: float = Field(gt=0)
|
|
planet_bearing_ball_count: int = Field(gt=0)
|
|
output_bearing_ball_count: int = Field(gt=0)
|
|
housing_outer_diameter_mm: float = Field(gt=0)
|
|
housing_inner_diameter_mm: float = Field(gt=0)
|
|
total_length_mm: float = Field(gt=0)
|
|
source: str = "computed_by_industrial_rules"
|
|
|
|
|
|
class PhysicalComponent(StrictModel):
|
|
component_id: str
|
|
kind: str
|
|
role: str
|
|
formula_ref: str
|
|
parent_assembly: str | None = None
|
|
virtual: bool = False
|
|
|
|
|
|
class PhysicalRelation(StrictModel):
|
|
relation_id: str
|
|
relation_type: str
|
|
source_component_id: str
|
|
target_component_id: str
|
|
source: Literal["computed_assembly_topology"] = "computed_assembly_topology"
|
|
formula_ref: str
|
|
details: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class AssemblyTopology(StrictModel):
|
|
topology_id: str
|
|
detail_level: DetailLevel
|
|
components: list[PhysicalComponent]
|
|
relations: list[PhysicalRelation]
|
|
|
|
|
|
class StageParameters(StrictModel):
|
|
stage_id: str
|
|
z_sun: int = Field(gt=0)
|
|
z_planet: int = Field(gt=0)
|
|
z_ring: int = Field(gt=0)
|
|
planet_count: int = Field(gt=0)
|
|
module_mm: float = Field(gt=0)
|
|
pressure_angle_deg: float = Field(gt=0)
|
|
face_width_mm: float = Field(gt=0)
|
|
backlash_mm: float = Field(ge=0)
|
|
addendum_coeff: float = Field(gt=0)
|
|
clearance_coeff: float = Field(ge=0)
|
|
ring_rim_thickness_mm: float = Field(gt=0)
|
|
tooth_form: SupportedCadToothForm
|
|
helix_angle_deg: float = Field(default=0.0, ge=0, le=35)
|
|
helix_hand: Literal["right", "left"] = "right"
|
|
|
|
@model_validator(mode="after")
|
|
def validate_tooth_form_parameters(self) -> "StageParameters":
|
|
if self.tooth_form == "spur" and abs(self.helix_angle_deg) > 1e-12:
|
|
raise ValueError("spur gears require helix_angle_deg=0")
|
|
if self.tooth_form == "helical" and not (5.0 <= self.helix_angle_deg <= 35.0):
|
|
raise ValueError("helical gears require 5 <= helix_angle_deg <= 35")
|
|
return self
|
|
|
|
|
|
class ReducerParameters(StrictModel):
|
|
z_sun: int = Field(gt=0)
|
|
z_planet: int = Field(gt=0)
|
|
z_ring: int = Field(gt=0)
|
|
planet_count: int = Field(gt=0)
|
|
module_mm: float = Field(gt=0)
|
|
pressure_angle_deg: float = Field(gt=0)
|
|
face_width_mm: float = Field(gt=0)
|
|
backlash_mm: float = Field(ge=0)
|
|
addendum_coeff: float = Field(gt=0)
|
|
clearance_coeff: float = Field(ge=0)
|
|
ring_rim_thickness_mm: float = Field(gt=0)
|
|
tooth_form: SupportedCadToothForm
|
|
helix_angle_deg: float = Field(default=0.0, ge=0, le=35)
|
|
helix_hand: Literal["right", "left"] = "right"
|
|
gear_tooth_counts: dict[str, int] = Field(default_factory=dict)
|
|
stages: list[StageParameters] | None = None
|
|
|
|
@model_validator(mode="after")
|
|
def validate_tooth_form_parameters(self) -> "ReducerParameters":
|
|
if self.tooth_form == "spur" and abs(self.helix_angle_deg) > 1e-12:
|
|
raise ValueError("spur gears require helix_angle_deg=0")
|
|
if self.tooth_form == "helical" and not (5.0 <= self.helix_angle_deg <= 35.0):
|
|
raise ValueError("helical gears require 5 <= helix_angle_deg <= 35")
|
|
return self
|
|
|
|
|
|
class ReducerDerived(StrictModel):
|
|
ratio: float
|
|
speed_mode: Literal["speed_reducer", "speed_increaser", "direct"] = "speed_reducer"
|
|
direction: Literal["same", "opposite", "zero"]
|
|
sun_pitch_radius_mm: float
|
|
planet_pitch_radius_mm: float
|
|
ring_pitch_radius_mm: float
|
|
sun_pitch_diameter_mm: float
|
|
planet_pitch_diameter_mm: float
|
|
ring_pitch_diameter_mm: float
|
|
center_distance_mm: float
|
|
planet_orbit_radius_mm: float
|
|
ring_outer_diameter_mm: float
|
|
planet_outer_diameter_mm: float
|
|
planet_neighbor_distance_mm: float
|
|
planet_neighbor_clearance_mm: float
|
|
stages: list["StageDerived"] | None = None
|
|
axial_stack: dict[str, Any] | None = None
|
|
|
|
|
|
class StageDerived(StrictModel):
|
|
stage_id: str
|
|
ratio: float
|
|
direction: Literal["same", "opposite", "zero"]
|
|
speed_mode: Literal["speed_reducer", "speed_increaser", "direct"] = "speed_reducer"
|
|
sun_pitch_radius_mm: float
|
|
planet_pitch_radius_mm: float
|
|
ring_pitch_radius_mm: float
|
|
sun_pitch_diameter_mm: float
|
|
planet_pitch_diameter_mm: float
|
|
ring_pitch_diameter_mm: float
|
|
center_distance_mm: float
|
|
planet_orbit_radius_mm: float
|
|
ring_outer_diameter_mm: float
|
|
planet_outer_diameter_mm: float
|
|
planet_neighbor_distance_mm: float
|
|
planet_neighbor_clearance_mm: float
|
|
z_offset_mm: float = 0.0
|
|
|
|
|
|
class SearchCandidate(StrictModel):
|
|
stage_id: str | None = None
|
|
z_sun: int
|
|
z_planet: int
|
|
z_ring: int
|
|
planet_count: int
|
|
module_mm: float
|
|
ratio: float
|
|
ratio_error: float
|
|
ring_outer_diameter_mm: float
|
|
planet_neighbor_clearance_mm: float
|
|
score: float
|
|
|
|
|
|
class CascadeSearchCandidate(StrictModel):
|
|
ratio: float
|
|
ratio_error: float
|
|
score: float
|
|
stage_candidates: list[SearchCandidate]
|
|
|
|
|
|
class ParameterSearchReport(StrictModel):
|
|
run_id: str
|
|
requirement_hash: str
|
|
template_hash: str
|
|
solver_version: str = SOLVER_VERSION
|
|
evaluated_count: int
|
|
accepted_count: int
|
|
failure_counts: dict[str, int]
|
|
selected_candidate: SearchCandidate
|
|
accepted_examples: list[SearchCandidate]
|
|
stage_candidate_counts: dict[str, int] | None = None
|
|
pair_evaluated_count: int | None = None
|
|
pair_failure_counts: dict[str, int] | None = None
|
|
selected_stage_candidates: list[SearchCandidate] | None = None
|
|
selected_total_solution: dict[str, Any] | None = None
|
|
selected_cascade_candidate: CascadeSearchCandidate | None = None
|
|
generated_at: str
|
|
|
|
|
|
class ReducerInstance(StrictModel):
|
|
formula_version: str = FORMULA_VERSION
|
|
run_id: str
|
|
created_at: str
|
|
requirement_hash: str
|
|
template_hash: str
|
|
solver_version: str = SOLVER_VERSION
|
|
topology_family: TopologyFamily
|
|
detail_level: DetailLevel = "kinematic_demo"
|
|
topology_template: TopologyTemplate | None = None
|
|
topology: ConcreteTopology | None = None
|
|
assembly_topology: AssemblyTopology | None = None
|
|
requirement: ReducerRequirement
|
|
boundary: BoundaryCondition
|
|
parameters: ReducerParameters
|
|
derived: ReducerDerived
|
|
industrial_parameters: IndustrialDesignParameters | None = None
|
|
selected_candidate: SearchCandidate
|
|
source: Literal["computed_by_parameter_solver"] = "computed_by_parameter_solver"
|
|
|
|
|
|
class EquationRecord(StrictModel):
|
|
id: str
|
|
expression: str
|
|
coefficients: dict[str, float]
|
|
rhs: float
|
|
residual: float | None = None
|
|
|
|
|
|
class KinematicSolution(StrictModel):
|
|
run_id: str
|
|
variables: list[str]
|
|
equations: list[EquationRecord]
|
|
speeds: dict[str, float]
|
|
ratio: float
|
|
speed_mode: Literal["speed_reducer", "speed_increaser", "direct"] = "speed_reducer"
|
|
direction: Literal["same", "opposite", "zero"]
|
|
residual_max_abs: float
|
|
solved_at: str
|
|
|
|
|
|
class MotionSample(StrictModel):
|
|
input_angle_deg: float
|
|
member_angles_deg: dict[str, float]
|
|
|
|
|
|
class MotionSamples(StrictModel):
|
|
run_id: str
|
|
samples: list[MotionSample]
|
|
formula: str
|
|
generated_at: str
|
|
|
|
|
|
class ComponentManifest(StrictModel):
|
|
component_id: str
|
|
formula_node: str
|
|
stage_id: str | None = None
|
|
local_role: str | None = None
|
|
role: str
|
|
kind: str
|
|
gear_type: str | None = None
|
|
tooth_count: int | None = None
|
|
tooth_form: Literal["spur", "helical"] | None = None
|
|
helix_angle_deg: float | None = None
|
|
helix_hand: Literal["right", "left"] | None = None
|
|
module_mm: float | None = None
|
|
pressure_angle_deg: float | None = None
|
|
face_width_mm: float | None = None
|
|
pitch_radius_mm: float | None = None
|
|
center_xyz_mm: tuple[float, float, float]
|
|
axis_xyz: tuple[float, float, float] = (0.0, 0.0, 1.0)
|
|
tags: list[str]
|
|
assembled_step_path: str | None = None
|
|
details: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class ConstraintManifest(StrictModel):
|
|
constraint_id: str
|
|
relation_type: str
|
|
component_a: str
|
|
component_b: str
|
|
source: Literal["simplecadapi_constraint", "formula_semantic"]
|
|
cad_constraint_kind: str | None = None
|
|
stage_id: str | None = None
|
|
relation_id: str | None = None
|
|
|
|
|
|
class AssemblyManifest(StrictModel):
|
|
run_id: str
|
|
formula_instance_hash: str
|
|
generated_at: str
|
|
generator: str
|
|
components: dict[str, ComponentManifest]
|
|
constraints: list[ConstraintManifest]
|
|
exported_files: dict[str, str]
|
|
geometry_summary: dict[str, Any]
|
|
|
|
|
|
class AssemblySnapshot(StrictModel):
|
|
run_id: str
|
|
formula_instance_hash: str | None = None
|
|
model_path: str
|
|
manifest_path: str
|
|
build_meta_path: str | None = None
|
|
components_from_model: list[str]
|
|
constraints_from_model: list[dict[str, Any]]
|
|
component_placements: dict[str, dict[str, Any]]
|
|
grounded_components: list[str]
|
|
gear_constraints: list[str]
|
|
internal_mesh_constraints: list[str]
|
|
revolute_constraints: list[str]
|
|
fixed_constraints: list[str]
|
|
manifest: AssemblyManifest
|
|
build_meta: dict[str, Any] | None = None
|
|
model_graph_node_count: int
|
|
exported_files: dict[str, str]
|
|
read_at: str
|
|
|
|
|
|
class ValidationCheck(StrictModel):
|
|
section: Literal["anti_cheat", "static", "kinematic", "assembly", "geometry", "interference"]
|
|
code: str
|
|
passed: bool
|
|
message: str
|
|
actual: Any | None = None
|
|
expected: Any | None = None
|
|
|
|
|
|
class ValidationReport(StrictModel):
|
|
run_id: str
|
|
overall_passed: bool
|
|
generated_at: str
|
|
checks: list[ValidationCheck]
|
|
summary: dict[str, int]
|