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]