Files
reducers/backend/src/models.py
T

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]