Split project into frontend and backend

This commit is contained in:
2026-08-13 16:24:36 +08:00
commit f051146486
274 changed files with 953932 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
.DS_Store
__pycache__/
*.py[cod]
.pytest_cache/
backend/.pytest_cache/
backend/output/
frontend/node_modules/
frontend/dist/
+23
View File
@@ -0,0 +1,23 @@
# Frontend / Backend Split
This repo is now split into:
- `backend/` - Python project, CLI, tests, CAD generation, requirements, docs
- `frontend/` - Vite/React UI
Run the backend from `backend/` and the frontend from `frontend/`.
Backend quick start:
```bash
cd backend
python -m pytest
```
Frontend quick start:
```bash
cd frontend
npm install
npm run dev
```
+124
View File
@@ -0,0 +1,124 @@
# Planetary Reducer System Demo
## 简介
这是一个面向行星减速器和关节模组的公式化设计、生成与验证 demo。系统从 `input/` 中的需求 JSON 出发,先建立构型图,再枚举并求解齿轮参数,随后用图驱动运动学方程验证传动比和转向,最后调用 SimpleCADAPI 真实生成 STEP/model/session,并用读回的装配数据、统一 `PlacementSolver` 位姿计划和 OCP/PythonOCC 几何检测生成验证报告。
当前主要支持单级 `simple_2k_h`、两级串联 `simple_2k_h_cascade`、`ferguson_wolfrom` 独立减速器,以及基于电机 STEP 和外壳资产的关节模组装配。关节模组当前只支持同轴输入/输出接口的 `simple_2k_h` 和 `simple_2k_h_cascade`;`ferguson_wolfrom` 的输入位于偏心行星销轴区域,暂不适配当前同轴电机和外壳接口。项目刻意区分输入、输出和过程文档:所有源需求和外部资产放在 `input/`,所有生成结果只进入 `output/`,说明文档和阶段报告放在 `progress/`。如果 CAD 或 OCP 环境不可用,生成和验证会真实失败并输出错误,不生成假 STEP 或假验证报告。
This project formalizes, solves, generates, and validates planetary reducers and
joint modules with real parameter solving, real kinematics, real CAD generation,
and real geometry checks.
## Project Layout
```text
input/ user requirements and source assets
output/ generated run outputs only
progress/ documents, reports, and working notes
src/ implementation
tests/ regression tests
```
Reducer configurations live under:
```text
src/planetary_reducer/configurations/
```
Reducer-internal component poses are solved by:
```text
src/planetary_reducer/placement/
```
- `src/models.py`:定义系统中所有核心数据模型。
- `src/cli.py`:命令行入口,负责 `run`、`joint-run`、`auto-joint-run` 等流程。
- `src/configurations/`:集中管理减速器构型,比如单级和两级串联。
- `src/placement/`:统一计算各零件的三维位置和姿态。
- `src/parameter_solver.py`:根据需求枚举并求解齿数、模数、行星数量等参数。
- `src/kinematics.py`:根据构型图建立运动方程,验证传动比和转向。
- `src/cad/`:调用 SimpleCADAPI 生成真实 STEP、model.json 和装配 manifest。
- `src/validators.py`:总验证入口,统一执行公式、运动学、装配、位姿和几何检查。
- `src/relation_validators/`:验证构型图中的啮合、转动副、固定、刚性和同轴关系。
- `src/physical_validators/`:验证工业装配中的轴承、销轴、螺栓、配合和间隙关系。
- `src/joint_module/`:生成并验证由电机、外壳和行星减速器组成的完整关节模组。
## CLI
Run a reducer:
```bash
PYTHONPATH=src \
/Users/lk/miniconda3/bin/python -m planetary_reducer.cli run \
--input input/requirements/reducers/simple_2k_h/simple_2kh_industrial_ratio_7p5_spur.json \
--output output/runs/industrial_ratio_7p5_spur
```
Run a joint module:
```bash
PYTHONPATH=src \
/Users/lk/miniconda3/bin/python -m planetary_reducer.cli joint-run \
--input input/requirements/joint_modules/3500_motor_housing.json \
--output output/runs/joint_3500_motor_housing
```
Auto-design a joint module from motor STEP and housing Python:
```bash
PYTHONPATH=src \
/Users/lk/miniconda3/bin/python -m planetary_reducer.cli auto-joint-run \
--motor-step input/assets/motors/3500_motor_part2.step \
--housing-python input/assets/housings/gear_housing.py \
--output output/runs/auto_joint_3500
```
The CLI intentionally accepts `--input` only under `input/` and `--output` only
under `output/`.
List all formula-driven CAD part generators copied from SimpleCADAPI:
```bash
PYTHONPATH=src \
/Users/lk/miniconda3/bin/python -m planetary_reducer.cli list-parts
```
Generate one standalone parametric part:
```bash
PYTHONPATH=src \
/Users/lk/miniconda3/bin/python -m planetary_reducer.cli generate-part \
--part roller_chain_sprocket \
--parameters '{"n_teeth": 12, "chain_pitch": 6.35, "roller_diameter": 3.3, "sprocket_thickness": 3.0, "bore_radius": 1.2}' \
--output output/runs/parts/roller_chain_sprocket
```
## Run Output
Reducer runs use:
```text
output/runs/<run>/
input_snapshot/
formula/
kinematics/
placement/
cad/
validation/
exports/
```
Important files include:
```text
formula/formula_instance.json
kinematics/kinematic_solution.json
placement/placement_plan.json
cad/reducer.step
cad/assembly_manifest.json
validation/validation_report.json
```
`placement/placement_plan.json` is the reducer-internal pose source of truth.
`assembly_manifest.json` must match it.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,219 @@
# 构型开发规范
本文档说明如何在本项目中新增或维护行星减速器构型。目标是让复合行星、多级串联、差动行星等新构型沿同一套结构扩展,避免把公式、位姿、CAD 和验证逻辑散落到各个模块中。
## 1. 总流程
新增构型必须按以下链路接入:
```text
requirement schema
-> configuration registry
-> topology template/runtime
-> parameter solver
-> kinematics
-> placement solver
-> CAD generator
-> assembly reader
-> validators
-> tests/examples
```
每一步只承担自己的职责:
- requirement 只描述用户目标、边界条件、齿形和候选范围。
- configuration 只描述构型图和构型族专属规则。
- parameter solver 只枚举、过滤、评分,不写死答案。
- kinematics 只从图关系生成运动方程。
- placement solver 只计算零件位姿。
- CAD generator 只按参数和位姿生成真实几何。
- validators 只读取 instance、snapshot、placement 和真实 CAD 输出进行验证。
## 2. 构型目录规范
每个构型族必须有独立目录:
```text
src/configurations/<family_name>/
family.py
topology.template.json
placement_rules.py
input/requirements/reducers/<family_name>/
tests/
```
必须在 `src/configurations/registry.py` 中注册:
```python
CONFIGURATIONS = {
"<family_name>": ConfigurationSpec(
family="<family_name>",
package_dir=CONFIGURATION_ROOT / "<family_name>",
topology_template_path=CONFIGURATION_ROOT / "<family_name>" / "topology.template.json",
),
}
```
禁止在 CLI、CAD generator、validator 中通过硬编码路径读取构型模板。
## 3. topology template 规范
`topology.template.json` 只描述构型,不写死最终齿数或具体输出文件。
节点字段必须遵守:
```text
id
kind: gear / carrier / housing / axis
role
gear_type: external / internal / null
repeat: single / planet_count
formula_ref
stage_id
local_role
```
关系字段必须遵守:
```text
id
source
target
relation_type: external_mesh / internal_mesh / revolute_joint / fixed / rigid / coaxial
carrier_ref
mesh_id
formula_ref
stage_id
```
命名规则:
- 单级构型使用 `sun/ring/planet/carrier/housing/main_axis`。
- 多级构型使用 `s1_sun/s1_ring/s1_planet_1/s1_carrier` 这类 stage 前缀。
- repeated 节点由 runtime 展开,不要在模板里手写 `planet_1..planet_n`。
- 关系 id 必须能反查来源,例如 `sun_to_planet_external_mesh`、`s1_output_to_s2_input_rigid`。
如果要新增 relation type,必须先扩展 relation validator registry 和运动学方程生成逻辑,不能只在模板中写一个新字符串。
## 4. family 规则规范
`family.py` 只放构型族专属逻辑,例如:
- 构型专属齿数闭合公式。
- stage/component 命名辅助。
- 多级构型拓扑构造。
- 构型专属派生参数。
通用逻辑必须放到通用模块:
- 通用参数约束放 `src/parameter_constraints/` 或 parameter solver 的通用规则层。
- 通用运动学关系放 `src/kinematics.py` 和 `src/relation_validators/`。
- 通用工业装配关系放 `src/physical_validators/`。
- 通用齿轮生成放 `src/cad/gear_factory.py`。
禁止事项:
- solver 中不得写死某组齿数作为答案。
- family 规则不得读取旧 `output/runs`。
- validation 不得无条件 `overall_passed=true`。
- SimpleCADAPI/OCP 缺失时不得生成假文件。
## 5. 参数求解规范
每个构型必须明确:
- 候选变量:齿数、模数、行星数、stage 数、齿形参数等。
- 闭合公式:例如 `z_ring = z_sun + 2 * z_planet`。
- 装配条件:例如行星均布、中心距一致、邻近行星间隙。
- 边界条件:input/fixed/output 或 fixed_members。
- 评分依据:ratio_error、外径、间隙、齿数惩罚等。
最终速比必须由图驱动运动学求解得到,不能只用手写传动比公式作为通过依据。手写公式只能用于搜索优化或静态交叉验证。
## 6. PlacementSolver 规范
每个构型必须注册 placement solver,并输出统一 `PlacementPlan`。
每个真实组件必须有 `ComponentPose`:
```text
component_id
frame_id
parent_frame_id
translation_mm
rotation_axis_angle_deg
axis_xyz
center_xyz_mm
source_relation_ids
source_formula_refs
details
```
约束:
- `center_xyz_mm`、`axis_xyz`、`rotation_axis_angle_deg` 必须来自 `PlacementPlan`。
- CAD manifest 中的位姿必须与 `placement_plan.json` 一致。
- CAD generator 不允许重新计算 planet、pin、bearing、spacer、bolt 等坐标。
- stage 构型必须使用 frame + z offset,不要把绝对坐标散落在 CAD 代码里。
## 7. CAD 生成规范
CAD generator 的职责是按参数和 placement 生成真实几何:
- 齿轮使用 `gear_factory`。
- 装配坐标使用 `PlacementPlan`。
- 输出真实 `STEP/model.json/session.json/assembly_manifest.json/build_meta.json`。
- `assembly_manifest.components[*]` 必须能反查 formula node、stage/local_role、tooth_count、module、center、axis、assembled_step_path。
如果某个构型暂时没有 CAD 能力,CLI generate/run 必须明确失败,例如 `unsupported_topology_family_for_cad`,不能生成占位 STEP。
## 8. 验证规范
验证分三层:
```text
relation validators
family validators
geometry/OCP validators
```
relation validators 自动覆盖:
- `external_mesh`
- `internal_mesh`
- `revolute_joint`
- `fixed`
- `rigid`
- `coaxial`
family validators 只写构型族专属公式,例如齿数闭合、stage coupling、复合行星同轴齿轮组等。
几何验证必须读取真实 STEP/component STEP:
- STEP 存在且非空。
- 组件体积 > 0。
- 中心距、同轴、均布、轴向 offset 正确。
- OCP 正体积干涉必须失败,并报告 component pair、common volume、possible cause。
## 9. 最小验收文件
每个新构型至少提供:
- 示例 requirement:`input/requirements/reducers/<family_name>/<example>.json`
- 构型模板:`src/configurations/<family_name>/topology.template.json`
- family 逻辑:`src/configurations/<family_name>/family.py`
- placement solver:`src/placement/<family_name>.py`
- 注册项:configuration registry 和 placement registry
- 单元测试:注册、拓扑展开、参数求解、运动学、placement
- CAD 集成测试:真实 STEP/model/session/manifest、placement 一致、validation report
## 10. 推荐新增构型顺序
建议优先:
1. 复合行星 / stepped planet:一个行星轴上多个齿轮,验证图驱动和 placement 扩展能力。
2. 差动行星:支持两个输入或一个输入一个控制输入。
3. 任意 N 级 cascade:从固定两级扩展为 stage list。
4. RV / 摆线针轮:单独作为新传动族处理。
新增构型时先做 `kinematic_demo`,再做 `industrial_core`,最后再接入关节模组。
@@ -0,0 +1,392 @@
# 构型 JSON 填写说明
本文档给负责构型设计的机械工程师使用。你只需要按照现有 `topology.template.json` 格式创建新的构型 JSON,不需要关心 Python 代码如何实现。
构型 JSON 描述的是“这个传动系统由哪些运动构件组成、构件之间有什么机械关系”。它不填写目标速比、齿数、模数、材料、输出路径,也不填写具体三维坐标。
## 1. 文件位置和命名
每个构型族一个目录:
```text
src/configurations/<family_name>/topology.template.json
```
例子:
```text
src/configurations/simple_2k_h/topology.template.json
src/configurations/simple_2k_h_cascade/topology.template.json
```
`<family_name>` 用小写英文和下划线,例如:
```text
simple_2k_h
simple_2k_h_cascade
compound_planet
differential_planet
```
## 2. 顶层结构
一个构型 JSON 必须包含:
```json
{
"template_id": "simple_2k_h_v0_1",
"family": "simple_2k_h",
"description": "简短描述",
"nodes": [],
"relations": []
}
```
字段说明:
| 字段 | 必填 | 怎么填 |
|---|---:|---|
| `template_id` | 是 | 模板唯一编号,建议 `<family>_v0_1` |
| `family` | 是 | 构型族名称,必须和目录名一致 |
| `description` | 是 | 一句话说明构型,不写公式长文 |
| `nodes` | 是 | 构件节点列表 |
| `relations` | 是 | 构件关系列表 |
## 3. nodes 怎么填
`nodes` 表示构型里有哪些构件或参考对象。
节点格式:
```json
{
"id": "sun",
"kind": "gear",
"role": "sun",
"gear_type": "external",
"repeat": "single",
"formula_ref": "nodes.sun",
"stage_id": null,
"local_role": "sun"
}
```
字段说明:
| 字段 | 必填 | 可选值 / 填法 | 说明 |
|---|---:|---|---|
| `id` | 是 | 英文唯一名 | 关系里会用它引用节点 |
| `kind` | 是 | `gear` / `carrier` / `housing` / `axis` | 节点类型 |
| `role` | 是 | `sun`、`ring`、`planet`、`carrier` 等 | 机械角色 |
| `gear_type` | 是 | `external` / `internal` / `null` | 只有齿轮填齿轮类型 |
| `repeat` | 是 | `single` / `planet_count` | 是否按行星数量展开 |
| `formula_ref` | 是 | 字符串 | 便于反查来源 |
| `stage_id` | 否 | `null` 或 `s1`、`s2` | 多级构型可用 |
| `local_role` | 否 | `sun`、`ring`、`planet` 等 | stage 内局部角色 |
### kind 选择
| 机械对象 | `kind` |
|---|---|
| 太阳轮、齿圈、行星轮 | `gear` |
| 行星架 | `carrier` |
| 壳体、机架、固定参考 | `housing` |
| 主轴线、参考轴 | `axis` |
### gear_type 选择
| 齿轮 | `gear_type` |
|---|---|
| 太阳轮 | `external` |
| 行星轮 | `external` |
| 内齿圈 | `internal` |
| 非齿轮节点 | `null` |
### repeat 选择
| 情况 | `repeat` |
|---|---|
| 只有一个,例如 sun、ring、carrier、housing | `single` |
| 按行星数量重复,例如 planet | `planet_count` |
注意:不要手写 `planet_1`、`planet_2`、`planet_3`。只写一个 `planet` 节点,并设置:
```json
"repeat": "planet_count"
```
程序会自动展开。
## 4. relations 怎么填
`relations` 表示节点之间有什么机械关系。
关系格式:
```json
{
"id": "sun_planet_external_mesh",
"source": "sun",
"target": "planet",
"relation_type": "external_mesh",
"carrier_ref": "carrier",
"mesh_id": "sun_planet",
"formula_ref": "relations.sun_planet_external_mesh",
"stage_id": null
}
```
字段说明:
| 字段 | 必填 | 怎么填 |
|---|---:|---|
| `id` | 是 | 关系唯一名 |
| `source` | 是 | 起点 node id |
| `target` | 是 | 终点 node id |
| `relation_type` | 是 | 见下表 |
| `carrier_ref` | 否 | 行星啮合通常填 `carrier` |
| `mesh_id` | 否 | 同一组啮合的名字 |
| `formula_ref` | 是 | 便于反查来源 |
| `stage_id` | 否 | 多级构型可填 stage 标识 |
当前允许的 `relation_type`:
| relation_type | 含义 | 典型 source -> target |
|---|---|---|
| `external_mesh` | 外齿轮与外齿轮啮合 | `sun -> planet` |
| `internal_mesh` | 内齿圈与外齿轮啮合 | `ring -> planet` |
| `revolute_joint` | 转动副 | `planet -> carrier` |
| `coaxial` | 同轴 | `sun -> main_axis` |
| `fixed` | 固定到壳体或固定参考 | `ring -> housing` |
| `rigid` | 刚性连接,同速转动 | `s1_carrier -> s2_sun` |
## 5. 常见关系填写规则
### 外啮合
太阳轮和行星轮外啮合:
```json
{
"id": "sun_planet_external_mesh",
"source": "sun",
"target": "planet",
"relation_type": "external_mesh",
"carrier_ref": "carrier",
"mesh_id": "sun_planet",
"formula_ref": "relations.sun_planet_external_mesh"
}
```
要点:
- `source` 和 `target` 都应该是 `gear_type = external`。
- 行星机构里要填 `carrier_ref`,表示啮合是在行星架参考系下建立运动方程。
### 内啮合
内齿圈和行星轮内啮合:
```json
{
"id": "ring_planet_internal_mesh",
"source": "ring",
"target": "planet",
"relation_type": "internal_mesh",
"carrier_ref": "carrier",
"mesh_id": "ring_planet",
"formula_ref": "relations.ring_planet_internal_mesh"
}
```
要点:
- 内齿圈节点必须是 `gear_type = internal`。
- 行星轮节点必须是 `gear_type = external`。
### 行星轮转动副
行星轮绕行星架销轴转动:
```json
{
"id": "planet_carrier_pin_revolute",
"source": "planet",
"target": "carrier",
"relation_type": "revolute_joint",
"carrier_ref": "carrier",
"mesh_id": null,
"formula_ref": "relations.planet_carrier_pin_revolute"
}
```
### 同轴
太阳轮、齿圈、行星架和主轴同轴:
```json
{
"id": "sun_main_axis_coaxial",
"source": "sun",
"target": "main_axis",
"relation_type": "coaxial",
"carrier_ref": null,
"mesh_id": null,
"formula_ref": "relations.sun_main_axis_coaxial"
}
```
通常至少要写:
```text
sun -> main_axis
ring -> main_axis
carrier -> main_axis
```
### 固定
齿圈固定到壳体:
```json
{
"id": "ring_housing_fixed",
"source": "ring",
"target": "housing",
"relation_type": "fixed",
"carrier_ref": null,
"mesh_id": null,
"formula_ref": "relations.ring_housing_fixed"
}
```
注意:实际哪个构件固定,还会受 requirement 的 `fixed` 字段影响。单级边界变体中,如果 requirement 写 `fixed = sun`,运行时会把 fixed 关系改成 sun 固定到 housing。
### 刚性连接
两级串联中,前一级输出和后一级输入刚性连接:
```json
{
"id": "stage_output_to_next_input_rigid",
"source": "stage_carrier",
"target": "stage_sun",
"relation_type": "rigid",
"carrier_ref": null,
"mesh_id": null,
"formula_ref": "cascade.relations.output_to_next_input",
"stage_id": "cascade"
}
```
要点:
- `rigid` 表示两个运动构件角速度相同。
- 多级构型里通常由程序根据 stage 生成真实组件名,例如 `s1_carrier -> s2_sun`。
## 6. 单级 simple_2k_h 示例
这是最常见结构:
```text
sun 输入
ring 固定
carrier 输出
planet 均布
```
必须包含节点:
```text
sun: external gear, single
planet: external gear, planet_count
ring: internal gear, single
carrier: carrier, single
housing: housing, single
main_axis: axis, single
```
必须包含关系:
```text
sun --external_mesh--> planet
ring --internal_mesh--> planet
planet --revolute_joint--> carrier
sun/ring/carrier --coaxial--> main_axis
ring --fixed--> housing
```
对应文件参考:
```text
src/configurations/simple_2k_h/topology.template.json
```
## 7. 两级串联 simple_2k_h_cascade 示例
两级串联可以理解为:
```text
s1 是一个 simple_2k_h
s2 是一个 simple_2k_h
s1.output 刚性连接到 s2.input
```
模板里使用 `stage_sun/stage_planet/stage_ring/stage_carrier` 表示“每一级的局部节点”。程序会展开成:
```text
s1_sun / s1_ring / s1_planet_1..n / s1_carrier
s2_sun / s2_ring / s2_planet_1..n / s2_carrier
```
对应文件参考:
```text
src/configurations/simple_2k_h_cascade/topology.template.json
```
## 8. 填写检查清单
提交新的构型 JSON 前,逐项检查:
- `template_id` 唯一。
- `family` 和目录名一致。
- 每个 `relation.source`、`relation.target` 都能在 `nodes.id` 中找到。
- 齿轮节点的 `gear_type` 正确。
- 非齿轮节点的 `gear_type` 是 `null`。
- 需要重复的行星轮只写一个节点,并设置 `repeat = planet_count`。
- 每组行星啮合关系都填写了正确的 `carrier_ref`。
- 主运动构件都和 `main_axis` 有 `coaxial` 关系。
- 固定关系使用 `fixed`。
- 级间或同轴齿轮组刚性连接使用 `rigid`。
- 没有使用未注册的 `relation_type`。
- 没有在 topology JSON 中填写齿数、模数、速比、坐标、材料或输出路径。
## 9. 不应该写进构型 JSON 的内容
以下内容不要写进 `topology.template.json`:
- `target_ratio`
- `z_sun`、`z_planet`、`z_ring`
- `module_mm`
- `planet_count_candidates`
- `face_width_mm`
- `center_xyz_mm`
- `axis_xyz`
- STEP 文件路径
- 材料、颜色、轴承型号
- validation report 结果
这些属于 requirement、参数求解、placement、CAD 或验证输出,不属于构型模板。
## 10. 常见错误
| 错误 | 后果 |
|---|---|
| 忘记写 `carrier_ref` | 行星啮合运动方程无法正确使用行星架参考系 |
| 把 `planet_1/planet_2/planet_3` 写进模板 | runtime 无法按候选行星数自动展开 |
| 内齿圈写成 `external` | 内啮合验证失败 |
| 非齿轮节点 `gear_type` 没写 `null` | schema 或验证失败 |
| 使用未注册 relation type | 验证器会报 unsupported relation type |
| 在 topology 中写齿数和模数 | 构型和参数耦合,无法复用 |
@@ -0,0 +1,360 @@
# 行星减速器公式化系统流程说明
当前 demo 的目标:把行星减速器描述成一套可被 agent 和程序共同读取的“公式化规则”,并基于这些规则完成参数求解、运动学求解、CAD 生成和验证。当前版本先覆盖单级 `simple_2k_h`:太阳轮输入、内齿圈固定、行星架输出、多个行星轮均布、直齿。
## 1. 总体流程
```text
用户需求 requirements.json
-> 构型模板 topology template
-> 参数求解 formula_instance.json
-> 运动学求解 kinematic_solution.json
-> CAD 生成 reducer.step / reducer.model.json / reducer.session.json
-> 装配读回 assembly_snapshot.json
-> 验证报告 validation_report.json
```
核心要求:
| 要求 | 说明 |
| --- | --- |
| 可共享 | Pydantic 数据模型作为统一接口,多个 agent/模块读取同一份公式实例 |
| 可计算 | 参数由求解器枚举和筛选,不写死齿数 |
| 可生成 | CAD 由 SimpleCADAPI 真实生成 STEP/model/session |
| 可反查 | 每个组件写入 `run_id`、`formula_ref`、齿数、模数、中心、轴线、啮合关系 |
| 可验证 | 验证器读回本次输出文件,重新计算并检查装配和几何 |
## 2. 构型设计
构型用图结构表达:节点是机械构件或参考对象,边是机械关系。这样同一张构型图可以服务参数求解、运动学求解、CAD 生成和验证。
### 2.1 simple_2k_h 构型图
```mermaid
graph TD
sun["sun 外齿太阳轮"]
planet["planet 外齿行星轮 x N"]
ring["ring 内齿圈"]
carrier["carrier 行星架"]
housing["housing 固定参考"]
axis["main_axis 主轴线"]
sun -- "external_mesh 外啮合" --> planet
ring -- "internal_mesh 内啮合" --> planet
planet -- "revolute_joint 行星销转动副" --> carrier
ring -- "fixed 固定" --> housing
sun -- "coaxial 同轴" --> axis
ring -- "coaxial 同轴" --> axis
carrier -- "coaxial 同轴" --> axis
```
### 2.2 节点和边规范
| 节点 | 类型 | 作用 |
| --- | --- | --- |
| `sun` | 外齿轮 | 输入件 |
| `planet` | 外齿轮,重复件 | 与太阳轮、内齿圈啮合 |
| `ring` | 内齿轮 | 固定件 |
| `carrier` | 行星架 | 输出件 |
| `housing` | 固定参考 | 接地基准 |
| `main_axis` | 轴线 | 同轴基准 |
| 边 | 含义 |
| --- | --- |
| `external_mesh(sun, planet)` | 太阳轮和行星轮外啮合 |
| `internal_mesh(ring, planet)` | 内齿圈和行星轮内啮合 |
| `revolute_joint(planet, carrier)` | 行星轮安装在行星销上,可自转 |
| `fixed(ring, housing)` | 内齿圈固定 |
| `coaxial(sun/ring/carrier, main_axis)` | 太阳轮、内齿圈、行星架同轴 |
构型合法性检查:
```text
必须存在 sun、planet、ring、carrier
sun 和 planet 必须是 external gear
ring 必须是 internal gear
必须存在 sun-planet external_mesh
必须存在 ring-planet internal_mesh
必须存在 planet-carrier revolute_joint
input / fixed / output 必须引用合法运动构件
```
## 3. 参数求解
### 3.1 输入和变量
本例需求:
```text
target_ratio = 7.5
ratio_tolerance = 0.01
input = sun
fixed = ring
output = carrier
planet_count_candidates = [3, 4, 5]
module_candidates_mm = [0.5, 0.75, 1.0, 1.25, 1.5]
max_outer_diameter_mm = 100
min_planet_neighbor_clearance_mm = 0.3
min_sun_teeth = 12
max_sun_teeth = 80
```
求解变量:`z_sun`、`z_planet`、`z_ring`、`planet_count`、`module_mm`。默认参数:压力角 20°、齿宽 8 mm、齿侧间隙 0.03 mm、齿顶系数 1.0、顶隙系数 0.25、齿圈轮缘厚度 2.5 mm。
### 3.2 约束公式
| 约束 | 公式 | 作用 |
| --- | --- | --- |
| 齿数闭合 | `z_ring = z_sun + 2 * z_planet` | 保证太阳轮、行星轮、内齿圈能同时啮合 |
| 中心距一致 | `m*(z_sun+z_planet)/2 = m*(z_ring-z_planet)/2` | 保证外啮合中心距等于内啮合中心距 |
| 行星轮均布 | `(z_sun + z_ring) % planet_count == 0` | 保证多个行星轮等分布置时齿位能对齐 |
| 轨道半径 | `orbit_radius = center_distance` | 决定行星轮中心位置 |
| 行星轮间隙 | `2*orbit_radius*sin(pi/planet_count) - m*(z_planet+2*addendum_coeff) >= min_clearance` | 避免相邻行星轮互相碰撞 |
| 齿圈外径 | `m*z_ring + 2*m*(addendum_coeff+clearance_coeff) + 2*ring_rim_thickness <= max_outer_diameter` | 满足用户外径限制 |
| 传动比 | `abs(kinematic_ratio - target_ratio) <= ratio_tolerance` | 满足减速比要求 |
### 3.3 求解方法
求解器采用枚举过滤,不写死答案:
```text
for z_sun in range(min_sun_teeth, max_sun_teeth):
for z_planet in allowed_planet_teeth:
z_ring = z_sun + 2 * z_planet
for planet_count in planet_count_candidates:
for module_mm in module_candidates_mm:
检查齿数闭合、均布、中心距、外径、间隙
调用运动学求解器计算 kinematic_ratio
保留满足 target_ratio 的候选
按 ratio_error、外径、间隙、齿数评分,选择最优候选
```
本例真实枚举结果:
```text
总候选数 = 158355
通过候选数 = 8
最终选择:
z_sun = 12
z_planet = 33
z_ring = 78
planet_count = 3
module_mm = 0.5
ratio = 7.5
center_distance = 11.25 mm
ring_outer_diameter = 45.25 mm
planet_neighbor_clearance = 1.9856 mm
```
## 4. 运动学求解
运动学求解器的作用是根据构型关系计算传动比、输出方向、固定件速度和方程残差。它不是直接写死 `i = 1 + z_ring / z_sun`,而是从啮合关系生成线性方程。
变量:
```text
omega_sun
omega_ring
omega_carrier
omega_planet
```
外啮合方程:
```text
z_sun * (omega_sun - omega_carrier)
+ z_planet * (omega_planet - omega_carrier)
= 0
```
内啮合方程:
```text
z_ring * (omega_ring - omega_carrier)
- z_planet * (omega_planet - omega_carrier)
= 0
```
边界条件:
```text
omega_input = 1
omega_fixed = 0
```
本例即 `omega_sun = 1`、`omega_ring = 0`。程序把方程整理为 `A * omega = b`,用高斯消元求解,并检查矩阵是否奇异、是否欠约束、残差是否接近 0。
本例求解结果:
```text
omega_sun = 1.0
omega_ring = 0.0
omega_carrier = 0.1333333333
omega_planet = -0.1818181818
i = omega_sun / omega_carrier = 7.5
direction = same
residual_max_abs = 0
```
## 5. CAD 生成和读回
CAD 生成器读取 `formula_instance.json`,用 SimpleCADAPI 真实生成 `sun`、`ring`、`planet_1..planet_n`、`carrier`、`housing`,并导出:
```text
reducer.step
reducer.model.json
reducer.session.json
assembly_manifest.json
assembly_snapshot.json
validation_report.json
```
读回时不只看 manifest,还读取 `reducer.model.json` 中的真实组件、约束和 placement,生成统一的 `AssemblySnapshot`。每个组件必须能反查到公式来源,例如 `component_id`、`formula_ref`、齿数、模数、中心、轴线、啮合关系和 `run_id`。
## 6. 验证方法
### 6.1 静态公式验证
静态公式验证只检查参数关系,不依赖 CAD。
| 检查项 | 本例结果 |
| --- | --- |
| 齿数为正整数 | `12, 33, 78`,通过 |
| 齿数闭合 | `78 = 12 + 2 * 33`,通过 |
| 均布条件 | `(12 + 78) / 3 = 30`,通过 |
| 中心距一致 | `sun-planet = ring-planet = 11.25 mm`,通过 |
| 行星轮间隙 | `1.9856 mm >= 0.3 mm`,通过 |
| 齿圈外径 | `45.25 mm <= 100 mm`,通过 |
### 6.2 运动学验证
运动学验证重新建立方程并求解,不直接相信实例中的传动比。
检查项:
```text
重新求解 ratio
ratio 是否等于 formula_instance
ratio 是否满足 requirements
omega_fixed 是否等于 0
输出方向是否一致
residual_max_abs 是否在容差内
```
本例结果:`ratio = 7.5`、`omega_ring = 0`、`direction = same`、`residual_max_abs = 0`,通过。
### 6.3 装配和几何位置验证
装配语义检查:
```text
sun / ring / carrier / planet_i 是否存在
ring 是否 grounded
housing 是否 fixed 到 ring
sun 是否有输入转动副
carrier 是否有输出转动副
planet_i 是否有行星销转动副
sun-planet 是否有 external_mesh 约束
ring-planet 是否有 internal_mesh 语义
```
几何位置检查:
```text
sun / ring / carrier / housing 是否同轴
planet_i 是否在 orbit_radius 上
planet_i 是否按 360 / planet_count 等分
distance(sun, planet_i) = r_sun + r_planet
distance(ring, planet_i) = r_ring - r_planet
STEP / model / session 是否存在且非空
CAD 实体体积是否大于 0
```
本例:`r_sun = 3.0 mm`、`r_planet = 8.25 mm`、`r_ring = 19.5 mm`,所以外啮合和内啮合中心距都等于 `11.25 mm`。
### 6.4 几何干涉检测
中心距正确不代表齿轮实体一定不穿模,所以加入 OpenCascade/OCP 的真实 B-Rep 检测。
检测流程:
```text
1. 把装配后的每个组件单独导出 STEP
2. OCP 读取每个组件 STEP
3. 检查 shape 是否有效、体积是否大于 0
4. 用包围盒筛掉明显不相交的零件对
5. 对可能相交的零件对执行 Boolean Common
6. 计算公共体积 common_volume
7. common_volume > 1e-6 mm^3 判定为穿模
```
判定公式:
```text
common_shape = BooleanCommon(shape_a, shape_b)
common_volume = Volume(common_shape)
common_volume <= 1e-6 mm^3 通过
common_volume > 1e-6 mm^3 失败
```
如果失败,可以导出公共干涉体 STEP,例如 `common_planet_1_vs_ring.step`,用于直接查看两个零件真实重叠的位置。
本例初始版本检测到:
```text
ring vs planet_1: common_volume ≈ 19.331 mm^3
ring vs planet_2: common_volume ≈ 19.331 mm^3
ring vs planet_3: common_volume ≈ 19.331 mm^3
```
定位结果:齿数、中心距、传动比都正确,问题是内齿圈、太阳轮、行星轮三者齿槽相位没有同时对准。当前规则是行星轮按中心角和齿数关系补偿自转相位,内齿圈相位按行星轮齿数奇偶选择:
```text
internal_ring_phase_correction_deg = 180 / z_ring, if z_planet is odd
internal_ring_phase_correction_deg = 0, if z_planet is even
planet_phase_deg = 180 - 180 / z_planet + planet_center_angle_deg * (1 + z_sun / z_planet)
```
修正后:`OCP interferences = []`、`overall_passed = true`、`32 passed`、`0 failed`。
### 6.5 齿轮啮合检测
齿轮啮合不是只看中心距,而是分四层检查:
| 层级 | 检查内容 |
| --- | --- |
| 参数层 | `module`、`pressure_angle`、`tooth_form` 一致,齿数闭合正确 |
| 中心距层 | 外啮合 `distance = r_sun + r_planet`,内啮合 `distance = r_ring - r_planet` |
| 装配语义层 | CAD 中存在 `external_mesh` 和 `internal_mesh` 关系 |
| 实体层 | OCP 检查真实齿形 `common_volume <= 1e-6 mm^3` |
如果参数层和中心距层通过,但实体层失败,通常说明问题在齿形相位、齿顶齿根参数、齿侧间隙或内齿轮生成算法。本例就是通过实体层检测发现并修正了内齿圈相位问题。
## 7. 当前结果
本例最终参数:
```text
z_sun = 12
z_planet = 33
z_ring = 78
planet_count = 3
module_mm = 0.5
ratio = 7.5
ring_outer_diameter = 45.25 mm
planet_neighbor_clearance = 1.9856 mm
```
最终验证结果:
```text
overall_passed = true
32 passed
0 failed
```
当前已覆盖:构型描述、参数求解、运动学求解、CAD 真实生成、装配读回、静态公式验证、运动学验证、几何位置验证、OCP 实体干涉检测、齿轮啮合分层检测。
当前未覆盖:齿根弯曲强度、齿面接触强度、轴承寿命、润滑和热分析、制造公差、全周期运动采样、精确齿侧间隙测量。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,190 @@
from dataclasses import dataclass
from pathlib import Path
import math
import cadquery as cq
# All dimensions are millimetres. The main body is a revolved radial section.
INNER_BOTTOM_RADIUS = 60.0
INNER_STEP_RADIUS = 57.5
INNER_SHOULDER_RADIUS = 70.0
INNER_WALL_RADIUS = 79.0
BOTTOM_OUTER_RADIUS = 77.5
BODY_OUTER_RADIUS = 82.5
TOP_OUTER_RADIUS = 85.0
BASE_MIN_Z = -18.0
BASE_MAX_Z = 92.0
BASE_HEIGHT = BASE_MAX_Z - BASE_MIN_Z
@dataclass(frozen=True)
class HousingScale:
radial_scale: float = 1.0
axial_scale: float = 1.0
source: str = "default_template_scale"
def scaled_dimensions(scale: HousingScale) -> dict[str, float]:
return {
"inner_bottom_radius": INNER_BOTTOM_RADIUS * scale.radial_scale,
"inner_step_radius": INNER_STEP_RADIUS * scale.radial_scale,
"inner_shoulder_radius": INNER_SHOULDER_RADIUS * scale.radial_scale,
"inner_wall_radius": INNER_WALL_RADIUS * scale.radial_scale,
"bottom_outer_radius": BOTTOM_OUTER_RADIUS * scale.radial_scale,
"body_outer_radius": BODY_OUTER_RADIUS * scale.radial_scale,
"top_outer_radius": TOP_OUTER_RADIUS * scale.radial_scale,
"min_z": BASE_MIN_Z * scale.axial_scale,
"max_z": BASE_MAX_Z * scale.axial_scale,
"height": BASE_HEIGHT * scale.axial_scale,
"radial_scale": scale.radial_scale,
"axial_scale": scale.axial_scale,
"source": scale.source,
}
def scale_from_motor(
*,
motor_radial_diameter_mm: float,
motor_axis_length_mm: float,
radial_clearance_mm: float = 14.0,
reducer_axial_allowance_mm: float = 42.0,
min_radial_scale: float = 0.45,
max_radial_scale: float = 1.50,
min_axial_scale: float = 0.55,
max_axial_scale: float = 1.80,
) -> HousingScale:
target_inner_radius = motor_radial_diameter_mm / 2.0 + radial_clearance_mm
radial_scale = target_inner_radius / INNER_WALL_RADIUS
target_height = motor_axis_length_mm + reducer_axial_allowance_mm
axial_scale = target_height / BASE_HEIGHT
return HousingScale(
radial_scale=max(min_radial_scale, min(max_radial_scale, radial_scale)),
axial_scale=max(min_axial_scale, min(max_axial_scale, axial_scale)),
source="computed_from_motor_bbox",
)
def _polar_points(radius: float, start_degrees: float, count: int):
return [
(
radius * math.cos(math.radians(start_degrees + 360.0 * index / count)),
radius * math.sin(math.radians(start_degrees + 360.0 * index / count)),
)
for index in range(count)
]
def _radial_point(radius: float, angle_degrees: float, z: float) -> cq.Vector:
angle = math.radians(angle_degrees)
return cq.Vector(radius * math.cos(angle), radius * math.sin(angle), z)
def _radial_direction(angle_degrees: float) -> cq.Vector:
angle = math.radians(angle_degrees)
return cq.Vector(math.cos(angle), math.sin(angle), 0.0)
def build_body(scale: HousingScale = HousingScale()):
rd = scale.radial_scale
zd = scale.axial_scale
def r(value: float) -> float:
return value * rd
def z(value: float) -> float:
return value * zd
# Exact circular-arc midpoints for the two revolved outer blends.
lower_arc_mid = (r(78.8397459621556), z(-8.66025403784439))
upper_arc_mid = (r(83.1458565330652), z(71.9211556282713))
radial_section = (
cq.Workplane("XZ")
.moveTo(r(INNER_BOTTOM_RADIUS), z(-18.0))
.lineTo(r(BOTTOM_OUTER_RADIUS), z(-18.0))
.lineTo(r(BOTTOM_OUTER_RADIUS), z(-13.6602540378))
.threePointArc(lower_arc_mid, (r(BODY_OUTER_RADIUS), z(-5.0)))
.lineTo(r(BODY_OUTER_RADIUS), z(68.3856217223))
.threePointArc(upper_arc_mid, (r(TOP_OUTER_RADIUS), z(75.0)))
.lineTo(r(TOP_OUTER_RADIUS), z(92.0))
.lineTo(r(INNER_WALL_RADIUS), z(92.0))
.lineTo(r(INNER_WALL_RADIUS), z(0.0))
.lineTo(r(INNER_SHOULDER_RADIUS), z(0.0))
.lineTo(r(INNER_SHOULDER_RADIUS), z(-3.0))
.lineTo(r(INNER_STEP_RADIUS), z(-3.0))
.lineTo(r(INNER_STEP_RADIUS), z(-5.0))
.lineTo(r(INNER_BOTTOM_RADIUS), z(-5.0))
.close()
)
return radial_section.revolve(360.0, (0.0, 0.0), (0.0, 1.0))
def build(scale: HousingScale = HousingScale()):
rd = scale.radial_scale
zd = scale.axial_scale
def r(value: float) -> float:
return value * rd
def z(value: float) -> float:
return value * zd
part = build_body(scale)
# Ten Ø5 blind drilled holes, equally spaced on a Ø135 pitch circle.
drill_tip_height = z(1.5021515475689)
for x, y in _polar_points(r(67.5), 18.0, 10):
straight_hole = cq.Solid.makeCylinder(
r(2.5), z(10.0), cq.Vector(x, y, z(-18.0)), cq.Vector(0.0, 0.0, 1.0)
)
drill_tip = cq.Solid.makeCone(
r(2.5),
0.0,
drill_tip_height,
cq.Vector(x, y, z(-8.0)),
cq.Vector(0.0, 0.0, 1.0),
)
part = part.cut(straight_hole).cut(drill_tip)
# Four Ø2.5 blind holes opening at z=0, with 118-degree drill tips.
small_tip_height = z(0.75107577378445)
for x, y in _polar_points(r(71.875), 0.0, 4):
straight_hole = cq.Solid.makeCylinder(
r(1.25), z(4.24892422621555), cq.Vector(x, y, z(0.0)), cq.Vector(0.0, 0.0, -1.0)
)
drill_tip = cq.Solid.makeCone(
r(1.25),
0.0,
small_tip_height,
cq.Vector(x, y, z(-4.24892422621555)),
cq.Vector(0.0, 0.0, -1.0),
)
part = part.cut(straight_hole).cut(drill_tip)
# Eight radial Ø6.6 holes with 90-degree countersinks around the top rim.
for angle in range(0, 360, 45):
direction = _radial_direction(float(angle))
straight_hole = cq.Solid.makeCylinder(
r(3.3), r(3.9), _radial_point(r(78.0), float(angle), z(85.1)), direction
)
countersink = cq.Solid.makeCone(
r(3.3),
r(6.4),
r(3.1),
_radial_point(r(81.9), float(angle), z(85.1)),
direction,
)
part = part.cut(straight_hole).cut(countersink)
return part
def export_scaled(output: Path, scale: HousingScale) -> None:
cq.exporters.export(build(scale), str(output))
if __name__ == "__main__":
output = Path(__file__).resolve().with_name("gear_housing.step")
export_scaled(output, HousingScale())
print(output)
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
{
"module_type": "planetary_joint_module",
"motor_step": "../../assets/motors/3500_motor_part2.step",
"housing_step": "../../assets/housings/generated_housing_baseline.step",
"reducer_requirement": "../reducers/simple_2k_h/simple_2kh_industrial_ratio_7p5_spur.json",
"placement_policy": "infer_then_write_editable_json",
"motor_to_reducer_relation": "motor_output_to_sun_keyed_input",
"housing_relation": "ring_fixed_to_housing",
"joint_output": "carrier_output_shaft",
"output_extension_mm": 8.0,
"coupling_clearance_mm": 1.0,
"input_shaft_insertion_depth_mm": 4.0,
"motor_output_side": "axis_max",
"axis_angle_tolerance_deg": 0.5,
"axis_distance_tolerance_mm": 0.25,
"volume_tolerance_mm3": 0.000001
}
@@ -0,0 +1,17 @@
{
"module_type": "planetary_joint_module",
"motor_step": "../../assets/motors/3500_motor_part2.step",
"housing_step": "../../assets/housings/generated_housing_baseline.step",
"reducer_requirement": "../reducers/simple_2k_h_cascade/simple_2kh_cascade_ratio_9_spur.json",
"placement_policy": "infer_then_write_editable_json",
"motor_to_reducer_relation": "motor_output_to_s1_sun_input",
"housing_relation": "stage_rings_fixed_to_housing",
"joint_output": "s2_carrier_output",
"output_extension_mm": 2.0,
"coupling_clearance_mm": 0.5,
"input_shaft_insertion_depth_mm": 1.0,
"motor_output_side": "axis_max",
"axis_angle_tolerance_deg": 0.5,
"axis_distance_tolerance_mm": 0.25,
"volume_tolerance_mm3": 0.000001
}
@@ -0,0 +1,26 @@
{
"target_ratio": 531.25,
"ratio_tolerance": 0.001,
"topology_family": "ferguson_wolfrom",
"detail_level": "kinematic_demo",
"input": "planet1",
"fixed": "static_carrier",
"output": "output_carrier",
"tooth_form": "spur",
"planet_count_candidates": [3],
"module_candidates_mm": [0.25],
"pressure_angle_deg": 20.0,
"face_width_mm": 6.0,
"backlash_mm": 0.03,
"addendum_coeff": 1.0,
"clearance_coeff": 0.25,
"ring_rim_thickness_mm": 2.5,
"gear_tooth_counts": {
"ring1": 150,
"sun1": 102,
"planet1": 24,
"ring2": 144,
"sun2": 96,
"planet2": 24
}
}
@@ -0,0 +1,25 @@
{
"target_ratio": 1.5,
"ratio_tolerance": 0.000001,
"topology_family": "simple_2k_h",
"input": "ring",
"fixed": "sun",
"output": "carrier",
"tooth_form": "spur",
"planet_count_candidates": [3],
"module_candidates_mm": [0.5],
"pressure_angle_deg": 20.0,
"face_width_mm": 8.0,
"backlash_mm": 0.03,
"addendum_coeff": 1.0,
"clearance_coeff": 0.25,
"ring_rim_thickness_mm": 2.5,
"constraints": {
"max_outer_diameter_mm": 100.0,
"min_planet_neighbor_clearance_mm": 0.3,
"min_sun_teeth": 36,
"max_sun_teeth": 36,
"min_planet_teeth": 18,
"max_planet_teeth": 18
}
}
@@ -0,0 +1,25 @@
{
"target_ratio": 0.3333333333333333,
"ratio_tolerance": 0.000001,
"topology_family": "simple_2k_h",
"input": "carrier",
"fixed": "ring",
"output": "sun",
"tooth_form": "spur",
"planet_count_candidates": [3],
"module_candidates_mm": [0.5],
"pressure_angle_deg": 20.0,
"face_width_mm": 8.0,
"backlash_mm": 0.03,
"addendum_coeff": 1.0,
"clearance_coeff": 0.25,
"ring_rim_thickness_mm": 2.5,
"constraints": {
"max_outer_diameter_mm": 100.0,
"min_planet_neighbor_clearance_mm": 0.3,
"min_sun_teeth": 36,
"max_sun_teeth": 36,
"min_planet_teeth": 18,
"max_planet_teeth": 18
}
}
@@ -0,0 +1,27 @@
{
"target_ratio": 2.0,
"ratio_tolerance": 0.000001,
"topology_family": "simple_2k_h",
"input": "sun",
"fixed": "carrier",
"output": "ring",
"tooth_form": "helical",
"helix_angle_deg": 15.0,
"helix_hand": "right",
"planet_count_candidates": [3],
"module_candidates_mm": [0.5],
"pressure_angle_deg": 20.0,
"face_width_mm": 8.0,
"backlash_mm": 0.03,
"addendum_coeff": 1.0,
"clearance_coeff": 0.25,
"ring_rim_thickness_mm": 2.5,
"constraints": {
"max_outer_diameter_mm": 100.0,
"min_planet_neighbor_clearance_mm": 0.3,
"min_sun_teeth": 36,
"max_sun_teeth": 36,
"min_planet_teeth": 18,
"max_planet_teeth": 18
}
}
@@ -0,0 +1,25 @@
{
"target_ratio": 2.0,
"ratio_tolerance": 0.000001,
"topology_family": "simple_2k_h",
"input": "sun",
"fixed": "carrier",
"output": "ring",
"tooth_form": "spur",
"planet_count_candidates": [3],
"module_candidates_mm": [0.5],
"pressure_angle_deg": 20.0,
"face_width_mm": 8.0,
"backlash_mm": 0.03,
"addendum_coeff": 1.0,
"clearance_coeff": 0.25,
"ring_rim_thickness_mm": 2.5,
"constraints": {
"max_outer_diameter_mm": 100.0,
"min_planet_neighbor_clearance_mm": 0.3,
"min_sun_teeth": 36,
"max_sun_teeth": 36,
"min_planet_teeth": 18,
"max_planet_teeth": 18
}
}
@@ -0,0 +1,28 @@
{
"target_ratio": 7.5,
"ratio_tolerance": 0.01,
"topology_family": "simple_2k_h",
"detail_level": "industrial_core",
"input": "sun",
"fixed": "ring",
"output": "carrier",
"tooth_form": "helical",
"helix_angle_deg": 15.0,
"helix_hand": "right",
"planet_count_candidates": [3],
"module_candidates_mm": [0.5],
"pressure_angle_deg": 20.0,
"face_width_mm": 8.0,
"backlash_mm": 0.03,
"addendum_coeff": 1.0,
"clearance_coeff": 0.25,
"ring_rim_thickness_mm": 2.5,
"constraints": {
"max_outer_diameter_mm": 100.0,
"min_planet_neighbor_clearance_mm": 0.3,
"min_sun_teeth": 20,
"max_sun_teeth": 20,
"min_planet_teeth": 55,
"max_planet_teeth": 55
}
}
@@ -0,0 +1,26 @@
{
"target_ratio": 7.5,
"ratio_tolerance": 0.01,
"topology_family": "simple_2k_h",
"detail_level": "industrial_core",
"input": "sun",
"fixed": "ring",
"output": "carrier",
"tooth_form": "spur",
"planet_count_candidates": [3],
"module_candidates_mm": [0.5],
"pressure_angle_deg": 20.0,
"face_width_mm": 8.0,
"backlash_mm": 0.03,
"addendum_coeff": 1.0,
"clearance_coeff": 0.25,
"ring_rim_thickness_mm": 2.5,
"constraints": {
"max_outer_diameter_mm": 100.0,
"min_planet_neighbor_clearance_mm": 0.3,
"min_sun_teeth": 20,
"max_sun_teeth": 20,
"min_planet_teeth": 55,
"max_planet_teeth": 55
}
}
@@ -0,0 +1,25 @@
{
"target_ratio": 10.0,
"ratio_tolerance": 0.01,
"topology_family": "simple_2k_h",
"input": "sun",
"fixed": "ring",
"output": "carrier",
"tooth_form": "spur",
"planet_count_candidates": [3, 4, 5],
"module_candidates_mm": [0.5, 0.75, 1.0, 1.25, 1.5],
"pressure_angle_deg": 20.0,
"face_width_mm": 8.0,
"backlash_mm": 0.03,
"addendum_coeff": 1.0,
"clearance_coeff": 0.25,
"ring_rim_thickness_mm": 2.5,
"constraints": {
"max_outer_diameter_mm": 100.0,
"min_planet_neighbor_clearance_mm": 0.3,
"min_sun_teeth": 18,
"max_sun_teeth": 80,
"min_planet_teeth": 18,
"max_planet_teeth": 160
}
}
@@ -0,0 +1,25 @@
{
"target_ratio": 2.5,
"ratio_tolerance": 0.01,
"topology_family": "simple_2k_h",
"input": "sun",
"fixed": "ring",
"output": "carrier",
"tooth_form": "spur",
"planet_count_candidates": [3, 4, 5],
"module_candidates_mm": [0.5, 0.75, 1.0, 1.25, 1.5],
"pressure_angle_deg": 20.0,
"face_width_mm": 8.0,
"backlash_mm": 0.03,
"addendum_coeff": 1.0,
"clearance_coeff": 0.25,
"ring_rim_thickness_mm": 2.5,
"constraints": {
"max_outer_diameter_mm": 100.0,
"min_planet_neighbor_clearance_mm": 0.3,
"min_sun_teeth": 18,
"max_sun_teeth": 80,
"min_planet_teeth": 18,
"max_planet_teeth": 160
}
}
@@ -0,0 +1,25 @@
{
"target_ratio": 3.0,
"ratio_tolerance": 0.01,
"topology_family": "simple_2k_h",
"input": "sun",
"fixed": "ring",
"output": "carrier",
"tooth_form": "spur",
"planet_count_candidates": [3, 4, 5],
"module_candidates_mm": [0.5, 0.75, 1.0, 1.25, 1.5],
"pressure_angle_deg": 20.0,
"face_width_mm": 8.0,
"backlash_mm": 0.03,
"addendum_coeff": 1.0,
"clearance_coeff": 0.25,
"ring_rim_thickness_mm": 2.5,
"constraints": {
"max_outer_diameter_mm": 100.0,
"min_planet_neighbor_clearance_mm": 0.3,
"min_sun_teeth": 18,
"max_sun_teeth": 80,
"min_planet_teeth": 18,
"max_planet_teeth": 160
}
}
@@ -0,0 +1,27 @@
{
"target_ratio": 3.0,
"ratio_tolerance": 0.000001,
"topology_family": "simple_2k_h",
"input": "sun",
"fixed": "ring",
"output": "carrier",
"tooth_form": "helical",
"helix_angle_deg": 15.0,
"helix_hand": "right",
"planet_count_candidates": [3],
"module_candidates_mm": [0.5],
"pressure_angle_deg": 20.0,
"face_width_mm": 8.0,
"backlash_mm": 0.03,
"addendum_coeff": 1.0,
"clearance_coeff": 0.25,
"ring_rim_thickness_mm": 2.5,
"constraints": {
"max_outer_diameter_mm": 100.0,
"min_planet_neighbor_clearance_mm": 0.3,
"min_sun_teeth": 36,
"max_sun_teeth": 36,
"min_planet_teeth": 18,
"max_planet_teeth": 18
}
}
@@ -0,0 +1,25 @@
{
"target_ratio": 3.5,
"ratio_tolerance": 0.01,
"topology_family": "simple_2k_h",
"input": "sun",
"fixed": "ring",
"output": "carrier",
"tooth_form": "spur",
"planet_count_candidates": [3, 4, 5],
"module_candidates_mm": [0.5, 0.75, 1.0, 1.25, 1.5],
"pressure_angle_deg": 20.0,
"face_width_mm": 8.0,
"backlash_mm": 0.03,
"addendum_coeff": 1.0,
"clearance_coeff": 0.25,
"ring_rim_thickness_mm": 2.5,
"constraints": {
"max_outer_diameter_mm": 100.0,
"min_planet_neighbor_clearance_mm": 0.3,
"min_sun_teeth": 18,
"max_sun_teeth": 80,
"min_planet_teeth": 18,
"max_planet_teeth": 160
}
}
@@ -0,0 +1,25 @@
{
"target_ratio": 4.0,
"ratio_tolerance": 0.01,
"topology_family": "simple_2k_h",
"input": "sun",
"fixed": "ring",
"output": "carrier",
"tooth_form": "spur",
"planet_count_candidates": [3, 4, 5],
"module_candidates_mm": [0.5, 0.75, 1.0, 1.25, 1.5],
"pressure_angle_deg": 20.0,
"face_width_mm": 8.0,
"backlash_mm": 0.03,
"addendum_coeff": 1.0,
"clearance_coeff": 0.25,
"ring_rim_thickness_mm": 2.5,
"constraints": {
"max_outer_diameter_mm": 100.0,
"min_planet_neighbor_clearance_mm": 0.3,
"min_sun_teeth": 18,
"max_sun_teeth": 80,
"min_planet_teeth": 18,
"max_planet_teeth": 160
}
}
@@ -0,0 +1,25 @@
{
"target_ratio": 5.0,
"ratio_tolerance": 0.01,
"topology_family": "simple_2k_h",
"input": "sun",
"fixed": "ring",
"output": "carrier",
"tooth_form": "spur",
"planet_count_candidates": [3, 4, 5],
"module_candidates_mm": [0.5, 0.75, 1.0, 1.25, 1.5],
"pressure_angle_deg": 20.0,
"face_width_mm": 8.0,
"backlash_mm": 0.03,
"addendum_coeff": 1.0,
"clearance_coeff": 0.25,
"ring_rim_thickness_mm": 2.5,
"constraints": {
"max_outer_diameter_mm": 100.0,
"min_planet_neighbor_clearance_mm": 0.3,
"min_sun_teeth": 18,
"max_sun_teeth": 80,
"min_planet_teeth": 18,
"max_planet_teeth": 160
}
}
@@ -0,0 +1,25 @@
{
"target_ratio": 6.0,
"ratio_tolerance": 0.01,
"topology_family": "simple_2k_h",
"input": "sun",
"fixed": "ring",
"output": "carrier",
"tooth_form": "spur",
"planet_count_candidates": [3, 4, 5],
"module_candidates_mm": [0.5, 0.75, 1.0, 1.25, 1.5],
"pressure_angle_deg": 20.0,
"face_width_mm": 8.0,
"backlash_mm": 0.03,
"addendum_coeff": 1.0,
"clearance_coeff": 0.25,
"ring_rim_thickness_mm": 2.5,
"constraints": {
"max_outer_diameter_mm": 100.0,
"min_planet_neighbor_clearance_mm": 0.3,
"min_sun_teeth": 18,
"max_sun_teeth": 80,
"min_planet_teeth": 18,
"max_planet_teeth": 160
}
}
@@ -0,0 +1,25 @@
{
"target_ratio": 7.5,
"ratio_tolerance": 0.01,
"topology_family": "simple_2k_h",
"input": "sun",
"fixed": "ring",
"output": "carrier",
"tooth_form": "spur",
"planet_count_candidates": [3, 4, 5],
"module_candidates_mm": [0.5, 0.75, 1.0, 1.25, 1.5],
"pressure_angle_deg": 20.0,
"face_width_mm": 8.0,
"backlash_mm": 0.03,
"addendum_coeff": 1.0,
"clearance_coeff": 0.25,
"ring_rim_thickness_mm": 2.5,
"constraints": {
"max_outer_diameter_mm": 100.0,
"min_planet_neighbor_clearance_mm": 0.3,
"min_sun_teeth": 18,
"max_sun_teeth": 80,
"min_planet_teeth": 18,
"max_planet_teeth": 160
}
}
@@ -0,0 +1,25 @@
{
"target_ratio": 8.0,
"ratio_tolerance": 0.01,
"topology_family": "simple_2k_h",
"input": "sun",
"fixed": "ring",
"output": "carrier",
"tooth_form": "spur",
"planet_count_candidates": [3, 4, 5],
"module_candidates_mm": [0.5, 0.75, 1.0, 1.25, 1.5],
"pressure_angle_deg": 20.0,
"face_width_mm": 8.0,
"backlash_mm": 0.03,
"addendum_coeff": 1.0,
"clearance_coeff": 0.25,
"ring_rim_thickness_mm": 2.5,
"constraints": {
"max_outer_diameter_mm": 100.0,
"min_planet_neighbor_clearance_mm": 0.3,
"min_sun_teeth": 18,
"max_sun_teeth": 80,
"min_planet_teeth": 18,
"max_planet_teeth": 160
}
}
@@ -0,0 +1,44 @@
{
"target_ratio": 6.0,
"ratio_tolerance": 0.000001,
"topology_family": "simple_2k_h_cascade",
"tooth_form": "spur",
"planet_count_candidates": [3],
"module_candidates_mm": [0.5],
"pressure_angle_deg": 20.0,
"face_width_mm": 8.0,
"backlash_mm": 0.03,
"addendum_coeff": 1.0,
"clearance_coeff": 0.25,
"ring_rim_thickness_mm": 2.5,
"constraints": {
"max_outer_diameter_mm": 100.0,
"min_planet_neighbor_clearance_mm": 0.3,
"min_sun_teeth": 36,
"max_sun_teeth": 36,
"min_planet_teeth": 18,
"max_planet_teeth": 18
},
"stages": [
{
"stage_id": "s1",
"input": "sun",
"fixed": "ring",
"output": "carrier",
"planet_count_candidates": [3],
"module_candidates_mm": [0.5]
},
{
"stage_id": "s2",
"input": "sun",
"fixed": "carrier",
"output": "ring",
"planet_count_candidates": [3],
"module_candidates_mm": [0.5]
}
],
"cascade": {
"coupling_policy": "stage_output_to_next_stage_input",
"stage_gap_mm": 2.0
}
}
@@ -0,0 +1,46 @@
{
"target_ratio": 9.0,
"ratio_tolerance": 0.000001,
"topology_family": "simple_2k_h_cascade",
"tooth_form": "helical",
"helix_angle_deg": 15.0,
"helix_hand": "right",
"planet_count_candidates": [3],
"module_candidates_mm": [0.5],
"pressure_angle_deg": 20.0,
"face_width_mm": 8.0,
"backlash_mm": 0.03,
"addendum_coeff": 1.0,
"clearance_coeff": 0.25,
"ring_rim_thickness_mm": 2.5,
"constraints": {
"max_outer_diameter_mm": 100.0,
"min_planet_neighbor_clearance_mm": 0.3,
"min_sun_teeth": 36,
"max_sun_teeth": 36,
"min_planet_teeth": 18,
"max_planet_teeth": 18
},
"stages": [
{
"stage_id": "s1",
"input": "sun",
"fixed": "ring",
"output": "carrier",
"planet_count_candidates": [3],
"module_candidates_mm": [0.5]
},
{
"stage_id": "s2",
"input": "sun",
"fixed": "ring",
"output": "carrier",
"planet_count_candidates": [3],
"module_candidates_mm": [0.5]
}
],
"cascade": {
"coupling_policy": "stage_output_to_next_stage_input",
"stage_gap_mm": 2.0
}
}
@@ -0,0 +1,44 @@
{
"target_ratio": 9.0,
"ratio_tolerance": 0.000001,
"topology_family": "simple_2k_h_cascade",
"tooth_form": "spur",
"planet_count_candidates": [3],
"module_candidates_mm": [0.5],
"pressure_angle_deg": 20.0,
"face_width_mm": 8.0,
"backlash_mm": 0.03,
"addendum_coeff": 1.0,
"clearance_coeff": 0.25,
"ring_rim_thickness_mm": 2.5,
"constraints": {
"max_outer_diameter_mm": 100.0,
"min_planet_neighbor_clearance_mm": 0.3,
"min_sun_teeth": 36,
"max_sun_teeth": 36,
"min_planet_teeth": 18,
"max_planet_teeth": 18
},
"stages": [
{
"stage_id": "s1",
"input": "sun",
"fixed": "ring",
"output": "carrier",
"planet_count_candidates": [3],
"module_candidates_mm": [0.5]
},
{
"stage_id": "s2",
"input": "sun",
"fixed": "ring",
"output": "carrier",
"planet_count_candidates": [3],
"module_candidates_mm": [0.5]
}
],
"cascade": {
"coupling_policy": "stage_output_to_next_stage_input",
"stage_gap_mm": 2.0
}
}
+190
View File
@@ -0,0 +1,190 @@
from dataclasses import dataclass
from pathlib import Path
import math
import cadquery as cq
# All dimensions are millimetres. The main body is a revolved radial section.
INNER_BOTTOM_RADIUS = 60.0
INNER_STEP_RADIUS = 57.5
INNER_SHOULDER_RADIUS = 70.0
INNER_WALL_RADIUS = 79.0
BOTTOM_OUTER_RADIUS = 77.5
BODY_OUTER_RADIUS = 82.5
TOP_OUTER_RADIUS = 85.0
BASE_MIN_Z = -18.0
BASE_MAX_Z = 92.0
BASE_HEIGHT = BASE_MAX_Z - BASE_MIN_Z
@dataclass(frozen=True)
class HousingScale:
radial_scale: float = 1.0
axial_scale: float = 1.0
source: str = "default_template_scale"
def scaled_dimensions(scale: HousingScale) -> dict[str, float]:
return {
"inner_bottom_radius": INNER_BOTTOM_RADIUS * scale.radial_scale,
"inner_step_radius": INNER_STEP_RADIUS * scale.radial_scale,
"inner_shoulder_radius": INNER_SHOULDER_RADIUS * scale.radial_scale,
"inner_wall_radius": INNER_WALL_RADIUS * scale.radial_scale,
"bottom_outer_radius": BOTTOM_OUTER_RADIUS * scale.radial_scale,
"body_outer_radius": BODY_OUTER_RADIUS * scale.radial_scale,
"top_outer_radius": TOP_OUTER_RADIUS * scale.radial_scale,
"min_z": BASE_MIN_Z * scale.axial_scale,
"max_z": BASE_MAX_Z * scale.axial_scale,
"height": BASE_HEIGHT * scale.axial_scale,
"radial_scale": scale.radial_scale,
"axial_scale": scale.axial_scale,
"source": scale.source,
}
def scale_from_motor(
*,
motor_radial_diameter_mm: float,
motor_axis_length_mm: float,
radial_clearance_mm: float = 14.0,
reducer_axial_allowance_mm: float = 42.0,
min_radial_scale: float = 0.45,
max_radial_scale: float = 1.50,
min_axial_scale: float = 0.55,
max_axial_scale: float = 1.80,
) -> HousingScale:
target_inner_radius = motor_radial_diameter_mm / 2.0 + radial_clearance_mm
radial_scale = target_inner_radius / INNER_WALL_RADIUS
target_height = motor_axis_length_mm + reducer_axial_allowance_mm
axial_scale = target_height / BASE_HEIGHT
return HousingScale(
radial_scale=max(min_radial_scale, min(max_radial_scale, radial_scale)),
axial_scale=max(min_axial_scale, min(max_axial_scale, axial_scale)),
source="computed_from_motor_bbox",
)
def _polar_points(radius: float, start_degrees: float, count: int):
return [
(
radius * math.cos(math.radians(start_degrees + 360.0 * index / count)),
radius * math.sin(math.radians(start_degrees + 360.0 * index / count)),
)
for index in range(count)
]
def _radial_point(radius: float, angle_degrees: float, z: float) -> cq.Vector:
angle = math.radians(angle_degrees)
return cq.Vector(radius * math.cos(angle), radius * math.sin(angle), z)
def _radial_direction(angle_degrees: float) -> cq.Vector:
angle = math.radians(angle_degrees)
return cq.Vector(math.cos(angle), math.sin(angle), 0.0)
def build_body(scale: HousingScale = HousingScale()):
rd = scale.radial_scale
zd = scale.axial_scale
def r(value: float) -> float:
return value * rd
def z(value: float) -> float:
return value * zd
# Exact circular-arc midpoints for the two revolved outer blends.
lower_arc_mid = (r(78.8397459621556), z(-8.66025403784439))
upper_arc_mid = (r(83.1458565330652), z(71.9211556282713))
radial_section = (
cq.Workplane("XZ")
.moveTo(r(INNER_BOTTOM_RADIUS), z(-18.0))
.lineTo(r(BOTTOM_OUTER_RADIUS), z(-18.0))
.lineTo(r(BOTTOM_OUTER_RADIUS), z(-13.6602540378))
.threePointArc(lower_arc_mid, (r(BODY_OUTER_RADIUS), z(-5.0)))
.lineTo(r(BODY_OUTER_RADIUS), z(68.3856217223))
.threePointArc(upper_arc_mid, (r(TOP_OUTER_RADIUS), z(75.0)))
.lineTo(r(TOP_OUTER_RADIUS), z(92.0))
.lineTo(r(INNER_WALL_RADIUS), z(92.0))
.lineTo(r(INNER_WALL_RADIUS), z(0.0))
.lineTo(r(INNER_SHOULDER_RADIUS), z(0.0))
.lineTo(r(INNER_SHOULDER_RADIUS), z(-3.0))
.lineTo(r(INNER_STEP_RADIUS), z(-3.0))
.lineTo(r(INNER_STEP_RADIUS), z(-5.0))
.lineTo(r(INNER_BOTTOM_RADIUS), z(-5.0))
.close()
)
return radial_section.revolve(360.0, (0.0, 0.0), (0.0, 1.0))
def build(scale: HousingScale = HousingScale()):
rd = scale.radial_scale
zd = scale.axial_scale
def r(value: float) -> float:
return value * rd
def z(value: float) -> float:
return value * zd
part = build_body(scale)
# Ten Ø5 blind drilled holes, equally spaced on a Ø135 pitch circle.
drill_tip_height = z(1.5021515475689)
for x, y in _polar_points(r(67.5), 18.0, 10):
straight_hole = cq.Solid.makeCylinder(
r(2.5), z(10.0), cq.Vector(x, y, z(-18.0)), cq.Vector(0.0, 0.0, 1.0)
)
drill_tip = cq.Solid.makeCone(
r(2.5),
0.0,
drill_tip_height,
cq.Vector(x, y, z(-8.0)),
cq.Vector(0.0, 0.0, 1.0),
)
part = part.cut(straight_hole).cut(drill_tip)
# Four Ø2.5 blind holes opening at z=0, with 118-degree drill tips.
small_tip_height = z(0.75107577378445)
for x, y in _polar_points(r(71.875), 0.0, 4):
straight_hole = cq.Solid.makeCylinder(
r(1.25), z(4.24892422621555), cq.Vector(x, y, z(0.0)), cq.Vector(0.0, 0.0, -1.0)
)
drill_tip = cq.Solid.makeCone(
r(1.25),
0.0,
small_tip_height,
cq.Vector(x, y, z(-4.24892422621555)),
cq.Vector(0.0, 0.0, -1.0),
)
part = part.cut(straight_hole).cut(drill_tip)
# Eight radial Ø6.6 holes with 90-degree countersinks around the top rim.
for angle in range(0, 360, 45):
direction = _radial_direction(float(angle))
straight_hole = cq.Solid.makeCylinder(
r(3.3), r(3.9), _radial_point(r(78.0), float(angle), z(85.1)), direction
)
countersink = cq.Solid.makeCone(
r(3.3),
r(6.4),
r(3.1),
_radial_point(r(81.9), float(angle), z(85.1)),
direction,
)
part = part.cut(straight_hole).cut(countersink)
return part
def export_scaled(output: Path, scale: HousingScale) -> None:
cq.exporters.export(build(scale), str(output))
if __name__ == "__main__":
output = Path(__file__).resolve().with_name("gear_housing.step")
export_scaled(output, HousingScale())
print(output)
+255
View File
@@ -0,0 +1,255 @@
from pathlib import Path
import math
import cadquery as cq
AXIS = cq.Vector(0.0, 1.0, 0.0)
def _point_on_xz(radius: float, angle_degrees: float, y: float) -> cq.Vector:
angle = math.radians(angle_degrees)
return cq.Vector(radius * math.cos(angle), y, radius * math.sin(angle))
def _cylinder(radius: float, y0: float, length: float, x: float = 0.0, z: float = 0.0):
return cq.Solid.makeCylinder(radius, length, cq.Vector(x, y0, z), AXIS)
def _ring(outer_radius: float, inner_radius: float, y0: float, length: float):
return _cylinder(outer_radius, y0, length).cut(_cylinder(inner_radius, y0, length))
def _conical_ring(
outer_start: float,
outer_end: float,
inner_start: float,
inner_end: float,
y0: float,
length: float,
):
outer = cq.Solid.makeCone(outer_start, outer_end, length, cq.Vector(0.0, y0, 0.0), AXIS)
inner = cq.Solid.makeCone(inner_start, inner_end, length, cq.Vector(0.0, y0, 0.0), AXIS)
return outer.cut(inner)
def _sector_cut(angle_center: float, angle_width: float, y0: float, length: float, radius: float = 100.0):
start = math.radians(angle_center - angle_width / 2.0)
end = math.radians(angle_center + angle_width / 2.0)
points = [
(0.0, 0.0),
(radius * math.cos(start), radius * math.sin(start)),
(radius * math.cos(end), radius * math.sin(end)),
]
plane = cq.Plane(origin=(0.0, y0, 0.0), xDir=(1.0, 0.0, 0.0), normal=(0.0, 1.0, 0.0))
return cq.Workplane(plane).polyline(points).close().extrude(length).val()
def _annular_sector_cut(
angle_center: float,
angle_width: float,
inner_radius: float,
outer_radius: float,
y0: float,
length: float,
profile_fillet_radius: float = 0.0,
axial_fillet_radius: float = 0.0,
):
start = math.radians(angle_center - angle_width / 2.0)
middle = math.radians(angle_center)
end = math.radians(angle_center + angle_width / 2.0)
inner_start = (inner_radius * math.cos(start), inner_radius * math.sin(start))
inner_middle = (inner_radius * math.cos(middle), inner_radius * math.sin(middle))
inner_end = (inner_radius * math.cos(end), inner_radius * math.sin(end))
outer_start = (outer_radius * math.cos(start), outer_radius * math.sin(start))
outer_middle = (outer_radius * math.cos(middle), outer_radius * math.sin(middle))
outer_end = (outer_radius * math.cos(end), outer_radius * math.sin(end))
plane = cq.Plane(origin=(0.0, y0, 0.0), xDir=(1.0, 0.0, 0.0), normal=(0.0, 1.0, 0.0))
cutter = (
cq.Workplane(plane)
.moveTo(*inner_start)
.lineTo(*outer_start)
.threePointArc(outer_middle, outer_end)
.lineTo(*inner_end)
.threePointArc(inner_middle, inner_start)
.close()
.extrude(length)
)
if profile_fillet_radius > 0.0:
# Round the corners of the annular profile (fillet axes parallel to Y).
cutter = cutter.edges("|Y").fillet(profile_fillet_radius)
if axial_fillet_radius > 0.0:
# Round only the straight radial edges at the two axial ends. This
# produces the R10/R5/R2 window run-outs present in the source STEP.
end_edges = [
edge
for edge in cutter.edges("%LINE").vals()
if abs(edge.Center().y - y0) < 1.0e-6
or abs(edge.Center().y - (y0 + length)) < 1.0e-6
]
cutter = cutter.newObject(end_edges).fillet(axial_fillet_radius)
return cutter.val()
def _radial_bar(angle_degrees: float, y0: float, length: float, inner_radius: float, outer_radius: float, width: float):
radial_length = outer_radius - inner_radius
bar = (
cq.Workplane("XY")
.box(radial_length, length, width)
.translate(((inner_radius + outer_radius) / 2.0, y0 + length / 2.0, 0.0))
)
return bar.rotate((0.0, 0.0, 0.0), (0.0, 1.0, 0.0), -angle_degrees).val()
def _rounded_radial_slot(angle_degrees: float, y0: float, length: float):
"""A 40 mm wide radial window with R2 corners in its axial section."""
plane = cq.Plane(
origin=(40.0, y0 + length / 2.0, 0.0),
xDir=(0.0, 1.0, 0.0),
normal=(1.0, 0.0, 0.0),
)
slot = (
cq.Workplane(plane)
.rect(length - 4.0, 36.0)
.offset2D(2.0)
.extrude(42.0)
.rotate((0.0, 0.0, 0.0), (0.0, 1.0, 0.0), -angle_degrees)
)
return slot.val()
def _support_blend(angle_degrees: float, mirrored: bool):
"""One of the six R2 blends between the R64 and R29.2 support arcs."""
sharp = (-62.80715975198, 12.298808230444)
inner_tangent = (-62.1855700706093, 15.131254904777)
lobe_tangent = (-63.6707499640005, 16.8512614004542)
inner_center = (0.0, 0.0)
blend_center = (-61.6824120316291, 17.0669284278203)
lobe_center = (-34.6410161514, 20.0)
def transform(point):
x, z = point
if mirrored:
axis = math.radians(150.0)
ux, uz = math.cos(axis), math.sin(axis)
projection = x * ux + z * uz
x, z = 2.0 * projection * ux - x, 2.0 * projection * uz - z
angle = math.radians(angle_degrees)
return (
x * math.cos(angle) - z * math.sin(angle),
x * math.sin(angle) + z * math.cos(angle),
)
def arc_middle(center, start, end, radius):
ux = (start[0] - center[0]) / radius + (end[0] - center[0]) / radius
uz = (start[1] - center[1]) / radius + (end[1] - center[1]) / radius
scale = radius / math.hypot(ux, uz)
return center[0] + ux * scale, center[1] + uz * scale
points = [transform(point) for point in (sharp, inner_tangent, lobe_tangent)]
centers = [transform(point) for point in (inner_center, blend_center, lobe_center)]
plane = cq.Plane(origin=(0.0, 90.2, 0.0), xDir=(1.0, 0.0, 0.0), normal=(0.0, -1.0, 0.0))
return (
cq.Workplane(plane)
.moveTo(*points[0])
.threePointArc(arc_middle(centers[0], points[0], points[1], 64.0), points[1])
.threePointArc(arc_middle(centers[1], points[1], points[2], 2.0), points[2])
.threePointArc(arc_middle(centers[2], points[2], points[0], 29.2), points[0])
.close()
.extrude(10.0)
.val()
)
def build():
# Front cylindrical shell and its smaller continuation.
part = cq.Workplane(obj=_ring(79.4, 74.4, -10.0, 22.0))
part = part.union(_ring(78.4, 74.4, 12.0, 43.2))
# Nine radial R2 holes through the front lip, spaced every 40 degrees.
for angle in (90.0, 50.0, 10.0, -30.0, -70.0, -110.0, -150.0, -190.0, -230.0):
start = _point_on_xz(72.0, angle, -5.0)
direction = _point_on_xz(1.0, angle, 0.0)
direction = cq.Vector(direction.x, 0.0, direction.z)
part = part.cut(cq.Solid.makeCylinder(2.0, 10.0, start, direction))
# Six long ventilation windows, 40-degree openings with 20-degree ribs.
for center in (-110.0, -50.0, 10.0, 70.0, 130.0, 190.0):
part = part.cut(
_annular_sector_cut(center, 40.0, 70.0, 82.0, 12.0, 40.0, axial_fillet_radius=10.0)
)
# Taper from the large front shell to the narrower middle shell.
part = part.union(_conical_ring(78.4, 73.2, 74.4, 69.2, 55.2, 20.0))
part = part.union(_ring(73.2, 60.0, 75.2, 5.0))
# Three small axial holes in the first shoulder ring.
for angle in (30.0, 150.0, 270.0):
p = _point_on_xz(64.0, angle, 75.2)
part = part.cut(cq.Solid.makeCylinder(2.0, 5.0, p, AXIS))
# Three-lobed support plate: a disk with three large circular openings.
support = cq.Workplane(obj=_ring(73.2, 64.0, 80.2, 10.0))
for x, z in ((34.6410161514, 20.0), (-34.6410161514, 20.0), (0.0, -40.0)):
support = support.cut(_cylinder(29.2, 80.2, 10.0, x, z))
part = part.union(support)
# Thin middle shell and its second, angularly shifted six-window pattern.
part = part.union(_ring(73.2, 69.2, 90.2, 30.0))
for center in (-150.0, -90.0, -30.0, 30.0, 90.0, 150.0):
part = part.cut(
_annular_sector_cut(center, 40.0, 65.0, 76.0, 90.2, 20.0, axial_fillet_radius=5.0)
)
# Rear shell is one continuous 2:7 taper. Its inner cavity changes from
# a steep R69.2 -> R63.6 cone to the matching shallow taper at Y=125.8.
taper_outer = cq.Solid.makeCone(
73.2, 64.91428571428571, 29.0, cq.Vector(0.0, 120.2, 0.0), AXIS
)
taper_inner_a = cq.Solid.makeCone(69.2, 63.6, 5.6, cq.Vector(0.0, 120.2, 0.0), AXIS)
taper_inner_b = cq.Solid.makeCone(63.6, 59.2, 15.4, cq.Vector(0.0, 125.8, 0.0), AXIS)
part = part.union(taper_outer.cut(taper_inner_a).cut(taper_inner_b))
hub = cq.Workplane(obj=_ring(38.0, 26.0, 129.2, 4.0)).union(
_ring(38.0, 30.0, 133.2, 16.0)
)
part = part.union(hub)
# Stop 0.0001 mm before the coincident rear-plate face so OpenCascade
# keeps the six ribs in one valid solid.
for center in (30.0, 90.0, 150.0, 210.0, 270.0, 330.0):
part = part.cut(_rounded_radial_slot(center, 131.2, 9.9999))
# Central bore through the solid rear plate, then its six fan windows.
part = part.cut(_cylinder(30.0, 141.2, 8.0))
for center in (45.0, 105.0, 165.0, 225.0, 285.0, 345.0):
cutter = _annular_sector_cut(
center, 30.0, 38.0, 59.2, 141.2, 8.0, profile_fillet_radius=2.0
)
part = part.cut(cutter)
# These holes pass through both the end plate and the previously added hub.
for angle in (-90.0, -30.0, 30.0, 90.0, 150.0, 210.0):
p = _point_on_xz(34.0, angle, 141.2)
part = part.cut(cq.Solid.makeCylinder(1.81198, 8.0, p, AXIS))
# Add the small support blends last so they cannot interfere with fusing
# the face-connected rear shell and end plate.
for mirrored in (False, True):
for angle in (0.0, 120.0, 240.0):
part = part.union(_support_blend(angle, mirrored))
# Remove only the six 0.0001 mm run-out residuals after the complete part
# is connected. This reaches the exact Y=141.2 boundary without causing
# the earlier coincident-face split into six solids.
for center in (30.0, 90.0, 150.0, 210.0, 270.0, 330.0):
full_slot = _rounded_radial_slot(center, 131.2, 10.0)
stable_slot = _rounded_radial_slot(center, 131.2, 9.9999)
part = part.cut(full_slot.cut(stable_slot))
return part
if __name__ == "__main__":
output = Path(__file__).resolve().with_name("外壳框架.step")
cq.exporters.export(build(), str(output))
print(output)
File diff suppressed because one or more lines are too long
+19
View File
@@ -0,0 +1,19 @@
[project]
name = "planetary-reducer-system-demo"
version = "0.1.0"
description = "A real, parameter-driven simple planetary reducer formula/generation/validation demo."
requires-python = ">=3.10"
dependencies = [
"pydantic>=2.0",
"networkx>=3.0",
"simplecadapi>=2.0.2",
]
[project.optional-dependencies]
test = [
"pytest>=8.0",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
+106
View File
@@ -0,0 +1,106 @@
---
name: planetary-reducer-generator
description: Generate and validate planetary reducer and joint module data from this repository using the standardized input/output/placement/CAD/validation flow.
---
# Planetary Reducer Generator
## 何时使用
当用户要求在本项目中生成、验证、导出行星减速器、两级串联减速器、工业级减速器、关节模组或 URDF 时使用本 skill。
当前关节模组只支持同轴输入/输出接口的 `simple_2k_h` 和 `simple_2k_h_cascade`。`ferguson_wolfrom` 可以作为独立减速器和 reducer URDF 生成,但它的输入位于偏心行星销轴区域,暂不支持拼入当前同轴电机和外壳关节模组。
本 skill 是项目内流程规范,不是全局安装 skill。使用时以项目根目录为工作目录,项目根目录必须包含 `pyproject.toml`、`src/`、`input/`、`output/`。
## 硬规则
- 只能读取 `input/` 下的需求和资产;不要从旧输出目录反推设计。
- 所有生成结果只能写入 `output/runs/<run_name>/`。
- 不允许把已有 `output/runs` 文件当作本次通过依据;每次生成必须重新运行 CLI。
- 不允许伪造 STEP、`model.json`、`session.json`、`validation_report.json`。
- 不允许绕过 SimpleCADAPI、OCP/PythonOCC、图驱动运动学或验证器。
- SimpleCADAPI 或 OCP 缺失时必须真实失败并报告错误。
- 返回结果时必须给 Markdown 文件链接,不只给纯文本路径。
## 必读参考
- `references/reducer-generation-flow.md`:标准执行流程。
- `references/output-contract.md`:输出目录和关键文件契约。
- `references/validation-checklist.md`:验收检查清单。
- `references/configuration-development-standard.md`:新增或维护构型时必须遵守的开发规范。
- `references/topology-json-authoring-guide.md`:给机械工程师填写构型 JSON 的字段说明。
## 标准流程
1. 确认当前目录是项目根目录。
2. 选择 `input/` 下的 requirement 或外部资产。
3. 运行 CLI:普通减速器用 `run`,关节模组用 `joint-run`,自动设计关节模组用 `auto-joint-run`。
4. 读取对应 validation report。
5. 判断 `overall_passed`,并检查关键文件存在且非空。
6. 返回 STEP、公式实例、位姿计划、验证报告等 Markdown 文件链接和关键数值。
## 新增或维护构型
当用户要求新增复合行星、多级串联、差动行星、摆线针轮等构型时,先阅读 `references/configuration-development-standard.md`。新增构型必须通过 configuration registry、topology template、parameter solver、graph-driven kinematics、PlacementSolver、CAD generator 和 validators 的标准链路接入。
不要在 CLI、solver、CAD 或 validator 中临时写死构型逻辑。所有 CAD 组件位姿必须来自 `PlacementPlan`,所有验证必须读取真实生成结果。
如果用户只需要机械工程师创建新的 `topology.template.json`,优先把 `references/topology-json-authoring-guide.md` 交给他。该文档只解释 JSON 字段和填写规则,不要求机械工程师理解代码实现。
## 命令模板
Reducer:
```bash
PYTHONPATH=/Users/lk/Projects/cadSet/SimpleCADAPI/src:. \
/Users/lk/miniconda3/bin/python -m src.cli run \
--input input/requirements/reducers/simple_2k_h/simple_2kh_industrial_ratio_7p5_spur.json \
--output output/runs/industrial_ratio_7p5_spur
```
Joint module:
```bash
PYTHONPATH=/Users/lk/Projects/cadSet/SimpleCADAPI/src:. \
/Users/lk/miniconda3/bin/python -m src.cli joint-run \
--input input/requirements/joint_modules/3500_motor_housing.json \
--output output/runs/joint_3500_motor_housing
```
Auto joint module:
```bash
PYTHONPATH=/Users/lk/Projects/cadSet/SimpleCADAPI/src:. \
/Users/lk/miniconda3/bin/python -m src.cli auto-joint-run \
--motor-step input/assets/motors/3500_motor_part2.step \
--housing-python input/assets/housings/gear_housing.py \
--output output/runs/auto_joint_3500
```
URDF:
```bash
PYTHONPATH=/Users/lk/Projects/cadSet/SimpleCADAPI/src:. \
/Users/lk/miniconda3/bin/python -m src.cli export-urdf \
--joint-manifest output/runs/joint_3500_motor_housing/joint_module_manifest.json \
--reducer-instance output/runs/joint_3500_motor_housing/reducer/formula/formula_instance.json \
--output output/runs/joint_3500_motor_housing/exports/urdf
```
## 返回格式
成功时至少返回:
- `formula/formula_instance.json`
- `kinematics/kinematic_solution.json`
- `placement/placement_plan.json`
- `cad/reducer.step` 或 `joint_module.step`
- `validation/validation_report.json` 或 `joint_module_validation_report.json`
失败时返回:
- 失败阶段
- 错误文件链接,例如 `generation_error.json`、`joint_generation_error.json`、`urdf_export_error.json`
- validation 中失败的 check code、message、actual、expected
- 如果是几何干涉,返回 component pair、干涉体积、possible_cause、suggested_fix
@@ -0,0 +1,109 @@
# Agent 构型开发规范
当用户要求新增或维护减速器构型时,必须遵守本文档。不要直接在 solver、CAD、validator 中临时硬编码新构型。
## 必须新增或检查的内容
每个构型族必须具备:
```text
src/configurations/<family_name>/
family.py
topology.template.json
placement_rules.py
input/requirements/reducers/<family_name>/
src/placement/<family_name>.py
```
并且必须注册:
- `src/configurations/registry.py`
- `src/placement/registry.py`
## 开发顺序
按以下顺序做,不要跳步:
```text
requirement
-> topology.template.json
-> configuration registry
-> topology runtime check
-> parameter solving rule
-> graph-driven kinematics
-> placement solver
-> CAD generator support
-> validators
-> tests
-> examples
```
## Topology 要求
节点必须写清楚:
```text
id / kind / role / gear_type / repeat / formula_ref / stage_id / local_role
```
关系必须写清楚:
```text
id / source / target / relation_type / carrier_ref / mesh_id / formula_ref / stage_id
```
只允许使用已经注册的 relation type:
```text
external_mesh
internal_mesh
revolute_joint
fixed
rigid
coaxial
```
如果需要新 relation type,先扩展 relation validator 和运动学方程生成,不能只改模板。
## Placement 要求
所有 CAD 组件的位姿必须来自 `PlacementPlan`。每个组件必须有:
```text
component_id
frame_id
translation_mm
rotation_axis_angle_deg
axis_xyz
center_xyz_mm
source_formula_refs
```
CAD generator 不能重新计算 planet、pin、bearing、spacer、bolt 坐标。manifest 与 placement plan 不一致必须验证失败。
## 参数和运动学要求
- 参数求解必须枚举候选并过滤,不能写死答案。
- family 专属公式放在 `family.py` 或 family rules。
- 最终 ratio 必须由图驱动运动学求解得到。
- fixed/rigid/input/output 边界必须从 requirement 或 topology 得到。
## CAD 和验证要求
- 生成失败必须真实失败,不允许输出假 STEP。
- `simplecadapi` 不可用时不得跳过后声称成功。
- OCP/PythonOCC 是实体干涉最终裁判。
- `validation_report.overall_passed` 只能由所有 check 结果汇总得到。
- 失败时必须返回失败 check code、actual、expected;几何干涉必须返回 component pair 和 possible cause。
## 最小测试
每个新构型至少补:
- registry 能加载构型。
- topology 能展开。
- parameter solver 能找到目标 ratio。
- kinematics ratio、direction、fixed speed 正确。
- placement plan 覆盖所有 manifest component。
- CAD 能真实生成 STEP/model/session。
- validation report 通过或明确报告真实失败原因。
@@ -0,0 +1,91 @@
# Output Contract
## Reducer run
所有普通减速器生成结果必须写入:
```text
output/runs/<run_name>/
input_snapshot/
formula/
kinematics/
placement/
cad/
validation/
exports/
```
关键文件:
```text
formula/formula_instance.json
formula/parameter_search_report.json
formula/topology_graph.json
kinematics/kinematic_solution.json
kinematics/motion_samples.json
placement/placement_plan.json
cad/reducer.step
cad/reducer.model.json
cad/reducer.session.json
cad/assembly_manifest.json
cad/build_meta.json
validation/assembly_snapshot.json
validation/validation_report.json
```
工业级 reducer 还应包含:
```text
formula/industrial_parameters.json
formula/assembly_topology.json
cad/component_steps/*.step
cad/industrial_parameters.json
cad/assembly_topology.json
```
## Joint module run
关节模组生成结果必须写入:
```text
output/runs/<run_name>/
reducer/
external_step_metrics.json
joint_placement.json
joint_module.step
joint_module_manifest.json
joint_module_validation_report.json
joint_interference_report.json
```
`reducer/` 内部继续使用 reducer run 的标准结构:
```text
reducer/input_snapshot/
reducer/formula/
reducer/kinematics/
reducer/placement/
reducer/cad/
reducer/validation/
```
## URDF export
URDF 导出结果必须写入 `output/runs/<run_name>/exports/urdf/` 或其子目录。必须包含:
```text
*.urdf
package.xml
validation_report.json
export_manifest.json
meshes/
```
URDF 必须体现传动关系,不能只导出固定几何。当前项目用 `mimic` joint 表达太阳轮输入和行星架输出之间的传动比。
## 路径规则
- CLI 的 `--input` 必须在 `input/` 下。
- CLI 的 `--output` 必须在 `output/` 下。
- 不允许写入项目根目录、`src/`、`tests/`、`docs/` 作为生成结果。
- 不允许把生成物复制到旧目录或临时参考目录作为交付。
@@ -0,0 +1,110 @@
# Reducer Generation Flow
## 1. 确认项目根目录
工作目录必须是项目根目录,并且至少包含:
```text
pyproject.toml
src/
input/
output/
```
如果这些目录不存在,不要猜测路径,也不要在其他目录生成文件。先报告 `project_root_not_found`。
## 2. 选择输入
普通减速器 requirement 必须来自:
```text
input/requirements/reducers/
```
关节模组 requirement 必须来自:
```text
input/requirements/joint_modules/
```
自动关节设计使用的电机 STEP 和外壳 Python 必须来自:
```text
input/assets/
```
不要读取旧 `output/runs` 的结果作为本次设计依据。旧输出只能用于用户明确要求“查看历史结果”时读取,不能作为验证通过依据。
## 3. 执行 reducer run
优先使用一条 `run` 命令完成求解、CAD 生成、读回和验证:
```bash
PYTHONPATH=/Users/lk/Projects/cadSet/SimpleCADAPI/src:. \
/Users/lk/miniconda3/bin/python -m src.cli run \
--input input/requirements/reducers/simple_2k_h/simple_2kh_industrial_ratio_7p5_spur.json \
--output output/runs/industrial_ratio_7p5_spur
```
只有调试单阶段问题时才拆开执行 `solve`、`generate`、`validate`。
## 4. 执行 joint-run
关节模组使用:
```bash
PYTHONPATH=/Users/lk/Projects/cadSet/SimpleCADAPI/src:. \
/Users/lk/miniconda3/bin/python -m src.cli joint-run \
--input input/requirements/joint_modules/3500_motor_housing.json \
--output output/runs/joint_3500_motor_housing
```
执行后必须读取:
```text
output/runs/<run>/joint_module_validation_report.json
output/runs/<run>/joint_module_manifest.json
```
## 5. 执行 auto-joint-run
自动关节设计使用:
```bash
PYTHONPATH=/Users/lk/Projects/cadSet/SimpleCADAPI/src:. \
/Users/lk/miniconda3/bin/python -m src.cli auto-joint-run \
--motor-step input/assets/motors/3500_motor_part2.step \
--housing-python input/assets/housings/gear_housing.py \
--output output/runs/auto_joint_3500
```
如果外壳包络不足、轴线识别失败或几何干涉失败,必须报告失败,不能调整报告为通过。
## 6. 判断结果
Reducer run 成功标准:
```text
validation/validation_report.json overall_passed == true
cad/reducer.step exists and non-empty
placement/placement_plan.json exists
```
Joint run 成功标准:
```text
joint_module_validation_report.json overall_passed == true
joint_module.step exists and non-empty
reducer/validation/validation_report.json exists
```
## 7. 返回给用户
必须返回 Markdown 文件链接,例如:
```markdown
[reducer.step](</absolute/path/output/runs/name/cad/reducer.step>)
[validation_report.json](</absolute/path/output/runs/name/validation/validation_report.json>)
```
不要只返回纯路径。
@@ -0,0 +1,113 @@
# Topology JSON Authoring Guide
这份说明给只负责创建构型 JSON 的机械工程师使用。构型 JSON 只描述机械构件和关系,不填写齿数、模数、目标速比、三维坐标或输出文件。
## 文件位置
```text
src/configurations/<family_name>/topology.template.json
```
`family_name` 必须和 JSON 顶层 `family` 一致。
## 顶层字段
```json
{
"template_id": "<family>_v0_1",
"family": "<family>",
"description": "one sentence",
"nodes": [],
"relations": []
}
```
## node 字段
每个 node 表示一个构件或参考对象:
```json
{
"id": "sun",
"kind": "gear",
"role": "sun",
"gear_type": "external",
"repeat": "single",
"formula_ref": "nodes.sun",
"stage_id": null,
"local_role": "sun"
}
```
填写规则:
- `kind`: `gear`、`carrier`、`housing`、`axis`
- `gear_type`: 外齿轮填 `external`,内齿圈填 `internal`,非齿轮填 `null`
- `repeat`: 单个构件填 `single`,按行星数量重复的行星轮填 `planet_count`
- `stage_id/local_role`: 多级构型用于表达级内角色;单级可以省略或填 `null`
不要在模板里手写 `planet_1..planet_n`。只写一个 `planet`,并设置 `repeat = planet_count`。
## relation 字段
每个 relation 表示构件之间的机械关系:
```json
{
"id": "sun_planet_external_mesh",
"source": "sun",
"target": "planet",
"relation_type": "external_mesh",
"carrier_ref": "carrier",
"mesh_id": "sun_planet",
"formula_ref": "relations.sun_planet_external_mesh",
"stage_id": null
}
```
允许的 `relation_type`:
```text
external_mesh
internal_mesh
revolute_joint
coaxial
fixed
rigid
```
常用关系:
- `sun --external_mesh--> planet`,`carrier_ref = carrier`
- `ring --internal_mesh--> planet`,`carrier_ref = carrier`
- `planet --revolute_joint--> carrier`
- `sun/ring/carrier --coaxial--> main_axis`
- `ring --fixed--> housing`
- `s1_output --rigid--> s2_input`
## 不要填写的内容
不要在 topology JSON 中填写:
- 目标速比
- 齿数
- 模数
- 行星数量候选
- 坐标和轴线
- STEP 路径
- 材料和颜色
- 验证结果
这些内容分别属于 requirement、参数求解、PlacementPlan、CAD 输出和 validation report。
## 提交前检查
- `template_id` 唯一。
- `family` 和目录名一致。
- 所有 relation 的 `source/target/carrier_ref` 都能在 nodes 中找到。
- 齿轮 `gear_type` 正确。
- 行星轮使用 `repeat = planet_count`。
- 行星啮合填写了 `carrier_ref`。
- 所有主运动构件都有 `coaxial` 到 `main_axis`。
- 固定关系用 `fixed`,刚性连接用 `rigid`。
- 没有使用未注册 relation type。
@@ -0,0 +1,70 @@
# Validation Checklist
## Reducer 验收
读取:
```text
output/runs/<run>/formula/formula_instance.json
output/runs/<run>/placement/placement_plan.json
output/runs/<run>/cad/assembly_manifest.json
output/runs/<run>/validation/validation_report.json
```
必须满足:
- `validation_report.overall_passed == true`
- `formula_instance.run_id == placement_plan.run_id`
- `formula_instance.run_id == assembly_manifest.run_id`
- `formula_instance.derived.ratio` 满足 requirement 的 `target_ratio ± ratio_tolerance`
- `cad/reducer.step` 存在且非空
- `cad/reducer.model.json` 存在且非空
- `cad/reducer.session.json` 存在且非空
- `placement/placement_plan.json` 存在
- `assembly_manifest.components[*].center_xyz_mm` 与 `placement_plan.component_poses[*].center_xyz_mm` 一致
## 必须关注的验证项
validation report 中以下类别不能失败:
- 静态公式验证:齿数正整数、齿数大于 17、齿数闭合、行星均布、中心距、外径、行星间隙。
- 运动学验证:速比、方向、fixed member 速度为 0、rigid 两端速度相等、残差。
- 拓扑关系验证:`external_mesh`、`internal_mesh`、`revolute_joint`、`fixed`、`rigid`、`coaxial`。
- 齿轮啮合验证:中心距、齿顶齿根间隙、backlash、齿相位、运动采样残差。
- 工业物理关系验证:轴承、销轴、螺栓、配合、间隙、联轴和刚性连接。
- OCP 几何检测:不得有未允许的正体积干涉。
## Joint module 验收
读取:
```text
output/runs/<run>/joint_module_manifest.json
output/runs/<run>/joint_module_validation_report.json
output/runs/<run>/joint_interference_report.json
output/runs/<run>/reducer/validation/validation_report.json
```
必须满足:
- `joint_module_validation_report.overall_passed == true`
- `joint_module.step` 存在且非空
- 电机 STEP、外壳 STEP、减速器 STEP 来源真实存在
- 电机轴线、外壳轴线、减速器主轴同轴检查通过
- 电机输出到太阳轮输入的关系存在
- 齿圈固定到外壳关系存在
- 行星架输出关系存在
- reducer 内部 validation report 通过
## 失败处理
如果验证失败,不能说生成成功。必须报告:
- 失败文件链接
- 失败 check code
- message
- actual
- expected
- 如果是干涉:component pair、common volume、possible cause、suggested fix
如果 SimpleCADAPI 或 OCP 缺失,报告环境错误。不要跳过后声称成功。
+3
View File
@@ -0,0 +1,3 @@
"""Planetary reducer formula/generation/validation demo."""
__version__ = "0.1.0"
+147
View File
@@ -0,0 +1,147 @@
from __future__ import annotations
import math
from pathlib import Path
from typing import Any
from .models import (
AssemblyManifest,
AssemblySnapshot,
read_json,
utc_now_iso,
write_model_json,
)
def _graph_nodes(model_payload: dict[str, Any]) -> list[dict[str, Any]]:
return list(model_payload.get("graph", {}).get("nodes", []))
def _latest_component_placements(nodes: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
placements: dict[str, dict[str, Any]] = {}
for node in nodes:
if node.get("op") == "make_solve_assembly_constraints_rassembly":
params = node.get("params", {})
value = params.get("component_placements")
if isinstance(value, dict):
placements = value
return placements
def _rotate_vector_axis_angle(
vector: tuple[float, float, float],
axis_angle: list[float] | tuple[float, ...] | None,
) -> list[float]:
if not axis_angle or len(axis_angle) != 4:
return [float(value) for value in vector]
ux, uy, uz, angle_degrees = (float(value) for value in axis_angle)
length = math.sqrt(ux * ux + uy * uy + uz * uz)
if length <= 0.0:
return [float(value) for value in vector]
ux, uy, uz = ux / length, uy / length, uz / length
angle = math.radians(angle_degrees)
cos_a = math.cos(angle)
sin_a = math.sin(angle)
x, y, z = vector
dot = ux * x + uy * y + uz * z
cross_x = uy * z - uz * y
cross_y = uz * x - ux * z
cross_z = ux * y - uy * x
return [
x * cos_a + cross_x * sin_a + ux * dot * (1.0 - cos_a),
y * cos_a + cross_y * sin_a + uy * dot * (1.0 - cos_a),
z * cos_a + cross_z * sin_a + uz * dot * (1.0 - cos_a),
]
def _placement_from_pose(pose: dict[str, Any]) -> dict[str, Any]:
axis_angle = pose.get("rotation_axis_angle_deg")
return {
"origin": pose.get("translation_mm") or pose.get("center_xyz_mm"),
"axis": pose.get("axis_xyz", [0.0, 0.0, 1.0]),
"x_axis": _rotate_vector_axis_angle((1.0, 0.0, 0.0), axis_angle),
"y_axis": _rotate_vector_axis_angle((0.0, 1.0, 0.0), axis_angle),
"z_axis": _rotate_vector_axis_angle((0.0, 0.0, 1.0), axis_angle),
"rotation_axis_angle_deg": axis_angle,
"source": "placement_plan",
}
def read_assembly_snapshot(
*,
model_path: Path,
manifest_path: Path,
build_meta_path: Path | None = None,
out_path: Path | None = None,
) -> AssemblySnapshot:
model_payload = read_json(model_path)
manifest = AssemblyManifest.model_validate(read_json(manifest_path))
build_meta = read_json(build_meta_path) if build_meta_path and build_meta_path.exists() else None
nodes = _graph_nodes(model_payload)
placements = _latest_component_placements(nodes)
placement_plan_path = manifest.exported_files.get("placement_plan")
if placement_plan_path:
plan_path = Path(placement_plan_path)
if plan_path.exists():
plan = read_json(plan_path)
plan_placements = {}
for component_id, pose in plan.get("component_poses", {}).items():
plan_placements[component_id] = _placement_from_pose(pose)
placements.update(plan_placements)
components: list[str] = []
constraints: list[dict[str, Any]] = []
grounded: list[str] = []
gear_constraints: list[str] = []
internal_mesh_constraints: list[str] = []
revolute_constraints: list[str] = []
fixed_constraints: list[str] = []
for node in nodes:
op = node.get("op")
params = node.get("params", {})
if op == "make_add_component_rassembly":
component_id = params.get("component_id")
if component_id:
components.append(component_id)
elif op == "make_ground_component_rassembly":
component_id = params.get("component_id")
if component_id:
grounded.append(component_id)
elif isinstance(op, str) and op.endswith("_constraint_rassembly"):
constraint_id = params.get("constraint_id")
if constraint_id:
constraints.append({"op": op, **params})
kind = params.get("constraint_kind")
if kind == "gear" or op == "make_gear_constraint_rassembly":
gear_constraints.append(constraint_id)
if kind == "belt" or op == "make_belt_constraint_rassembly":
internal_mesh_constraints.append(constraint_id)
if kind == "revolute" or op == "make_revolute_constraint_rassembly":
revolute_constraints.append(constraint_id)
if kind == "fixed" or op == "make_fixed_constraint_rassembly":
fixed_constraints.append(constraint_id)
snapshot = AssemblySnapshot(
run_id=manifest.run_id,
formula_instance_hash=manifest.formula_instance_hash,
model_path=str(model_path.resolve()),
manifest_path=str(manifest_path.resolve()),
build_meta_path=str(build_meta_path.resolve()) if build_meta_path else None,
components_from_model=components,
constraints_from_model=constraints,
component_placements=placements,
grounded_components=grounded,
gear_constraints=gear_constraints,
internal_mesh_constraints=internal_mesh_constraints,
revolute_constraints=revolute_constraints,
fixed_constraints=fixed_constraints,
manifest=manifest,
build_meta=build_meta,
model_graph_node_count=len(nodes),
exported_files=manifest.exported_files,
read_at=utc_now_iso(),
)
if out_path:
write_model_json(out_path, snapshot)
return snapshot
+23
View File
@@ -0,0 +1,23 @@
"""CAD backends and parametric part factories for the reducer system."""
from .parametric_parts import (
PART_CAPABILITIES,
ParametricPartCapability,
export_parametric_part,
get_parametric_part_capability,
list_parametric_part_capabilities,
list_parametric_part_capability_manifests,
make_parametric_part,
validate_part_parameters,
)
__all__ = [
"PART_CAPABILITIES",
"ParametricPartCapability",
"export_parametric_part",
"get_parametric_part_capability",
"list_parametric_part_capabilities",
"list_parametric_part_capability_manifests",
"make_parametric_part",
"validate_part_parameters",
]
+167
View File
@@ -0,0 +1,167 @@
from __future__ import annotations
import inspect
from dataclasses import dataclass
from typing import Any, Literal
from ..models import ReducerInstance
GearComponent = str
@dataclass(frozen=True)
class GearSpec:
component: GearComponent
n_teeth: int
gear_type: Literal["external", "internal"]
module_mm: float
pressure_angle_deg: float
face_width_mm: float
addendum_coeff: float
clearance_coeff: float
backlash_mm: float
tooth_form: Literal["spur", "helical"]
helix_angle_deg: float
helix_hand: Literal["right", "left"]
ring_rim_thickness_mm: float | None = None
def opposite_hand(hand: Literal["right", "left"]) -> Literal["right", "left"]:
return "left" if hand == "right" else "right"
def hand_for_component(
component: GearComponent,
*,
base_hand: Literal["right", "left"],
) -> Literal["right", "left"]:
if component in {"sun", "sun1", "sun2"}:
return base_hand
if component in {"planet", "planet1", "planet2"}:
return opposite_hand(base_hand)
if component in {"ring", "ring1", "ring2"}:
return opposite_hand(base_hand)
raise ValueError(f"unsupported gear component: {component}")
def signed_helix_angle(spec: GearSpec) -> float:
if spec.tooth_form == "spur":
return 0.0
sign = 1.0 if spec.helix_hand == "right" else -1.0
return sign * spec.helix_angle_deg
def _call_with_supported_kwargs(func: Any, /, *args: Any, **kwargs: Any) -> Any:
signature = inspect.signature(func)
supported_kwargs = {name: value for name, value in kwargs.items() if name in signature.parameters}
return func(*args, **supported_kwargs)
def gear_spec_for_component(instance: ReducerInstance, component: GearComponent) -> GearSpec:
p = instance.parameters
if component == "sun":
return GearSpec(
component=component,
n_teeth=p.z_sun,
gear_type="external",
module_mm=p.module_mm,
pressure_angle_deg=p.pressure_angle_deg,
face_width_mm=p.face_width_mm,
addendum_coeff=p.addendum_coeff,
clearance_coeff=p.clearance_coeff,
backlash_mm=p.backlash_mm,
tooth_form=p.tooth_form,
helix_angle_deg=p.helix_angle_deg,
helix_hand=hand_for_component(component, base_hand=p.helix_hand),
)
if component == "planet":
return GearSpec(
component=component,
n_teeth=p.z_planet,
gear_type="external",
module_mm=p.module_mm,
pressure_angle_deg=p.pressure_angle_deg,
face_width_mm=p.face_width_mm,
addendum_coeff=p.addendum_coeff,
clearance_coeff=p.clearance_coeff,
backlash_mm=p.backlash_mm,
tooth_form=p.tooth_form,
helix_angle_deg=p.helix_angle_deg,
helix_hand=hand_for_component(component, base_hand=p.helix_hand),
)
if component == "ring":
return GearSpec(
component=component,
n_teeth=p.z_ring,
gear_type="internal",
module_mm=p.module_mm,
pressure_angle_deg=p.pressure_angle_deg,
face_width_mm=p.face_width_mm,
addendum_coeff=p.addendum_coeff,
clearance_coeff=p.clearance_coeff,
backlash_mm=p.backlash_mm,
tooth_form=p.tooth_form,
helix_angle_deg=p.helix_angle_deg,
helix_hand=hand_for_component(component, base_hand=p.helix_hand),
ring_rim_thickness_mm=p.ring_rim_thickness_mm,
)
raise ValueError(f"unsupported gear component: {component}")
def make_external_gear(scad: Any, spec: GearSpec) -> Any:
if spec.tooth_form == "spur":
return _call_with_supported_kwargs(
scad.std.gear.make_spur_gear_rsolid,
spec.n_teeth,
spec.module_mm,
spec.pressure_angle_deg,
spec.face_width_mm,
addendum_factor=spec.addendum_coeff,
clearance_factor=spec.clearance_coeff,
backlash=spec.backlash_mm,
)
if spec.tooth_form == "helical":
return _call_with_supported_kwargs(
scad.std.gear.make_helical_gear_rsolid,
spec.n_teeth,
spec.module_mm,
spec.pressure_angle_deg,
signed_helix_angle(spec),
spec.face_width_mm,
addendum_factor=spec.addendum_coeff,
clearance_factor=spec.clearance_coeff,
backlash=spec.backlash_mm,
)
raise ValueError(f"unsupported_tooth_form: {spec.tooth_form}")
def make_internal_ring_gear(scad: Any, spec: GearSpec) -> Any:
if spec.ring_rim_thickness_mm is None:
raise ValueError("ring_rim_thickness_mm is required for internal ring gears")
if spec.tooth_form == "spur":
return _call_with_supported_kwargs(
scad.std.gear.make_spur_ring_gear_rsolid,
spec.n_teeth,
spec.module_mm,
spec.pressure_angle_deg,
spec.face_width_mm,
spec.ring_rim_thickness_mm,
spec.backlash_mm,
addendum_factor=spec.addendum_coeff,
clearance_factor=spec.clearance_coeff,
)
if spec.tooth_form == "helical":
return _call_with_supported_kwargs(
scad.std.gear.make_helical_ring_gear_rsolid,
spec.n_teeth,
spec.module_mm,
spec.pressure_angle_deg,
signed_helix_angle(spec),
spec.face_width_mm,
spec.ring_rim_thickness_mm,
spec.backlash_mm,
addendum_factor=spec.addendum_coeff,
clearance_factor=spec.clearance_coeff,
)
raise ValueError(f"unsupported_tooth_form: {spec.tooth_form}")
+267
View File
@@ -0,0 +1,267 @@
from __future__ import annotations
from dataclasses import dataclass
from itertools import combinations
from pathlib import Path
from typing import Iterable
class OcpInterferenceError(RuntimeError):
pass
@dataclass(frozen=True)
class ShapeMetrics:
component_id: str
valid: bool
volume_mm3: float
bbox: tuple[float, float, float, float, float, float]
@dataclass(frozen=True)
class InterferenceResult:
component_a: str
component_b: str
common_volume_mm3: float
bbox_overlap: bool
checked_boolean: bool
interfering: bool
def _import_ocp():
try:
from OCP.Bnd import Bnd_Box
from OCP.BRep import BRep_Builder
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common
from OCP.BRepBndLib import BRepBndLib
from OCP.BRepAdaptor import BRepAdaptor_Surface
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.BRepCheck import BRepCheck_Analyzer
from OCP.BRepGProp import BRepGProp
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.GeomAbs import GeomAbs_Cylinder
from OCP.GeomAbs import GeomAbs_Plane
from OCP.GProp import GProp_GProps
from OCP.gp import gp_Ax1
from OCP.gp import gp_Dir
from OCP.gp import gp_Pnt
from OCP.gp import gp_Trsf
from OCP.gp import gp_Vec
from OCP.IFSelect import IFSelect_RetDone
from OCP.STEPControl import STEPControl_AsIs
from OCP.STEPControl import STEPControl_Reader
from OCP.STEPControl import STEPControl_Writer
from OCP.StlAPI import StlAPI_Writer
from OCP.TopAbs import TopAbs_FACE
from OCP.TopAbs import TopAbs_SOLID
from OCP.TopExp import TopExp_Explorer
from OCP.TopoDS import TopoDS
from OCP.TopoDS import TopoDS_Compound
except ModuleNotFoundError as exc:
raise OcpInterferenceError(
"ocp_unavailable: install pythonocc-core or cadquery-ocp/OCP to run B-Rep interference checks"
) from exc
return {
"Bnd_Box": Bnd_Box,
"BRep_Builder": BRep_Builder,
"BRepAlgoAPI_Common": BRepAlgoAPI_Common,
"BRepAdaptor_Surface": BRepAdaptor_Surface,
"BRepBndLib": BRepBndLib,
"BRepBuilderAPI_Transform": BRepBuilderAPI_Transform,
"BRepCheck_Analyzer": BRepCheck_Analyzer,
"BRepGProp": BRepGProp,
"BRepMesh_IncrementalMesh": BRepMesh_IncrementalMesh,
"GeomAbs_Cylinder": GeomAbs_Cylinder,
"GeomAbs_Plane": GeomAbs_Plane,
"GProp_GProps": GProp_GProps,
"gp_Ax1": gp_Ax1,
"gp_Dir": gp_Dir,
"gp_Pnt": gp_Pnt,
"gp_Trsf": gp_Trsf,
"gp_Vec": gp_Vec,
"IFSelect_RetDone": IFSelect_RetDone,
"STEPControl_AsIs": STEPControl_AsIs,
"STEPControl_Reader": STEPControl_Reader,
"STEPControl_Writer": STEPControl_Writer,
"StlAPI_Writer": StlAPI_Writer,
"TopAbs_FACE": TopAbs_FACE,
"TopAbs_SOLID": TopAbs_SOLID,
"TopExp_Explorer": TopExp_Explorer,
"TopoDS": TopoDS,
"TopoDS_Compound": TopoDS_Compound,
}
def read_step_shape(path: Path):
ocp = _import_ocp()
reader = ocp["STEPControl_Reader"]()
status = reader.ReadFile(str(path))
if status != ocp["IFSelect_RetDone"]:
raise OcpInterferenceError(f"step_read_failed: {path}")
reader.TransferRoots()
shape = reader.OneShape()
if shape.IsNull():
raise OcpInterferenceError(f"step_read_null_shape: {path}")
return shape
def shape_volume(shape) -> float:
ocp = _import_ocp()
props = ocp["GProp_GProps"]()
ocp["BRepGProp"].VolumeProperties_s(shape, props)
return float(props.Mass())
def shape_bbox(shape) -> tuple[float, float, float, float, float, float]:
ocp = _import_ocp()
box = ocp["Bnd_Box"]()
ocp["BRepBndLib"].Add_s(shape, box)
return tuple(float(value) for value in box.Get())
def shape_valid(shape) -> bool:
ocp = _import_ocp()
return bool(ocp["BRepCheck_Analyzer"](shape).IsValid())
def _bbox_overlap(
a: tuple[float, float, float, float, float, float],
b: tuple[float, float, float, float, float, float],
*,
tolerance_mm: float,
) -> bool:
axmin, aymin, azmin, axmax, aymax, azmax = a
bxmin, bymin, bzmin, bxmax, bymax, bzmax = b
return not (
axmax < bxmin - tolerance_mm
or bxmax < axmin - tolerance_mm
or aymax < bymin - tolerance_mm
or bymax < aymin - tolerance_mm
or azmax < bzmin - tolerance_mm
or bzmax < azmin - tolerance_mm
)
def common_shape(shape_a, shape_b):
ocp = _import_ocp()
common = ocp["BRepAlgoAPI_Common"](shape_a, shape_b)
common.Build()
if not common.IsDone():
raise OcpInterferenceError("boolean_common_failed")
shape = common.Shape()
if shape.IsNull():
return None
return shape
def common_volume(shape_a, shape_b) -> float:
shape = common_shape(shape_a, shape_b)
if shape is None:
return 0.0
return max(0.0, shape_volume(shape))
def export_step_shape(shape, path: Path) -> None:
ocp = _import_ocp()
path.parent.mkdir(parents=True, exist_ok=True)
writer = ocp["STEPControl_Writer"]()
writer.Transfer(shape, ocp["STEPControl_AsIs"])
status = writer.Write(str(path))
if status != ocp["IFSelect_RetDone"]:
raise OcpInterferenceError(f"step_write_failed: {path}")
def detect_interferences(
component_steps: dict[str, Path],
*,
check_pairs: Iterable[tuple[str, str]] | None = None,
volume_tolerance_mm3: float = 1e-6,
bbox_tolerance_mm: float = 1e-7,
) -> tuple[list[ShapeMetrics], list[InterferenceResult]]:
if len(component_steps) < 2:
raise OcpInterferenceError("need_at_least_two_component_steps")
shapes = {component_id: read_step_shape(path) for component_id, path in component_steps.items()}
metrics = [
ShapeMetrics(
component_id=component_id,
valid=shape_valid(shape),
volume_mm3=shape_volume(shape),
bbox=shape_bbox(shape),
)
for component_id, shape in shapes.items()
]
metric_by_id = {metric.component_id: metric for metric in metrics}
pairs = list(check_pairs) if check_pairs is not None else list(combinations(sorted(shapes), 2))
results: list[InterferenceResult] = []
for component_a, component_b in pairs:
if component_a not in shapes or component_b not in shapes:
raise OcpInterferenceError(f"unknown_interference_pair: {component_a}, {component_b}")
bbox_overlap = _bbox_overlap(
metric_by_id[component_a].bbox,
metric_by_id[component_b].bbox,
tolerance_mm=bbox_tolerance_mm,
)
if not bbox_overlap:
results.append(
InterferenceResult(
component_a=component_a,
component_b=component_b,
common_volume_mm3=0.0,
bbox_overlap=False,
checked_boolean=False,
interfering=False,
)
)
continue
volume = common_volume(shapes[component_a], shapes[component_b])
results.append(
InterferenceResult(
component_a=component_a,
component_b=component_b,
common_volume_mm3=volume,
bbox_overlap=True,
checked_boolean=True,
interfering=volume > volume_tolerance_mm3,
)
)
return metrics, results
def export_interference_steps(
component_steps: dict[str, Path],
*,
out_dir: Path,
check_pairs: Iterable[tuple[str, str]] | None = None,
volume_tolerance_mm3: float = 1e-6,
bbox_tolerance_mm: float = 1e-7,
) -> list[dict[str, object]]:
shapes = {component_id: read_step_shape(path) for component_id, path in component_steps.items()}
bboxes = {component_id: shape_bbox(shape) for component_id, shape in shapes.items()}
pairs = list(check_pairs) if check_pairs is not None else list(combinations(sorted(shapes), 2))
exports: list[dict[str, object]] = []
out_dir.mkdir(parents=True, exist_ok=True)
for component_a, component_b in pairs:
if component_a not in shapes or component_b not in shapes:
raise OcpInterferenceError(f"unknown_interference_pair: {component_a}, {component_b}")
if not _bbox_overlap(bboxes[component_a], bboxes[component_b], tolerance_mm=bbox_tolerance_mm):
continue
shape = common_shape(shapes[component_a], shapes[component_b])
if shape is None:
continue
volume = max(0.0, shape_volume(shape))
if volume <= volume_tolerance_mm3:
continue
filename = f"common_{component_a}_vs_{component_b}.step"
path = out_dir / filename
export_step_shape(shape, path)
exports.append(
{
"component_a": component_a,
"component_b": component_b,
"common_volume_mm3": volume,
"step": str(path.resolve()),
}
)
return exports
+427
View File
@@ -0,0 +1,427 @@
from __future__ import annotations
import importlib
import importlib.util
import inspect
import json
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Iterable, Literal, Mapping
from ..models import canonical_json, sha256_json, utc_now_iso, write_json
from .simplecad_generator import CadGenerationError, export_model_session_json, import_simplecadapi
PartResultKind = Literal["solid", "assembly"]
@dataclass(frozen=True)
class ParametricPartCapability:
part_id: str
family: str
api_path: str
result_kind: PartResultKind
description: str
required_packages: tuple[str, ...] = ("OCP",)
example_parameters: Mapping[str, Any] | None = None
def available(self) -> bool:
return all(importlib.util.find_spec(package) is not None for package in self.required_packages)
def missing_packages(self) -> tuple[str, ...]:
return tuple(
package
for package in self.required_packages
if importlib.util.find_spec(package) is None
)
PART_CAPABILITIES: dict[str, ParametricPartCapability] = {
"spur_gear": ParametricPartCapability(
part_id="spur_gear",
family="gear",
api_path="simplecadapi.std.gear.make_spur_gear_rsolid",
result_kind="solid",
description="Involute external spur gear.",
required_packages=("OCP", "py_slvs"),
example_parameters={"n_teeth": 24, "module": 1.0, "gear_height": 6.0},
),
"straight_bevel_gear": ParametricPartCapability(
part_id="straight_bevel_gear",
family="gear",
api_path="simplecadapi.std.gear.make_straight_bevel_gear_rsolid",
result_kind="solid",
description="Straight bevel gear with involute large-end section.",
required_packages=("OCP", "py_slvs"),
example_parameters={"n_teeth": 24, "module": 1.0, "pitch_angle": 45.0, "face_width": 6.0},
),
"helical_gear": ParametricPartCapability(
part_id="helical_gear",
family="gear",
api_path="simplecadapi.std.gear.make_helical_gear_rsolid",
result_kind="solid",
description="External helical involute gear.",
required_packages=("OCP", "py_slvs"),
example_parameters={"n_teeth": 24, "module": 1.0, "helix_angle": 20.0, "gear_height": 8.0},
),
"herringbone_gear": ParametricPartCapability(
part_id="herringbone_gear",
family="gear",
api_path="simplecadapi.std.gear.make_herringbone_gear_rsolid",
result_kind="solid",
description="External double-helical herringbone gear.",
required_packages=("OCP", "py_slvs"),
example_parameters={"n_teeth": 24, "module": 1.0, "helix_angle": 25.0, "gear_height": 10.0},
),
"spur_ring_gear": ParametricPartCapability(
part_id="spur_ring_gear",
family="gear",
api_path="simplecadapi.std.gear.make_spur_ring_gear_rsolid",
result_kind="solid",
description="Internal spur ring gear with outer rim.",
required_packages=("OCP", "py_slvs"),
example_parameters={"n_teeth": 60, "module": 1.0, "gear_height": 6.0, "rim_thickness": 3.0},
),
"helical_ring_gear": ParametricPartCapability(
part_id="helical_ring_gear",
family="gear",
api_path="simplecadapi.std.gear.make_helical_ring_gear_rsolid",
result_kind="solid",
description="Internal helical ring gear with outer rim.",
required_packages=("OCP", "py_slvs"),
example_parameters={"n_teeth": 60, "module": 1.0, "helix_angle": 20.0, "gear_height": 8.0, "rim_thickness": 3.0},
),
"herringbone_ring_gear": ParametricPartCapability(
part_id="herringbone_ring_gear",
family="gear",
api_path="simplecadapi.std.gear.make_herringbone_ring_gear_rsolid",
result_kind="solid",
description="Internal double-helical herringbone ring gear.",
required_packages=("OCP", "py_slvs"),
example_parameters={"n_teeth": 60, "module": 1.0, "helix_angle": 25.0, "gear_height": 10.0, "rim_thickness": 3.0},
),
"cycloidal_disc": ParametricPartCapability(
part_id="cycloidal_disc",
family="gear",
api_path="simplecadapi.std.gear.make_cycloidal_disc_rsolid",
result_kind="solid",
description="Cycloidal reducer disc with optional bore and output-pin holes.",
example_parameters={
"n_lobes": 10,
"ring_pin_pitch_radius": 24.0,
"roller_radius": 1.5,
"eccentricity": 0.8,
"gear_height": 4.0,
"bore_radius": 2.5,
},
),
"spur_rack": ParametricPartCapability(
part_id="spur_rack",
family="rack",
api_path="simplecadapi.std.gear.make_spur_rack_rsolid",
result_kind="solid",
description="Straight rack gear.",
required_packages=("OCP", "py_slvs"),
example_parameters={"module": 1.0, "n_teeth": 8, "rack_height": 5.0},
),
"helical_rack": ParametricPartCapability(
part_id="helical_rack",
family="rack",
api_path="simplecadapi.std.gear.make_helical_rack_rsolid",
result_kind="solid",
description="Helical rack gear.",
required_packages=("OCP", "py_slvs"),
example_parameters={"module": 1.0, "n_teeth": 8, "helix_angle": 20.0, "rack_height": 6.0},
),
"herringbone_rack": ParametricPartCapability(
part_id="herringbone_rack",
family="rack",
api_path="simplecadapi.std.gear.make_herringbone_rack_rsolid",
result_kind="solid",
description="Double-helical herringbone rack gear.",
required_packages=("OCP", "py_slvs"),
example_parameters={"module": 1.0, "n_teeth": 8, "helix_angle": 25.0, "rack_height": 8.0},
),
"ball_bearing": ParametricPartCapability(
part_id="ball_bearing",
family="bearing",
api_path="simplecadapi.std.bearing.make_ball_bearing_rassembly",
result_kind="assembly",
description="Radial ball bearing assembly with rings and rolling elements.",
example_parameters={
"bore_diameter": 8.0,
"outer_diameter": 22.0,
"bearing_width": 7.0,
"ball_diameter": 2.5,
"ball_count": 8,
"fuse_rolling_elements": True,
},
),
"roller_chain_sprocket": ParametricPartCapability(
part_id="roller_chain_sprocket",
family="chain",
api_path="simplecadapi.std.chain.make_roller_chain_sprocket_rsolid",
result_kind="solid",
description="Roller-chain sprocket from pitch and roller dimensions.",
example_parameters={"n_teeth": 12, "chain_pitch": 6.35, "roller_diameter": 3.3, "sprocket_thickness": 3.0, "bore_radius": 1.2},
),
"bolt": ParametricPartCapability(
part_id="bolt",
family="fastener",
api_path="simplecadapi.std.fastener.make_bolt_rsolid",
result_kind="solid",
description="Parameterized metric bolt with configurable head, drive, and thread representation.",
example_parameters={"diameter": 6.0, "length": 18.0, "head_style": "hex", "thread_detail": "cosmetic"},
),
"nut": ParametricPartCapability(
part_id="nut",
family="fastener",
api_path="simplecadapi.std.fastener.make_nut_rsolid",
result_kind="solid",
description="Parameterized metric nut with configurable body and thread representation.",
example_parameters={"diameter": 6.0, "width": 10.0, "height": 5.0, "thread_detail": "cosmetic"},
),
"n_hole_flange": ParametricPartCapability(
part_id="n_hole_flange",
family="flange",
api_path="simplecadapi.make_n_hole_flange_rsolid",
result_kind="solid",
description="Round flange with center bore, raised boss, and N-hole bolt circle.",
example_parameters={"flange_outer_diameter": 40.0, "flange_inner_diameter": 16.0, "flange_thickness": 5.0, "boss_outer_diameter": 24.0, "boss_height": 2.0, "hole_diameter": 3.0, "hole_circle_diameter": 32.0, "hole_count": 6, "chamfer_size": 0.0},
),
"naca_propeller_blade": ParametricPartCapability(
part_id="naca_propeller_blade",
family="aero",
api_path="simplecadapi.make_naca_propeller_blade_rsolid",
result_kind="solid",
description="Twisted NACA 0016 propeller blade loft.",
example_parameters={"blade_length": 5.0, "root_chord": 1.5, "tip_chord": 0.3, "total_twist_angle": 45.0, "num_sections": 4},
),
"threaded_rod": ParametricPartCapability(
part_id="threaded_rod",
family="fastener",
api_path="simplecadapi.make_threaded_rod_rsolid",
result_kind="solid",
description="Threaded rod with configurable threaded span and pitch.",
example_parameters={"thread_diameter": 6.0, "thread_length": 10.0, "total_length": 16.0, "thread_pitch": 1.0, "chamfer_size": 0.0},
),
}
def list_parametric_part_capabilities(
*,
family: str | None = None,
only_available: bool = False,
) -> list[ParametricPartCapability]:
capabilities = sorted(PART_CAPABILITIES.values(), key=lambda item: item.part_id)
if family is not None:
capabilities = [capability for capability in capabilities if capability.family == family]
if only_available:
capabilities = [capability for capability in capabilities if capability.available()]
return capabilities
def get_parametric_part_capability(part_id: str) -> ParametricPartCapability:
try:
return PART_CAPABILITIES[part_id]
except KeyError as exc:
supported = ", ".join(sorted(PART_CAPABILITIES))
raise CadGenerationError(f"unsupported_parametric_part: {part_id}; supported: {supported}") from exc
def capability_manifest(capability: ParametricPartCapability) -> dict[str, Any]:
return {
"part_id": capability.part_id,
"family": capability.family,
"api_path": capability.api_path,
"result_kind": capability.result_kind,
"description": capability.description,
"required_packages": list(capability.required_packages),
"available": capability.available(),
"missing_packages": list(capability.missing_packages()),
"signature": str(inspect.signature(resolve_part_factory(capability))),
"example_parameters": dict(capability.example_parameters or {}),
}
def list_parametric_part_capability_manifests(
*,
family: str | None = None,
only_available: bool = False,
) -> list[dict[str, Any]]:
return [
capability_manifest(capability)
for capability in list_parametric_part_capabilities(family=family, only_available=only_available)
]
def require_part_dependencies(capability: ParametricPartCapability) -> None:
missing = capability.missing_packages()
if missing:
raise CadGenerationError(
"parametric_part_missing_runtime_dependencies: "
f"{capability.part_id} requires {', '.join(missing)}"
)
def resolve_part_factory(capability_or_id: ParametricPartCapability | str) -> Callable[..., Any]:
capability = (
get_parametric_part_capability(capability_or_id)
if isinstance(capability_or_id, str)
else capability_or_id
)
module_name, function_name = capability.api_path.rsplit(".", 1)
if module_name == "simplecadapi":
module = import_simplecadapi()
else:
import_simplecadapi()
module = importlib.import_module(module_name)
factory = getattr(module, function_name)
if not callable(factory):
raise CadGenerationError(f"parametric_part_factory_not_callable: {capability.api_path}")
return factory
def validate_part_parameters(part_id: str, parameters: Mapping[str, Any]) -> dict[str, Any]:
capability = get_parametric_part_capability(part_id)
factory = resolve_part_factory(capability)
signature = inspect.signature(factory)
try:
bound = signature.bind(**dict(parameters))
except TypeError as exc:
raise CadGenerationError(f"invalid_parametric_part_parameters: {part_id}: {exc}") from exc
bound.apply_defaults()
return dict(bound.arguments)
def make_parametric_part(
part_id: str,
parameters: Mapping[str, Any] | None = None,
*,
check_dependencies: bool = True,
) -> Any:
capability = get_parametric_part_capability(part_id)
if check_dependencies:
require_part_dependencies(capability)
factory = resolve_part_factory(capability)
return factory(**dict(parameters or {}))
def _shape_for_export(scad: Any, result: Any) -> Any:
if hasattr(result, "component_ids"):
return scad.make_compound_from_assembly_rcompound(assembly=result)
return result
def _result_summary(result: Any, shape: Any) -> dict[str, Any]:
summary: dict[str, Any] = {"result_type": type(result).__name__, "shape_type": type(shape).__name__}
for attr_name, output_name in (("get_solids", "solid_count"), ("get_faces", "face_count"), ("get_edges", "edge_count")):
method = getattr(shape, attr_name, None)
if callable(method):
try:
summary[output_name] = len(method())
except Exception: # noqa: BLE001
pass
volume = getattr(shape, "get_volume", None)
if callable(volume):
try:
summary["volume_mm3"] = float(volume())
except Exception: # noqa: BLE001
pass
component_ids = getattr(result, "component_ids", None)
if callable(component_ids):
try:
summary["component_ids"] = list(component_ids())
except Exception: # noqa: BLE001
pass
constraint_ids = getattr(result, "constraint_ids", None)
if callable(constraint_ids):
try:
summary["constraint_ids"] = list(constraint_ids())
except Exception: # noqa: BLE001
pass
return summary
def _clear_export_dir(out_dir: Path, filenames: Iterable[str]) -> None:
out_dir.mkdir(parents=True, exist_ok=True)
for filename in filenames:
path = out_dir / filename
if path.exists():
path.unlink()
def export_parametric_part(
part_id: str,
parameters: Mapping[str, Any] | None,
out_dir: Path,
*,
check_dependencies: bool = True,
) -> dict[str, Any]:
capability = get_parametric_part_capability(part_id)
if check_dependencies:
require_part_dependencies(capability)
scad = import_simplecadapi()
export_names = [
"part.step",
"part.model.json",
"part.session.json",
"part_manifest.json",
"build_meta.json",
]
_clear_export_dir(out_dir, export_names)
resolved_parameters = validate_part_parameters(part_id, parameters or {})
graph_id = f"parametric_part_{part_id}_{sha256_json(resolved_parameters)[:12]}"
with scad.GraphSession(graph_id=graph_id) as session:
result = make_parametric_part(part_id, resolved_parameters, check_dependencies=False)
shape = _shape_for_export(scad, result)
model_json, session_json = export_model_session_json(scad, session=session)
step_path = out_dir / "part.step"
model_path = out_dir / "part.model.json"
session_path = out_dir / "part.session.json"
manifest_path = out_dir / "part_manifest.json"
build_meta_path = out_dir / "build_meta.json"
model_path.write_text(model_json, encoding="utf-8")
session_path.write_text(session_json, encoding="utf-8")
scad.export_step(shapes=shape, filename=str(step_path))
model_payload = json.loads(model_json)
summary = _result_summary(result, shape)
manifest = {
"part_id": part_id,
"family": capability.family,
"api_path": capability.api_path,
"result_kind": capability.result_kind,
"parameters": resolved_parameters,
"parameters_hash": sha256_json(resolved_parameters),
"generated_at": utc_now_iso(),
"generator": "simplecadapi_parametric_part_catalog_v0.1.0",
"capability": capability_manifest(capability),
"geometry_summary": {
**summary,
"model_graph_nodes": len(model_payload.get("graph", {}).get("nodes", [])),
},
"exported_files": {
"step": str(step_path.resolve()),
"model": str(model_path.resolve()),
"session": str(session_path.resolve()),
"manifest": str(manifest_path.resolve()),
"build_meta": str(build_meta_path.resolve()),
},
}
write_json(manifest_path, manifest)
build_meta = {
"part_id": part_id,
"graph_id": graph_id,
"simplecadapi_file": getattr(scad, "__file__", "unknown"),
"python_executable": sys.executable,
"parameters_canonical_json": canonical_json(resolved_parameters),
"generated_at": utc_now_iso(),
"exported_files": manifest["exported_files"],
}
write_json(build_meta_path, build_meta)
return manifest
+166
View File
@@ -0,0 +1,166 @@
from __future__ import annotations
from typing import Any
from ..models import ReducerInstance
from .simplecad_parts import make_planet_placement, role_for_component
def cref(scad: Any, *, component_id: str, connector_id: str):
return scad.make_connector_ref_rconnectorref(
component_id=component_id,
connector_id=connector_id,
)
def add_forwarded_connector(
scad: Any,
*,
assembly: Any,
connector_id: str,
source_component_id: str,
source_connector_id: str,
name: str | None = None,
offset: Any | None = None,
):
return scad.forward_connector_rassembly(
assembly=assembly,
connector_id=connector_id,
source_component_id=source_component_id,
source_connector_id=source_connector_id,
name=name,
offset=offset,
)
def require_assembly_api(scad: Any) -> None:
required = [
"add_component_rassembly",
"add_connector_rassembly",
"forward_connector_rassembly",
"add_fixed_constraint_rassembly",
"add_revolute_constraint_rassembly",
"add_gear_constraint_rassembly",
"add_belt_constraint_rassembly",
"ground_component_rassembly",
"solve_assembly_constraints_rassembly",
]
missing = [name for name in required if not hasattr(scad, name)]
if missing:
raise RuntimeError(
"simplecadapi_missing_required_assembly_api: " + ", ".join(missing)
)
def build_assembly(
scad: Any,
*,
instance: ReducerInstance,
parts: dict[str, Any],
):
require_assembly_api(scad)
p = instance.parameters
d = instance.derived
fixed_member = instance.boundary.fixed
if fixed_member is None:
raise RuntimeError("simplecad_assembly_requires_fixed_member")
assembly = scad.make_assembly_rassembly(
assembly_id=f"{instance.run_id}_simple_2kh_{p.tooth_form}_reducer",
name=f"Formula-generated simple 2K-H {p.tooth_form} planetary reducer",
)
for component_id, item, placement, name in (
("housing", parts["housing"], scad.identity_placement_rplacement(), "Housing/reference"),
(
"ring",
parts["ring"],
scad.identity_placement_rplacement(),
f"{role_for_component(instance, 'ring').title()} internal ring gear",
),
(
"sun",
parts["sun"],
scad.identity_placement_rplacement(),
f"{role_for_component(instance, 'sun').title()} sun gear",
),
(
"carrier",
parts["carrier"],
scad.identity_placement_rplacement(),
f"{role_for_component(instance, 'carrier').title()} carrier",
),
):
assembly = scad.add_component_rassembly(
assembly=assembly,
item=item,
component_id=component_id,
placement=placement,
name=name,
)
for index in range(p.planet_count):
assembly = scad.add_component_rassembly(
assembly=assembly,
item=parts["planet"],
component_id=f"planet_{index + 1}",
placement=make_planet_placement(scad, instance=instance, index=index),
name=f"Planet gear {index + 1}",
)
assembly = scad.ground_component_rassembly(assembly=assembly, component_id=fixed_member)
assembly = scad.add_fixed_constraint_rassembly(
assembly=assembly,
constraint_id=f"housing_fixed_to_{fixed_member}",
connector_a=cref(scad, component_id=fixed_member, connector_id="axis"),
connector_b=cref(scad, component_id="housing", connector_id="axis"),
name=f"Housing fixed to {fixed_member}",
)
for member in ("sun", "ring", "carrier"):
if member == fixed_member:
continue
role = role_for_component(instance, member)
assembly = scad.add_revolute_constraint_rassembly(
assembly=assembly,
constraint_id=f"{member}_{role}_revolute",
connector_a=cref(scad, component_id=fixed_member, connector_id="axis"),
connector_b=cref(scad, component_id=member, connector_id="axis"),
drive_angle_degrees=0.0 if role == "input" else None,
angle_limit=None,
name=f"{member.title()} {role} revolute",
)
for index in range(p.planet_count):
planet_id = f"planet_{index + 1}"
assembly = scad.add_revolute_constraint_rassembly(
assembly=assembly,
constraint_id=f"{planet_id}_pin_revolute",
connector_a=cref(scad, component_id="carrier", connector_id=f"planet_{index + 1}_axis"),
connector_b=cref(scad, component_id=planet_id, connector_id="axis"),
drive_angle_degrees=None,
angle_limit=None,
name=f"Planet {index + 1} pin bearing revolute",
)
assembly = scad.add_gear_constraint_rassembly(
assembly=assembly,
constraint_id=f"sun_to_{planet_id}_external_mesh",
connector_a=cref(scad, component_id="sun", connector_id="axis"),
connector_b=cref(scad, component_id=planet_id, connector_id="axis"),
pitch_radius_a=d.sun_pitch_radius_mm,
pitch_radius_b=d.planet_pitch_radius_mm,
phase_offset=None,
name=f"Sun external mesh to planet {index + 1}",
)
assembly = scad.add_belt_constraint_rassembly(
assembly=assembly,
constraint_id=f"ring_to_{planet_id}_internal_mesh",
connector_a=cref(scad, component_id="ring", connector_id="axis"),
connector_b=cref(scad, component_id=planet_id, connector_id="axis"),
pulley_radius_a=d.ring_pitch_radius_mm,
pulley_radius_b=d.planet_pitch_radius_mm,
phase_offset=None,
name=f"Ring internal mesh to planet {index + 1}",
)
assembly = scad.solve_assembly_constraints_rassembly(assembly=assembly, strict=True)
return assembly
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+447
View File
@@ -0,0 +1,447 @@
from __future__ import annotations
import importlib
import json
import math
import os
import shutil
import sys
from pathlib import Path
from typing import Any
from ..models import (
AssemblyManifest,
ComponentManifest,
ConstraintManifest,
ReducerInstance,
sha256_json,
utc_now_iso,
write_json,
write_model_json,
)
from ..placement import PlacementPlan, solve_reducer_placements
from .gear_factory import gear_spec_for_component
from .simplecad_assembly import build_assembly
from .simplecad_parts import (
internal_ring_phase_correction_degrees,
make_carrier,
make_housing,
make_materials,
make_planet,
make_ring,
make_sun,
role_for_component,
)
class CadGenerationError(RuntimeError):
pass
def guard_no_gear_test() -> None:
loaded = [name for name in sys.modules if name == "Gear_Test" or name.startswith("Gear_Test.")]
if loaded:
raise CadGenerationError("anti_cheat_failure: Gear_Test is imported: " + ", ".join(loaded))
def _is_transient_simplecad_graph_id_collision(exc: Exception) -> bool:
text = f"{type(exc).__name__}: {exc}"
return "node id '" in text and "already exists in graph" in text
def _clear_generation_dir(out_dir: Path) -> None:
if not out_dir.exists():
return
for child in out_dir.iterdir():
if child.is_dir():
shutil.rmtree(child)
else:
child.unlink()
def import_simplecadapi() -> Any:
guard_no_gear_test()
try:
scad = importlib.import_module("simplecadapi")
except ModuleNotFoundError as exc:
raise CadGenerationError(
"simplecadapi_unavailable: install simplecadapi or run with a PYTHONPATH "
"pointing to SimpleCADAPI/src"
) from exc
for name in ["std", "GraphSession", "export_step", "export_model_json", "export_session_json"]:
if not hasattr(scad, name):
raise CadGenerationError(f"simplecadapi_missing_required_api: {name}")
if not hasattr(scad.std, "gear"):
raise CadGenerationError("simplecadapi_missing_required_api: std.gear")
for name in [
"make_spur_gear_rsolid",
"make_spur_ring_gear_rsolid",
"make_helical_gear_rsolid",
"make_helical_ring_gear_rsolid",
]:
if not hasattr(scad.std.gear, name):
raise CadGenerationError(f"simplecadapi_missing_required_gear_api: {name}")
return scad
def export_model_session_json(
scad: Any,
*,
session: Any,
recursion_limit: int = 50000,
) -> tuple[str, str]:
old_limit = sys.getrecursionlimit()
if old_limit < recursion_limit:
sys.setrecursionlimit(recursion_limit)
try:
return scad.export_model_json(session=session), scad.export_session_json(session=session)
finally:
if old_limit < recursion_limit:
sys.setrecursionlimit(old_limit)
def _component_manifests(
instance: ReducerInstance,
*,
placement_plan: PlacementPlan,
component_step_paths: dict[str, str] | None = None,
) -> dict[str, ComponentManifest]:
p = instance.parameters
d = instance.derived
common = [f"formula.run_id.{instance.run_id}"]
component_step_paths = component_step_paths or {}
sun_spec = gear_spec_for_component(instance, "sun")
planet_spec = gear_spec_for_component(instance, "planet")
ring_spec = gear_spec_for_component(instance, "ring")
def tags(component_id: str, formula_node: str) -> list[str]:
role = role_for_component(instance, component_id)
values = common + [f"formula.node.{formula_node}"]
if role in {"input", "fixed", "output"}:
values.append(f"formula.role.{role}")
return values
components: dict[str, ComponentManifest] = {
"housing": ComponentManifest(
component_id="housing",
formula_node="housing",
role="housing",
kind="housing",
center_xyz_mm=placement_plan.require_pose("housing").center_xyz_mm,
axis_xyz=placement_plan.require_pose("housing").axis_xyz,
tags=tags("housing", "housing"),
assembled_step_path=component_step_paths.get("housing"),
),
"ring": ComponentManifest(
component_id="ring",
formula_node="ring",
role=role_for_component(instance, "ring"),
kind="gear",
gear_type="internal",
tooth_count=p.z_ring,
tooth_form=p.tooth_form,
helix_angle_deg=ring_spec.helix_angle_deg,
helix_hand=ring_spec.helix_hand,
module_mm=p.module_mm,
pressure_angle_deg=p.pressure_angle_deg,
face_width_mm=p.face_width_mm,
pitch_radius_mm=d.ring_pitch_radius_mm,
center_xyz_mm=placement_plan.require_pose("ring").center_xyz_mm,
axis_xyz=placement_plan.require_pose("ring").axis_xyz,
tags=tags("ring", "ring"),
assembled_step_path=component_step_paths.get("ring"),
),
"sun": ComponentManifest(
component_id="sun",
formula_node="sun",
role=role_for_component(instance, "sun"),
kind="gear",
gear_type="external",
tooth_count=p.z_sun,
tooth_form=p.tooth_form,
helix_angle_deg=sun_spec.helix_angle_deg,
helix_hand=sun_spec.helix_hand,
module_mm=p.module_mm,
pressure_angle_deg=p.pressure_angle_deg,
face_width_mm=p.face_width_mm,
pitch_radius_mm=d.sun_pitch_radius_mm,
center_xyz_mm=placement_plan.require_pose("sun").center_xyz_mm,
axis_xyz=placement_plan.require_pose("sun").axis_xyz,
tags=tags("sun", "sun"),
assembled_step_path=component_step_paths.get("sun"),
),
"carrier": ComponentManifest(
component_id="carrier",
formula_node="carrier",
role=role_for_component(instance, "carrier"),
kind="carrier",
center_xyz_mm=placement_plan.require_pose("carrier").center_xyz_mm,
axis_xyz=placement_plan.require_pose("carrier").axis_xyz,
tags=tags("carrier", "carrier"),
assembled_step_path=component_step_paths.get("carrier"),
),
}
for index in range(p.planet_count):
planet_id = f"planet_{index + 1}"
pose = placement_plan.require_pose(planet_id)
components[f"planet_{index + 1}"] = ComponentManifest(
component_id=f"planet_{index + 1}",
formula_node="planet",
role="planet",
kind="gear",
gear_type="external",
tooth_count=p.z_planet,
tooth_form=p.tooth_form,
helix_angle_deg=planet_spec.helix_angle_deg,
helix_hand=planet_spec.helix_hand,
module_mm=p.module_mm,
pressure_angle_deg=p.pressure_angle_deg,
face_width_mm=p.face_width_mm,
pitch_radius_mm=d.planet_pitch_radius_mm,
center_xyz_mm=pose.center_xyz_mm,
axis_xyz=pose.axis_xyz,
tags=tags(f"planet_{index + 1}", "planet"),
assembled_step_path=component_step_paths.get(f"planet_{index + 1}"),
)
return components
def _placement_z_angle_degrees(placement: Any) -> float:
return math.degrees(math.atan2(float(placement.x_axis[1]), float(placement.x_axis[0])))
def _shape_in_world(scad: Any, *, shape: Any, placement: Any) -> Any:
angle = _placement_z_angle_degrees(placement)
rotated = scad.rotate_shape(
shape=shape,
angle=angle,
axis=(0.0, 0.0, 1.0),
origin=(0.0, 0.0, 0.0),
)
return scad.translate_shape(rotated, tuple(float(value) for value in placement.origin))
def _export_assembled_component_steps(
scad: Any,
*,
assembly: Any,
out_dir: Path,
) -> dict[str, str]:
component_dir = out_dir / "component_steps"
component_dir.mkdir(parents=True, exist_ok=True)
paths: dict[str, str] = {}
for component_id in assembly.component_ids():
component = assembly.get_component(component_id)
item = component.item
if hasattr(item, "body"):
shape = item.body
elif hasattr(item, "component_ids"):
shape = scad.make_compound_from_assembly_rcompound(assembly=item)
else:
continue
shape = _shape_in_world(scad, shape=shape, placement=component.placement)
path = component_dir / f"{component_id}.step"
scad.export_step(shapes=shape, filename=str(path))
paths[component_id] = str(path.resolve())
return paths
def _constraint_manifests(instance: ReducerInstance) -> list[ConstraintManifest]:
fixed_member = instance.boundary.fixed
if fixed_member is None:
raise CadGenerationError("simplecad_generator_requires_fixed_member")
constraints = [
ConstraintManifest(
constraint_id=f"housing_fixed_to_{fixed_member}",
relation_type="fixed",
component_a=fixed_member,
component_b="housing",
source="simplecadapi_constraint",
cad_constraint_kind="fixed",
)
]
for member in ("sun", "ring", "carrier"):
if member == fixed_member:
continue
role = role_for_component(instance, member)
constraints.append(
ConstraintManifest(
constraint_id=f"{member}_{role}_revolute",
relation_type="revolute_joint",
component_a=fixed_member,
component_b=member,
source="simplecadapi_constraint",
cad_constraint_kind="revolute",
)
)
for index in range(instance.parameters.planet_count):
planet_id = f"planet_{index + 1}"
constraints.extend(
[
ConstraintManifest(
constraint_id=f"{planet_id}_pin_revolute",
relation_type="revolute_joint",
component_a="carrier",
component_b=planet_id,
source="simplecadapi_constraint",
cad_constraint_kind="revolute",
),
ConstraintManifest(
constraint_id=f"sun_to_{planet_id}_external_mesh",
relation_type="external_mesh",
component_a="sun",
component_b=planet_id,
source="simplecadapi_constraint",
cad_constraint_kind="gear",
),
ConstraintManifest(
constraint_id=f"ring_to_{planet_id}_internal_mesh",
relation_type="internal_mesh",
component_a="ring",
component_b=planet_id,
source="simplecadapi_constraint",
cad_constraint_kind="belt",
),
]
)
return constraints
def generate_simplecad(instance: ReducerInstance, out_dir: Path) -> AssemblyManifest:
for attempt in range(2):
try:
return _generate_simplecad_once(instance, out_dir)
except Exception as exc: # noqa: BLE001
if attempt == 0 and _is_transient_simplecad_graph_id_collision(exc):
_clear_generation_dir(out_dir)
continue
raise
raise CadGenerationError("simplecad_generation_retry_exhausted")
def _generate_simplecad_once(instance: ReducerInstance, out_dir: Path) -> AssemblyManifest:
if instance.detail_level == "industrial_core":
from .simplecad_industrial_generator import generate_simplecad_industrial
return generate_simplecad_industrial(instance, out_dir)
if instance.topology_family == "simple_2k_h_cascade":
from .simplecad_cascade_generator import generate_simplecad_cascade
return generate_simplecad_cascade(instance, out_dir)
if instance.topology_family == "ferguson_wolfrom":
from .simplecad_ferguson_wolfrom_generator import generate_simplecad_ferguson_wolfrom
return generate_simplecad_ferguson_wolfrom(instance, out_dir)
if instance.topology_family != "simple_2k_h":
raise CadGenerationError(f"unsupported_topology_family_for_cad: {instance.topology_family}")
guard_no_gear_test()
if instance.parameters.tooth_form not in {"spur", "helical"}:
raise CadGenerationError(f"unsupported_tooth_form: {instance.parameters.tooth_form}")
scad = import_simplecadapi()
out_dir.mkdir(parents=True, exist_ok=True)
step_path = out_dir / "reducer.step"
model_path = out_dir / "reducer.model.json"
session_path = out_dir / "reducer.session.json"
manifest_path = out_dir / "assembly_manifest.json"
build_meta_path = out_dir / "build_meta.json"
placement_plan_path = out_dir / "placement_plan.json"
placement_plan = solve_reducer_placements(instance)
graph_id = f"{instance.run_id}_simple_2kh_{instance.parameters.tooth_form}_generation"
with scad.GraphSession(graph_id=graph_id) as session:
materials = make_materials(scad)
parts = {
"sun": make_sun(scad, instance=instance, material=materials["gear"]),
"planet": make_planet(scad, instance=instance, material=materials["gear"]),
"ring": make_ring(scad, instance=instance, material=materials["ring"]),
"carrier": make_carrier(scad, instance=instance, material=materials["carrier"]),
"housing": make_housing(scad, instance=instance, material=materials["housing"]),
}
assembly = build_assembly(scad, instance=instance, parts=parts)
preview = scad.make_compound_from_assembly_rcompound(assembly=assembly)
component_step_paths = _export_assembled_component_steps(
scad,
assembly=assembly,
out_dir=out_dir,
)
model_json, session_json = export_model_session_json(scad, session=session)
model_path.write_text(model_json, encoding="utf-8")
session_path.write_text(session_json, encoding="utf-8")
scad.export_step(shapes=preview, filename=str(step_path))
write_model_json(placement_plan_path, placement_plan)
model_payload = json.loads(model_json)
preview_solids = len(preview.get_solids())
preview_volume = float(preview.get_volume())
manifest = AssemblyManifest(
run_id=instance.run_id,
formula_instance_hash=sha256_json(instance.model_dump(mode="json")),
generated_at=utc_now_iso(),
generator=f"simplecadapi_{instance.parameters.tooth_form}_simple_2kh_v0.1.0",
components=_component_manifests(instance, placement_plan=placement_plan),
constraints=_constraint_manifests(instance),
exported_files={
"step": str(step_path.resolve()),
"model": str(model_path.resolve()),
"session": str(session_path.resolve()),
"manifest": str(manifest_path.resolve()),
"build_meta": str(build_meta_path.resolve()),
"component_steps_dir": str((out_dir / "component_steps").resolve()),
"placement_plan": str(placement_plan_path.resolve()),
},
geometry_summary={
"preview_solids": preview_solids,
"preview_volume": preview_volume,
"model_graph_nodes": len(model_payload.get("graph", {}).get("nodes", [])),
"assembled_component_step_count": len(component_step_paths),
"internal_ring_phase_correction_deg": internal_ring_phase_correction_degrees(
z_planet=instance.parameters.z_planet,
z_ring=instance.parameters.z_ring,
),
"planet_phase_formula": "180 - 180 / z_planet + planet_center_angle_deg * (1 + z_sun / z_planet)",
"tooth_form": instance.parameters.tooth_form,
"helix_angle_deg": instance.parameters.helix_angle_deg,
"helix_hand": instance.parameters.helix_hand,
"placement_solver": placement_plan.solver_version,
},
)
manifest = manifest.model_copy(
update={"components": _component_manifests(instance, placement_plan=placement_plan, component_step_paths=component_step_paths)}
)
write_model_json(manifest_path, manifest)
build_meta = {
"run_id": instance.run_id,
"formula_instance_hash": manifest.formula_instance_hash,
"generator": manifest.generator,
"simplecadapi_file": getattr(scad, "__file__", "unknown"),
"graph_id": graph_id,
"tooth_form": instance.parameters.tooth_form,
"helix_angle_deg": instance.parameters.helix_angle_deg,
"helix_hand": instance.parameters.helix_hand,
"boundary": instance.boundary.model_dump(mode="json"),
"assembly_id": getattr(assembly, "assembly_id", None),
"component_ids": assembly.component_ids(),
"constraint_ids": assembly.constraint_ids(),
"preview_solids": preview_solids,
"preview_volume": preview_volume,
"model_graph_nodes": manifest.geometry_summary["model_graph_nodes"],
"internal_ring_phase_correction_deg": manifest.geometry_summary["internal_ring_phase_correction_deg"],
"planet_phase_formula": manifest.geometry_summary["planet_phase_formula"],
"exported_files": manifest.exported_files,
"placement_plan_hash": sha256_json(placement_plan.model_dump(mode="json")),
"file_mtime_ns": {
"step": step_path.stat().st_mtime_ns,
"model": model_path.stat().st_mtime_ns,
"session": session_path.stat().st_mtime_ns,
"placement_plan": placement_plan_path.stat().st_mtime_ns,
},
"generated_at": utc_now_iso(),
}
write_json(build_meta_path, build_meta)
return manifest
File diff suppressed because it is too large Load Diff
+341
View File
@@ -0,0 +1,341 @@
from __future__ import annotations
import math
from typing import Any
from ..models import ReducerInstance
from ..placement.simple_2k_h import (
planet_center_xy,
planet_phase_angle_degrees,
)
from .gear_factory import (
gear_spec_for_component,
make_external_gear,
make_internal_ring_gear,
)
def make_z_rotation_placement(scad: Any, *, origin: tuple[float, float, float], angle_degrees: float):
angle = math.radians(angle_degrees)
return scad.make_placement_rplacement(
origin=origin,
x_axis=(math.cos(angle), math.sin(angle), 0.0),
y_axis=(-math.sin(angle), math.cos(angle), 0.0),
)
def _axis_face(body: Any, target_z: float):
candidates = []
for face in body.get_faces():
try:
normal = face.get_normal_at()
if normal.z < 0.7:
continue
center = face.get_center()
candidates.append((abs(center.z - target_z), -face.get_area(), face))
except Exception: # noqa: BLE001
continue
if not candidates:
raise ValueError("no +Z axis face found")
return min(candidates, key=lambda item: item[0:2])[2]
def add_axis_connector(
scad: Any,
*,
body: Any,
part: Any,
connector_id: str,
origin: tuple[float, float, float],
name: str,
):
connector_factory = getattr(scad, "make_placement_connector_rconnector", None)
if callable(connector_factory):
placement = scad.make_placement_rplacement(
origin=origin,
x_axis=(1.0, 0.0, 0.0),
y_axis=(0.0, 1.0, 0.0),
)
connector = connector_factory(
connector_id=connector_id,
placement=placement,
name=name,
)
else:
face_connector = getattr(scad, "make_face_connector_rconnector", None)
if not callable(face_connector):
from simplecadapi import make_face_connector_rconnector as face_connector
connector = face_connector(
connector_id=connector_id,
face=_axis_face(body, origin[2]),
name=name,
)
return scad.add_connector_rpart(part=part, connector=connector)
def make_axis_part(
scad: Any,
*,
part_id: str,
body: Any,
name: str,
material: Any,
connector_specs: tuple[tuple[str, tuple[float, float, float], str], ...],
):
part = scad.make_part_rpart(part_id=part_id, body=body, name=name)
part = scad.assign_material_rpart(part=part, material=material)
for connector_id, origin, connector_name in connector_specs:
part = add_axis_connector(
scad,
body=body,
part=part,
connector_id=connector_id,
origin=origin,
name=connector_name,
)
return part
def make_materials(scad: Any) -> dict[str, Any]:
return {
"gear": scad.make_material_rmaterial(
material_id="case_hardened_steel",
name="Case hardened gear steel",
density=7.85e-6,
density_unit="kg/mm^3",
color=(0.70, 0.70, 0.74),
),
"ring": scad.make_material_rmaterial(
material_id="nitrided_internal_ring_steel",
name="Nitrided internal ring steel",
density=7.85e-6,
density_unit="kg/mm^3",
color=(0.42, 0.43, 0.47),
),
"carrier": scad.make_material_rmaterial(
material_id="aluminum_7075_t6",
name="7075-T6 aluminum carrier",
density=2.81e-6,
density_unit="kg/mm^3",
color=(0.18, 0.46, 0.70),
),
"housing": scad.make_material_rmaterial(
material_id="anodized_aluminum_reference_housing",
name="Anodized aluminum housing/reference",
density=2.70e-6,
density_unit="kg/mm^3",
color=(0.35, 0.36, 0.38),
),
}
def _bore_radius_for_sun(instance: ReducerInstance) -> float:
return max(0.8, min(2.2, instance.derived.sun_pitch_radius_mm * 0.35))
def _pin_radius_for_planet(instance: ReducerInstance) -> float:
return max(0.6, min(2.0, instance.derived.planet_pitch_radius_mm * 0.22))
def role_for_component(instance: ReducerInstance, component_id: str) -> str:
if component_id == instance.boundary.input:
return "input"
if component_id == instance.boundary.fixed:
return "fixed"
if component_id == instance.boundary.output:
return "output"
if component_id == "housing":
return "housing"
if component_id.startswith("planet"):
return "planet"
return component_id
def apply_formula_tags(scad: Any, *, shape: Any, instance: ReducerInstance, component_id: str) -> Any:
tagged = scad.apply_tag(shape=shape, tag=f"formula.run_id.{instance.run_id}")
tagged = scad.apply_tag(shape=tagged, tag=f"formula.node.{component_id}")
role = role_for_component(instance, component_id)
if role in {"input", "fixed", "output"}:
tagged = scad.apply_tag(shape=tagged, tag=f"formula.role.{role}")
return tagged
def make_sun(scad: Any, *, instance: ReducerInstance, material: Any):
p = instance.parameters
spec = gear_spec_for_component(instance, "sun")
g = make_external_gear(scad, spec)
bore = scad.make_cylinder_rsolid(
radius=_bore_radius_for_sun(instance),
height=p.face_width_mm + 2.0,
bottom_face_center=(0.0, 0.0, -1.0),
axis=(0.0, 0.0, 1.0),
)
g = scad.cut_rsolid(g, bore, skip_non_intersecting=False)
g = apply_formula_tags(scad, shape=g, instance=instance, component_id="sun")
return make_axis_part(
scad,
part_id="sun_gear",
body=g,
name=f"{role_for_component(instance, 'sun').title()} {p.tooth_form} sun gear {p.z_sun}t m={p.module_mm}",
material=material,
connector_specs=(("axis", (0.0, 0.0, p.face_width_mm / 2.0), "Sun axis"),),
)
def make_planet(scad: Any, *, instance: ReducerInstance, material: Any):
p = instance.parameters
spec = gear_spec_for_component(instance, "planet")
g = make_external_gear(scad, spec)
bore = scad.make_cylinder_rsolid(
radius=_pin_radius_for_planet(instance) + 0.25,
height=p.face_width_mm + 2.0,
bottom_face_center=(0.0, 0.0, -1.0),
axis=(0.0, 0.0, 1.0),
)
g = scad.cut_rsolid(g, bore, skip_non_intersecting=False)
g = scad.apply_tag(shape=g, tag=f"formula.run_id.{instance.run_id}")
g = scad.apply_tag(shape=g, tag="formula.node.planet")
return make_axis_part(
scad,
part_id="planet_gear",
body=g,
name=f"Reusable {p.tooth_form} planet gear {p.z_planet}t m={p.module_mm}",
material=material,
connector_specs=(("axis", (0.0, 0.0, p.face_width_mm / 2.0), "Planet spin axis"),),
)
def internal_ring_phase_correction_degrees(*, z_planet: int, z_ring: int) -> float:
return 180.0 / z_ring if z_planet % 2 else 0.0
def make_ring(scad: Any, *, instance: ReducerInstance, material: Any):
p = instance.parameters
spec = gear_spec_for_component(instance, "ring")
g = make_internal_ring_gear(scad, spec)
phase = internal_ring_phase_correction_degrees(z_planet=p.z_planet, z_ring=p.z_ring)
if abs(phase) > 1e-12:
g = scad.rotate_shape(
shape=g,
angle=phase,
axis=(0.0, 0.0, 1.0),
origin=(0.0, 0.0, 0.0),
)
g = apply_formula_tags(scad, shape=g, instance=instance, component_id="ring")
return make_axis_part(
scad,
part_id="ring_gear",
body=g,
name=f"{role_for_component(instance, 'ring').title()} internal {p.tooth_form} ring gear {p.z_ring}t m={p.module_mm}",
material=material,
connector_specs=(("axis", (0.0, 0.0, p.face_width_mm / 2.0), "Ring axis"),),
)
def make_planet_placement(scad: Any, *, instance: ReducerInstance, index: int):
x, y = planet_center_xy(instance, index)
return make_z_rotation_placement(scad, origin=(x, y, 0.0), angle_degrees=planet_phase_angle_degrees(instance, index))
def make_carrier(scad: Any, *, instance: ReducerInstance, material: Any):
p = instance.parameters
carrier_thickness = max(2.5, min(4.0, p.face_width_mm * 0.35))
bottom_z = -carrier_thickness - 0.5
pin_radius = _pin_radius_for_planet(instance)
hub_radius = max(_bore_radius_for_sun(instance) + 2.0, instance.derived.sun_pitch_radius_mm * 0.75)
disk_radius = instance.derived.planet_orbit_radius_mm + pin_radius * 2.5
disk = scad.make_cylinder_rsolid(
radius=disk_radius,
height=carrier_thickness,
bottom_face_center=(0.0, 0.0, bottom_z),
axis=(0.0, 0.0, 1.0),
)
hub = scad.make_cylinder_rsolid(
radius=hub_radius,
height=carrier_thickness,
bottom_face_center=(0.0, 0.0, bottom_z),
axis=(0.0, 0.0, 1.0),
)
pins = []
bosses = []
for index in range(p.planet_count):
x, y = planet_center_xy(instance, index)
bosses.append(
scad.make_cylinder_rsolid(
radius=pin_radius * 1.8,
height=carrier_thickness,
bottom_face_center=(x, y, bottom_z),
axis=(0.0, 0.0, 1.0),
)
)
pins.append(
scad.make_cylinder_rsolid(
radius=pin_radius,
height=p.face_width_mm + carrier_thickness + 1.0,
bottom_face_center=(x, y, bottom_z),
axis=(0.0, 0.0, 1.0),
)
)
body = scad.union_rsolid([disk, hub, bosses, pins], glue=False)
center_bore = scad.make_cylinder_rsolid(
radius=_bore_radius_for_sun(instance) + 0.35,
height=carrier_thickness + 2.0,
bottom_face_center=(0.0, 0.0, bottom_z - 1.0),
axis=(0.0, 0.0, 1.0),
)
body = scad.cut_rsolid(body, center_bore, skip_non_intersecting=False)
body = apply_formula_tags(scad, shape=body, instance=instance, component_id="carrier")
connectors = [
("axis", (0.0, 0.0, p.face_width_mm / 2.0), "Carrier axis"),
("output_axis", (0.0, 0.0, p.face_width_mm / 2.0), "Public carrier axis"),
]
connectors.extend(
(
f"planet_{index + 1}_axis",
(*planet_center_xy(instance, index), p.face_width_mm / 2.0),
f"Planet {index + 1} pin axis",
)
for index in range(p.planet_count)
)
return make_axis_part(
scad,
part_id="carrier",
body=body,
name=f"{role_for_component(instance, 'carrier').title()} carrier with {p.planet_count} pins",
material=material,
connector_specs=tuple(connectors),
)
def make_housing(scad: Any, *, instance: ReducerInstance, material: Any):
p = instance.parameters
outer_radius = instance.derived.ring_outer_diameter_mm / 2.0 + 2.0
inner_radius = max(outer_radius - 3.0, instance.derived.ring_pitch_radius_mm + 1.0)
bottom_z = -2.2
height = 1.2
outer = scad.make_cylinder_rsolid(
radius=outer_radius,
height=height,
bottom_face_center=(0.0, 0.0, bottom_z),
axis=(0.0, 0.0, 1.0),
)
bore = scad.make_cylinder_rsolid(
radius=inner_radius,
height=height + 2.0,
bottom_face_center=(0.0, 0.0, bottom_z - 1.0),
axis=(0.0, 0.0, 1.0),
)
body = scad.cut_rsolid(outer, bore, skip_non_intersecting=False)
body = scad.apply_tag(shape=body, tag=f"formula.run_id.{instance.run_id}")
body = scad.apply_tag(shape=body, tag="formula.node.housing")
return make_axis_part(
scad,
part_id="housing",
body=body,
name="Minimal fixed housing/reference ring",
material=material,
connector_specs=(("axis", (0.0, 0.0, p.face_width_mm / 2.0), "Housing axis"),),
)
+172
View File
@@ -0,0 +1,172 @@
from __future__ import annotations
import math
from dataclasses import dataclass
from .models import ReducerInstance, StageDerived
CASCADE_INTERSTAGE_COMPONENT_IDS = {
"cascade_input_shaft",
"cascade_output_shaft",
"interstage_shaft",
"interstage_coupler",
"interstage_bearing_1",
"interstage_bearing_2",
"interstage_support_1",
"interstage_support_2",
}
def stage_carrier_plate_thickness_mm(stage_gap_mm: float) -> float:
return max(0.8, min(1.2, max(0.8, stage_gap_mm * 0.45)))
def sun_bore_radius_from_derived(derived: StageDerived) -> float:
return max(0.8, min(2.2, derived.sun_pitch_radius_mm * 0.35))
@dataclass(frozen=True)
class CascadeInterstageDesign:
source_component_id: str
target_component_id: str
shaft_diameter_mm: float
shaft_bottom_z_mm: float
shaft_top_z_mm: float
s1_output_axis_z_mm: float
s2_input_axis_z_mm: float
coupler_inner_diameter_mm: float
coupler_outer_diameter_mm: float
coupler_width_mm: float
coupler_center_z_mm: float
bearing_inner_diameter_mm: float
bearing_outer_diameter_mm: float
bearing_width_mm: float
bearing_ball_diameter_mm: float
bearing_ball_count: int
bearing_centers_z_mm: dict[str, float]
support_inner_diameter_mm: float
support_outer_diameter_mm: float
support_width_mm: float
support_centers_z_mm: dict[str, float]
housing_inner_diameter_mm: float
housing_outer_diameter_mm: float
s1_carrier_top_z_mm: float
s2_sun_bottom_z_mm: float
s2_carrier_top_z_mm: float
s2_sun_bore_diameter_mm: float
@property
def shaft_span_mm(self) -> float:
return self.shaft_top_z_mm - self.shaft_bottom_z_mm
def _stage_by_id(instance: ReducerInstance) -> dict[str, StageDerived]:
return {stage.stage_id: stage for stage in (instance.derived.stages or [])}
def compute_cascade_interstage_design(instance: ReducerInstance) -> CascadeInterstageDesign:
if instance.topology_family != "simple_2k_h_cascade":
raise ValueError("cascade interstage design requires simple_2k_h_cascade")
if not instance.parameters.stages or len(instance.parameters.stages) != 2:
raise ValueError("cascade interstage design requires exactly two stage parameter sets")
if not instance.requirement.stages or len(instance.requirement.stages) != 2:
raise ValueError("cascade interstage design requires exactly two stage requirements")
stage_derived = _stage_by_id(instance)
s1 = instance.parameters.stages[0]
s2 = instance.parameters.stages[1]
s1d = stage_derived[s1.stage_id]
s2d = stage_derived[s2.stage_id]
stage_gap = float((instance.derived.axial_stack or {}).get("stage_gap_mm", 2.0))
s1_plate_thickness = stage_carrier_plate_thickness_mm(stage_gap)
s2_plate_thickness = stage_carrier_plate_thickness_mm(stage_gap)
s1_carrier_top = s1d.z_offset_mm + s1.face_width_mm + 0.25 + s1_plate_thickness
s2_sun_bottom = s2d.z_offset_mm
available_gap = max(0.35, s2_sun_bottom - s1_carrier_top)
s2_sun_bore_radius = sun_bore_radius_from_derived(s2d)
shaft_radius = min(
max(0.45, s2_sun_bore_radius * 0.72),
max(0.35, s2_sun_bore_radius - 0.20),
)
bearing_inner_radius = shaft_radius + max(0.08, s2.module_mm * 0.18)
bearing_outer_radius = bearing_inner_radius + max(0.35, s2.module_mm * 0.90)
bearing_width = max(0.28, min(0.60, available_gap * 0.45))
bearing_ball_diameter = max(
0.12,
min(
(bearing_outer_radius - bearing_inner_radius) * 0.62,
bearing_width * 0.42,
),
)
ball_pitch_radius = (bearing_inner_radius + bearing_outer_radius) / 2.0
estimated_ball_count = int((2.0 * math.pi * ball_pitch_radius) / (bearing_ball_diameter * 1.5))
ball_count = max(8, min(18, estimated_ball_count))
bearing_1_center = s1_carrier_top + available_gap * 0.35
s2_carrier_top = s2d.z_offset_mm + s2.face_width_mm + 0.25 + s2_plate_thickness
bearing_2_center = s2_carrier_top + 0.35 + bearing_width / 2.0
coupler_width = max(0.20, min(0.40, available_gap * 0.28))
coupler_center = s2_sun_bottom - coupler_width / 2.0 - 0.05
support_inner_radius = bearing_outer_radius + max(0.08, s2.module_mm * 0.20)
max_ring_outer_radius = max(stage.ring_outer_diameter_mm / 2.0 for stage in stage_derived.values())
housing_inner_radius = max_ring_outer_radius + 2.5
housing_outer_radius = max_ring_outer_radius + 5.0
support_outer_radius = housing_inner_radius - 0.25
coupler_inner_radius = shaft_radius + max(0.04, s2.module_mm * 0.10)
coupler_outer_radius = min(
support_inner_radius - max(0.08, s2.module_mm * 0.16),
s2_sun_bore_radius + max(0.25, s2.module_mm * 0.75),
)
coupler_outer_radius = max(coupler_outer_radius, coupler_inner_radius + max(0.20, s2.module_mm * 0.40))
shaft_bottom = max(s1d.z_offset_mm, s1.face_width_mm - 0.50)
shaft_top = bearing_2_center + bearing_width / 2.0 + 0.40
first_requirement, second_requirement = instance.requirement.stages
source_component_id = f"{first_requirement.stage_id}_{first_requirement.output}"
target_component_id = f"{second_requirement.stage_id}_{second_requirement.input}"
return CascadeInterstageDesign(
source_component_id=source_component_id,
target_component_id=target_component_id,
shaft_diameter_mm=2.0 * shaft_radius,
shaft_bottom_z_mm=shaft_bottom,
shaft_top_z_mm=shaft_top,
s1_output_axis_z_mm=s2d.z_offset_mm + s2.face_width_mm / 2.0,
s2_input_axis_z_mm=s2d.z_offset_mm + s2.face_width_mm / 2.0,
coupler_inner_diameter_mm=2.0 * coupler_inner_radius,
coupler_outer_diameter_mm=2.0 * coupler_outer_radius,
coupler_width_mm=coupler_width,
coupler_center_z_mm=coupler_center,
bearing_inner_diameter_mm=2.0 * bearing_inner_radius,
bearing_outer_diameter_mm=2.0 * bearing_outer_radius,
bearing_width_mm=bearing_width,
bearing_ball_diameter_mm=bearing_ball_diameter,
bearing_ball_count=ball_count,
bearing_centers_z_mm={
"interstage_bearing_1": bearing_1_center,
"interstage_bearing_2": bearing_2_center,
},
support_inner_diameter_mm=2.0 * support_inner_radius,
support_outer_diameter_mm=2.0 * support_outer_radius,
support_width_mm=bearing_width,
support_centers_z_mm={
"interstage_support_1": bearing_1_center,
"interstage_support_2": bearing_2_center,
},
housing_inner_diameter_mm=2.0 * housing_inner_radius,
housing_outer_diameter_mm=2.0 * housing_outer_radius,
s1_carrier_top_z_mm=s1_carrier_top,
s2_sun_bottom_z_mm=s2_sun_bottom,
s2_carrier_top_z_mm=s2_carrier_top,
s2_sun_bore_diameter_mm=2.0 * s2_sun_bore_radius,
)
def cascade_interstage_component_ids(instance: ReducerInstance) -> set[str]:
if instance.topology_family != "simple_2k_h_cascade":
return set()
return set(CASCADE_INTERSTAGE_COMPONENT_IDS)
+507
View File
@@ -0,0 +1,507 @@
from __future__ import annotations
import argparse
import json
import shutil
import sys
import traceback
from pathlib import Path
import networkx as nx
from networkx.readwrite import json_graph
from .assembly_reader import read_assembly_snapshot
from .cad.ocp_interference import OcpInterferenceError, export_interference_steps
from .cad.parametric_parts import (
export_parametric_part,
list_parametric_part_capability_manifests,
)
from .cad.simplecad_generator import CadGenerationError, generate_simplecad, guard_no_gear_test
from .kinematics import motion_samples, solve_instance
from .joint_module.auto_design import run_auto_joint_design
from .joint_module.generator import run_joint_module
from .joint_module.urdf_exporter import export_urdf
from .models import (
AssemblyManifest,
ReducerInstance,
read_json,
write_json,
write_model_json,
)
from .parameter_solver import ParameterSolveError, load_requirement, solve_parameters
from .placement import solve_reducer_placements
from .reducer_urdf_exporter import export_reducer_urdf
from .topology import TopologyError, build_graph, validate_topology
from .topology_runtime import load_default_template
from .validators import validate_from_files
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
RUN_MARKER = ".planetary_reducer_run_dir"
CONTROLLED_OUTPUTS = [
"input_snapshot",
"formula",
"kinematics",
"placement",
"cad",
"validation",
"exports",
"generation_error.json",
"joint_generation_error.json",
"auto_generation_error.json",
"urdf_export_error.json",
]
def _prepare_run_dir(out_dir: Path) -> None:
out_dir.mkdir(parents=True, exist_ok=True)
marker = out_dir / RUN_MARKER
for name in CONTROLLED_OUTPUTS:
path = out_dir / name
if path.exists():
if path.is_dir():
shutil.rmtree(path)
else:
path.unlink()
marker.write_text("planetary_reducer_system_demo\n", encoding="utf-8")
def _project_root() -> Path:
return PACKAGE_ROOT
def _ensure_under(path: Path, root: Path, *, label: str) -> Path:
resolved = path.expanduser().resolve()
root_resolved = root.resolve()
try:
resolved.relative_to(root_resolved)
except ValueError as exc:
raise ValueError(f"{label}_must_be_under_{root.name}: {resolved}") from exc
return resolved
def _input_path(path_text: str) -> Path:
return _ensure_under(Path(path_text), _project_root() / "input", label="input")
def _output_path(path_text: str) -> Path:
return _ensure_under(Path(path_text), _project_root() / "output", label="output")
def _run_paths(out_dir: Path) -> dict[str, Path]:
return {
"input_snapshot": out_dir / "input_snapshot",
"formula": out_dir / "formula",
"kinematics": out_dir / "kinematics",
"placement": out_dir / "placement",
"cad": out_dir / "cad",
"validation": out_dir / "validation",
"exports": out_dir / "exports",
}
def _prepare_structured_run_dir(out_dir: Path) -> dict[str, Path]:
_prepare_run_dir(out_dir)
paths = _run_paths(out_dir)
for path in paths.values():
path.mkdir(parents=True, exist_ok=True)
return paths
def _copy_input_snapshot(input_path: Path, snapshot_dir: Path) -> None:
snapshot_dir.mkdir(parents=True, exist_ok=True)
target = snapshot_dir / input_path.name
if input_path.exists() and input_path.is_file():
shutil.copy2(input_path, target)
def _load_instance(path: Path) -> ReducerInstance:
return ReducerInstance.model_validate(read_json(path))
def cmd_solve(args: argparse.Namespace) -> int:
guard_no_gear_test()
out_dir = _output_path(args.output)
out_dir.mkdir(parents=True, exist_ok=True)
requirement_path = _input_path(args.input)
requirement = load_requirement(requirement_path)
template = load_default_template(requirement.topology_family)
validate_topology(template, requirement)
graph = build_graph(template)
instance, report = solve_parameters(requirement=requirement, template=template)
solution = solve_instance(instance)
samples = motion_samples(instance, solution)
placement_plan = solve_reducer_placements(instance)
paths = _run_paths(out_dir)
for path in paths.values():
path.mkdir(parents=True, exist_ok=True)
_copy_input_snapshot(requirement_path, paths["input_snapshot"])
write_model_json(paths["formula"] / "formula_instance.json", instance)
write_model_json(paths["formula"] / "parameter_search_report.json", report)
write_model_json(paths["kinematics"] / "kinematic_solution.json", solution)
write_model_json(paths["kinematics"] / "motion_samples.json", samples)
write_model_json(paths["placement"] / "placement_plan.json", placement_plan)
if instance.industrial_parameters is not None:
write_model_json(paths["formula"] / "industrial_parameters.json", instance.industrial_parameters)
if instance.assembly_topology is not None:
write_model_json(paths["formula"] / "assembly_topology.json", instance.assembly_topology)
graph_payload = json_graph.node_link_data(graph, edges="edges")
write_json(paths["formula"] / "topology_graph.json", graph_payload)
print(f"solved run_id={instance.run_id} ratio={instance.derived.ratio:.9f}")
return 0
def cmd_generate(args: argparse.Namespace) -> int:
guard_no_gear_test()
out_dir = _output_path(args.output)
out_dir.mkdir(parents=True, exist_ok=True)
instance = _load_instance(Path(args.instance))
try:
manifest = generate_simplecad(instance, out_dir)
except Exception as exc: # noqa: BLE001
payload = {
"run_id": instance.run_id,
"error_type": type(exc).__name__,
"message": str(exc),
"traceback": traceback.format_exc(),
}
write_json(out_dir / "generation_error.json", payload)
print(f"generation failed: {type(exc).__name__}: {exc}", file=sys.stderr)
return 2
print(f"generated run_id={manifest.run_id} step={manifest.exported_files['step']}")
return 0
def cmd_validate(args: argparse.Namespace) -> int:
guard_no_gear_test()
instance = _load_instance(Path(args.instance))
model_path = Path(args.model)
manifest_path = Path(args.manifest)
build_meta_path = Path(args.build_meta) if args.build_meta else manifest_path.parent / "build_meta.json"
report = validate_from_files(
instance=instance,
model_path=model_path,
manifest_path=manifest_path,
build_meta_path=build_meta_path,
snapshot_out=Path(args.snapshot_out) if args.snapshot_out else manifest_path.parent / "assembly_snapshot.json",
report_out=_output_path(args.output),
)
print(f"validated run_id={report.run_id} passed={report.overall_passed}")
return 0 if report.overall_passed else 3
def cmd_export_interference(args: argparse.Namespace) -> int:
manifest = AssemblyManifest.model_validate(read_json(Path(args.manifest)))
component_steps = {
component_id: Path(component.assembled_step_path)
for component_id, component in manifest.components.items()
if component.assembled_step_path
}
try:
exports = export_interference_steps(
component_steps,
out_dir=_output_path(args.output),
volume_tolerance_mm3=float(args.volume_tolerance),
)
except OcpInterferenceError as exc:
print(f"export interference failed: {exc}", file=sys.stderr)
return 4
write_json(_output_path(args.output) / "interference_exports.json", {"run_id": manifest.run_id, "exports": exports})
print(f"exported_interference_steps={len(exports)} out={_output_path(args.output)}")
return 0 if exports else 5
def cmd_run(args: argparse.Namespace) -> int:
out_dir = _output_path(args.output)
paths = _prepare_structured_run_dir(out_dir)
requirement_path = _input_path(args.input)
solve_args = argparse.Namespace(input=str(requirement_path), output=str(out_dir))
try:
solve_code = cmd_solve(solve_args)
except (TopologyError, ParameterSolveError) as exc:
write_json(
out_dir / "generation_error.json",
{"stage": "solve", "error_type": type(exc).__name__, "message": str(exc)},
)
print(f"solve failed: {type(exc).__name__}: {exc}", file=sys.stderr)
return 1
if solve_code != 0:
return solve_code
instance_path = paths["formula"] / "formula_instance.json"
generate_code = cmd_generate(
argparse.Namespace(instance=str(instance_path), output=str(paths["cad"]))
)
if generate_code != 0:
return generate_code
placement_plan = paths["cad"] / "placement_plan.json"
if placement_plan.exists():
shutil.copy2(placement_plan, paths["placement"] / "placement_plan.json")
return cmd_validate(
argparse.Namespace(
instance=str(instance_path),
model=str(paths["cad"] / "reducer.model.json"),
manifest=str(paths["cad"] / "assembly_manifest.json"),
build_meta=str(paths["cad"] / "build_meta.json"),
snapshot_out=str(paths["validation"] / "assembly_snapshot.json"),
output=str(paths["validation"] / "validation_report.json"),
)
)
def cmd_joint_run(args: argparse.Namespace) -> int:
out_dir = _output_path(args.output)
out_dir.mkdir(parents=True, exist_ok=True)
try:
manifest = run_joint_module(
requirement_path=_input_path(args.input),
out_dir=out_dir,
)
except Exception as exc: # noqa: BLE001
write_json(
out_dir / "joint_generation_error.json",
{
"stage": "joint-run",
"error_type": type(exc).__name__,
"message": str(exc),
"traceback": traceback.format_exc(),
},
)
print(f"joint-run failed: {type(exc).__name__}: {exc}", file=sys.stderr)
return 6
validation_path = Path(manifest.exported_files["joint_validation_report"])
validation = read_json(validation_path)
passed = bool(validation.get("overall_passed"))
print(
f"joint-run run_id={manifest.run_id} reducer_run_id={manifest.reducer_run_id} "
f"passed={passed} step={manifest.exported_files['joint_step']}"
)
return 0 if passed else 7
def cmd_auto_joint_run(args: argparse.Namespace) -> int:
out_dir = _output_path(args.output)
out_dir.mkdir(parents=True, exist_ok=True)
try:
manifest = run_auto_joint_design(
motor_step=Path(args.motor_step),
housing_source=Path(args.housing_python),
out_dir=out_dir,
tooth_form=args.tooth_form,
radial_clearance_mm=float(args.radial_clearance_mm),
reducer_axial_allowance_mm=float(args.reducer_axial_allowance_mm),
reducer_radial_clearance_mm=float(args.reducer_radial_clearance_mm),
)
except Exception as exc: # noqa: BLE001
write_json(
out_dir / "auto_generation_error.json",
{
"stage": "auto-joint-run",
"error_type": type(exc).__name__,
"message": str(exc),
"traceback": traceback.format_exc(),
},
)
print(f"auto-joint-run failed: {type(exc).__name__}: {exc}", file=sys.stderr)
return 9
validation_path = Path(manifest.exported_files["joint_validation_report"])
validation = read_json(validation_path)
passed = bool(validation.get("overall_passed"))
print(
f"auto-joint-run run_id={manifest.run_id} reducer_run_id={manifest.reducer_run_id} "
f"passed={passed} step={manifest.exported_files['joint_step']}"
)
return 0 if passed else 10
def cmd_export_urdf(args: argparse.Namespace) -> int:
out_dir = _output_path(args.output)
out_dir.mkdir(parents=True, exist_ok=True)
try:
manifest = export_urdf(
joint_manifest_path=Path(args.joint_manifest),
reducer_instance_path=Path(args.reducer_instance),
out_dir=out_dir,
linear_deflection_mm=float(args.linear_deflection_mm),
)
except Exception as exc: # noqa: BLE001
write_json(
out_dir / "urdf_export_error.json",
{
"stage": "export-urdf",
"error_type": type(exc).__name__,
"message": str(exc),
"traceback": traceback.format_exc(),
},
)
print(f"export-urdf failed: {type(exc).__name__}: {exc}", file=sys.stderr)
return 8
print(
f"export-urdf run_id={manifest.run_id} urdf={manifest.urdf_path} "
f"ratio={manifest.transmission_summary['ratio']}"
)
return 0
def cmd_export_reducer_urdf(args: argparse.Namespace) -> int:
out_dir = _output_path(args.output)
out_dir.mkdir(parents=True, exist_ok=True)
try:
manifest = export_reducer_urdf(
assembly_manifest_path=Path(args.assembly_manifest),
reducer_instance_path=Path(args.reducer_instance),
out_dir=out_dir,
linear_deflection_mm=float(args.linear_deflection_mm),
)
except Exception as exc: # noqa: BLE001
write_json(
out_dir / "urdf_export_error.json",
{
"stage": "export-reducer-urdf",
"error_type": type(exc).__name__,
"message": str(exc),
"traceback": traceback.format_exc(),
},
)
print(f"export-reducer-urdf failed: {type(exc).__name__}: {exc}", file=sys.stderr)
return 11
print(
f"export-reducer-urdf reducer_run_id={manifest['reducer_run_id']} "
f"urdf={manifest['urdf_path']} ratio={manifest['transmission_summary']['ratio']}"
)
return 0
def _load_json_text_or_file(value: str) -> object:
path = Path(value)
if path.exists():
return read_json(path)
return json.loads(value)
def cmd_list_parts(args: argparse.Namespace) -> int:
capabilities = list_parametric_part_capability_manifests(
family=args.family,
only_available=bool(args.only_available),
)
print(json.dumps(capabilities, indent=2, ensure_ascii=False))
return 0
def cmd_generate_part(args: argparse.Namespace) -> int:
guard_no_gear_test()
out_dir = _output_path(args.output)
out_dir.mkdir(parents=True, exist_ok=True)
parameters = _load_json_text_or_file(args.parameters) if args.parameters else {}
if not isinstance(parameters, dict):
print("generate-part parameters must be a JSON object", file=sys.stderr)
return 12
try:
manifest = export_parametric_part(
args.part,
parameters,
out_dir,
check_dependencies=not bool(args.skip_dependency_check),
)
except Exception as exc: # noqa: BLE001
write_json(
out_dir / "generation_error.json",
{
"stage": "generate-part",
"part_id": args.part,
"error_type": type(exc).__name__,
"message": str(exc),
"traceback": traceback.format_exc(),
},
)
print(f"generate-part failed: {type(exc).__name__}: {exc}", file=sys.stderr)
return 13
print(f"generated part_id={args.part} step={manifest['exported_files']['step']}")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="planetary_reducer")
sub = parser.add_subparsers(dest="command", required=True)
solve = sub.add_parser("solve")
solve.add_argument("--input", required=True)
solve.add_argument("--output", required=True)
solve.set_defaults(func=cmd_solve)
generate = sub.add_parser("generate")
generate.add_argument("--instance", required=True)
generate.add_argument("--output", required=True)
generate.set_defaults(func=cmd_generate)
validate = sub.add_parser("validate")
validate.add_argument("--instance", required=True)
validate.add_argument("--model", required=True)
validate.add_argument("--manifest", required=True)
validate.add_argument("--build-meta")
validate.add_argument("--snapshot-out")
validate.add_argument("--output", required=True)
validate.set_defaults(func=cmd_validate)
run = sub.add_parser("run")
run.add_argument("--input", required=True)
run.add_argument("--output", required=True)
run.set_defaults(func=cmd_run)
joint_run = sub.add_parser("joint-run")
joint_run.add_argument("--input", required=True)
joint_run.add_argument("--output", required=True)
joint_run.set_defaults(func=cmd_joint_run)
auto_joint_run = sub.add_parser("auto-joint-run")
auto_joint_run.add_argument("--motor-step", required=True)
auto_joint_run.add_argument("--housing-python", required=True)
auto_joint_run.add_argument("--output", required=True)
auto_joint_run.add_argument("--tooth-form", choices=["spur", "helical"], default="spur")
auto_joint_run.add_argument("--radial-clearance-mm", default="14.0")
auto_joint_run.add_argument("--reducer-axial-allowance-mm", default="42.0")
auto_joint_run.add_argument("--reducer-radial-clearance-mm", default="8.0")
auto_joint_run.set_defaults(func=cmd_auto_joint_run)
export_urdf_parser = sub.add_parser("export-urdf")
export_urdf_parser.add_argument("--joint-manifest", required=True)
export_urdf_parser.add_argument("--reducer-instance", required=True)
export_urdf_parser.add_argument("--output", required=True)
export_urdf_parser.add_argument("--linear-deflection-mm", default="0.25")
export_urdf_parser.set_defaults(func=cmd_export_urdf)
export_reducer_urdf_parser = sub.add_parser("export-reducer-urdf")
export_reducer_urdf_parser.add_argument("--assembly-manifest", required=True)
export_reducer_urdf_parser.add_argument("--reducer-instance", required=True)
export_reducer_urdf_parser.add_argument("--output", required=True)
export_reducer_urdf_parser.add_argument("--linear-deflection-mm", default="0.25")
export_reducer_urdf_parser.set_defaults(func=cmd_export_reducer_urdf)
export_interference = sub.add_parser("export-interference")
export_interference.add_argument("--manifest", required=True)
export_interference.add_argument("--output", required=True)
export_interference.add_argument("--volume-tolerance", default="1e-6")
export_interference.set_defaults(func=cmd_export_interference)
list_parts = sub.add_parser("list-parts")
list_parts.add_argument("--family")
list_parts.add_argument("--only-available", action="store_true")
list_parts.set_defaults(func=cmd_list_parts)
generate_part = sub.add_parser("generate-part")
generate_part.add_argument("--part", required=True)
generate_part.add_argument("--parameters", help="JSON object or path to a JSON file", default="{}")
generate_part.add_argument("--output", required=True)
generate_part.add_argument("--skip-dependency-check", action="store_true")
generate_part.set_defaults(func=cmd_generate_part)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
return int(args.func(args))
if __name__ == "__main__":
raise SystemExit(main())
+5
View File
@@ -0,0 +1,5 @@
from __future__ import annotations
from .registry import ConfigurationSpec, get_configuration, load_configuration_template
__all__ = ["ConfigurationSpec", "get_configuration", "load_configuration_template"]
@@ -0,0 +1,2 @@
"""Ferguson-Wolfrom mechanical-paradox planetary configuration."""
@@ -0,0 +1,73 @@
from __future__ import annotations
import math
from ...models import ReducerParameters, ReducerRequirement
REQUIRED_GEARS = ("sun1", "sun2", "planet1", "planet2", "ring1", "ring2")
def validate_tooth_closure(requirement: ReducerRequirement) -> list[str]:
teeth = requirement.gear_tooth_counts
errors: list[str] = []
if teeth.get("ring1") != teeth.get("sun1", 0) + 2 * teeth.get("planet1", 0):
errors.append("ring1 must equal sun1 + 2 * planet1")
if teeth.get("ring2") != teeth.get("sun2", 0) + 2 * teeth.get("planet2", 0):
errors.append("ring2 must equal sun2 + 2 * planet2")
return errors
def validate_equal_spacing(requirement: ReducerRequirement) -> list[str]:
teeth = requirement.gear_tooth_counts
errors: list[str] = []
for planet_count in requirement.planet_count_candidates:
if (teeth["sun1"] + teeth["ring1"]) % planet_count != 0:
errors.append(f"stage1 equal spacing fails for planet_count={planet_count}")
if (teeth["sun2"] + teeth["ring2"]) % planet_count != 0:
errors.append(f"stage2 equal spacing fails for planet_count={planet_count}")
return errors
def theoretical_ratio(teeth: dict[str, int]) -> float:
"""Return input planet spin / output carrier speed for the fixed static-carrier layout."""
numerator = teeth["planet1"] * teeth["ring1"] * (teeth["ring2"] + teeth["sun2"])
denominator = teeth["ring1"] * teeth["sun2"] - teeth["ring2"] * teeth["sun1"]
return numerator / denominator
def base_geometry(
parameters: ReducerParameters,
*,
addendum_coeff: float,
clearance_coeff: float,
ring_rim_thickness_mm: float,
) -> dict[str, float]:
teeth = parameters.gear_tooth_counts
module_mm = parameters.module_mm
orbit1 = module_mm * (teeth["sun1"] + teeth["planet1"]) / 2.0
orbit2 = module_mm * (teeth["sun2"] + teeth["planet2"]) / 2.0
outer_diameters = [
module_mm * (teeth["ring1"] + 2.0 * (addendum_coeff + clearance_coeff)) + 2.0 * ring_rim_thickness_mm,
module_mm * (teeth["ring2"] + 2.0 * (addendum_coeff + clearance_coeff)) + 2.0 * ring_rim_thickness_mm,
]
planet_outer_diameters = [
module_mm * (teeth["planet1"] + 2.0 * addendum_coeff),
module_mm * (teeth["planet2"] + 2.0 * addendum_coeff),
]
neighbor_distance = 2.0 * max(orbit1, orbit2) * math.sin(math.pi / parameters.planet_count)
return {
"sun_pitch_radius_mm": module_mm * teeth["sun1"] / 2.0,
"planet_pitch_radius_mm": module_mm * teeth["planet1"] / 2.0,
"ring_pitch_radius_mm": module_mm * teeth["ring1"] / 2.0,
"sun_pitch_diameter_mm": module_mm * teeth["sun1"],
"planet_pitch_diameter_mm": module_mm * teeth["planet1"],
"ring_pitch_diameter_mm": module_mm * teeth["ring1"],
"center_distance_mm": orbit1,
"planet_orbit_radius_mm": orbit1,
"ring_outer_diameter_mm": max(outer_diameters),
"planet_outer_diameter_mm": max(planet_outer_diameters),
"planet_neighbor_distance_mm": neighbor_distance,
"planet_neighbor_clearance_mm": neighbor_distance - max(planet_outer_diameters),
}
@@ -0,0 +1,11 @@
from __future__ import annotations
from ...models import ReducerInstance
from ...placement.models import ComponentPose
def component_pose(instance: ReducerInstance, component_id: str) -> ComponentPose | None:
from ...placement.ferguson_wolfrom import ferguson_wolfrom_component_pose
return ferguson_wolfrom_component_pose(instance, component_id)
@@ -0,0 +1,260 @@
{
"template_id": "ferguson_wolfrom_v0_1",
"family": "ferguson_wolfrom",
"description": "Ferguson-Wolfrom style mechanical-paradox planetary reducer with two rigidly-coupled internal rings, two rigidly-coupled sun gears, one fixed/static carrier, and one rotating output carrier. The input drives the first planet set spin and the output is the second carrier.",
"nodes": [
{
"id": "sun1",
"kind": "gear",
"role": "sun",
"gear_type": "external",
"repeat": "single",
"formula_ref": "nodes.sun1",
"stage_id": null,
"local_role": "sun_fixed_side"
},
{
"id": "sun2",
"kind": "gear",
"role": "sun",
"gear_type": "external",
"repeat": "single",
"formula_ref": "nodes.sun2",
"stage_id": null,
"local_role": "sun_output_side"
},
{
"id": "planet1",
"kind": "gear",
"role": "planet",
"gear_type": "external",
"repeat": "planet_count",
"formula_ref": "nodes.planet1",
"stage_id": null,
"local_role": "input_planet"
},
{
"id": "planet2",
"kind": "gear",
"role": "planet",
"gear_type": "external",
"repeat": "planet_count",
"formula_ref": "nodes.planet2",
"stage_id": null,
"local_role": "output_planet"
},
{
"id": "ring1",
"kind": "gear",
"role": "ring",
"gear_type": "internal",
"repeat": "single",
"formula_ref": "nodes.ring1",
"stage_id": null,
"local_role": "ring_fixed_side"
},
{
"id": "ring2",
"kind": "gear",
"role": "ring",
"gear_type": "internal",
"repeat": "single",
"formula_ref": "nodes.ring2",
"stage_id": null,
"local_role": "ring_output_side"
},
{
"id": "static_carrier",
"kind": "carrier",
"role": "carrier",
"gear_type": null,
"repeat": "single",
"formula_ref": "nodes.static_carrier",
"stage_id": null,
"local_role": "static_carrier"
},
{
"id": "output_carrier",
"kind": "carrier",
"role": "carrier",
"gear_type": null,
"repeat": "single",
"formula_ref": "nodes.output_carrier",
"stage_id": null,
"local_role": "output_carrier"
},
{
"id": "housing",
"kind": "housing",
"role": "housing",
"gear_type": null,
"repeat": "single",
"formula_ref": "nodes.housing",
"stage_id": null,
"local_role": "housing"
},
{
"id": "main_axis",
"kind": "axis",
"role": "main_axis",
"gear_type": null,
"repeat": "single",
"formula_ref": "nodes.main_axis",
"stage_id": null,
"local_role": "main_axis"
}
],
"relations": [
{
"id": "sun1_planet1_external_mesh",
"source": "sun1",
"target": "planet1",
"relation_type": "external_mesh",
"carrier_ref": "static_carrier",
"mesh_id": "sun1_planet1",
"formula_ref": "relations.sun1_planet1_external_mesh",
"stage_id": null
},
{
"id": "ring1_planet1_internal_mesh",
"source": "ring1",
"target": "planet1",
"relation_type": "internal_mesh",
"carrier_ref": "static_carrier",
"mesh_id": "ring1_planet1",
"formula_ref": "relations.ring1_planet1_internal_mesh",
"stage_id": null
},
{
"id": "sun2_planet2_external_mesh",
"source": "sun2",
"target": "planet2",
"relation_type": "external_mesh",
"carrier_ref": "output_carrier",
"mesh_id": "sun2_planet2",
"formula_ref": "relations.sun2_planet2_external_mesh",
"stage_id": null
},
{
"id": "ring2_planet2_internal_mesh",
"source": "ring2",
"target": "planet2",
"relation_type": "internal_mesh",
"carrier_ref": "output_carrier",
"mesh_id": "ring2_planet2",
"formula_ref": "relations.ring2_planet2_internal_mesh",
"stage_id": null
},
{
"id": "planet1_static_carrier_revolute",
"source": "planet1",
"target": "static_carrier",
"relation_type": "revolute_joint",
"carrier_ref": "static_carrier",
"mesh_id": null,
"formula_ref": "relations.planet1_static_carrier_revolute",
"stage_id": null
},
{
"id": "planet2_output_carrier_revolute",
"source": "planet2",
"target": "output_carrier",
"relation_type": "revolute_joint",
"carrier_ref": "output_carrier",
"mesh_id": null,
"formula_ref": "relations.planet2_output_carrier_revolute",
"stage_id": null
},
{
"id": "sun1_sun2_rigid",
"source": "sun1",
"target": "sun2",
"relation_type": "rigid",
"carrier_ref": null,
"mesh_id": null,
"formula_ref": "relations.sun1_sun2_rigid",
"stage_id": null
},
{
"id": "ring1_ring2_rigid",
"source": "ring1",
"target": "ring2",
"relation_type": "rigid",
"carrier_ref": null,
"mesh_id": null,
"formula_ref": "relations.ring1_ring2_rigid",
"stage_id": null
},
{
"id": "sun1_main_axis_coaxial",
"source": "sun1",
"target": "main_axis",
"relation_type": "coaxial",
"carrier_ref": null,
"mesh_id": null,
"formula_ref": "relations.sun1_main_axis_coaxial",
"stage_id": null
},
{
"id": "sun2_main_axis_coaxial",
"source": "sun2",
"target": "main_axis",
"relation_type": "coaxial",
"carrier_ref": null,
"mesh_id": null,
"formula_ref": "relations.sun2_main_axis_coaxial",
"stage_id": null
},
{
"id": "ring1_main_axis_coaxial",
"source": "ring1",
"target": "main_axis",
"relation_type": "coaxial",
"carrier_ref": null,
"mesh_id": null,
"formula_ref": "relations.ring1_main_axis_coaxial",
"stage_id": null
},
{
"id": "ring2_main_axis_coaxial",
"source": "ring2",
"target": "main_axis",
"relation_type": "coaxial",
"carrier_ref": null,
"mesh_id": null,
"formula_ref": "relations.ring2_main_axis_coaxial",
"stage_id": null
},
{
"id": "static_carrier_main_axis_coaxial",
"source": "static_carrier",
"target": "main_axis",
"relation_type": "coaxial",
"carrier_ref": null,
"mesh_id": null,
"formula_ref": "relations.static_carrier_main_axis_coaxial",
"stage_id": null
},
{
"id": "output_carrier_main_axis_coaxial",
"source": "output_carrier",
"target": "main_axis",
"relation_type": "coaxial",
"carrier_ref": null,
"mesh_id": null,
"formula_ref": "relations.output_carrier_main_axis_coaxial",
"stage_id": null
},
{
"id": "static_carrier_housing_fixed",
"source": "static_carrier",
"target": "housing",
"relation_type": "fixed",
"carrier_ref": null,
"mesh_id": null,
"formula_ref": "relations.static_carrier_housing_fixed",
"stage_id": null
}
]
}
+51
View File
@@ -0,0 +1,51 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from ..models import TopologyTemplate, read_json
CONFIGURATION_ROOT = Path(__file__).resolve().parent
@dataclass(frozen=True)
class ConfigurationSpec:
family: str
package_dir: Path
topology_template_path: Path
CONFIGURATIONS: dict[str, ConfigurationSpec] = {
"simple_2k_h": ConfigurationSpec(
family="simple_2k_h",
package_dir=CONFIGURATION_ROOT / "simple_2k_h",
topology_template_path=CONFIGURATION_ROOT / "simple_2k_h" / "topology.template.json",
),
"simple_2k_h_cascade": ConfigurationSpec(
family="simple_2k_h_cascade",
package_dir=CONFIGURATION_ROOT / "simple_2k_h_cascade",
topology_template_path=CONFIGURATION_ROOT / "simple_2k_h_cascade" / "topology.template.json",
),
"ferguson_wolfrom": ConfigurationSpec(
family="ferguson_wolfrom",
package_dir=CONFIGURATION_ROOT / "ferguson_wolfrom",
topology_template_path=CONFIGURATION_ROOT / "ferguson_wolfrom" / "topology.template.json",
),
}
class ConfigurationRegistryError(ValueError):
pass
def get_configuration(family: str) -> ConfigurationSpec:
spec = CONFIGURATIONS.get(family)
if spec is None:
raise ConfigurationRegistryError(f"unsupported_topology_family: {family}")
return spec
def load_configuration_template(family: str) -> TopologyTemplate:
spec = get_configuration(family)
return TopologyTemplate.model_validate(read_json(spec.topology_template_path))
@@ -0,0 +1,2 @@
from __future__ import annotations
@@ -0,0 +1,5 @@
from __future__ import annotations
from ...industrial import build_industrial_assembly_topology, compute_industrial_parameters
__all__ = ["build_industrial_assembly_topology", "compute_industrial_parameters"]
@@ -0,0 +1,13 @@
from __future__ import annotations
from ...models import ReducerInstance
from ...placement.models import ComponentPose
def component_pose(
instance: ReducerInstance,
component_id: str,
) -> ComponentPose | None:
from ...placement.simple_2k_h import simple_2kh_component_pose
return simple_2kh_component_pose(instance, component_id)
@@ -0,0 +1,120 @@
{
"template_id": "simple_2k_h_v0_1",
"family": "simple_2k_h",
"description": "Simple 2K-H planetary reducer topology: sun, repeated planets, internal ring, carrier, housing reference. Tooth form is selected by the reducer requirement/parameters, not by topology.",
"nodes": [
{
"id": "sun",
"kind": "gear",
"role": "sun",
"gear_type": "external",
"repeat": "single",
"formula_ref": "nodes.sun"
},
{
"id": "planet",
"kind": "gear",
"role": "planet",
"gear_type": "external",
"repeat": "planet_count",
"formula_ref": "nodes.planet"
},
{
"id": "ring",
"kind": "gear",
"role": "ring",
"gear_type": "internal",
"repeat": "single",
"formula_ref": "nodes.ring"
},
{
"id": "carrier",
"kind": "carrier",
"role": "carrier",
"gear_type": null,
"repeat": "single",
"formula_ref": "nodes.carrier"
},
{
"id": "housing",
"kind": "housing",
"role": "housing",
"gear_type": null,
"repeat": "single",
"formula_ref": "nodes.housing"
},
{
"id": "main_axis",
"kind": "axis",
"role": "main_axis",
"gear_type": null,
"repeat": "single",
"formula_ref": "nodes.main_axis"
}
],
"relations": [
{
"id": "sun_planet_external_mesh",
"source": "sun",
"target": "planet",
"relation_type": "external_mesh",
"carrier_ref": "carrier",
"mesh_id": "sun_planet",
"formula_ref": "relations.sun_planet_external_mesh"
},
{
"id": "ring_planet_internal_mesh",
"source": "ring",
"target": "planet",
"relation_type": "internal_mesh",
"carrier_ref": "carrier",
"mesh_id": "ring_planet",
"formula_ref": "relations.ring_planet_internal_mesh"
},
{
"id": "planet_carrier_pin_revolute",
"source": "planet",
"target": "carrier",
"relation_type": "revolute_joint",
"carrier_ref": "carrier",
"mesh_id": null,
"formula_ref": "relations.planet_carrier_pin_revolute"
},
{
"id": "sun_main_axis_coaxial",
"source": "sun",
"target": "main_axis",
"relation_type": "coaxial",
"carrier_ref": null,
"mesh_id": null,
"formula_ref": "relations.sun_main_axis_coaxial"
},
{
"id": "ring_main_axis_coaxial",
"source": "ring",
"target": "main_axis",
"relation_type": "coaxial",
"carrier_ref": null,
"mesh_id": null,
"formula_ref": "relations.ring_main_axis_coaxial"
},
{
"id": "carrier_main_axis_coaxial",
"source": "carrier",
"target": "main_axis",
"relation_type": "coaxial",
"carrier_ref": null,
"mesh_id": null,
"formula_ref": "relations.carrier_main_axis_coaxial"
},
{
"id": "ring_housing_fixed",
"source": "ring",
"target": "housing",
"relation_type": "fixed",
"carrier_ref": null,
"mesh_id": null,
"formula_ref": "relations.ring_housing_fixed"
}
]
}
@@ -0,0 +1,2 @@
from __future__ import annotations
@@ -0,0 +1,315 @@
from __future__ import annotations
import math
from ...models import (
BoundaryCondition,
ConcreteNode,
ConcreteRelation,
ConcreteTopology,
ReducerParameters,
ReducerRequirement,
StageDerived,
StageParameters,
StageRequirement,
)
MOTION_MEMBERS = {"sun", "ring", "carrier"}
def stage_component_id(stage_id: str, local_role: str) -> str:
return f"{stage_id}_{local_role}"
def local_role(component_id: str) -> str:
return component_id.split("_", 1)[1] if "_" in component_id else component_id
def stage_boundary(stage: StageRequirement) -> BoundaryCondition:
fixed_members = (
[stage_component_id(stage.stage_id, stage.fixed)]
if stage.fixed is not None
else []
)
return BoundaryCondition(
input=stage_component_id(stage.stage_id, stage.input),
fixed=fixed_members[0] if len(fixed_members) == 1 else None,
output=stage_component_id(stage.stage_id, stage.output),
fixed_members=fixed_members,
)
def cascade_boundary(requirement: ReducerRequirement) -> BoundaryCondition:
if not requirement.stages or len(requirement.stages) != 2:
raise ValueError("simple_2k_h_cascade requires exactly two stages")
fixed_members = []
for stage in requirement.stages:
if stage.fixed is not None:
fixed_members.append(stage_component_id(stage.stage_id, stage.fixed))
return BoundaryCondition(
input=stage_component_id(requirement.stages[0].stage_id, requirement.stages[0].input),
fixed=fixed_members[0] if len(fixed_members) == 1 else None,
output=stage_component_id(requirement.stages[1].stage_id, requirement.stages[1].output),
fixed_members=fixed_members,
)
def stage_geometry(parameters: StageParameters) -> dict[str, float]:
sun_pitch_radius = parameters.module_mm * parameters.z_sun / 2.0
planet_pitch_radius = parameters.module_mm * parameters.z_planet / 2.0
ring_pitch_radius = parameters.module_mm * parameters.z_ring / 2.0
center_distance = parameters.module_mm * (parameters.z_sun + parameters.z_planet) / 2.0
planet_outer_diameter = parameters.module_mm * (
parameters.z_planet + 2.0 * parameters.addendum_coeff
)
neighbor_distance = 2.0 * center_distance * math.sin(math.pi / parameters.planet_count)
ring_outer_diameter = (
parameters.module_mm * parameters.z_ring
+ 2.0 * parameters.module_mm * (parameters.addendum_coeff + parameters.clearance_coeff)
+ 2.0 * parameters.ring_rim_thickness_mm
)
return {
"sun_pitch_radius_mm": sun_pitch_radius,
"planet_pitch_radius_mm": planet_pitch_radius,
"ring_pitch_radius_mm": ring_pitch_radius,
"sun_pitch_diameter_mm": sun_pitch_radius * 2.0,
"planet_pitch_diameter_mm": planet_pitch_radius * 2.0,
"ring_pitch_diameter_mm": ring_pitch_radius * 2.0,
"center_distance_mm": center_distance,
"planet_orbit_radius_mm": center_distance,
"ring_outer_diameter_mm": ring_outer_diameter,
"planet_outer_diameter_mm": planet_outer_diameter,
"planet_neighbor_distance_mm": neighbor_distance,
"planet_neighbor_clearance_mm": neighbor_distance - planet_outer_diameter,
}
def stage_derived(
*,
parameters: StageParameters,
ratio: float,
direction: str,
speed_mode: str,
z_offset_mm: float,
) -> StageDerived:
return StageDerived(
stage_id=parameters.stage_id,
ratio=abs(ratio),
direction=direction,
speed_mode=speed_mode,
z_offset_mm=z_offset_mm,
**stage_geometry(parameters),
)
def stage_parameter_by_id(parameters: ReducerParameters, stage_id: str) -> StageParameters:
if not parameters.stages:
raise ValueError("cascade parameters are missing stages")
for stage in parameters.stages:
if stage.stage_id == stage_id:
return stage
raise ValueError(f"missing cascade stage parameters: {stage_id}")
def _stage_nodes(stage: StageParameters) -> list[ConcreteNode]:
nodes = [
ConcreteNode(
component_id=stage_component_id(stage.stage_id, "sun"),
template_node_id=stage_component_id(stage.stage_id, "sun"),
kind="gear",
role="sun",
gear_type="external",
repeat_index=None,
stage_id=stage.stage_id,
local_role="sun",
),
ConcreteNode(
component_id=stage_component_id(stage.stage_id, "ring"),
template_node_id=stage_component_id(stage.stage_id, "ring"),
kind="gear",
role="ring",
gear_type="internal",
repeat_index=None,
stage_id=stage.stage_id,
local_role="ring",
),
ConcreteNode(
component_id=stage_component_id(stage.stage_id, "carrier"),
template_node_id=stage_component_id(stage.stage_id, "carrier"),
kind="carrier",
role="carrier",
gear_type=None,
repeat_index=None,
stage_id=stage.stage_id,
local_role="carrier",
),
]
for index in range(1, stage.planet_count + 1):
nodes.append(
ConcreteNode(
component_id=f"{stage.stage_id}_planet_{index}",
template_node_id=stage_component_id(stage.stage_id, "planet"),
kind="gear",
role="planet",
gear_type="external",
repeat_index=index,
stage_id=stage.stage_id,
local_role="planet",
)
)
return nodes
def _rel(
*,
relation_id: str,
relation_type: str,
source: str,
target: str,
source_template_node: str,
target_template_node: str,
stage_id: str | None = None,
carrier: str | None = None,
mesh_id: str | None = None,
) -> ConcreteRelation:
return ConcreteRelation(
relation_id=relation_id,
template_relation_id=relation_id,
relation_type=relation_type,
source_component_id=source,
target_component_id=target,
source_template_node=source_template_node,
target_template_node=target_template_node,
carrier_component_id=carrier,
mesh_id=mesh_id,
stage_id=stage_id,
)
def _stage_relations(stage: StageParameters, requirement_stage: StageRequirement) -> list[ConcreteRelation]:
sid = stage.stage_id
sun = stage_component_id(sid, "sun")
ring = stage_component_id(sid, "ring")
carrier = stage_component_id(sid, "carrier")
relations: list[ConcreteRelation] = []
for index in range(1, stage.planet_count + 1):
planet = f"{sid}_planet_{index}"
relations.extend(
[
_rel(
relation_id=f"{sid}_sun_to_planet_{index}_external_mesh",
relation_type="external_mesh",
source=sun,
target=planet,
source_template_node=sun,
target_template_node=stage_component_id(sid, "planet"),
carrier=carrier,
mesh_id=f"{sid}_sun_planet",
stage_id=sid,
),
_rel(
relation_id=f"{sid}_ring_to_planet_{index}_internal_mesh",
relation_type="internal_mesh",
source=ring,
target=planet,
source_template_node=ring,
target_template_node=stage_component_id(sid, "planet"),
carrier=carrier,
mesh_id=f"{sid}_ring_planet",
stage_id=sid,
),
_rel(
relation_id=f"{sid}_planet_{index}_pin_revolute",
relation_type="revolute_joint",
source=planet,
target=carrier,
source_template_node=stage_component_id(sid, "planet"),
target_template_node=carrier,
carrier=carrier,
stage_id=sid,
),
]
)
for local in ["sun", "ring", "carrier"]:
component = stage_component_id(sid, local)
relations.append(
_rel(
relation_id=f"{sid}_{local}_main_axis_coaxial",
relation_type="coaxial",
source=component,
target="main_axis",
source_template_node=component,
target_template_node="main_axis",
stage_id=sid,
)
)
if requirement_stage.fixed is not None:
fixed_component = stage_component_id(sid, requirement_stage.fixed)
relations.append(
_rel(
relation_id=f"{sid}_{requirement_stage.fixed}_housing_fixed",
relation_type="fixed",
source=fixed_component,
target="housing",
source_template_node=fixed_component,
target_template_node="housing",
stage_id=sid,
)
)
return relations
def build_cascade_topology(
requirement: ReducerRequirement,
parameters: ReducerParameters,
) -> ConcreteTopology:
if not requirement.stages or not parameters.stages:
raise ValueError("cascade topology requires requirement.stages and parameters.stages")
stage_requirements = {stage.stage_id: stage for stage in requirement.stages}
nodes = [
ConcreteNode(
component_id="housing",
template_node_id="housing",
kind="housing",
role="housing",
gear_type=None,
repeat_index=None,
local_role="housing",
),
ConcreteNode(
component_id="main_axis",
template_node_id="main_axis",
kind="axis",
role="main_axis",
gear_type=None,
repeat_index=None,
local_role="main_axis",
),
]
relations: list[ConcreteRelation] = []
for stage in parameters.stages:
nodes.extend(_stage_nodes(stage))
relations.extend(_stage_relations(stage, stage_requirements[stage.stage_id]))
first_requirement, second_requirement = requirement.stages
source = stage_component_id(first_requirement.stage_id, first_requirement.output)
target = stage_component_id(second_requirement.stage_id, second_requirement.input)
relations.append(
_rel(
relation_id=f"{source}_rigid_to_{target}",
relation_type="rigid",
source=source,
target=target,
source_template_node=source,
target_template_node=target,
stage_id="cascade",
)
)
return ConcreteTopology(
template_id="simple_2kh_cascade_runtime_v0_1",
family="simple_2k_h_cascade",
nodes=nodes,
relations=relations,
)
@@ -0,0 +1,10 @@
from __future__ import annotations
from ...models import ReducerInstance
from ...placement.models import PlacementPlan
def placement_plan(instance: ReducerInstance) -> PlacementPlan:
from ...placement.simple_2k_h_cascade import solve_cascade_placements
return solve_cascade_placements(instance)
@@ -0,0 +1,129 @@
{
"template_id": "simple_2k_h_cascade_v0_1",
"family": "simple_2k_h_cascade",
"description": "Two-stage cascade of simple 2K-H planetary stages. Runtime topology expands s1 and s2 with stage-prefixed components and a rigid coupling from stage 1 output to stage 2 input.",
"nodes": [
{
"id": "stage_sun",
"kind": "gear",
"role": "sun",
"gear_type": "external",
"repeat": "single",
"formula_ref": "stage.nodes.sun",
"stage_id": null,
"local_role": "sun"
},
{
"id": "stage_planet",
"kind": "gear",
"role": "planet",
"gear_type": "external",
"repeat": "planet_count",
"formula_ref": "stage.nodes.planet",
"stage_id": null,
"local_role": "planet"
},
{
"id": "stage_ring",
"kind": "gear",
"role": "ring",
"gear_type": "internal",
"repeat": "single",
"formula_ref": "stage.nodes.ring",
"stage_id": null,
"local_role": "ring"
},
{
"id": "stage_carrier",
"kind": "carrier",
"role": "carrier",
"gear_type": null,
"repeat": "single",
"formula_ref": "stage.nodes.carrier",
"stage_id": null,
"local_role": "carrier"
},
{
"id": "housing",
"kind": "housing",
"role": "housing",
"gear_type": null,
"repeat": "single",
"formula_ref": "nodes.housing",
"stage_id": null,
"local_role": "housing"
},
{
"id": "main_axis",
"kind": "axis",
"role": "main_axis",
"gear_type": null,
"repeat": "single",
"formula_ref": "nodes.main_axis",
"stage_id": null,
"local_role": "main_axis"
}
],
"relations": [
{
"id": "stage_sun_planet_external_mesh",
"source": "stage_sun",
"target": "stage_planet",
"relation_type": "external_mesh",
"carrier_ref": "stage_carrier",
"mesh_id": "stage_sun_planet",
"formula_ref": "stage.relations.sun_planet_external_mesh",
"stage_id": null
},
{
"id": "stage_ring_planet_internal_mesh",
"source": "stage_ring",
"target": "stage_planet",
"relation_type": "internal_mesh",
"carrier_ref": "stage_carrier",
"mesh_id": "stage_ring_planet",
"formula_ref": "stage.relations.ring_planet_internal_mesh",
"stage_id": null
},
{
"id": "stage_planet_carrier_pin_revolute",
"source": "stage_planet",
"target": "stage_carrier",
"relation_type": "revolute_joint",
"carrier_ref": "stage_carrier",
"mesh_id": null,
"formula_ref": "stage.relations.planet_carrier_pin_revolute",
"stage_id": null
},
{
"id": "stage_member_main_axis_coaxial",
"source": "stage_sun",
"target": "main_axis",
"relation_type": "coaxial",
"carrier_ref": null,
"mesh_id": null,
"formula_ref": "stage.relations.coaxial",
"stage_id": null
},
{
"id": "stage_fixed_housing",
"source": "stage_ring",
"target": "housing",
"relation_type": "fixed",
"carrier_ref": null,
"mesh_id": null,
"formula_ref": "stage.relations.fixed",
"stage_id": null
},
{
"id": "stage_output_to_next_input_rigid",
"source": "stage_carrier",
"target": "stage_sun",
"relation_type": "rigid",
"carrier_ref": null,
"mesh_id": null,
"formula_ref": "cascade.relations.output_to_next_input",
"stage_id": "cascade"
}
]
}
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
import argparse
import json
import mimetypes
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
from pathlib import Path
from urllib.parse import unquote, urlparse
BACKEND_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_RUN_NAME = "frontend_split_motor_7nm"
def _read_json(path: Path) -> object:
return json.loads(path.read_text(encoding="utf-8"))
def _project_summary(run_name: str) -> dict[str, object]:
run = BACKEND_ROOT / "output" / "runs" / run_name
formula = _read_json(run / "reducer" / "formula" / "formula_instance.json")
kinematics = _read_json(run / "reducer" / "kinematics" / "kinematic_solution.json")
reducer_validation = _read_json(run / "reducer" / "validation" / "validation_report.json")
joint_validation = _read_json(run / "joint_module_validation_report.json")
return {
"source": f"output/runs/{run_name}",
"runId": joint_validation["run_id"],
"ratio": formula["derived"]["ratio"],
"parameters": formula["parameters"],
"derived": formula["derived"],
"speeds": kinematics["speeds"],
"reducerValidation": reducer_validation["summary"],
"jointValidation": joint_validation["summary"],
}
class DevRequestHandler(SimpleHTTPRequestHandler):
run_name = DEFAULT_RUN_NAME
def _send_json(self, status: int, payload: object) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
def _send_file(self, path: Path) -> None:
if not path.exists() or not path.is_file():
self._send_json(404, {"error": f"file_not_found: {path}"})
return
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
body = path.read_bytes()
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None: # noqa: N802
parsed = urlparse(self.path)
if parsed.path == "/api/project-summary":
try:
self._send_json(200, _project_summary(self.run_name))
except Exception as exc: # noqa: BLE001
self._send_json(404, {"error": str(exc)})
return
prefix = "/project-assets/urdf/"
if parsed.path.startswith(prefix):
relative_path = Path(unquote(parsed.path[len(prefix):]))
if relative_path.is_absolute() or ".." in relative_path.parts:
self._send_json(400, {"error": "invalid_asset_path"})
return
asset_root = BACKEND_ROOT / "output" / "runs" / self.run_name / "exports" / "urdf"
self._send_file(asset_root / relative_path)
return
self._send_json(404, {"error": "not_found"})
def main() -> None:
parser = argparse.ArgumentParser(description="Serve reducer project data for the frontend dev app.")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8000)
parser.add_argument("--run-name", default=DEFAULT_RUN_NAME)
args = parser.parse_args()
DevRequestHandler.run_name = args.run_name
server = ThreadingHTTPServer((args.host, args.port), DevRequestHandler)
print(f"backend dev server: http://{args.host}:{args.port} run={args.run_name}", flush=True)
server.serve_forever()
if __name__ == "__main__":
main()
+608
View File
@@ -0,0 +1,608 @@
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Any
from .kinematics import KinematicSolveError, build_equations, solve_instance
from .models import (
AssemblySnapshot,
ComponentManifest,
ConcreteRelation,
ConcreteTopology,
ReducerInstance,
StageParameters,
ValidationCheck,
)
MESH_RELATION_TYPES = {"external_mesh", "internal_mesh"}
@dataclass(frozen=True)
class MeshDesign:
module_mm: float
addendum_coeff: float
clearance_coeff: float
backlash_mm: float
@property
def nominal_tip_root_clearance_mm(self) -> float:
return self.clearance_coeff * self.module_mm
@property
def circular_pitch_mm(self) -> float:
return math.pi * self.module_mm
@dataclass(frozen=True)
class GearRadii:
pitch_mm: float
tip_mm: float
root_mm: float
def _check(
checks: list[ValidationCheck],
*,
code: str,
passed: bool,
message: str,
actual: Any | None = None,
expected: Any | None = None,
) -> None:
checks.append(
ValidationCheck(
section="geometry",
code=code,
passed=bool(passed),
message=message,
actual=actual,
expected=expected,
)
)
def _stage_parameters(instance: ReducerInstance, stage_id: str | None) -> StageParameters | None:
if stage_id is None or not instance.parameters.stages:
return None
return next((stage for stage in instance.parameters.stages if stage.stage_id == stage_id), None)
def _mesh_design(instance: ReducerInstance, relation: ConcreteRelation) -> MeshDesign:
stage = _stage_parameters(instance, relation.stage_id)
if stage is not None:
return MeshDesign(
module_mm=stage.module_mm,
addendum_coeff=stage.addendum_coeff,
clearance_coeff=stage.clearance_coeff,
backlash_mm=stage.backlash_mm,
)
p = instance.parameters
return MeshDesign(
module_mm=p.module_mm,
addendum_coeff=p.addendum_coeff,
clearance_coeff=p.clearance_coeff,
backlash_mm=p.backlash_mm,
)
def _origin(snapshot: AssemblySnapshot, component_id: str) -> list[float] | None:
placement = snapshot.component_placements.get(component_id, {})
origin = placement.get("origin")
if origin is None:
component = snapshot.manifest.components.get(component_id)
if component is not None:
origin = component.center_xyz_mm
if origin is None:
return None
return [float(value) for value in origin]
def _xy_distance(a: list[float], b: list[float]) -> float:
return math.hypot(a[0] - b[0], a[1] - b[1])
def _xy_angle_deg(origin: list[float]) -> float:
return (math.degrees(math.atan2(origin[1], origin[0])) + 360.0) % 360.0
def _placement_angle_deg(snapshot: AssemblySnapshot, component_id: str) -> float | None:
placement = snapshot.component_placements.get(component_id, {})
x_axis = placement.get("x_axis")
if not x_axis:
return None
return (math.degrees(math.atan2(float(x_axis[1]), float(x_axis[0]))) + 360.0) % 360.0
def _nearest_mod_error(actual: float, expected: float, period: float) -> float:
if period <= 0.0:
return float("inf")
return abs((actual - expected + period / 2.0) % period - period / 2.0)
def _gear_radii(component: ComponentManifest, design: MeshDesign) -> GearRadii | None:
if component.pitch_radius_mm is None or component.gear_type is None:
return None
pitch = float(component.pitch_radius_mm)
addendum = design.addendum_coeff * design.module_mm
dedendum = (design.addendum_coeff + design.clearance_coeff) * design.module_mm
if component.gear_type == "external":
return GearRadii(
pitch_mm=pitch,
tip_mm=pitch + addendum,
root_mm=max(0.0, pitch - dedendum),
)
if component.gear_type == "internal":
return GearRadii(
pitch_mm=pitch,
tip_mm=max(0.0, pitch - addendum),
root_mm=pitch + dedendum,
)
return None
def _tip_root_clearances(
relation: ConcreteRelation,
source: ComponentManifest,
target: ComponentManifest,
design: MeshDesign,
center_distance: float,
) -> dict[str, float] | None:
source_radii = _gear_radii(source, design)
target_radii = _gear_radii(target, design)
if source_radii is None or target_radii is None:
return None
if relation.relation_type == "external_mesh":
return {
"source_tip_to_target_root_mm": center_distance - source_radii.tip_mm - target_radii.root_mm,
"target_tip_to_source_root_mm": center_distance - target_radii.tip_mm - source_radii.root_mm,
}
if relation.relation_type == "internal_mesh":
if source.gear_type == "internal" and target.gear_type == "external":
internal_radii = source_radii
external_radii = target_radii
internal_id = source.component_id
external_id = target.component_id
elif target.gear_type == "internal" and source.gear_type == "external":
internal_radii = target_radii
external_radii = source_radii
internal_id = target.component_id
external_id = source.component_id
else:
return None
return {
f"{external_id}_tip_to_{internal_id}_root_mm": (
internal_radii.root_mm - center_distance - external_radii.tip_mm
),
f"{internal_id}_tip_to_{external_id}_root_mm": (
internal_radii.tip_mm - center_distance - external_radii.root_mm
),
}
return None
def _component_local_role(component: ComponentManifest) -> str:
raw_values = [
component.local_role,
component.formula_node,
component.component_id,
component.role,
]
for raw in raw_values:
if not raw:
continue
if raw in {"sun", "ring", "planet", "carrier"}:
return raw
if raw.startswith("sun"):
return "sun"
if raw.startswith("ring"):
return "ring"
if raw.startswith("planet"):
return "planet"
if raw.endswith("_sun"):
return "sun"
if raw.endswith("_ring"):
return "ring"
if raw.endswith("_planet"):
return "planet"
if component.component_id.startswith("planet_") and component.kind == "gear":
return "planet"
if "_planet_" in component.component_id and component.kind == "gear":
return "planet"
return component.role
def _planet_mesh_side(component: ComponentManifest) -> str | None:
for raw in [component.formula_node, component.local_role, component.component_id]:
if not raw:
continue
if raw.startswith("planet1"):
return "1"
if raw.startswith("planet2"):
return "2"
return None
def _sun_matches_planet(component: ComponentManifest, planet: ComponentManifest) -> bool:
side = _planet_mesh_side(planet)
if side is not None:
return component.formula_node == f"sun{side}" or component.component_id == f"sun{side}"
return _component_local_role(component) == "sun" and component.stage_id == planet.stage_id
def _expected_placement_phase_deg(
*,
component: ComponentManifest,
snapshot: AssemblySnapshot,
) -> tuple[float | None, str]:
local_role = _component_local_role(component)
if local_role in {"sun", "ring"}:
return 0.0, "coaxial gear placement phase policy"
if local_role != "planet":
return None, "not_a_mesh_gear"
origin = _origin(snapshot, component.component_id)
if origin is None:
return None, "missing_placement"
if component.tooth_count is None:
return None, "missing_tooth_count"
sun = next((item for item in snapshot.manifest.components.values() if _sun_matches_planet(item, component)), None)
if sun is None or sun.tooth_count is None:
return None, "missing_sun_tooth_count"
center_angle = _xy_angle_deg(origin)
planet_teeth = float(component.tooth_count)
sun_teeth = float(sun.tooth_count)
expected = (180.0 - 180.0 / planet_teeth) + center_angle * (1.0 + sun_teeth / planet_teeth)
return (
expected % 360.0,
"formula_phase_policy: 180deg - half_planet_tooth_pitch + center_angle * (1 + z_sun / z_planet)",
)
def _validate_radial_clearance(
*,
relation: ConcreteRelation,
source: ComponentManifest | None,
target: ComponentManifest | None,
snapshot: AssemblySnapshot,
design: MeshDesign,
checks: list[ValidationCheck],
) -> None:
code = f"gear_mesh.{relation.relation_id}.tip_root_clearance"
origin_a = _origin(snapshot, relation.source_component_id)
origin_b = _origin(snapshot, relation.target_component_id)
if source is None or target is None or origin_a is None or origin_b is None:
_check(
checks,
code=code,
passed=False,
message="mesh tip/root clearance can be computed only when both gear manifests and placements exist",
actual="missing_component_or_placement",
)
return
center_distance = _xy_distance(origin_a, origin_b)
clearances = _tip_root_clearances(relation, source, target, design, center_distance)
if clearances is None:
_check(
checks,
code=code,
passed=False,
message="mesh tip/root clearance requires a valid external/external or internal/external gear pair",
actual={
"source_gear_type": source.gear_type,
"target_gear_type": target.gear_type,
"relation_type": relation.relation_type,
},
)
return
min_clearance = min(clearances.values())
expected = design.nominal_tip_root_clearance_mm
tolerance = max(1e-9, design.module_mm * 1e-9)
_check(
checks,
code=code,
passed=min_clearance >= expected - tolerance,
message="mesh radial tip/root clearance is consistent with module and clearance coefficient",
actual={
"center_distance_mm": center_distance,
"clearances_mm": clearances,
"min_clearance_mm": min_clearance,
"module_mm": design.module_mm,
"clearance_coeff": design.clearance_coeff,
},
expected=f">= {expected} mm",
)
def _validate_backlash(
*,
relation: ConcreteRelation,
design: MeshDesign,
checks: list[ValidationCheck],
) -> None:
max_reasonable = 0.20 * design.circular_pitch_mm
_check(
checks,
code=f"gear_mesh.{relation.relation_id}.backlash_range",
passed=0.0 <= design.backlash_mm <= max_reasonable,
message="mesh backlash is non-negative and below 20% of circular pitch",
actual={
"backlash_mm": design.backlash_mm,
"circular_pitch_mm": design.circular_pitch_mm,
"backlash_to_circular_pitch": (
design.backlash_mm / design.circular_pitch_mm
if design.circular_pitch_mm > 0
else None
),
},
expected=f"0 <= backlash_mm <= {max_reasonable} mm",
)
def _validate_phase(
*,
relation: ConcreteRelation,
source: ComponentManifest | None,
target: ComponentManifest | None,
snapshot: AssemblySnapshot,
checks: list[ValidationCheck],
) -> None:
code = f"gear_mesh.{relation.relation_id}.placement_phase_policy"
if source is None or target is None:
_check(
checks,
code=code,
passed=False,
message="mesh placement phase check requires both gear manifests",
actual="missing_component",
)
return
errors: dict[str, Any] = {}
passed = True
for component in [source, target]:
if component.tooth_count is None:
passed = False
errors[component.component_id] = "missing_tooth_count"
continue
actual = _placement_angle_deg(snapshot, component.component_id)
expected, source_text = _expected_placement_phase_deg(
component=component,
snapshot=snapshot,
)
if actual is None or expected is None:
passed = False
errors[component.component_id] = {
"actual": actual,
"expected": expected,
"source": source_text,
}
continue
pitch = 360.0 / float(component.tooth_count)
error = _nearest_mod_error(actual, expected, pitch)
errors[component.component_id] = {
"actual_phase_deg": actual,
"expected_phase_deg": expected,
"tooth_pitch_deg": pitch,
"phase_error_deg_mod_tooth_pitch": error,
"source": source_text,
}
passed = passed and error <= 1e-6
_check(
checks,
code=code,
passed=passed,
message="mesh gear component placements follow the deterministic tooth phase policy",
actual=errors,
expected="phase error <= 1e-6 deg modulo each gear tooth pitch",
)
def _phase_policy_value(snapshot: AssemblySnapshot, stage_id: str | None) -> float | None:
summary = snapshot.manifest.geometry_summary or {}
if stage_id:
stage_policy = summary.get("stage_phase_policy")
if isinstance(stage_policy, dict):
stage_entry = stage_policy.get(stage_id)
if isinstance(stage_entry, dict):
value = stage_entry.get("internal_ring_phase_correction_deg")
if value is not None:
return float(value)
value = summary.get("internal_ring_phase_correction_deg")
if value is not None:
return float(value)
if snapshot.build_meta:
meta_value = snapshot.build_meta.get("internal_ring_phase_correction_deg")
if meta_value is not None:
return float(meta_value)
return None
def _ferguson_wolfrom_phase_policy_value(
snapshot: AssemblySnapshot,
internal: ComponentManifest,
) -> float | None:
summary = snapshot.manifest.geometry_summary or {}
policy = summary.get("ferguson_wolfrom_phase_policy")
if not isinstance(policy, dict):
return None
entry = policy.get(internal.component_id) or policy.get(internal.formula_node)
if not isinstance(entry, dict):
return None
value = entry.get("internal_ring_phase_correction_deg")
return None if value is None else float(value)
def _validate_internal_ring_phase_policy(
*,
relation: ConcreteRelation,
source: ComponentManifest | None,
target: ComponentManifest | None,
snapshot: AssemblySnapshot,
checks: list[ValidationCheck],
) -> None:
if relation.relation_type != "internal_mesh":
return
internal = None
planet = None
for component in [source, target]:
if component is not None and component.gear_type == "internal":
internal = component
if component is not None and _component_local_role(component) == "planet":
planet = component
code = f"gear_mesh.{relation.relation_id}.internal_ring_half_tooth_phase"
if internal is None or internal.tooth_count is None or planet is None or planet.tooth_count is None:
_check(
checks,
code=code,
passed=False,
message="internal mesh phase check requires internal ring and planet tooth counts",
actual="missing_internal_or_planet_gear",
)
return
actual = _ferguson_wolfrom_phase_policy_value(snapshot, internal)
if actual is None:
actual = _phase_policy_value(snapshot, relation.stage_id)
expected = 180.0 / float(internal.tooth_count) if int(planet.tooth_count) % 2 else 0.0
if actual is None:
_check(
checks,
code=code,
passed=False,
message="internal ring phase correction is not recorded in manifest geometry summary",
actual=None,
expected=expected,
)
return
_check(
checks,
code=code,
passed=abs(actual - expected) <= 1e-9,
message="internal ring phase correction follows planet tooth-count parity",
actual=actual,
expected=expected,
)
def _validate_motion_sampling(
*,
instance: ReducerInstance,
topology: ConcreteTopology,
checks: list[ValidationCheck],
) -> None:
mesh_relations = [
relation
for relation in topology.relations
if relation.relation_type in MESH_RELATION_TYPES
]
if not mesh_relations:
return
try:
solution = solve_instance(instance)
equations = build_equations(
parameters=instance.parameters,
boundary=instance.boundary,
topology=topology,
)
except KinematicSolveError as exc:
_check(
checks,
code="gear_mesh.motion_sampling.solve",
passed=False,
message="mesh motion sampling requires a solvable kinematic system",
actual=str(exc),
)
return
equations_by_id = {equation.id: equation for equation in equations}
input_speed = solution.speeds[instance.boundary.input]
input_angles = [0.0, abs(solution.ratio) / 4.0, abs(solution.ratio) / 2.0, abs(solution.ratio)]
for relation in mesh_relations:
equation = equations_by_id.get(relation.relation_id)
if equation is None:
_check(
checks,
code=f"gear_mesh.{relation.relation_id}.motion_sample_residual",
passed=False,
message="mesh relation has no generated motion equation",
actual=relation.relation_id,
)
continue
residuals: list[dict[str, float]] = []
for input_angle in input_angles:
member_angles = {
name: input_angle * speed / input_speed
for name, speed in solution.speeds.items()
}
residual = (
sum(
float(coefficient) * member_angles[variable]
for variable, coefficient in equation.coefficients.items()
)
- equation.rhs
)
residuals.append({"input_angle_deg": input_angle, "residual_deg_teeth": residual})
max_abs = max(abs(item["residual_deg_teeth"]) for item in residuals)
_check(
checks,
code=f"gear_mesh.{relation.relation_id}.motion_sample_residual",
passed=max_abs <= 1e-9,
message="sampled input angles satisfy the mesh rolling equation",
actual={
"samples": residuals,
"max_abs_residual": max_abs,
"equation": equation.expression,
},
expected="<= 1e-9",
)
def validate_gear_mesh_geometry(
instance: ReducerInstance,
topology: ConcreteTopology,
snapshot: AssemblySnapshot,
checks: list[ValidationCheck],
) -> None:
mesh_relations = [
relation
for relation in topology.relations
if relation.relation_type in MESH_RELATION_TYPES
]
if not mesh_relations:
return
for relation in mesh_relations:
source = snapshot.manifest.components.get(relation.source_component_id)
target = snapshot.manifest.components.get(relation.target_component_id)
design = _mesh_design(instance, relation)
_validate_radial_clearance(
relation=relation,
source=source,
target=target,
snapshot=snapshot,
design=design,
checks=checks,
)
_validate_backlash(relation=relation, design=design, checks=checks)
_validate_phase(
relation=relation,
source=source,
target=target,
snapshot=snapshot,
checks=checks,
)
_validate_internal_ring_phase_policy(
relation=relation,
source=source,
target=target,
snapshot=snapshot,
checks=checks,
)
_validate_motion_sampling(instance=instance, topology=topology, checks=checks)
+290
View File
@@ -0,0 +1,290 @@
from __future__ import annotations
import math
from .models import (
AssemblyTopology,
IndustrialDesignParameters,
PhysicalComponent,
PhysicalRelation,
ReducerInstance,
ReducerParameters,
ReducerDerived,
)
def _sun_bore_radius(parameters: ReducerParameters, derived: ReducerDerived) -> float:
return max(0.8, min(2.2, derived.sun_pitch_radius_mm * 0.35))
def _planet_bore_radius(derived: ReducerDerived) -> float:
pin_radius_from_demo = max(0.6, min(2.0, derived.planet_pitch_radius_mm * 0.22))
return pin_radius_from_demo + 0.25
def compute_industrial_parameters(
*,
parameters: ReducerParameters,
derived: ReducerDerived,
) -> IndustrialDesignParameters:
planet_bore_radius = _planet_bore_radius(derived)
pin_radius = max(0.35, min(planet_bore_radius * 0.62, derived.planet_pitch_radius_mm * 0.18))
pin_radius = min(pin_radius, planet_bore_radius - 0.35)
pin_radius = max(0.25, pin_radius)
bearing_inner_radius = pin_radius + max(0.08, parameters.module_mm * 0.12)
bearing_outer_radius = planet_bore_radius - max(0.06, parameters.module_mm * 0.08)
if bearing_outer_radius <= bearing_inner_radius + 0.05:
bearing_inner_radius = max(0.18, bearing_outer_radius - 0.08)
pin_radius = max(0.12, bearing_inner_radius - max(0.06, parameters.module_mm * 0.08))
sun_bore_radius = _sun_bore_radius(parameters, derived)
input_shaft_radius = max(0.35, sun_bore_radius - max(0.12, parameters.module_mm * 0.2))
output_shaft_radius = max(input_shaft_radius + 0.25, derived.sun_pitch_radius_mm * 0.45)
radial_clearance = max(0.8, parameters.module_mm * 1.2)
axial_clearance = max(0.35, parameters.module_mm * 0.8)
carrier_plate_thickness = max(2.0, parameters.face_width_mm * 0.30)
carrier_front_plate_thickness = max(1.2, parameters.face_width_mm * 0.18)
cover_thickness = max(1.5, parameters.face_width_mm * 0.22)
output_shaft_extension = max(12.0, parameters.face_width_mm * 1.50)
housing_wall = max(2.2, parameters.module_mm * 4.0)
housing_inner_diameter = derived.ring_outer_diameter_mm + 2.0 * radial_clearance
housing_outer_diameter = housing_inner_diameter + 2.0 * housing_wall
bolt_circle_diameter = housing_outer_diameter - housing_wall
bolt_diameter = max(1.0, parameters.module_mm * 2.0)
output_bearing_inner_radius = output_shaft_radius + max(0.10, parameters.module_mm * 0.18)
output_bearing_outer_radius = output_bearing_inner_radius + max(1.0, parameters.module_mm * 2.2)
key_width = max(0.6, parameters.module_mm * 1.3, input_shaft_radius * 0.35)
key_height = max(0.35, parameters.module_mm * 0.9, input_shaft_radius * 0.22)
key_length = max(parameters.face_width_mm * 0.75, parameters.face_width_mm - 1.0)
planet_bearing_radial_space = max(
0.05,
bearing_outer_radius - bearing_inner_radius,
)
output_bearing_radial_space = max(
0.05,
output_bearing_outer_radius - output_bearing_inner_radius,
)
bearing_ball_diameter = max(
0.35,
min(
planet_bearing_radial_space * 0.72,
output_bearing_radial_space * 0.72,
parameters.face_width_mm * 0.18,
),
)
def ball_count(inner_radius: float, outer_radius: float) -> int:
pitch_radius = (inner_radius + outer_radius) / 2.0
return max(6, int((2.0 * math.pi * pitch_radius) / (bearing_ball_diameter * 1.35)))
total_length = (
parameters.face_width_mm
+ carrier_plate_thickness
+ carrier_front_plate_thickness
+ 2.0 * cover_thickness
+ 6.0 * axial_clearance
+ output_shaft_extension
)
return IndustrialDesignParameters(
planet_pin_diameter_mm=2.0 * pin_radius,
planet_bearing_inner_diameter_mm=2.0 * bearing_inner_radius,
planet_bearing_outer_diameter_mm=2.0 * bearing_outer_radius,
planet_bearing_width_mm=max(1.0, parameters.face_width_mm * 0.72),
planet_spacer_thickness_mm=max(0.35, parameters.module_mm * 0.8),
carrier_plate_thickness_mm=carrier_plate_thickness,
carrier_front_plate_thickness_mm=carrier_front_plate_thickness,
carrier_pin_fit_length_mm=max(0.8, carrier_plate_thickness * 0.55),
input_shaft_diameter_mm=2.0 * input_shaft_radius,
output_shaft_diameter_mm=2.0 * output_shaft_radius,
output_bearing_inner_diameter_mm=2.0 * output_bearing_inner_radius,
output_bearing_outer_diameter_mm=2.0 * output_bearing_outer_radius,
housing_wall_thickness_mm=housing_wall,
cover_thickness_mm=cover_thickness,
bearing_seat_clearance_mm=max(0.05, parameters.module_mm * 0.10),
axial_clearance_mm=axial_clearance,
radial_clearance_mm=radial_clearance,
bolt_circle_diameter_mm=bolt_circle_diameter,
bolt_count=max(6, parameters.planet_count * 2),
bolt_diameter_mm=bolt_diameter,
sun_key_width_mm=key_width,
sun_key_height_mm=key_height,
sun_key_length_mm=key_length,
bearing_ball_diameter_mm=bearing_ball_diameter,
planet_bearing_ball_count=ball_count(bearing_inner_radius, bearing_outer_radius),
output_bearing_ball_count=ball_count(output_bearing_inner_radius, output_bearing_outer_radius),
housing_outer_diameter_mm=housing_outer_diameter,
housing_inner_diameter_mm=housing_inner_diameter,
total_length_mm=total_length,
)
def planet_component_ids(planet_count: int, prefix: str = "") -> list[str]:
return [f"{prefix}planet_{index + 1}" for index in range(planet_count)]
def build_industrial_assembly_topology(instance: ReducerInstance) -> AssemblyTopology:
if instance.topology_family != "simple_2k_h":
raise ValueError("industrial_core assembly topology currently supports only simple_2k_h")
p = instance.parameters
components: list[PhysicalComponent] = [
PhysicalComponent(component_id="sun", kind="gear", role="sun", formula_ref="kinematic.sun"),
PhysicalComponent(component_id="ring", kind="gear", role="ring", formula_ref="kinematic.ring"),
PhysicalComponent(component_id="carrier", kind="carrier_body", role="carrier", formula_ref="kinematic.carrier", parent_assembly="carrier_assembly"),
PhysicalComponent(component_id="carrier_front_plate", kind="carrier_front_plate", role="carrier_retention", formula_ref="industrial.carrier_front_plate", parent_assembly="carrier_assembly"),
PhysicalComponent(component_id="housing", kind="housing_body", role="housing", formula_ref="industrial.housing_body", parent_assembly="housing_core"),
PhysicalComponent(component_id="sun_input_shaft", kind="shaft", role="input_support", formula_ref="industrial.sun_input_shaft"),
PhysicalComponent(component_id="sun_input_key", kind="key", role="input_key", formula_ref="industrial.sun_input_key"),
PhysicalComponent(component_id="output_shaft", kind="shaft", role="output_support", formula_ref="industrial.output_shaft", parent_assembly="carrier_assembly"),
PhysicalComponent(component_id="output_bearing_1", kind="bearing", role="output_support", formula_ref="industrial.output_bearing_1"),
PhysicalComponent(component_id="output_bearing_2", kind="bearing", role="output_support", formula_ref="industrial.output_bearing_2"),
PhysicalComponent(component_id="front_cover", kind="cover", role="housing_cover", formula_ref="industrial.front_cover", parent_assembly="housing_core"),
PhysicalComponent(component_id="rear_cover", kind="cover", role="housing_cover", formula_ref="industrial.rear_cover", parent_assembly="housing_core"),
PhysicalComponent(component_id="ring_mount", kind="mount", role="ring_mount", formula_ref="industrial.ring_mount", parent_assembly="housing_core"),
PhysicalComponent(component_id="seal_input", kind="seal", role="input_seal", formula_ref="industrial.seal_input"),
PhysicalComponent(component_id="seal_output", kind="seal", role="output_seal", formula_ref="industrial.seal_output"),
]
for planet_id in planet_component_ids(p.planet_count):
components.append(PhysicalComponent(component_id=planet_id, kind="gear", role="planet", formula_ref="kinematic.planet"))
for index in range(1, p.planet_count + 1):
components.extend(
[
PhysicalComponent(component_id=f"planet_pin_{index}", kind="pin", role="planet_pin", formula_ref=f"industrial.planet_pin_{index}", parent_assembly="carrier_assembly"),
PhysicalComponent(component_id=f"planet_bearing_{index}", kind="bearing", role="planet_bearing", formula_ref=f"industrial.planet_bearing_{index}"),
PhysicalComponent(component_id=f"planet_spacer_{index}", kind="spacer", role="planet_retention", formula_ref=f"industrial.planet_spacer_{index}", parent_assembly="carrier_assembly"),
]
)
for index in range(1, max(6, p.planet_count * 2) + 1):
components.append(
PhysicalComponent(
component_id=f"housing_bolt_{index}",
kind="fastener",
role="housing_fastener",
formula_ref=f"industrial.housing_bolt_{index}",
parent_assembly="housing_core",
)
)
relations: list[PhysicalRelation] = [
PhysicalRelation(relation_id="sun_input_shaft_keyed_to_sun", relation_type="keyed", source_component_id="sun_input_shaft", target_component_id="sun", formula_ref="industrial.input_keyed_coupling", details={"key": "sun_input_key"}),
PhysicalRelation(relation_id="carrier_body_rigid_to_output_shaft", relation_type="rigid", source_component_id="carrier", target_component_id="output_shaft", formula_ref="industrial.output_coupling"),
PhysicalRelation(relation_id="carrier_front_plate_rigid_to_carrier", relation_type="rigid", source_component_id="carrier_front_plate", target_component_id="carrier", formula_ref="industrial.carrier_cage"),
PhysicalRelation(relation_id="output_shaft_supported_by_housing", relation_type="bearing_support", source_component_id="output_shaft", target_component_id="housing", formula_ref="industrial.output_bearing_support", details={"bearings": ["output_bearing_1", "output_bearing_2"]}),
PhysicalRelation(relation_id="ring_bolted_to_housing", relation_type="bolted", source_component_id="ring", target_component_id="housing", formula_ref="industrial.ring_mount", details={"mount": "ring_mount"}),
PhysicalRelation(relation_id="front_cover_bolted_to_housing", relation_type="bolted", source_component_id="front_cover", target_component_id="housing", formula_ref="industrial.front_cover"),
PhysicalRelation(relation_id="rear_cover_bolted_to_housing", relation_type="bolted", source_component_id="rear_cover", target_component_id="housing", formula_ref="industrial.rear_cover"),
PhysicalRelation(relation_id="input_seal_coaxial_to_input_shaft", relation_type="coaxial", source_component_id="seal_input", target_component_id="sun_input_shaft", formula_ref="industrial.input_seal"),
PhysicalRelation(relation_id="output_seal_coaxial_to_output_shaft", relation_type="coaxial", source_component_id="seal_output", target_component_id="output_shaft", formula_ref="industrial.output_seal"),
]
for index in range(1, p.planet_count + 1):
planet_id = f"planet_{index}"
pin_id = f"planet_pin_{index}"
bearing_id = f"planet_bearing_{index}"
spacer_id = f"planet_spacer_{index}"
relations.extend(
[
PhysicalRelation(relation_id=f"{pin_id}_press_fit_to_carrier", relation_type="press_fit", source_component_id=pin_id, target_component_id="carrier", formula_ref=f"industrial.{pin_id}.carrier_fit"),
PhysicalRelation(relation_id=f"{planet_id}_bearing_revolute_on_{pin_id}", relation_type="bearing_revolute", source_component_id=planet_id, target_component_id=pin_id, formula_ref=f"industrial.{planet_id}.bearing_revolute", details={"bearing": bearing_id}),
PhysicalRelation(relation_id=f"{bearing_id}_slip_fit_in_{planet_id}", relation_type="slip_fit", source_component_id=bearing_id, target_component_id=planet_id, formula_ref=f"industrial.{bearing_id}.planet_bore"),
PhysicalRelation(relation_id=f"{planet_id}_axial_retention", relation_type="axial_retention", source_component_id=planet_id, target_component_id=spacer_id, formula_ref=f"industrial.{planet_id}.retention"),
PhysicalRelation(relation_id=f"{planet_id}_carrier_clearance", relation_type="clearance", source_component_id=planet_id, target_component_id="carrier", formula_ref=f"industrial.{planet_id}.carrier_clearance"),
]
)
for index in range(1, max(6, p.planet_count * 2) + 1):
relations.append(
PhysicalRelation(
relation_id=f"housing_bolt_{index}_bolts_cover_stack",
relation_type="bolted",
source_component_id=f"housing_bolt_{index}",
target_component_id="housing",
formula_ref=f"industrial.housing_bolt_{index}",
)
)
return AssemblyTopology(
topology_id=f"{instance.run_id}_industrial_core_assembly_topology",
detail_level="industrial_core",
components=components,
relations=relations,
)
def relation_context_for_pair(instance: ReducerInstance, a: str, b: str) -> list[dict[str, str]]:
if instance.assembly_topology is None:
return []
pair = {a, b}
matches = []
for relation in instance.assembly_topology.relations:
if {relation.source_component_id, relation.target_component_id} == pair:
matches.append(
{
"relation_id": relation.relation_id,
"relation_type": relation.relation_type,
"formula_ref": relation.formula_ref,
}
)
return matches
def classify_interference_pair(instance: ReducerInstance, a: str, b: str) -> str:
relation_types = {item["relation_type"] for item in relation_context_for_pair(instance, a, b)}
names = {a, b}
if {"external_mesh", "internal_mesh"} & relation_types or (
any(name.startswith("planet_") for name in names) and ("sun" in names or "ring" in names)
):
return "gear_phase_error"
if "clearance" in relation_types:
return "insufficient_clearance"
if any("bearing" in name for name in names) and (
"housing" in names
or any(name in {"front_cover", "rear_cover", "ring_mount"} for name in names)
):
return "bearing_seat_collision"
if any("pin" in name for name in names):
return "pin_too_large"
if any("key" in name for name in names):
return "keyway_collision"
if any("interstage" in name for name in names):
return "axial_stack_overlap"
if "carrier" in names or any("carrier" in name for name in names):
return "carrier_collision"
if "housing" in names or any(name in {"front_cover", "rear_cover", "ring_mount"} for name in names):
return "housing_collision"
if any("s1_" in name or "s2_" in name for name in names):
return "axial_stack_overlap"
return "unknown_interference"
def suggested_fix_for_interference(cause: str, a: str, b: str) -> str:
if cause == "gear_phase_error":
return "check gear phase policy and mesh center distance for the interfering gear pair"
if cause == "wrong_center_distance":
return "recompute pitch radii and component placements from the solved parameters"
if cause == "insufficient_clearance":
return "increase the relevant radial_clearance_mm or axial_clearance_mm rule and regenerate"
if cause == "pin_too_large":
return "reduce planet_pin_diameter_mm or enlarge the planet bore and bearing clearance rules"
if cause == "keyway_collision":
return "increase sun keyway clearance, reduce sun_key_height_mm, or move the key fully into the keyed slot"
if cause == "carrier_collision":
return "enlarge carrier relief bores or move the carrier plate/bearing stack axially"
if cause == "housing_collision":
return "increase housing_inner_diameter_mm, cover clearances, or move the housing feature axially"
if cause == "bearing_seat_collision":
return "increase bearing seat clearance or move the bearing away from the housing/cover solid"
if cause == "axial_stack_overlap":
return "increase stage_gap_mm or recompute axial stack offsets"
return f"inspect {a} and {b} STEP bodies and add a specific clearance or fit rule"
def bolt_xy_positions(params: IndustrialDesignParameters) -> list[tuple[float, float]]:
radius = params.bolt_circle_diameter_mm / 2.0
return [
(
radius * math.cos(2.0 * math.pi * index / params.bolt_count),
radius * math.sin(2.0 * math.pi * index / params.bolt_count),
)
for index in range(params.bolt_count)
]
+2
View File
@@ -0,0 +1,2 @@
"""Joint module generation and validation helpers."""
+353
View File
@@ -0,0 +1,353 @@
from __future__ import annotations
import importlib.util
import sys
import traceback
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from ..models import read_json, sha256_json, write_json
from ..parameter_solver import load_requirement
from .datum_inference import infer_step_datum_axis
from .generator import JointModuleError, run_joint_module
from .models import JointModuleManifest
PACKAGE_ROOT = Path(__file__).resolve().parents[2]
AUTO_CONTROLLED_OUTPUTS = [
"auto_design_input.json",
"auto_design_report.json",
"generated_housing.step",
"generated_reducer_requirement.json",
"generated_joint_requirement.json",
"auto_generation_error.json",
]
class AutoJointDesignError(RuntimeError):
pass
@dataclass(frozen=True)
class AutoJointEnvelope:
motor_radial_diameter_mm: float
motor_axis_length_mm: float
housing_inner_diameter_mm: float
housing_outer_diameter_mm: float
housing_axis_length_mm: float
reducer_max_outer_diameter_mm: float
target_ratio: float
module_candidates_mm: list[float]
planet_count_candidates: list[int]
def _patch_cadquery_visual_imports() -> None:
try:
import OCP.IVtkOCC as ivtkocc # type: ignore
class _Dummy:
def __init__(self, *args: object, **kwargs: object) -> None:
pass
def __getattr__(self, name: str) -> object:
raise RuntimeError("CadQuery VTK visualization bridge is unavailable")
for name in ["IVtkOCC_Shape", "IVtkOCC_ShapeMesher"]:
if not hasattr(ivtkocc, name):
setattr(ivtkocc, name, _Dummy)
try:
import OCP.IVtkVTK as ivtkvtk # type: ignore
if not hasattr(ivtkvtk, "IVtkVTK_ShapeData"):
setattr(ivtkvtk, "IVtkVTK_ShapeData", _Dummy)
except Exception: # noqa: BLE001
return
except Exception: # noqa: BLE001
return
def _load_housing_module(path: Path) -> Any:
_patch_cadquery_visual_imports()
spec = importlib.util.spec_from_file_location("auto_joint_housing_source", path)
if spec is None or spec.loader is None:
raise AutoJointDesignError(f"cannot_load_housing_python: {path}")
module = importlib.util.module_from_spec(spec)
sys.modules["auto_joint_housing_source"] = module
spec.loader.exec_module(module)
return module
def _resolve_path(path_text: str, *, requirement_path: Path | None = None) -> Path:
path = Path(path_text).expanduser()
if path.is_absolute():
return path
if requirement_path is not None:
relative_to_requirement = requirement_path.parent / path
if relative_to_requirement.exists():
return relative_to_requirement.resolve()
relative_to_package = PACKAGE_ROOT / path
if relative_to_package.exists():
return relative_to_package.resolve()
return path.resolve()
def _axis_extent(metrics: Any) -> float:
axis = metrics.inferred_axis
if axis is None:
raise AutoJointDesignError(f"{metrics.component_id}: missing inferred axis")
selected = str(axis.details.get("selected_axis_dimension", "z"))
xmin, ymin, zmin, xmax, ymax, zmax = metrics.bbox
extents = {
"x": xmax - xmin,
"y": ymax - ymin,
"z": zmax - zmin,
}
return float(extents[selected])
def _radial_diameter(metrics: Any) -> float:
axis = metrics.inferred_axis
if axis is None:
raise AutoJointDesignError(f"{metrics.component_id}: missing inferred axis")
selected = str(axis.details.get("selected_axis_dimension", "z"))
xmin, ymin, zmin, xmax, ymax, zmax = metrics.bbox
extents = {
"x": xmax - xmin,
"y": ymax - ymin,
"z": zmax - zmin,
}
radial_axes = [axis_name for axis_name in ["x", "y", "z"] if axis_name != selected]
return max(float(extents[radial_axes[0]]), float(extents[radial_axes[1]]))
def _ratio_from_envelope(envelope: AutoJointEnvelope) -> float:
if envelope.reducer_max_outer_diameter_mm < 46.0:
return 3.0
if envelope.reducer_max_outer_diameter_mm < 58.0:
return 5.0
if envelope.reducer_max_outer_diameter_mm < 76.0:
return 7.5
return 10.0
def _module_candidates_for_outer_diameter(max_outer_diameter_mm: float) -> list[float]:
if max_outer_diameter_mm < 45.0:
return [0.3, 0.35, 0.4, 0.5]
if max_outer_diameter_mm < 65.0:
return [0.35, 0.4, 0.5, 0.6, 0.75]
return [0.35, 0.4, 0.5, 0.75, 1.0]
def compute_auto_envelope(
*,
motor_step: Path,
housing_source: Path,
radial_clearance_mm: float,
reducer_axial_allowance_mm: float,
reducer_radial_clearance_mm: float,
) -> tuple[AutoJointEnvelope, dict[str, Any], Any]:
motor_metrics = infer_step_datum_axis(component_id="motor", step_path=motor_step, role="motor")
motor_radial_diameter = _radial_diameter(motor_metrics)
motor_axis_length = _axis_extent(motor_metrics)
housing_module = _load_housing_module(housing_source)
if not all(hasattr(housing_module, name) for name in ["scale_from_motor", "scaled_dimensions"]):
raise AutoJointDesignError(
"housing source must provide scale_from_motor(...) and scaled_dimensions(...)"
)
scale = housing_module.scale_from_motor(
motor_radial_diameter_mm=motor_radial_diameter,
motor_axis_length_mm=motor_axis_length,
radial_clearance_mm=radial_clearance_mm,
reducer_axial_allowance_mm=reducer_axial_allowance_mm,
)
dimensions = housing_module.scaled_dimensions(scale)
housing_inner_diameter = float(dimensions["inner_wall_radius"]) * 2.0
housing_outer_diameter = float(dimensions["top_outer_radius"]) * 2.0
housing_axis_length = float(dimensions["height"])
reducer_max_outer = max(24.0, housing_inner_diameter - 2.0 * reducer_radial_clearance_mm)
envelope = AutoJointEnvelope(
motor_radial_diameter_mm=motor_radial_diameter,
motor_axis_length_mm=motor_axis_length,
housing_inner_diameter_mm=housing_inner_diameter,
housing_outer_diameter_mm=housing_outer_diameter,
housing_axis_length_mm=housing_axis_length,
reducer_max_outer_diameter_mm=reducer_max_outer,
target_ratio=0.0,
module_candidates_mm=_module_candidates_for_outer_diameter(reducer_max_outer),
planet_count_candidates=[3, 4],
)
envelope = AutoJointEnvelope(
**{
**envelope.__dict__,
"target_ratio": _ratio_from_envelope(envelope),
}
)
return envelope, {
"motor_metrics": motor_metrics.model_dump(mode="json"),
"housing_scale": {
"radial_scale": float(scale.radial_scale),
"axial_scale": float(scale.axial_scale),
"source": str(scale.source),
},
"housing_dimensions": dimensions,
}, housing_module
def export_generated_housing(
*,
housing_module: Any,
scale_payload: dict[str, Any],
out_path: Path,
) -> None:
if not hasattr(housing_module, "HousingScale") or not hasattr(housing_module, "export_scaled"):
raise AutoJointDesignError("housing source must provide HousingScale and export_scaled")
scale = housing_module.HousingScale(
radial_scale=float(scale_payload["radial_scale"]),
axial_scale=float(scale_payload["axial_scale"]),
source=str(scale_payload.get("source", "computed_from_motor_bbox")),
)
out_path.parent.mkdir(parents=True, exist_ok=True)
housing_module.export_scaled(out_path, scale)
if not out_path.exists() or out_path.stat().st_size <= 0:
raise AutoJointDesignError(f"generated_housing_step_missing_or_empty: {out_path}")
def build_reducer_requirement(envelope: AutoJointEnvelope, *, tooth_form: str = "spur") -> dict[str, Any]:
return {
"target_ratio": envelope.target_ratio,
"ratio_tolerance": 0.000001,
"topology_family": "simple_2k_h",
"detail_level": "industrial_core",
"input": "sun",
"fixed": "ring",
"output": "carrier",
"tooth_form": tooth_form,
"planet_count_candidates": envelope.planet_count_candidates,
"module_candidates_mm": envelope.module_candidates_mm,
"pressure_angle_deg": 20.0,
"face_width_mm": min(8.0, max(5.0, envelope.housing_axis_length_mm * 0.10)),
"backlash_mm": 0.03,
"addendum_coeff": 1.0,
"clearance_coeff": 0.25,
"ring_rim_thickness_mm": max(1.8, envelope.reducer_max_outer_diameter_mm * 0.035),
"constraints": {
"max_outer_diameter_mm": envelope.reducer_max_outer_diameter_mm,
"min_planet_neighbor_clearance_mm": 0.3,
"min_sun_teeth": 18,
"max_sun_teeth": 96,
"min_planet_teeth": 18,
"max_planet_teeth": 96,
},
}
def build_joint_requirement(
*,
motor_step: Path,
housing_step: Path,
reducer_requirement: Path,
) -> dict[str, Any]:
return {
"module_type": "planetary_joint_module",
"motor_step": str(motor_step.resolve()),
"housing_step": str(housing_step.resolve()),
"reducer_requirement": str(reducer_requirement.resolve()),
"placement_policy": "infer_then_write_editable_json",
"motor_to_reducer_relation": "motor_output_to_sun_keyed_input",
"housing_relation": "ring_fixed_to_housing",
"joint_output": "carrier_output_shaft",
"input_shaft_insertion_depth_mm": 4.0,
"output_extension_mm": 8.0,
}
def prepare_auto_dir(out_dir: Path) -> None:
out_dir.mkdir(parents=True, exist_ok=True)
for name in AUTO_CONTROLLED_OUTPUTS:
path = out_dir / name
if path.exists():
path.unlink()
def run_auto_joint_design(
*,
motor_step: Path,
housing_source: Path,
out_dir: Path,
tooth_form: str = "spur",
radial_clearance_mm: float = 14.0,
reducer_axial_allowance_mm: float = 42.0,
reducer_radial_clearance_mm: float = 8.0,
) -> JointModuleManifest:
prepare_auto_dir(out_dir)
if not motor_step.exists():
raise AutoJointDesignError(f"missing_motor_step: {motor_step}")
if not housing_source.exists():
raise AutoJointDesignError(f"missing_housing_source: {housing_source}")
envelope, evidence, housing_module = compute_auto_envelope(
motor_step=motor_step,
housing_source=housing_source,
radial_clearance_mm=radial_clearance_mm,
reducer_axial_allowance_mm=reducer_axial_allowance_mm,
reducer_radial_clearance_mm=reducer_radial_clearance_mm,
)
housing_step = out_dir / "generated_housing.step"
export_generated_housing(
housing_module=housing_module,
scale_payload=evidence["housing_scale"],
out_path=housing_step,
)
reducer_requirement_path = out_dir / "generated_reducer_requirement.json"
joint_requirement_path = out_dir / "generated_joint_requirement.json"
reducer_requirement_payload = build_reducer_requirement(envelope, tooth_form=tooth_form)
write_json(reducer_requirement_path, reducer_requirement_payload)
# Validate before launching the heavy generation path.
load_requirement(reducer_requirement_path)
joint_requirement_payload = build_joint_requirement(
motor_step=motor_step,
housing_step=housing_step,
reducer_requirement=reducer_requirement_path,
)
write_json(joint_requirement_path, joint_requirement_payload)
write_json(
out_dir / "auto_design_input.json",
{
"motor_step": str(motor_step.resolve()),
"housing_source": str(housing_source.resolve()),
"tooth_form": tooth_form,
"radial_clearance_mm": radial_clearance_mm,
"reducer_axial_allowance_mm": reducer_axial_allowance_mm,
"reducer_radial_clearance_mm": reducer_radial_clearance_mm,
},
)
write_json(
out_dir / "auto_design_report.json",
{
"run_id": f"auto_{uuid.uuid4().hex[:12]}",
"requirement_hash": sha256_json(joint_requirement_payload),
"envelope": envelope.__dict__,
"evidence": evidence,
"generated_files": {
"housing_step": str(housing_step.resolve()),
"reducer_requirement": str(reducer_requirement_path.resolve()),
"joint_requirement": str(joint_requirement_path.resolve()),
},
},
)
try:
return run_joint_module(requirement_path=joint_requirement_path, out_dir=out_dir / "joint")
except Exception as exc: # noqa: BLE001
write_json(
out_dir / "auto_generation_error.json",
{
"stage": "joint",
"error_type": type(exc).__name__,
"message": str(exc),
"traceback": traceback.format_exc(),
},
)
raise
+203
View File
@@ -0,0 +1,203 @@
from __future__ import annotations
import math
from pathlib import Path
from ..cad.ocp_interference import _import_ocp
from ..cad.ocp_interference import read_step_shape
from .models import DatumAxis, StepShapeMetrics
from .step_geometry import bbox_center, metrics_for_shape
class DatumInferenceError(RuntimeError):
pass
def _axis_from_bbox(metrics: StepShapeMetrics, *, role: str) -> DatumAxis:
xmin, ymin, zmin, xmax, ymax, zmax = metrics.bbox
extents = {
"x": xmax - xmin,
"y": ymax - ymin,
"z": zmax - zmin,
}
directions = {
"x": (1.0, 0.0, 0.0),
"y": (0.0, 1.0, 0.0),
"z": (0.0, 0.0, 1.0),
}
pairs = [
("x", "y", "z"),
("x", "z", "y"),
("y", "z", "x"),
]
best_axis = "z"
best_score = 1.0
for radial_a, radial_b, axis in pairs:
radial_max = max(abs(extents[radial_a]), abs(extents[radial_b]), 1e-12)
score = abs(extents[radial_a] - extents[radial_b]) / radial_max
if score < best_score:
best_score = score
best_axis = axis
if role == "motor" and best_score > 0.12:
# A motor is usually a cylinder where two radial bbox dimensions match.
best_axis = min(extents, key=lambda key: abs(extents[key] - sorted(extents.values())[1]))
elif role == "housing" and best_score > 0.12:
# For a joint housing, prefer the axis perpendicular to the largest near-round footprint.
best_axis = "z"
confidence = max(0.0, min(0.95, 1.0 - best_score))
if metrics.cylinder_face_count > 0:
confidence = min(0.99, confidence + 0.05)
return DatumAxis(
origin_mm=bbox_center(metrics.bbox),
direction_xyz=directions[best_axis],
confidence=confidence,
method="bbox_equal_radial_extents_with_face_count_hint",
details={
"role": role,
"bbox_extents_mm": extents,
"selected_axis_dimension": best_axis,
"radial_extent_mismatch_ratio": best_score,
"plane_face_count": metrics.plane_face_count,
"cylinder_face_count": metrics.cylinder_face_count,
},
)
def _axis_coordinate(point: tuple[float, float, float], axis_dimension: str) -> float:
return {"x": point[0], "y": point[1], "z": point[2]}[axis_dimension]
def _radial_distance(point: tuple[float, float, float], axis_dimension: str) -> float:
if axis_dimension == "x":
return math.hypot(point[1], point[2])
if axis_dimension == "y":
return math.hypot(point[0], point[2])
return math.hypot(point[0], point[1])
def _bbox_axis_range(
bbox: tuple[float, float, float, float, float, float],
axis_dimension: str,
) -> tuple[float, float]:
if axis_dimension == "x":
return (bbox[0], bbox[3])
if axis_dimension == "y":
return (bbox[1], bbox[4])
return (bbox[2], bbox[5])
def _infer_motor_output_bore_details(shape, metrics: StepShapeMetrics, axis: DatumAxis) -> dict[str, object]:
axis_dimension = str(axis.details.get("selected_axis_dimension", "z"))
shape_min, shape_max = _bbox_axis_range(metrics.bbox, axis_dimension)
ocp = _import_ocp()
explorer = ocp["TopExp_Explorer"](shape, ocp["TopAbs_FACE"])
candidates: list[dict[str, object]] = []
while explorer.More():
try:
face = ocp["TopoDS"].Face_s(explorer.Current())
surface = ocp["BRepAdaptor_Surface"](face)
if surface.GetType() != ocp["GeomAbs_Cylinder"]:
explorer.Next()
continue
cylinder = surface.Cylinder()
axis_ref = cylinder.Axis()
direction = axis_ref.Direction()
direction_values = (direction.X(), direction.Y(), direction.Z())
axis_parallel = abs(
{
"x": direction_values[0],
"y": direction_values[1],
"z": direction_values[2],
}[axis_dimension]
)
if axis_parallel < 0.95:
explorer.Next()
continue
location = axis_ref.Location()
location_values = (location.X(), location.Y(), location.Z())
radial_distance = _radial_distance(location_values, axis_dimension)
radius = float(cylinder.Radius())
if radial_distance > max(0.25, radius * 0.05):
explorer.Next()
continue
box = ocp["Bnd_Box"]()
ocp["BRepBndLib"].Add_s(face, box)
face_min, face_max = _bbox_axis_range(tuple(float(value) for value in box.Get()), axis_dimension)
touches_min = abs(face_min - shape_min) <= 0.5
touches_max = abs(face_max - shape_max) <= 0.5
if not (touches_min or touches_max):
explorer.Next()
continue
if radius <= 0.25:
explorer.Next()
continue
candidates.append(
{
"radius_mm": radius,
"axis_min_mm": face_min,
"axis_max_mm": face_max,
"touches_axis_min": touches_min,
"touches_axis_max": touches_max,
"depth_mm": abs(face_max - face_min),
"radial_distance_mm": radial_distance,
}
)
except Exception: # noqa: BLE001
pass
explorer.Next()
axis_max_candidates = [item for item in candidates if item["touches_axis_max"]]
axis_min_candidates = [item for item in candidates if item["touches_axis_min"]]
chosen = None
output_side = None
if axis_max_candidates:
chosen = min(axis_max_candidates, key=lambda item: float(item["radius_mm"]))
output_side = "axis_max"
elif axis_min_candidates:
chosen = min(axis_min_candidates, key=lambda item: float(item["radius_mm"]))
output_side = "axis_min"
if chosen is None:
return {
"motor_output_bore_inferred": False,
"central_axis_end_cylinder_count": len(candidates),
}
return {
"motor_output_bore_inferred": True,
"motor_output_side": output_side,
"motor_output_bore_radius_mm": chosen["radius_mm"],
"motor_output_bore_axis_min_mm": chosen["axis_min_mm"],
"motor_output_bore_axis_max_mm": chosen["axis_max_mm"],
"motor_output_bore_depth_mm": chosen["depth_mm"],
"central_axis_end_cylinder_count": len(candidates),
}
def infer_step_datum_axis(
*,
component_id: str,
step_path: Path,
role: str,
min_confidence: float = 0.6,
) -> StepShapeMetrics:
shape = read_step_shape(step_path)
metrics = metrics_for_shape(component_id=component_id, step_path=step_path, shape=shape)
axis = _axis_from_bbox(metrics, role=role)
if role == "motor":
axis = axis.model_copy(
update={
"details": {
**axis.details,
**_infer_motor_output_bore_details(shape, metrics, axis),
}
}
)
metrics = metrics.model_copy(update={"inferred_axis": axis})
if not metrics.valid or metrics.volume_mm3 <= 0.0:
raise DatumInferenceError(f"{component_id}: invalid or zero-volume STEP")
if axis.confidence < min_confidence:
raise DatumInferenceError(
f"{component_id}: datum_inference_failed confidence={axis.confidence:.3f}"
)
return metrics
+773
View File
@@ -0,0 +1,773 @@
from __future__ import annotations
import shutil
import uuid
from dataclasses import dataclass
from pathlib import Path
from networkx.readwrite import json_graph
from ..assembly_reader import read_assembly_snapshot
from ..cad.ocp_interference import read_step_shape
from ..cad.simplecad_generator import generate_simplecad, guard_no_gear_test
from ..kinematics import KinematicSolution, motion_samples, solve_instance
from ..models import (
AssemblyManifest,
ReducerInstance,
ValidationReport,
read_json,
sha256_json,
utc_now_iso,
write_json,
write_model_json,
)
from ..parameter_solver import load_requirement, solve_parameters
from ..topology import build_graph, validate_topology
from ..topology_runtime import load_default_template
from ..validators import validate_from_files
from .datum_inference import infer_step_datum_axis
from .models import (
ComponentPlacement,
DatumAxis,
JointComponentManifest,
JointInterferenceFinding,
JointModuleManifest,
JointModuleRequirement,
JointRelation,
StepShapeMetrics,
)
from .reducer_adapters import JointAdapterError, ReducerJointAdapter, get_reducer_joint_adapter
from .step_geometry import (
apply_placement_to_direction,
apply_placement_to_point,
apply_transform,
axis_angle_between,
bbox_axis_range,
detect_interferences_from_placed_shapes,
dot,
metrics_for_shape,
union_bbox,
vector_add,
vector_scale,
vector_sub,
write_compound_step,
)
from .validators import build_joint_module_validation_report, finding_from_interference_result
PACKAGE_ROOT = Path(__file__).resolve().parents[2]
JOINT_CONTROLLED_OUTPUTS = [
"external_step_metrics.json",
"joint_placement.json",
"joint_module.step",
"joint_module_manifest.json",
"joint_module_validation_report.json",
"joint_interference_report.json",
"joint_generation_error.json",
]
REDUCER_RUN_DIRS = [
"input_snapshot",
"formula",
"kinematics",
"placement",
"cad",
"validation",
"exports",
]
class JointModuleError(RuntimeError):
pass
@dataclass(frozen=True)
class ReducerRunArtifacts:
instance: ReducerInstance
solution: KinematicSolution
manifest: AssemblyManifest
validation_report: ValidationReport
out_dir: Path
def load_joint_requirement(path: Path) -> JointModuleRequirement:
return JointModuleRequirement.model_validate(read_json(path))
def _resolve_path(path_text: str, *, requirement_path: Path) -> Path:
path = Path(path_text).expanduser()
if path.is_absolute():
return path
relative_to_requirement = requirement_path.parent / path
if relative_to_requirement.exists():
return relative_to_requirement.resolve()
relative_to_package = PACKAGE_ROOT / path
if relative_to_package.exists():
return relative_to_package.resolve()
return relative_to_requirement.resolve()
def prepare_joint_dir(out_dir: Path) -> None:
out_dir.mkdir(parents=True, exist_ok=True)
for name in JOINT_CONTROLLED_OUTPUTS:
path = out_dir / name
if path.exists():
path.unlink()
reducer_dir = out_dir / "reducer"
if reducer_dir.exists():
marker = reducer_dir / ".planetary_reducer_run_dir"
if not marker.exists():
raise JointModuleError(f"refusing_to_delete_unmarked_reducer_dir: {reducer_dir}")
shutil.rmtree(reducer_dir)
def _prepare_reducer_dir(out_dir: Path) -> None:
out_dir.mkdir(parents=True, exist_ok=True)
for name in [*REDUCER_RUN_DIRS, "generation_error.json"]:
path = out_dir / name
if path.exists():
if path.is_dir():
shutil.rmtree(path)
else:
path.unlink()
(out_dir / ".planetary_reducer_run_dir").write_text(
"planetary_reducer_system_demo\n",
encoding="utf-8",
)
for name in REDUCER_RUN_DIRS:
(out_dir / name).mkdir(parents=True, exist_ok=True)
def _reducer_run_paths(out_dir: Path) -> dict[str, Path]:
return {name: out_dir / name for name in REDUCER_RUN_DIRS}
def _run_reducer_pipeline(requirement_path: Path, out_dir: Path) -> ReducerRunArtifacts:
guard_no_gear_test()
_prepare_reducer_dir(out_dir)
paths = _reducer_run_paths(out_dir)
requirement = load_requirement(requirement_path)
template = load_default_template(requirement.topology_family)
errors = validate_topology(template, requirement)
if errors:
raise JointModuleError(f"reducer_topology_invalid: {errors}")
graph = build_graph(template)
instance, search_report = solve_parameters(requirement=requirement, template=template)
solution = solve_instance(instance)
samples = motion_samples(instance, solution)
shutil.copy2(requirement_path, paths["input_snapshot"] / requirement_path.name)
write_model_json(paths["formula"] / "formula_instance.json", instance)
write_model_json(paths["formula"] / "parameter_search_report.json", search_report)
write_model_json(paths["kinematics"] / "kinematic_solution.json", solution)
write_model_json(paths["kinematics"] / "motion_samples.json", samples)
if instance.industrial_parameters is not None:
write_model_json(paths["formula"] / "industrial_parameters.json", instance.industrial_parameters)
if instance.assembly_topology is not None:
write_model_json(paths["formula"] / "assembly_topology.json", instance.assembly_topology)
write_json(paths["formula"] / "topology_graph.json", json_graph.node_link_data(graph, edges="edges"))
manifest = generate_simplecad(instance, paths["cad"])
placement_plan = paths["cad"] / "placement_plan.json"
if placement_plan.exists():
shutil.copy2(placement_plan, paths["placement"] / "placement_plan.json")
validation_report = validate_from_files(
instance=instance,
model_path=paths["cad"] / "reducer.model.json",
manifest_path=paths["cad"] / "assembly_manifest.json",
build_meta_path=paths["cad"] / "build_meta.json",
snapshot_out=paths["validation"] / "assembly_snapshot.json",
report_out=paths["validation"] / "validation_report.json",
)
return ReducerRunArtifacts(
instance=instance,
solution=solution,
manifest=manifest,
validation_report=validation_report,
out_dir=out_dir,
)
def _axis_with_placement(
*,
origin: tuple[float, float, float],
direction: tuple[float, float, float],
placement: ComponentPlacement,
confidence: float = 0.95,
method: str = "transformed_known_axis",
details: dict[str, object] | None = None,
) -> DatumAxis:
return DatumAxis(
origin_mm=apply_placement_to_point(origin, placement),
direction_xyz=apply_placement_to_direction(direction, placement),
confidence=confidence,
method=method,
details=details or {},
)
def _component_axis_origin(component_id: str, artifacts: ReducerRunArtifacts) -> tuple[float, float, float]:
component = artifacts.manifest.components.get(component_id)
if component is None:
return (0.0, 0.0, 0.0)
return tuple(float(value) for value in component.center_xyz_mm)
def _axis_align_translation(
*,
axis_origin_target: tuple[float, float, float],
axis_direction_target: tuple[float, float, float],
rotated_axis_origin: tuple[float, float, float],
rotated_bbox_axis_value: float,
desired_bbox_axis_value: float,
) -> tuple[float, float, float]:
axis = axis_direction_target
target_perp = vector_sub(axis_origin_target, vector_scale(axis, dot(axis_origin_target, axis)))
source_perp = vector_sub(rotated_axis_origin, vector_scale(axis, dot(rotated_axis_origin, axis)))
perp_delta = vector_sub(target_perp, source_perp)
axis_delta = desired_bbox_axis_value - rotated_bbox_axis_value
return vector_add(perp_delta, vector_scale(axis, axis_delta))
def _axis_center(axis_range: tuple[float, float]) -> float:
return (axis_range[0] + axis_range[1]) / 2.0
def _clamp(value: float, lower: float, upper: float) -> float:
return max(lower, min(upper, value))
def _json_float(value: float) -> float | str:
if value == float("inf"):
return "inf"
if value == -float("inf"):
return "-inf"
return value
def _motor_output_bore_face_axis_value(
*,
motor_metrics: StepShapeMetrics,
motor_output_side: str,
) -> float | None:
axis = motor_metrics.inferred_axis
if axis is None:
return None
details = axis.details
key = "motor_output_bore_axis_max_mm" if motor_output_side == "axis_max" else "motor_output_bore_axis_min_mm"
value = details.get(key)
if isinstance(value, (int, float)):
return float(value)
fallback_key = "motor_output_face_axis_value_mm"
fallback_value = details.get(fallback_key)
if isinstance(fallback_value, (int, float)):
return float(fallback_value)
return None
def _build_placements(
*,
requirement: JointModuleRequirement,
motor_metrics: StepShapeMetrics,
housing_metrics: StepShapeMetrics,
reducer_shapes: dict[str, object],
reducer_step_paths: dict[str, Path],
artifacts: ReducerRunArtifacts,
adapter: ReducerJointAdapter,
) -> dict[str, ComponentPlacement]:
if motor_metrics.inferred_axis is None or housing_metrics.inferred_axis is None:
raise JointModuleError("datum_inference_failed: motor and housing axes are required")
housing_axis = housing_metrics.inferred_axis
housing_axis_direction = housing_axis.direction_xyz
housing_axis_origin = housing_axis.origin_mm
housing_min, housing_max = bbox_axis_range(housing_metrics.bbox, housing_axis_direction)
inside_margin = requirement.coupling_clearance_mm
housing_placement = ComponentPlacement(
translation_mm=(0.0, 0.0, 0.0),
rotation_axis_angle_deg=(0.0, 0.0, 1.0, 0.0),
details={"policy": "fixed_external_housing_reference"},
)
reducer_rotation = axis_angle_between((0.0, 0.0, 1.0), housing_axis_direction)
reducer_rotation_only = ComponentPlacement(rotation_axis_angle_deg=reducer_rotation)
rotated_bboxes: dict[str, tuple[float, float, float, float, float, float]] = {}
for component_id, shape in reducer_shapes.items():
rotated_bboxes[component_id] = metrics_for_shape(
component_id=component_id,
step_path=reducer_step_paths[component_id],
shape=apply_transform(shape, reducer_rotation_only),
).bbox
core_component_ids = [
component_id
for component_id in reducer_shapes
if adapter.is_reducer_core_component(component_id)
]
if not core_component_ids:
core_component_ids = list(reducer_shapes)
rotated_core_bbox = union_bbox(rotated_bboxes[component_id] for component_id in core_component_ids)
core_min, core_max = bbox_axis_range(rotated_core_bbox, housing_axis_direction)
input_axis_component_id = adapter.input_axis_component_id(artifacts.instance, artifacts.manifest)
output_component_id = adapter.output_component_id(artifacts.instance, artifacts.manifest)
motor_fit_policy = adapter.motor_fit_policy(artifacts.instance, artifacts.manifest)
input_shaft_bbox = rotated_bboxes.get(input_axis_component_id)
output_shaft_bbox = rotated_bboxes.get(output_component_id)
if input_shaft_bbox is None:
raise JointModuleError(f"input_axis_component_step_required_for_joint_module_placement: {input_axis_component_id}")
if output_shaft_bbox is None:
raise JointModuleError(f"output_component_step_required_for_joint_module_placement: {output_component_id}")
input_min, input_max = bbox_axis_range(input_shaft_bbox, housing_axis_direction)
output_min, output_max = bbox_axis_range(output_shaft_bbox, housing_axis_direction)
input_side = "axis_max" if _axis_center((input_min, input_max)) >= _axis_center((output_min, output_max)) else "axis_min"
preferred_input_side = adapter.preferred_reducer_input_side(artifacts.instance, artifacts.manifest)
if preferred_input_side is not None and input_side != preferred_input_side:
flip_rotation = axis_angle_between((0.0, 0.0, -1.0), housing_axis_direction)
reducer_rotation = flip_rotation
reducer_rotation_only = ComponentPlacement(rotation_axis_angle_deg=reducer_rotation)
rotated_bboxes = {
component_id: metrics_for_shape(
component_id=component_id,
step_path=reducer_step_paths[component_id],
shape=apply_transform(shape, reducer_rotation_only),
).bbox
for component_id, shape in reducer_shapes.items()
}
rotated_core_bbox = union_bbox(rotated_bboxes[component_id] for component_id in core_component_ids)
core_min, core_max = bbox_axis_range(rotated_core_bbox, housing_axis_direction)
input_shaft_bbox = rotated_bboxes[input_axis_component_id]
output_shaft_bbox = rotated_bboxes[output_component_id]
input_min, input_max = bbox_axis_range(input_shaft_bbox, housing_axis_direction)
output_min, output_max = bbox_axis_range(output_shaft_bbox, housing_axis_direction)
input_side = "axis_max" if _axis_center((input_min, input_max)) >= _axis_center((output_min, output_max)) else "axis_min"
output_side = "axis_min" if input_side == "axis_max" else "axis_max"
input_side_sign = 1.0 if input_side == "axis_max" else -1.0
motor_output_side = str(
motor_metrics.inferred_axis.details.get(
"motor_output_side",
requirement.motor_output_side,
)
)
if motor_output_side not in {"axis_min", "axis_max"}:
motor_output_side = requirement.motor_output_side
input_axis_direction_local = artifacts.manifest.components[input_axis_component_id].axis_xyz
input_axis_direction_target = apply_placement_to_direction(input_axis_direction_local, reducer_rotation_only)
motor_body_direction = vector_scale(input_axis_direction_target, input_side_sign)
motor_target_axis = (
vector_scale(motor_body_direction, -1.0)
if motor_output_side == "axis_max"
else motor_body_direction
)
motor_rotation = axis_angle_between(motor_metrics.inferred_axis.direction_xyz, motor_target_axis)
motor_rotation_only = ComponentPlacement(rotation_axis_angle_deg=motor_rotation)
rotated_motor = apply_transform(read_step_shape(Path(motor_metrics.step_path)), motor_rotation_only)
rotated_motor_bbox = metrics_for_shape(
component_id="motor",
step_path=Path(motor_metrics.step_path),
shape=rotated_motor,
).bbox
motor_axis_min, motor_axis_max = bbox_axis_range(rotated_motor_bbox, housing_axis_direction)
output_side_sign = 1.0 if motor_output_side == "axis_max" else -1.0
output_points_to_axis_max = output_side_sign * dot(motor_target_axis, housing_axis_direction) >= 0.0
motor_output_face_axis_value = _motor_output_bore_face_axis_value(
motor_metrics=motor_metrics,
motor_output_side=motor_output_side,
)
if motor_output_face_axis_value is None:
motor_output_face_axis_value = motor_axis_max if output_points_to_axis_max else motor_axis_min
motor_relative_min = motor_axis_min - motor_output_face_axis_value
motor_relative_max = motor_axis_max - motor_output_face_axis_value
input_shaft_attachment = input_max if input_side == "axis_max" else input_min
housing_center = _axis_center((housing_min, housing_max))
preferred_core_translation = housing_center - _axis_center((core_min, core_max))
core_lower_bound = housing_min + inside_margin - core_min
core_upper_bound = housing_max - inside_margin - core_max
input_insertion_depth_mm = (
requirement.input_shaft_insertion_depth_mm
if adapter.input_insertion_required(artifacts.instance, artifacts.manifest)
else 0.0
)
motor_lower_bound = (
housing_min
+ inside_margin
- motor_relative_min
- input_shaft_attachment
+ input_side_sign * input_insertion_depth_mm
)
motor_upper_bound = (
housing_max
- inside_margin
- motor_relative_max
- input_shaft_attachment
+ input_side_sign * input_insertion_depth_mm
)
if motor_fit_policy == "external_input_side":
motor_lower_bound = -float("inf")
motor_upper_bound = float("inf")
feasible_lower = max(core_lower_bound, motor_lower_bound)
feasible_upper = min(core_upper_bound, motor_upper_bound)
if feasible_lower > feasible_upper:
raise JointModuleError(
"joint_axial_stack_does_not_fit_housing: "
f"required_translation_interval=({feasible_lower:.6f}, {feasible_upper:.6f}) "
f"core_interval=({core_lower_bound:.6f}, {core_upper_bound:.6f}) "
f"motor_interval=({motor_lower_bound:.6f}, {motor_upper_bound:.6f})"
)
if output_side == "axis_min":
output_extension_target = housing_min - requirement.output_extension_mm - output_min
else:
output_extension_target = housing_max + requirement.output_extension_mm - output_max
reducer_axis_translation = _clamp(output_extension_target, feasible_lower, feasible_upper)
desired_output_axis_value = (
output_min + reducer_axis_translation if output_side == "axis_min" else output_max + reducer_axis_translation
)
rotated_reducer_axis_origin = apply_placement_to_point((0.0, 0.0, 0.0), reducer_rotation_only)
reducer_translation = _axis_align_translation(
axis_origin_target=housing_axis_origin,
axis_direction_target=housing_axis_direction,
rotated_axis_origin=rotated_reducer_axis_origin,
rotated_bbox_axis_value=0.0,
desired_bbox_axis_value=reducer_axis_translation,
)
reducer_placement = ComponentPlacement(
translation_mm=reducer_translation,
rotation_axis_angle_deg=reducer_rotation,
details={
"policy": "fit_motor_and_reducer_core_inside_housing_then_expose_output_shaft",
"housing_axis_min_mm": housing_min,
"housing_axis_max_mm": housing_max,
"inside_margin_mm": inside_margin,
"reducer_core_component_ids": core_component_ids,
"reducer_core_axis_min_mm": core_min + reducer_axis_translation,
"reducer_core_axis_max_mm": core_max + reducer_axis_translation,
"preferred_core_translation_mm": preferred_core_translation,
"reducer_input_side": input_side,
"reducer_output_side": output_side,
"input_axis_component_id": input_axis_component_id,
"output_component_id": output_component_id,
"reducer_axis_component_id": adapter.reducer_axis_component_id(artifacts.instance, artifacts.manifest),
"motor_fit_policy": motor_fit_policy,
"desired_output_axis_value_mm": desired_output_axis_value,
"requested_output_extension_mm": requirement.output_extension_mm,
"translation_interval_mm": {
"core_lower": _json_float(core_lower_bound),
"core_upper": _json_float(core_upper_bound),
"motor_lower": _json_float(motor_lower_bound),
"motor_upper": _json_float(motor_upper_bound),
"feasible_lower": _json_float(feasible_lower),
"feasible_upper": _json_float(feasible_upper),
"selected": _json_float(reducer_axis_translation),
"output_extension_target": _json_float(output_extension_target),
"preferred_core_translation": _json_float(preferred_core_translation),
},
"housing_axis": housing_axis.model_dump(mode="json"),
},
)
input_axis_origin_local = _component_axis_origin(input_axis_component_id, artifacts)
input_axis_origin_target = apply_placement_to_point(input_axis_origin_local, reducer_placement)
input_axis_direction_target = apply_placement_to_direction(input_axis_direction_local, reducer_placement)
placed_reducer_bboxes = [
metrics_for_shape(
component_id=component_id,
step_path=reducer_step_paths[component_id],
shape=apply_transform(shape, reducer_placement),
).bbox
for component_id, shape in reducer_shapes.items()
]
reducer_bbox = union_bbox(placed_reducer_bboxes)
input_shaft_shape = reducer_shapes.get(input_axis_component_id)
input_shaft_path = reducer_step_paths.get(input_axis_component_id)
if input_shaft_shape is None or input_shaft_path is None:
raise JointModuleError(f"input_axis_component_step_required_for_motor_insertion_placement: {input_axis_component_id}")
placed_input_shaft = apply_transform(input_shaft_shape, reducer_placement)
input_shaft_bbox = metrics_for_shape(
component_id=input_axis_component_id,
step_path=input_shaft_path,
shape=placed_input_shaft,
).bbox
shaft_axis_min, shaft_axis_max = bbox_axis_range(input_shaft_bbox, housing_axis_direction)
shaft_attachment_axis_value = shaft_axis_max if input_side == "axis_max" else shaft_axis_min
desired_motor_output_face_axis_value = (
shaft_attachment_axis_value
- input_side_sign * input_insertion_depth_mm
)
rotated_motor_axis_origin = apply_placement_to_point(
motor_metrics.inferred_axis.origin_mm,
motor_rotation_only,
)
motor_translation = _axis_align_translation(
axis_origin_target=input_axis_origin_target,
axis_direction_target=input_axis_direction_target,
rotated_axis_origin=rotated_motor_axis_origin,
rotated_bbox_axis_value=motor_output_face_axis_value,
desired_bbox_axis_value=desired_motor_output_face_axis_value,
)
motor_placement = ComponentPlacement(
translation_mm=motor_translation,
rotation_axis_angle_deg=motor_rotation,
details={
"policy": "align_motor_output_bore_to_sun_input_shaft_with_insertion_depth",
"motor_output_side": motor_output_side,
"motor_output_face_axis_value_mm": desired_motor_output_face_axis_value,
"motor_output_bore_face_axis_value_mm": desired_motor_output_face_axis_value,
"motor_output_bbox_face_axis_value_mm": motor_axis_max if output_points_to_axis_max else motor_axis_min,
"input_axis_component_id": input_axis_component_id,
"input_axis_component_axis_min_mm": shaft_axis_min,
"input_axis_component_axis_max_mm": shaft_axis_max,
"input_axis_origin_mm": input_axis_origin_target,
"input_axis_direction_xyz": input_axis_direction_target,
"input_shaft_insertion_depth_mm": input_insertion_depth_mm,
"motor_axis": motor_metrics.inferred_axis.model_dump(mode="json"),
},
)
return {
"housing": housing_placement,
"reducer": reducer_placement,
"motor": motor_placement,
}
def run_joint_module(
*,
requirement_path: Path,
out_dir: Path,
) -> JointModuleManifest:
prepare_joint_dir(out_dir)
requirement = load_joint_requirement(requirement_path)
motor_step = _resolve_path(requirement.motor_step, requirement_path=requirement_path)
housing_step = _resolve_path(requirement.housing_step, requirement_path=requirement_path)
reducer_requirement = _resolve_path(requirement.reducer_requirement, requirement_path=requirement_path)
if not motor_step.exists():
raise JointModuleError(f"missing_motor_step: {motor_step}")
if not housing_step.exists():
raise JointModuleError(f"missing_housing_step: {housing_step}")
if not reducer_requirement.exists():
raise JointModuleError(f"missing_reducer_requirement: {reducer_requirement}")
reducer_requirement_model = load_requirement(reducer_requirement)
try:
get_reducer_joint_adapter(reducer_requirement_model.topology_family)
except JointAdapterError as exc:
raise JointModuleError(str(exc)) from exc
motor_metrics = infer_step_datum_axis(component_id="motor", step_path=motor_step, role="motor")
housing_metrics = infer_step_datum_axis(component_id="housing", step_path=housing_step, role="housing")
write_json(
out_dir / "external_step_metrics.json",
{
"motor": motor_metrics.model_dump(mode="json"),
"housing": housing_metrics.model_dump(mode="json"),
},
)
artifacts = _run_reducer_pipeline(reducer_requirement, out_dir / "reducer")
adapter = get_reducer_joint_adapter(artifacts.instance.topology_family)
reducer_step_paths = {
component_id: Path(component.assembled_step_path)
for component_id, component in artifacts.manifest.components.items()
if component.assembled_step_path
and Path(component.assembled_step_path).exists()
and adapter.include_reducer_component(component_id)
}
if not reducer_step_paths:
raise JointModuleError("no_reducer_component_steps_available_for_joint_module")
reducer_shapes = {
component_id: read_step_shape(path)
for component_id, path in reducer_step_paths.items()
}
placements = _build_placements(
requirement=requirement,
motor_metrics=motor_metrics,
housing_metrics=housing_metrics,
reducer_shapes=reducer_shapes,
reducer_step_paths=reducer_step_paths,
artifacts=artifacts,
adapter=adapter,
)
write_json(
out_dir / "joint_placement.json",
{component_id: placement.model_dump(mode="json") for component_id, placement in placements.items()},
)
placed_shapes = {
"motor": apply_transform(read_step_shape(motor_step), placements["motor"]),
"housing": apply_transform(read_step_shape(housing_step), placements["housing"]),
}
source_paths = {"motor": motor_step, "housing": housing_step}
for component_id, shape in reducer_shapes.items():
placed_shapes[component_id] = apply_transform(shape, placements["reducer"])
source_paths[component_id] = reducer_step_paths[component_id]
joint_step_path = out_dir / "joint_module.step"
write_compound_step(placed_shapes, joint_step_path)
raw_external_by_id = {"motor": motor_metrics, "housing": housing_metrics}
joint_components: dict[str, JointComponentManifest] = {}
for component_id in ["motor", "housing"]:
raw_metrics = raw_external_by_id[component_id]
placed_metrics = metrics_for_shape(
component_id=component_id,
step_path=source_paths[component_id],
shape=placed_shapes[component_id],
)
axis = raw_metrics.inferred_axis
if axis is not None:
placement = placements[component_id]
placed_metrics = placed_metrics.model_copy(
update={
"inferred_axis": _axis_with_placement(
origin=axis.origin_mm,
direction=axis.direction_xyz,
placement=placement,
confidence=axis.confidence,
method=f"placed_{axis.method}",
details=axis.details,
)
}
)
joint_components[component_id] = JointComponentManifest(
component_id=component_id,
role=component_id,
source_type="external_step",
source_step_path=str(source_paths[component_id].resolve()),
placement=placements[component_id],
raw_metrics=raw_metrics,
placed_metrics=placed_metrics,
formula_ref=f"joint.external.{component_id}",
)
for component_id, path in reducer_step_paths.items():
component = artifacts.manifest.components[component_id]
raw_shape = reducer_shapes[component_id]
raw_metrics = metrics_for_shape(component_id=component_id, step_path=path, shape=raw_shape)
placed_metrics = metrics_for_shape(
component_id=component_id,
step_path=path,
shape=placed_shapes[component_id],
)
placed_metrics = placed_metrics.model_copy(
update={
"inferred_axis": _axis_with_placement(
origin=_component_axis_origin(component_id, artifacts),
direction=component.axis_xyz,
placement=placements["reducer"],
confidence=0.95,
details={"source": "reducer_component_manifest_axis"},
)
}
)
joint_components[component_id] = JointComponentManifest(
component_id=component_id,
role=component.role,
source_type="generated_reducer",
source_step_path=str(path.resolve()),
reducer_component_id=component_id,
placement=placements["reducer"],
raw_metrics=raw_metrics,
placed_metrics=placed_metrics,
formula_ref=f"reducer.{component.formula_node}",
details={
"reducer_component_kind": component.kind,
"reducer_tags": component.tags,
},
)
check_pairs = adapter.interference_check_pairs(
instance=artifacts.instance,
manifest=artifacts.manifest,
component_ids=set(placed_shapes),
)
placed_metrics_raw, interference_results = detect_interferences_from_placed_shapes(
placed_shapes,
step_paths=source_paths,
check_pairs=check_pairs,
volume_tolerance_mm3=requirement.volume_tolerance_mm3,
)
metrics_by_id = {metric.component_id: metric for metric in placed_metrics_raw}
interference_findings = [
finding_from_interference_result(
component_a=result.component_a,
component_b=result.component_b,
common_volume_mm3=result.common_volume_mm3,
bbox_overlap=result.bbox_overlap,
checked_boolean=result.checked_boolean,
interfering=result.interfering,
bbox_a=metrics_by_id[result.component_a].bbox,
bbox_b=metrics_by_id[result.component_b].bbox,
)
for result in interference_results
]
run_id = f"joint_{uuid.uuid4().hex[:12]}"
manifest_path = out_dir / "joint_module_manifest.json"
validation_path = out_dir / "joint_module_validation_report.json"
interference_path = out_dir / "joint_interference_report.json"
requirement_hash = sha256_json(requirement.model_dump(mode="json"))
manifest = JointModuleManifest(
run_id=run_id,
reducer_run_id=artifacts.instance.run_id,
generated_at=utc_now_iso(),
requirement_hash=requirement_hash,
reducer_output_dir=str(artifacts.out_dir.resolve()),
reducer_validation_passed=artifacts.validation_report.overall_passed,
components=joint_components,
relations=adapter.joint_relations(artifacts.instance, artifacts.manifest),
placements={
"motor": placements["motor"],
"housing": placements["housing"],
"reducer": placements["reducer"],
},
exported_files={
"joint_step": str(joint_step_path.resolve()),
"joint_manifest": str(manifest_path.resolve()),
"joint_validation_report": str(validation_path.resolve()),
"joint_interference_report": str(interference_path.resolve()),
"external_step_metrics": str((out_dir / "external_step_metrics.json").resolve()),
"joint_placement": str((out_dir / "joint_placement.json").resolve()),
"reducer_dir": str(artifacts.out_dir.resolve()),
},
geometry_summary={
"component_count": len(joint_components),
"reducer_component_count": sum(
1 for component in joint_components.values() if component.source_type == "generated_reducer"
),
"external_component_count": 2,
"checked_interference_pair_count": len(check_pairs),
"positive_interference_count": sum(1 for item in interference_findings if item.interfering),
"excluded_reducer_shell_components": [
component_id
for component_id in artifacts.manifest.components
if not adapter.include_reducer_component(component_id)
],
"placement_policy": requirement.placement_policy,
"joint_adapter_family": adapter.family,
},
)
report = build_joint_module_validation_report(
requirement=requirement,
manifest=manifest,
reducer_instance=artifacts.instance,
reducer_solution=artifacts.solution,
reducer_validation_report=artifacts.validation_report,
interference_findings=interference_findings,
)
write_model_json(manifest_path, manifest)
write_model_json(validation_path, report)
write_json(
interference_path,
{
"run_id": run_id,
"findings": [item.model_dump(mode="json") for item in interference_findings],
},
)
return manifest
+179
View File
@@ -0,0 +1,179 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import Field
from ..models import StrictModel, utc_now_iso
Vector3 = tuple[float, float, float]
BBox = tuple[float, float, float, float, float, float]
AxisAngle = tuple[float, float, float, float]
class JointModuleRequirement(StrictModel):
module_type: Literal["planetary_joint_module"] = "planetary_joint_module"
motor_step: str
housing_step: str
reducer_requirement: str
placement_policy: Literal["infer_then_write_editable_json"] = "infer_then_write_editable_json"
motor_to_reducer_relation: str = "motor_output_to_sun_keyed_input"
housing_relation: str = "ring_fixed_to_housing"
joint_output: str = "carrier_output_shaft"
motor_component_id: str = "motor"
housing_component_id: str = "housing"
output_extension_mm: float = Field(default=8.0, ge=0)
coupling_clearance_mm: float = Field(default=1.0, ge=0)
input_shaft_insertion_depth_mm: float = Field(default=4.0, gt=0)
motor_output_side: Literal["axis_min", "axis_max"] = "axis_max"
axis_angle_tolerance_deg: float = Field(default=0.5, gt=0)
axis_distance_tolerance_mm: float = Field(default=0.25, ge=0)
volume_tolerance_mm3: float = Field(default=1e-6, gt=0)
class DatumAxis(StrictModel):
origin_mm: Vector3
direction_xyz: Vector3
confidence: float = Field(ge=0, le=1)
method: str
details: dict[str, Any] = Field(default_factory=dict)
class StepShapeMetrics(StrictModel):
component_id: str
step_path: str
valid: bool
volume_mm3: float
bbox: BBox
solid_count: int
face_count: int
plane_face_count: int
cylinder_face_count: int
inferred_axis: DatumAxis | None = None
class ComponentPlacement(StrictModel):
translation_mm: Vector3 = (0.0, 0.0, 0.0)
rotation_axis_angle_deg: AxisAngle = (0.0, 0.0, 1.0, 0.0)
source: str = "computed_by_joint_module_placement_solver"
details: dict[str, Any] = Field(default_factory=dict)
class JointComponentManifest(StrictModel):
component_id: str
role: str
source_type: Literal["external_step", "generated_reducer"]
source_step_path: str
reducer_component_id: str | None = None
included_in_joint_step: bool = True
placement: ComponentPlacement
raw_metrics: StepShapeMetrics
placed_metrics: StepShapeMetrics
formula_ref: str | None = None
details: dict[str, Any] = Field(default_factory=dict)
class JointRelation(StrictModel):
relation_id: str
relation_type: str
source_component_id: str
target_component_id: str
formula_ref: str
details: dict[str, Any] = Field(default_factory=dict)
class JointInterferenceFinding(StrictModel):
component_a: str
component_b: str
common_volume_mm3: float
bbox_overlap: bool
checked_boolean: bool
interfering: bool
bbox_a: BBox
bbox_b: BBox
relation_context: str | None = None
possible_cause: str | None = None
suggested_fix: str | None = None
class JointModuleManifest(StrictModel):
run_id: str
reducer_run_id: str
generated_at: str
requirement_hash: str
reducer_output_dir: str
reducer_validation_passed: bool
components: dict[str, JointComponentManifest]
relations: list[JointRelation]
placements: dict[str, ComponentPlacement]
exported_files: dict[str, str]
geometry_summary: dict[str, Any]
class JointValidationCheck(StrictModel):
section: Literal["static", "kinematic", "assembly", "geometry", "interference"]
code: str
passed: bool
message: str
actual: Any | None = None
expected: Any | None = None
class JointValidationReport(StrictModel):
run_id: str
overall_passed: bool
generated_at: str
checks: list[JointValidationCheck]
summary: dict[str, int]
class UrdfExportManifest(StrictModel):
run_id: str
joint_run_id: str
reducer_run_id: str
generated_at: str
robot_name: str
package_name: str
urdf_path: str
package_xml_path: str
mesh_dir: str
motion_demo_path: str
validation_report_path: str
mesh_files: dict[str, str]
transmission_summary: dict[str, Any]
class UrdfValidationCheck(StrictModel):
code: str
passed: bool
message: str
actual: Any | None = None
expected: Any | None = None
class UrdfValidationReport(StrictModel):
run_id: str
overall_passed: bool
generated_at: str
checks: list[UrdfValidationCheck]
summary: dict[str, int]
def build_joint_validation_report(
*,
run_id: str,
checks: list[JointValidationCheck],
) -> JointValidationReport:
summary = {
"total": len(checks),
"passed": sum(1 for check in checks if check.passed),
"failed": sum(1 for check in checks if not check.passed),
}
return JointValidationReport(
run_id=run_id,
overall_passed=summary["failed"] == 0,
generated_at=utc_now_iso(),
checks=checks,
summary=summary,
)
@@ -0,0 +1,323 @@
from __future__ import annotations
from dataclasses import dataclass
from ..models import AssemblyManifest, ReducerInstance
from .models import JointRelation
class JointAdapterError(RuntimeError):
pass
def _first_existing(manifest: AssemblyManifest, candidates: list[str]) -> str:
for component_id in candidates:
if component_id in manifest.components:
return component_id
raise JointAdapterError(f"missing_joint_port_component: {candidates}")
@dataclass(frozen=True)
class ReducerJointAdapter:
family: str
def input_speed_variable(self, instance: ReducerInstance) -> str:
return instance.boundary.input
def output_speed_variable(self, instance: ReducerInstance) -> str:
return instance.boundary.output
def fixed_speed_variables(self, instance: ReducerInstance) -> list[str]:
return list(instance.boundary.fixed_members or ([instance.boundary.fixed] if instance.boundary.fixed else []))
def input_axis_component_id(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str:
return _first_existing(manifest, [self.input_speed_variable(instance)])
def input_relation_target_component_id(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str:
return self.input_speed_variable(instance)
def reducer_axis_component_id(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str:
return _first_existing(manifest, [self.output_speed_variable(instance), self.input_axis_component_id(instance, manifest)])
def output_component_id(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str:
return _first_existing(manifest, [self.output_speed_variable(instance)])
def output_relation_source_component_id(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str:
return self.output_speed_variable(instance)
def output_relation_target_component_id(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str:
return self.output_component_id(instance, manifest)
def input_insertion_required(self, instance: ReducerInstance, manifest: AssemblyManifest) -> bool:
return False
def output_extension_required(self, instance: ReducerInstance, manifest: AssemblyManifest) -> bool:
return False
def motor_fit_policy(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str:
return "inside_housing"
def preferred_reducer_input_side(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str | None:
return None
def required_joint_component_ids(self, instance: ReducerInstance, manifest: AssemblyManifest) -> set[str]:
return {
self.input_axis_component_id(instance, manifest),
self.reducer_axis_component_id(instance, manifest),
self.output_component_id(instance, manifest),
*self.fixed_speed_variables(instance),
}
def include_reducer_component(self, component_id: str) -> bool:
return component_id != "housing"
def is_reducer_core_component(self, component_id: str) -> bool:
return self.include_reducer_component(component_id)
def joint_relations(self, instance: ReducerInstance, manifest: AssemblyManifest) -> list[JointRelation]:
input_component = self.input_relation_target_component_id(instance, manifest)
output_source = self.output_relation_source_component_id(instance, manifest)
output_target = self.output_relation_target_component_id(instance, manifest)
relations = [
JointRelation(
relation_id=f"motor_output_to_{input_component}",
relation_type="input_coupling",
source_component_id="motor",
target_component_id=input_component,
formula_ref=f"joint.motor_output_to_{self.input_speed_variable(instance)}",
details={"semantic": f"motor_speed = omega_{self.input_speed_variable(instance)}"},
)
]
for fixed in self.fixed_speed_variables(instance):
relations.append(
JointRelation(
relation_id=f"{fixed}_fixed_to_external_housing",
relation_type="fixed",
source_component_id=fixed,
target_component_id="housing",
formula_ref=f"joint.{fixed}_fixed_to_housing",
details={"semantic": f"omega_{fixed} = omega_housing = 0"},
)
)
relations.append(
JointRelation(
relation_id=f"{output_source}_is_joint_output",
relation_type="joint_output",
source_component_id=output_source,
target_component_id=output_target,
formula_ref=f"joint.{self.output_speed_variable(instance)}_to_output",
details={"semantic": f"joint_output_speed = omega_{self.output_speed_variable(instance)}"},
)
)
return relations
def expected_relation_ids(self, instance: ReducerInstance, manifest: AssemblyManifest) -> set[str]:
return {relation.relation_id for relation in self.joint_relations(instance, manifest)}
def interference_check_pairs(
self,
*,
instance: ReducerInstance,
manifest: AssemblyManifest,
component_ids: set[str],
) -> list[tuple[str, str]]:
pairs = [("motor", "housing")]
motor_side = {self.input_axis_component_id(instance, manifest)}
motor_side.add(self.input_relation_target_component_id(instance, manifest))
housing_side = set(self.fixed_speed_variables(instance))
housing_side.add(self.reducer_axis_component_id(instance, manifest))
housing_side.add(self.output_component_id(instance, manifest))
for component_id in sorted(motor_side & component_ids):
pairs.append(("motor", component_id))
for component_id in sorted(housing_side & component_ids):
pairs.append(("housing", component_id))
return pairs
class Simple2KHJointAdapter(ReducerJointAdapter):
def __init__(self) -> None:
super().__init__(family="simple_2k_h")
def input_axis_component_id(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str:
return _first_existing(manifest, ["sun_input_shaft", "sun"])
def input_relation_target_component_id(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str:
return "sun"
def reducer_axis_component_id(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str:
return _first_existing(manifest, ["sun", "ring", "carrier"])
def output_component_id(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str:
return _first_existing(manifest, ["output_shaft", "carrier"])
def output_relation_source_component_id(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str:
return "carrier"
def input_insertion_required(self, instance: ReducerInstance, manifest: AssemblyManifest) -> bool:
return "sun_input_shaft" in manifest.components
def preferred_reducer_input_side(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str | None:
return "axis_max"
def output_extension_required(self, instance: ReducerInstance, manifest: AssemblyManifest) -> bool:
return "output_shaft" in manifest.components
def include_reducer_component(self, component_id: str) -> bool:
excluded = {
"housing",
"front_cover",
"rear_cover",
"ring_mount",
"seal_input",
"seal_output",
}
return component_id not in excluded and not component_id.startswith("housing_bolt_")
def is_reducer_core_component(self, component_id: str) -> bool:
axial_coupling_components = {
"sun_input_shaft",
"sun_input_key",
"output_shaft",
"seal_input",
"seal_output",
}
return self.include_reducer_component(component_id) and component_id not in axial_coupling_components
def required_joint_component_ids(self, instance: ReducerInstance, manifest: AssemblyManifest) -> set[str]:
required = {"sun", "ring", "carrier", self.output_component_id(instance, manifest)}
required.add(self.input_axis_component_id(instance, manifest))
return required
def joint_relations(self, instance: ReducerInstance, manifest: AssemblyManifest) -> list[JointRelation]:
return [
JointRelation(
relation_id="motor_output_to_sun_keyed_input",
relation_type="keyed_input_coupling",
source_component_id="motor",
target_component_id="sun",
formula_ref="joint.motor_output_to_sun",
details={"semantic": "motor_speed = omega_sun"},
),
JointRelation(
relation_id="ring_fixed_to_external_housing",
relation_type="fixed",
source_component_id="ring",
target_component_id="housing",
formula_ref="joint.ring_fixed_to_housing",
details={"semantic": "omega_ring = omega_housing = 0"},
),
JointRelation(
relation_id="carrier_output_shaft_is_joint_output",
relation_type="rigid_output",
source_component_id="carrier",
target_component_id=self.output_component_id(instance, manifest),
formula_ref="joint.carrier_to_output",
details={"semantic": "joint_output_speed = omega_carrier"},
),
]
def interference_check_pairs(
self,
*,
instance: ReducerInstance,
manifest: AssemblyManifest,
component_ids: set[str],
) -> list[tuple[str, str]]:
pairs = [("motor", "housing")]
motor_side = {"sun", "sun_input_shaft", "sun_input_key"}
housing_side = {
"ring",
"carrier",
"output_shaft",
"output_bearing_1",
"output_bearing_2",
}
housing_side.update(
component_id
for component_id in component_ids
if component_id.startswith(("planet_", "planet_pin_", "planet_bearing_"))
)
for component_id in sorted(motor_side & component_ids):
pairs.append(("motor", component_id))
for component_id in sorted(housing_side & component_ids):
pairs.append(("housing", component_id))
return pairs
class CascadeJointAdapter(ReducerJointAdapter):
def __init__(self) -> None:
super().__init__(family="simple_2k_h_cascade")
def input_axis_component_id(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str:
return _first_existing(manifest, ["cascade_input_shaft", instance.boundary.input, "s1_sun"])
def input_relation_target_component_id(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str:
return instance.boundary.input
def reducer_axis_component_id(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str:
return _first_existing(manifest, ["cascade_output_shaft", instance.boundary.output, "s2_carrier", "s1_sun"])
def output_component_id(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str:
return _first_existing(manifest, ["cascade_output_shaft", instance.boundary.output, "s2_carrier"])
def output_relation_source_component_id(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str:
return instance.boundary.output
def input_insertion_required(self, instance: ReducerInstance, manifest: AssemblyManifest) -> bool:
return "cascade_input_shaft" in manifest.components
def preferred_reducer_input_side(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str | None:
return "axis_max"
def output_extension_required(self, instance: ReducerInstance, manifest: AssemblyManifest) -> bool:
return "cascade_output_shaft" in manifest.components
def is_reducer_core_component(self, component_id: str) -> bool:
axial_port_components = {"cascade_input_shaft", "cascade_output_shaft"}
return self.include_reducer_component(component_id) and component_id not in axial_port_components
def interference_check_pairs(
self,
*,
instance: ReducerInstance,
manifest: AssemblyManifest,
component_ids: set[str],
) -> list[tuple[str, str]]:
pairs = [("motor", "housing")]
motor_side = {
component_id
for component_id in component_ids
if component_id not in {"motor", "housing"} and self.include_reducer_component(component_id)
}
motor_side.add(self.input_axis_component_id(instance, manifest))
housing_side = set(self.fixed_speed_variables(instance))
housing_side.add(self.reducer_axis_component_id(instance, manifest))
housing_side.add(self.output_component_id(instance, manifest))
for component_id in sorted(motor_side & component_ids):
pairs.append(("motor", component_id))
for component_id in sorted(housing_side & component_ids):
pairs.append(("housing", component_id))
return pairs
ADAPTERS: dict[str, ReducerJointAdapter] = {
"simple_2k_h": Simple2KHJointAdapter(),
"simple_2k_h_cascade": CascadeJointAdapter(),
}
SUPPORTED_JOINT_FAMILIES = frozenset(ADAPTERS)
UNSUPPORTED_JOINT_FAMILY_REASONS = {
"ferguson_wolfrom": (
"eccentric_input_not_compatible_with_current_coaxial_motor_housing_joint_architecture"
),
}
def get_reducer_joint_adapter(family: str) -> ReducerJointAdapter:
adapter = ADAPTERS.get(family)
if adapter is None:
reason = UNSUPPORTED_JOINT_FAMILY_REASONS.get(family)
if reason is not None:
raise JointAdapterError(f"unsupported_reducer_family_for_joint_module: {family}: {reason}")
raise JointAdapterError(f"unsupported_reducer_family_for_joint_module: {family}")
return adapter
+291
View File
@@ -0,0 +1,291 @@
from __future__ import annotations
import math
from itertools import combinations
from pathlib import Path
from typing import Iterable
from ..cad.ocp_interference import (
InterferenceResult,
OcpInterferenceError,
ShapeMetrics,
_bbox_overlap,
_import_ocp,
common_volume,
read_step_shape,
shape_bbox,
shape_valid,
shape_volume,
)
from .models import BBox, ComponentPlacement, StepShapeMetrics
def _normalize(vector: Iterable[float]) -> tuple[float, float, float]:
values = tuple(float(value) for value in vector)
length = math.sqrt(sum(value * value for value in values))
if length <= 1e-12:
return (0.0, 0.0, 1.0)
return tuple(value / length for value in values) # type: ignore[return-value]
def dot(a: Iterable[float], b: Iterable[float]) -> float:
return sum(float(x) * float(y) for x, y in zip(a, b))
def cross(a: Iterable[float], b: Iterable[float]) -> tuple[float, float, float]:
ax, ay, az = (float(value) for value in a)
bx, by, bz = (float(value) for value in b)
return (ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx)
def vector_add(a: Iterable[float], b: Iterable[float]) -> tuple[float, float, float]:
return tuple(float(x) + float(y) for x, y in zip(a, b)) # type: ignore[return-value]
def vector_sub(a: Iterable[float], b: Iterable[float]) -> tuple[float, float, float]:
return tuple(float(x) - float(y) for x, y in zip(a, b)) # type: ignore[return-value]
def vector_scale(a: Iterable[float], scale: float) -> tuple[float, float, float]:
return tuple(float(x) * scale for x in a) # type: ignore[return-value]
def axis_angle_between(
source: Iterable[float],
target: Iterable[float],
) -> tuple[float, float, float, float]:
source_n = _normalize(source)
target_n = _normalize(target)
cross_value = cross(source_n, target_n)
cross_norm = math.sqrt(dot(cross_value, cross_value))
dot_value = max(-1.0, min(1.0, dot(source_n, target_n)))
if cross_norm <= 1e-12:
if dot_value >= 0.0:
return (0.0, 0.0, 1.0, 0.0)
fallback = cross(source_n, (1.0, 0.0, 0.0))
if math.sqrt(dot(fallback, fallback)) <= 1e-12:
fallback = cross(source_n, (0.0, 1.0, 0.0))
axis = _normalize(fallback)
return (*axis, 180.0)
axis = _normalize(cross_value)
return (*axis, math.degrees(math.atan2(cross_norm, dot_value)))
def rotate_vector(
vector: Iterable[float],
rotation_axis_angle_deg: Iterable[float],
) -> tuple[float, float, float]:
vx, vy, vz = (float(value) for value in vector)
ax, ay, az, angle_deg = (float(value) for value in rotation_axis_angle_deg)
axis = _normalize((ax, ay, az))
angle = math.radians(angle_deg)
cos_a = math.cos(angle)
sin_a = math.sin(angle)
ux, uy, uz = axis
cross_part = cross(axis, (vx, vy, vz))
dot_part = dot(axis, (vx, vy, vz))
return (
vx * cos_a + cross_part[0] * sin_a + ux * dot_part * (1.0 - cos_a),
vy * cos_a + cross_part[1] * sin_a + uy * dot_part * (1.0 - cos_a),
vz * cos_a + cross_part[2] * sin_a + uz * dot_part * (1.0 - cos_a),
)
def apply_placement_to_point(
point: Iterable[float],
placement: ComponentPlacement,
) -> tuple[float, float, float]:
rotated = rotate_vector(point, placement.rotation_axis_angle_deg)
return vector_add(rotated, placement.translation_mm)
def apply_placement_to_direction(
direction: Iterable[float],
placement: ComponentPlacement,
) -> tuple[float, float, float]:
return _normalize(rotate_vector(direction, placement.rotation_axis_angle_deg))
def bbox_axis_range(bbox: BBox, axis: Iterable[float]) -> tuple[float, float]:
xmin, ymin, zmin, xmax, ymax, zmax = bbox
axis_n = _normalize(axis)
values = []
for x in [xmin, xmax]:
for y in [ymin, ymax]:
for z in [zmin, zmax]:
values.append(dot((x, y, z), axis_n))
return (min(values), max(values))
def bbox_center(bbox: BBox) -> tuple[float, float, float]:
xmin, ymin, zmin, xmax, ymax, zmax = bbox
return ((xmin + xmax) / 2.0, (ymin + ymax) / 2.0, (zmin + zmax) / 2.0)
def union_bbox(bboxes: Iterable[BBox]) -> BBox:
items = list(bboxes)
if not items:
raise ValueError("union_bbox requires at least one bbox")
return (
min(item[0] for item in items),
min(item[1] for item in items),
min(item[2] for item in items),
max(item[3] for item in items),
max(item[4] for item in items),
max(item[5] for item in items),
)
def subshape_count(shape, kind_name: str) -> int:
ocp = _import_ocp()
kind = {
"solid": ocp["TopAbs_SOLID"],
"face": ocp["TopAbs_FACE"],
}[kind_name]
explorer = ocp["TopExp_Explorer"](shape, kind)
count = 0
while explorer.More():
count += 1
explorer.Next()
return count
def face_type_counts(shape) -> tuple[int, int]:
ocp = _import_ocp()
explorer = ocp["TopExp_Explorer"](shape, ocp["TopAbs_FACE"])
plane_count = 0
cylinder_count = 0
while explorer.More():
try:
face = ocp["TopoDS"].Face_s(explorer.Current())
surface = ocp["BRepAdaptor_Surface"](face)
surface_type = surface.GetType()
if surface_type == ocp["GeomAbs_Plane"]:
plane_count += 1
elif surface_type == ocp["GeomAbs_Cylinder"]:
cylinder_count += 1
except Exception: # noqa: BLE001
pass
explorer.Next()
return plane_count, cylinder_count
def metrics_for_shape(
*,
component_id: str,
step_path: Path,
shape,
) -> StepShapeMetrics:
plane_count, cylinder_count = face_type_counts(shape)
return StepShapeMetrics(
component_id=component_id,
step_path=str(step_path.resolve()),
valid=shape_valid(shape),
volume_mm3=shape_volume(shape),
bbox=shape_bbox(shape),
solid_count=subshape_count(shape, "solid"),
face_count=subshape_count(shape, "face"),
plane_face_count=plane_count,
cylinder_face_count=cylinder_count,
)
def metrics_for_step(component_id: str, path: Path) -> StepShapeMetrics:
shape = read_step_shape(path)
return metrics_for_shape(component_id=component_id, step_path=path, shape=shape)
def apply_transform(shape, placement: ComponentPlacement):
ocp = _import_ocp()
ax, ay, az, angle_deg = placement.rotation_axis_angle_deg
transformed = shape
if abs(angle_deg) > 1e-12:
rotation = ocp["gp_Trsf"]()
rotation.SetRotation(
ocp["gp_Ax1"](
ocp["gp_Pnt"](0.0, 0.0, 0.0),
ocp["gp_Dir"](float(ax), float(ay), float(az)),
),
math.radians(float(angle_deg)),
)
transformed = ocp["BRepBuilderAPI_Transform"](transformed, rotation, True).Shape()
tx, ty, tz = placement.translation_mm
if abs(tx) > 1e-12 or abs(ty) > 1e-12 or abs(tz) > 1e-12:
translation = ocp["gp_Trsf"]()
translation.SetTranslation(ocp["gp_Vec"](float(tx), float(ty), float(tz)))
transformed = ocp["BRepBuilderAPI_Transform"](transformed, translation, True).Shape()
return transformed
def write_compound_step(shapes: dict[str, object], path: Path) -> None:
if not shapes:
raise OcpInterferenceError("cannot_write_empty_joint_compound")
ocp = _import_ocp()
compound = ocp["TopoDS_Compound"]()
builder = ocp["BRep_Builder"]()
builder.MakeCompound(compound)
for shape in shapes.values():
builder.Add(compound, shape)
path.parent.mkdir(parents=True, exist_ok=True)
writer = ocp["STEPControl_Writer"]()
writer.Transfer(compound, ocp["STEPControl_AsIs"])
status = writer.Write(str(path))
if status != ocp["IFSelect_RetDone"]:
raise OcpInterferenceError(f"step_write_failed: {path}")
def detect_interferences_from_placed_shapes(
shapes: dict[str, object],
*,
step_paths: dict[str, Path],
check_pairs: Iterable[tuple[str, str]] | None = None,
volume_tolerance_mm3: float = 1e-6,
bbox_tolerance_mm: float = 1e-7,
) -> tuple[list[ShapeMetrics], list[InterferenceResult]]:
if len(shapes) < 2:
raise OcpInterferenceError("need_at_least_two_placed_shapes")
metrics = [
ShapeMetrics(
component_id=component_id,
valid=shape_valid(shape),
volume_mm3=shape_volume(shape),
bbox=shape_bbox(shape),
)
for component_id, shape in shapes.items()
]
metric_by_id = {metric.component_id: metric for metric in metrics}
pairs = list(check_pairs) if check_pairs is not None else list(combinations(sorted(shapes), 2))
results: list[InterferenceResult] = []
for component_a, component_b in pairs:
if component_a not in shapes or component_b not in shapes:
raise OcpInterferenceError(f"unknown_interference_pair: {component_a}, {component_b}")
bbox_overlap = _bbox_overlap(
metric_by_id[component_a].bbox,
metric_by_id[component_b].bbox,
tolerance_mm=bbox_tolerance_mm,
)
if not bbox_overlap:
results.append(
InterferenceResult(
component_a=component_a,
component_b=component_b,
common_volume_mm3=0.0,
bbox_overlap=False,
checked_boolean=False,
interfering=False,
)
)
continue
volume = common_volume(shapes[component_a], shapes[component_b])
results.append(
InterferenceResult(
component_a=component_a,
component_b=component_b,
common_volume_mm3=volume,
bbox_overlap=True,
checked_boolean=True,
interfering=volume > volume_tolerance_mm3,
)
)
return metrics, results
+688
View File
@@ -0,0 +1,688 @@
from __future__ import annotations
import math
import uuid
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Iterable
from ..cad.ocp_interference import _import_ocp, read_step_shape
from ..kinematics import solve_instance
from ..models import AssemblyManifest, ReducerInstance, read_json, utc_now_iso, write_json, write_model_json
from ..reducer_urdf_exporter import (
build_reducer_urdf_xml,
validate_reducer_urdf_export,
)
from .models import (
ComponentPlacement,
JointModuleManifest,
UrdfExportManifest,
UrdfValidationCheck,
UrdfValidationReport,
)
from .step_geometry import apply_transform
PACKAGE_NAME = "planetary_reducer_joint_module"
ROBOT_NAME = "planetary_reducer_joint_module"
MM_TO_M = 0.001
class UrdfExportError(RuntimeError):
pass
def load_joint_manifest(path: Path) -> JointModuleManifest:
return JointModuleManifest.model_validate(read_json(path))
def _m(value_mm: float) -> float:
return float(value_mm) * MM_TO_M
def _fmt(values: Iterable[float]) -> str:
return " ".join(f"{float(value):.12g}" for value in values)
def _axis_tuple(values: Iterable[float]) -> tuple[float, float, float]:
items = tuple(float(value) for value in values)
length = math.sqrt(sum(value * value for value in items))
if length <= 1e-12:
return (0.0, 0.0, 1.0)
return tuple(value / length for value in items) # type: ignore[return-value]
def _axis_origin(component) -> tuple[float, float, float]:
axis = component.placed_metrics.inferred_axis
if axis is None:
return (0.0, 0.0, 0.0)
return tuple(float(value) for value in axis.origin_mm)
def _axis_direction(component) -> tuple[float, float, float]:
axis = component.placed_metrics.inferred_axis
if axis is None:
return (0.0, 0.0, 1.0)
return _axis_tuple(axis.direction_xyz)
def _planet_index(component_id: str) -> int | None:
parts = component_id.split("_")
if len(parts) != 2 or parts[0] != "planet":
return None
suffix = parts[1]
if suffix.isdigit():
return int(suffix)
return None
def _planet_component_ids(manifest: JointModuleManifest) -> list[str]:
return sorted(
[
component_id
for component_id in manifest.components
if component_id.startswith("planet_") and _planet_index(component_id) is not None
],
key=lambda value: int(value.rsplit("_", 1)[-1]),
)
def compute_urdf_motion_map(
*,
reducer_instance: ReducerInstance,
) -> dict[str, float | str | dict[str, float]]:
solution = solve_instance(reducer_instance)
input_name = reducer_instance.boundary.input
input_speed = solution.speeds[input_name]
if abs(input_speed) <= 1e-12:
raise UrdfExportError("input speed is zero; cannot build URDF mimic multipliers")
carrier_multiplier = solution.speeds["carrier"] / input_speed
planet_relative_multiplier = (solution.speeds["planet"] - solution.speeds["carrier"]) / input_speed
return {
"input_joint": "sun_input_joint",
"carrier_joint_multiplier": carrier_multiplier,
"planet_spin_joint_multiplier": planet_relative_multiplier,
"sun_speed": solution.speeds["sun"],
"ring_speed": solution.speeds["ring"],
"carrier_speed": solution.speeds["carrier"],
"planet_absolute_speed": solution.speeds["planet"],
"ratio": abs(solution.ratio),
"direction": solution.direction,
"speed_mode": solution.speed_mode,
"speeds": solution.speeds,
"formula": {
"carrier_joint": "theta_carrier = theta_sun * omega_carrier / omega_sun",
"planet_spin_joint": "theta_planet_relative = theta_sun * (omega_planet - omega_carrier) / omega_sun",
},
}
def _export_stl(shape, path: Path, *, linear_deflection_mm: float) -> None:
ocp = _import_ocp()
path.parent.mkdir(parents=True, exist_ok=True)
mesher = ocp["BRepMesh_IncrementalMesh"](shape, float(linear_deflection_mm))
if hasattr(mesher, "Perform"):
mesher.Perform()
writer = ocp["StlAPI_Writer"]()
if hasattr(writer, "SetASCIIMode"):
writer.SetASCIIMode(False)
ok = writer.Write(shape, str(path))
if ok is False:
raise UrdfExportError(f"stl_write_failed: {path}")
def _export_meshes(
*,
manifest: JointModuleManifest,
mesh_dir: Path,
linear_deflection_mm: float,
) -> dict[str, str]:
mesh_paths: dict[str, str] = {}
mesh_dir.mkdir(parents=True, exist_ok=True)
for component_id, component in manifest.components.items():
if not component.included_in_joint_step:
continue
source_path = Path(component.source_step_path)
if not source_path.exists():
raise UrdfExportError(f"missing_component_step_for_urdf_mesh: {component_id}: {source_path}")
shape = read_step_shape(source_path)
placed = apply_transform(shape, component.placement)
mesh_path = mesh_dir / f"{component_id}.stl"
_export_stl(placed, mesh_path, linear_deflection_mm=linear_deflection_mm)
mesh_paths[component_id] = str(mesh_path.resolve())
return mesh_paths
def _add_materials(robot: ET.Element) -> None:
materials = {
"housing_gray": "0.45 0.48 0.50 0.45",
"motor_dark": "0.08 0.09 0.10 0.75",
"gear_gold": "0.95 0.70 0.18 1.0",
"ring_blue": "0.20 0.32 0.75 0.75",
"carrier_green": "0.20 0.65 0.35 0.85",
"bearing_silver": "0.75 0.75 0.78 0.85",
"steel": "0.65 0.66 0.68 1.0",
}
for name, rgba in materials.items():
material = ET.SubElement(robot, "material", {"name": name})
ET.SubElement(material, "color", {"rgba": rgba})
def _material_for(component_id: str, role: str) -> str:
if component_id == "housing":
return "housing_gray"
if component_id == "motor":
return "motor_dark"
if component_id == "ring":
return "ring_blue"
if "bearing" in component_id:
return "bearing_silver"
if component_id in {"carrier", "carrier_front_plate", "output_shaft"} or component_id.startswith(("planet_pin_", "planet_spacer_")):
return "carrier_green"
if role == "planet" or component_id == "sun":
return "gear_gold"
return "steel"
def _add_link(
robot: ET.Element,
*,
link_name: str,
mesh_filename: str | None = None,
material: str = "steel",
visual_origin_xyz: tuple[float, float, float] = (0.0, 0.0, 0.0),
collision: bool = True,
) -> None:
link = ET.SubElement(robot, "link", {"name": link_name})
if mesh_filename is None:
return
for tag in ["visual", "collision"] if collision else ["visual"]:
element = ET.SubElement(link, tag)
ET.SubElement(element, "origin", {"xyz": _fmt(visual_origin_xyz), "rpy": "0 0 0"})
geometry = ET.SubElement(element, "geometry")
ET.SubElement(
geometry,
"mesh",
{
"filename": mesh_filename,
"scale": "0.001 0.001 0.001",
},
)
if tag == "visual":
ET.SubElement(element, "material", {"name": material})
def _add_joint(
robot: ET.Element,
*,
name: str,
joint_type: str,
parent: str,
child: str,
origin_xyz: tuple[float, float, float] = (0.0, 0.0, 0.0),
axis: tuple[float, float, float] | None = None,
mimic: tuple[str, float, float] | None = None,
) -> None:
joint = ET.SubElement(robot, "joint", {"name": name, "type": joint_type})
ET.SubElement(joint, "origin", {"xyz": _fmt(origin_xyz), "rpy": "0 0 0"})
ET.SubElement(joint, "parent", {"link": parent})
ET.SubElement(joint, "child", {"link": child})
if axis is not None:
ET.SubElement(joint, "axis", {"xyz": _fmt(axis)})
if mimic is not None:
joint_name, multiplier, offset = mimic
ET.SubElement(
joint,
"mimic",
{
"joint": joint_name,
"multiplier": f"{float(multiplier):.12g}",
"offset": f"{float(offset):.12g}",
},
)
def _mesh_uri(package_name: str, component_id: str) -> str:
return f"package://{package_name}/meshes/{component_id}.stl"
def _visual_origin_for_link(
manifest: JointModuleManifest,
component_id: str,
*,
link_frame_origin_mm: tuple[float, float, float] = (0.0, 0.0, 0.0),
) -> tuple[float, float, float]:
return tuple(-_m(value) for value in link_frame_origin_mm)
def build_urdf_xml(
*,
manifest: JointModuleManifest,
reducer_instance: ReducerInstance,
package_name: str = PACKAGE_NAME,
robot_name: str = ROBOT_NAME,
) -> tuple[str, dict[str, float | str | dict[str, float]]]:
if reducer_instance.topology_family != "simple_2k_h":
reducer_manifest_path = Path(manifest.reducer_output_dir) / "cad" / "assembly_manifest.json"
if not reducer_manifest_path.exists():
raise UrdfExportError(f"missing_reducer_assembly_manifest: {reducer_manifest_path}")
reducer_manifest = AssemblyManifest.model_validate(read_json(reducer_manifest_path))
reducer_xml, motion = build_reducer_urdf_xml(
manifest=reducer_manifest,
reducer_instance=reducer_instance,
package_name=package_name,
robot_name=robot_name,
)
root = ET.fromstring(reducer_xml)
motor = manifest.components.get("motor")
if motor is not None:
_add_link(
root,
link_name="motor_link",
mesh_filename=_mesh_uri(package_name, "motor"),
material=_material_for("motor", motor.role),
)
_add_joint(
root,
name="base_to_motor",
joint_type="fixed",
parent="base_link",
child="motor_link",
)
ET.indent(root, space=" ")
return ET.tostring(root, encoding="unicode", xml_declaration=True), motion
if reducer_instance.boundary.input != "sun" or reducer_instance.boundary.fixed != "ring":
raise UrdfExportError("URDF exporter currently supports sun input and fixed ring reducer boundary")
motion = compute_urdf_motion_map(reducer_instance=reducer_instance)
robot = ET.Element("robot", {"name": robot_name})
_add_materials(robot)
_add_link(robot, link_name="base_link", collision=False)
axis = _axis_direction(manifest.components["sun"])
carrier_multiplier = float(motion["carrier_joint_multiplier"])
planet_spin_multiplier = float(motion["planet_spin_joint_multiplier"])
fixed_base_components = ["housing", "motor", "ring", "output_bearing_1", "output_bearing_2"]
for component_id in fixed_base_components:
component = manifest.components.get(component_id)
if component is None:
continue
link_name = f"{component_id}_link"
_add_link(
robot,
link_name=link_name,
mesh_filename=_mesh_uri(package_name, component_id),
material=_material_for(component_id, component.role),
)
_add_joint(
robot,
name=f"base_to_{component_id}",
joint_type="fixed",
parent="base_link",
child=link_name,
)
sun_component = manifest.components["sun"]
_add_link(
robot,
link_name="sun_link",
mesh_filename=_mesh_uri(package_name, "sun"),
material=_material_for("sun", sun_component.role),
)
_add_joint(
robot,
name="sun_input_joint",
joint_type="continuous",
parent="base_link",
child="sun_link",
axis=axis,
)
for component_id in ["sun_input_shaft", "sun_input_key"]:
component = manifest.components.get(component_id)
if component is None:
continue
link_name = f"{component_id}_link"
_add_link(
robot,
link_name=link_name,
mesh_filename=_mesh_uri(package_name, component_id),
material=_material_for(component_id, component.role),
)
_add_joint(
robot,
name=f"sun_to_{component_id}",
joint_type="fixed",
parent="sun_link",
child=link_name,
)
carrier_component = manifest.components["carrier"]
_add_link(
robot,
link_name="carrier_link",
mesh_filename=_mesh_uri(package_name, "carrier"),
material=_material_for("carrier", carrier_component.role),
)
_add_joint(
robot,
name="carrier_output_joint",
joint_type="continuous",
parent="base_link",
child="carrier_link",
axis=axis,
mimic=("sun_input_joint", carrier_multiplier, 0.0),
)
for component_id in ["carrier_front_plate", "output_shaft"]:
component = manifest.components.get(component_id)
if component is None:
continue
link_name = f"{component_id}_link"
_add_link(
robot,
link_name=link_name,
mesh_filename=_mesh_uri(package_name, component_id),
material=_material_for(component_id, component.role),
)
_add_joint(
robot,
name=f"carrier_to_{component_id}",
joint_type="fixed",
parent="carrier_link",
child=link_name,
)
for planet_id in _planet_component_ids(manifest):
index = int(planet_id.rsplit("_", 1)[-1])
planet_component = manifest.components[planet_id]
planet_origin_mm = _axis_origin(planet_component)
planet_origin_m = tuple(_m(value) for value in planet_origin_mm)
orbit_link = f"planet_{index}_orbit_link"
planet_link = f"planet_{index}_link"
_add_link(robot, link_name=orbit_link, collision=False)
_add_joint(
robot,
name=f"carrier_to_planet_{index}_orbit",
joint_type="fixed",
parent="carrier_link",
child=orbit_link,
origin_xyz=planet_origin_m,
)
_add_link(
robot,
link_name=planet_link,
mesh_filename=_mesh_uri(package_name, planet_id),
material=_material_for(planet_id, planet_component.role),
visual_origin_xyz=_visual_origin_for_link(
manifest,
planet_id,
link_frame_origin_mm=planet_origin_mm,
),
)
_add_joint(
robot,
name=f"planet_{index}_spin_joint",
joint_type="continuous",
parent=orbit_link,
child=planet_link,
axis=axis,
mimic=("sun_input_joint", planet_spin_multiplier, 0.0),
)
for suffix, parent_link in [
("pin", "carrier_link"),
("spacer", "carrier_link"),
("bearing", planet_link),
]:
component_id = f"planet_{suffix}_{index}"
if component_id not in manifest.components:
continue
component = manifest.components[component_id]
link_name = f"{component_id}_link"
link_frame_origin = planet_origin_mm if parent_link == planet_link else (0.0, 0.0, 0.0)
_add_link(
robot,
link_name=link_name,
mesh_filename=_mesh_uri(package_name, component_id),
material=_material_for(component_id, component.role),
visual_origin_xyz=_visual_origin_for_link(
manifest,
component_id,
link_frame_origin_mm=link_frame_origin,
),
)
_add_joint(
robot,
name=f"{parent_link.removesuffix('_link')}_to_{component_id}",
joint_type="fixed",
parent=parent_link,
child=link_name,
origin_xyz=(0.0, 0.0, 0.0),
)
ET.indent(robot, space=" ")
return ET.tostring(robot, encoding="unicode", xml_declaration=True), motion
def _package_xml(package_name: str) -> str:
root = ET.Element("package", {"format": "3"})
ET.SubElement(root, "name").text = package_name
ET.SubElement(root, "version").text = "0.1.0"
ET.SubElement(root, "description").text = "Generated planetary reducer joint module URDF package"
ET.SubElement(root, "maintainer", {"email": "noreply@example.com"}).text = "planetary_reducer_system_demo"
ET.SubElement(root, "license").text = "UNLICENSED"
ET.indent(root, space=" ")
return ET.tostring(root, encoding="unicode", xml_declaration=True)
def _joint_elements(root: ET.Element) -> list[ET.Element]:
return list(root.findall("joint"))
def _mesh_component_from_uri(uri: str) -> str | None:
prefix = f"package://{PACKAGE_NAME}/meshes/"
if not uri.startswith(prefix) or not uri.endswith(".stl"):
return None
return uri.removeprefix(prefix).removesuffix(".stl")
def validate_urdf_export(
*,
urdf_path: Path,
mesh_files: dict[str, str],
motion: dict[str, float | str | dict[str, float]],
reducer_instance: ReducerInstance,
out_path: Path | None = None,
) -> UrdfValidationReport:
if reducer_instance.topology_family != "simple_2k_h":
reducer_validation = validate_reducer_urdf_export(
urdf_path=urdf_path,
mesh_files=mesh_files,
motion=motion,
reducer_instance=reducer_instance,
package_name=PACKAGE_NAME,
)
checks = [
UrdfValidationCheck(
code=str(item["code"]),
passed=bool(item["passed"]),
message=str(item["message"]),
actual=item.get("actual"),
expected=item.get("expected"),
)
for item in reducer_validation["checks"]
]
report = UrdfValidationReport(
run_id=reducer_instance.run_id,
overall_passed=bool(reducer_validation["overall_passed"]),
generated_at=utc_now_iso(),
checks=checks,
summary=dict(reducer_validation["summary"]),
)
if out_path:
write_model_json(out_path, report)
return report
checks: list[UrdfValidationCheck] = []
def check(code: str, passed: bool, message: str, actual=None, expected=None) -> None:
checks.append(
UrdfValidationCheck(
code=code,
passed=bool(passed),
message=message,
actual=actual,
expected=expected,
)
)
try:
root = ET.parse(urdf_path).getroot()
except Exception as exc: # noqa: BLE001
root = None
check("urdf_xml_parse", False, f"URDF XML parse failed: {exc}")
if root is not None:
check("urdf_robot_root", root.tag == "robot" and bool(root.get("name")), "URDF root is a named robot")
link_names = [item.get("name") for item in root.findall("link")]
joint_names = [item.get("name") for item in _joint_elements(root)]
check(
"urdf_unique_links",
len(link_names) == len(set(link_names)) and "base_link" in set(link_names),
"URDF link names are unique and include base_link",
actual=link_names,
)
check(
"urdf_unique_joints",
len(joint_names) == len(set(joint_names)),
"URDF joint names are unique",
actual=joint_names,
)
links = {name for name in link_names if name}
child_links = []
bad_refs = []
for joint in _joint_elements(root):
parent = joint.find("parent")
child = joint.find("child")
parent_name = parent.get("link") if parent is not None else None
child_name = child.get("link") if child is not None else None
if parent_name not in links or child_name not in links:
bad_refs.append({"joint": joint.get("name"), "parent": parent_name, "child": child_name})
if child_name:
child_links.append(child_name)
check("urdf_joint_link_references", not bad_refs, "every joint parent/child references an existing link", actual=bad_refs, expected=[])
duplicate_children = sorted({item for item in child_links if child_links.count(item) > 1})
check("urdf_tree_children_unique", not duplicate_children and "base_link" not in child_links, "URDF joints form a tree with one parent per child link", actual=duplicate_children, expected=[])
mimic_by_joint = {
joint.get("name"): joint.find("mimic")
for joint in _joint_elements(root)
if joint.find("mimic") is not None
}
carrier_mimic = mimic_by_joint.get("carrier_output_joint")
carrier_multiplier = None if carrier_mimic is None else float(carrier_mimic.get("multiplier", "nan"))
expected_carrier = float(motion["carrier_joint_multiplier"])
check(
"urdf_carrier_mimic_ratio",
carrier_multiplier is not None and abs(carrier_multiplier - expected_carrier) <= 1e-12,
"carrier output joint mimics sun input with the solved reducer speed ratio",
actual=carrier_multiplier,
expected=expected_carrier,
)
expected_planet = float(motion["planet_spin_joint_multiplier"])
planet_mimics = {
name: float(element.get("multiplier", "nan"))
for name, element in mimic_by_joint.items()
if name and name.startswith("planet_") and name.endswith("_spin_joint")
}
check(
"urdf_planet_spin_mimic_ratio",
bool(planet_mimics)
and all(abs(value - expected_planet) <= 1e-12 for value in planet_mimics.values()),
"planet spin joints mimic sun input with solved relative planet spin speed",
actual=planet_mimics,
expected=expected_planet,
)
mesh_components = []
missing_meshes = []
for mesh in root.findall(".//mesh"):
component_id = _mesh_component_from_uri(mesh.get("filename", ""))
if component_id:
mesh_components.append(component_id)
mesh_path = Path(mesh_files.get(component_id, ""))
if not mesh_path.exists() or mesh_path.stat().st_size <= 0:
missing_meshes.append(component_id)
check("urdf_mesh_files_exist", not missing_meshes and bool(mesh_components), "all package:// mesh references exist on disk", actual=missing_meshes, expected=[])
summary = {
"total": len(checks),
"passed": sum(1 for item in checks if item.passed),
"failed": sum(1 for item in checks if not item.passed),
}
report = UrdfValidationReport(
run_id=reducer_instance.run_id,
overall_passed=summary["failed"] == 0,
generated_at=utc_now_iso(),
checks=checks,
summary=summary,
)
if out_path:
write_model_json(out_path, report)
return report
def export_urdf(
*,
joint_manifest_path: Path,
reducer_instance_path: Path,
out_dir: Path,
linear_deflection_mm: float = 0.25,
package_name: str = PACKAGE_NAME,
robot_name: str = ROBOT_NAME,
) -> UrdfExportManifest:
manifest = load_joint_manifest(joint_manifest_path)
reducer_instance = ReducerInstance.model_validate(read_json(reducer_instance_path))
out_dir.mkdir(parents=True, exist_ok=True)
mesh_dir = out_dir / "meshes"
mesh_files = _export_meshes(
manifest=manifest,
mesh_dir=mesh_dir,
linear_deflection_mm=linear_deflection_mm,
)
urdf_xml, motion = build_urdf_xml(
manifest=manifest,
reducer_instance=reducer_instance,
package_name=package_name,
robot_name=robot_name,
)
urdf_path = out_dir / "joint_module.urdf"
package_xml_path = out_dir / "package.xml"
motion_demo_path = out_dir / "joint_motion_demo.json"
validation_path = out_dir / "urdf_validation_report.json"
urdf_path.write_text(urdf_xml, encoding="utf-8")
package_xml_path.write_text(_package_xml(package_name), encoding="utf-8")
write_json(motion_demo_path, motion)
validation = validate_urdf_export(
urdf_path=urdf_path,
mesh_files=mesh_files,
motion=motion,
reducer_instance=reducer_instance,
out_path=validation_path,
)
if not validation.overall_passed:
raise UrdfExportError(f"urdf_validation_failed: {validation.summary}")
export_manifest_path = out_dir / "urdf_export_manifest.json"
export_manifest = UrdfExportManifest(
run_id=f"urdf_{uuid.uuid4().hex[:12]}",
joint_run_id=manifest.run_id,
reducer_run_id=manifest.reducer_run_id,
generated_at=utc_now_iso(),
robot_name=robot_name,
package_name=package_name,
urdf_path=str(urdf_path.resolve()),
package_xml_path=str(package_xml_path.resolve()),
mesh_dir=str(mesh_dir.resolve()),
motion_demo_path=str(motion_demo_path.resolve()),
validation_report_path=str(validation_path.resolve()),
mesh_files=mesh_files,
transmission_summary=motion,
)
write_model_json(export_manifest_path, export_manifest)
return export_manifest
+613
View File
@@ -0,0 +1,613 @@
from __future__ import annotations
import math
from typing import Any
from ..kinematics import solve_instance
from ..models import KinematicSolution, ReducerInstance, ValidationReport
from .models import (
JointInterferenceFinding,
JointModuleManifest,
JointModuleRequirement,
JointValidationCheck,
JointValidationReport,
build_joint_validation_report,
)
from .reducer_adapters import get_reducer_joint_adapter
from .step_geometry import bbox_axis_range, dot, union_bbox, vector_sub
def _bbox_axis_range(
bbox: tuple[float, float, float, float, float, float],
axis: tuple[float, float, float],
) -> tuple[float, float]:
values = [
dot((x, y, z), axis)
for x in [bbox[0], bbox[3]]
for y in [bbox[1], bbox[4]]
for z in [bbox[2], bbox[5]]
]
return (min(values), max(values))
def _check(
checks: list[JointValidationCheck],
*,
section: str,
code: str,
passed: bool,
message: str,
actual: Any | None = None,
expected: Any | None = None,
) -> None:
checks.append(
JointValidationCheck(
section=section,
code=code,
passed=bool(passed),
message=message,
actual=actual,
expected=expected,
)
)
def _angle_between_deg(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
dot_value = max(-1.0, min(1.0, abs(dot(a, b))))
return math.degrees(math.acos(dot_value))
def _distance_point_to_axis(
point: tuple[float, float, float],
axis_origin: tuple[float, float, float],
axis_direction: tuple[float, float, float],
) -> float:
delta = vector_sub(point, axis_origin)
projection = dot(delta, axis_direction)
closest = (
axis_origin[0] + axis_direction[0] * projection,
axis_origin[1] + axis_direction[1] * projection,
axis_origin[2] + axis_direction[2] * projection,
)
error = vector_sub(point, closest)
return math.sqrt(dot(error, error))
def _axis_for_component(manifest: JointModuleManifest, component_id: str) -> dict[str, Any] | None:
component = manifest.components.get(component_id)
if component is None:
return None
axis = component.placed_metrics.inferred_axis
if axis is None:
return None
return {
"origin_mm": axis.origin_mm,
"direction_xyz": axis.direction_xyz,
"confidence": axis.confidence,
"method": axis.method,
}
def relation_context_for_pair(component_a: str, component_b: str) -> str | None:
pair = {component_a, component_b}
if pair == {"motor", "housing"}:
return "external motor installed relative to external housing"
if "housing" in pair:
other = component_b if component_a == "housing" else component_a
return f"external housing clearance around reducer component {other}"
if "motor" in pair:
other = component_b if component_a == "motor" else component_a
return f"motor-to-reducer input-side clearance around {other}"
return None
def classify_joint_interference(component_a: str, component_b: str) -> str:
pair = {component_a, component_b}
if pair == {"motor", "housing"}:
return "motor_not_aligned"
if "housing" in pair:
other = component_b if component_a == "housing" else component_a
if other in {"ring", "carrier", "output_shaft"} or other.startswith(("planet_", "planet")):
return "housing_cavity_too_small"
return "reducer_outer_diameter_too_large"
if "motor" in pair:
other = component_b if component_a == "motor" else component_a
if other in {"sun", "sun_input_shaft", "sun_input_key"}:
return "input_coupling_collision"
return "wrong_axial_offset"
return "unknown_interference"
def suggested_fix_for_joint_interference(possible_cause: str) -> str:
return {
"motor_not_aligned": "check motor mounting face and axis placement in joint_placement.json",
"housing_cavity_too_small": "increase housing cavity or reduce/move the reducer package",
"reducer_outer_diameter_too_large": "reduce ring outside diameter or enlarge the external housing",
"input_coupling_collision": "move motor axially or redesign the sun input keyed coupling clearance",
"wrong_axial_offset": "adjust reducer or motor axial offset in joint_placement.json",
"unknown_interference": "inspect exported common interference STEP or component bboxes",
}.get(possible_cause, "inspect the reported component pair")
def finding_from_interference_result(
*,
component_a: str,
component_b: str,
common_volume_mm3: float,
bbox_overlap: bool,
checked_boolean: bool,
interfering: bool,
bbox_a: tuple[float, float, float, float, float, float],
bbox_b: tuple[float, float, float, float, float, float],
) -> JointInterferenceFinding:
possible_cause = classify_joint_interference(component_a, component_b) if interfering else None
return JointInterferenceFinding(
component_a=component_a,
component_b=component_b,
common_volume_mm3=common_volume_mm3,
bbox_overlap=bbox_overlap,
checked_boolean=checked_boolean,
interfering=interfering,
bbox_a=bbox_a,
bbox_b=bbox_b,
relation_context=relation_context_for_pair(component_a, component_b),
possible_cause=possible_cause,
suggested_fix=suggested_fix_for_joint_interference(possible_cause) if possible_cause else None,
)
def build_joint_module_validation_report(
*,
requirement: JointModuleRequirement,
manifest: JointModuleManifest,
reducer_instance: ReducerInstance,
reducer_solution: KinematicSolution | None,
reducer_validation_report: ValidationReport | None,
interference_findings: list[JointInterferenceFinding],
) -> JointValidationReport:
checks: list[JointValidationCheck] = []
adapter = get_reducer_joint_adapter(reducer_instance.topology_family)
try:
adapter_required = adapter.required_joint_component_ids(reducer_instance, manifest)
except Exception as exc: # noqa: BLE001
adapter_required = set()
_check(
checks,
section="static",
code="joint_adapter_required_components_resolved",
passed=False,
message=f"joint adapter could not resolve required reducer ports: {exc}",
)
required_components = {"motor", "housing", *adapter_required}
found_components = set(manifest.components)
_check(
checks,
section="static",
code="joint_required_components_present",
passed=required_components <= found_components,
message="joint manifest contains motor, external housing, and key reducer transmission port components",
actual=sorted(found_components),
expected=sorted(required_components),
)
_check(
checks,
section="static",
code="external_components_are_real_step_sources",
passed=all(
manifest.components.get(component_id)
and manifest.components[component_id].source_type == "external_step"
and manifest.components[component_id].source_step_path
for component_id in ["motor", "housing"]
),
message="motor and housing are tracked as external STEP geometry, not fake reducer model nodes",
actual={
component_id: (
manifest.components[component_id].source_type
if component_id in manifest.components
else None
)
for component_id in ["motor", "housing"]
},
expected="external_step",
)
_check(
checks,
section="static",
code="reducer_validation_passed",
passed=manifest.reducer_validation_passed
and reducer_validation_report is not None
and reducer_validation_report.overall_passed,
message="underlying industrial planetary reducer validation passed before joint assembly",
actual=None if reducer_validation_report is None else reducer_validation_report.summary,
expected="overall_passed=true",
)
try:
recomputed = solve_instance(reducer_instance)
except Exception as exc: # noqa: BLE001
recomputed = None
_check(
checks,
section="kinematic",
code="joint_reducer_kinematic_solve",
passed=False,
message=f"reducer kinematic solve failed: {exc}",
)
if recomputed is not None:
input_variable = adapter.input_speed_variable(reducer_instance)
output_variable = adapter.output_speed_variable(reducer_instance)
fixed_variables = adapter.fixed_speed_variables(reducer_instance)
input_speed = recomputed.speeds.get(input_variable)
output_speed = recomputed.speeds.get(output_variable)
_check(
checks,
section="kinematic",
code="motor_speed_maps_to_reducer_input_speed",
passed=input_speed is not None and abs(float(input_speed) - 1.0) <= 1e-12,
message="joint motor speed is mapped to the reducer input speed variable",
actual={
"boundary_input": input_variable,
"input_speed": input_speed,
"relation": requirement.motor_to_reducer_relation,
},
expected=f"omega_{input_variable}=1",
)
if reducer_instance.topology_family == "simple_2k_h":
_check(
checks,
section="kinematic",
code="motor_speed_maps_to_sun_speed",
passed=input_speed is not None and abs(float(input_speed) - 1.0) <= 1e-12,
message="compatibility check: joint motor speed is mapped to the reducer sun input speed",
actual={
"boundary_input": input_variable,
"sun_speed": input_speed,
"relation": requirement.motor_to_reducer_relation,
},
expected="input=sun and omega_sun=1",
)
fixed_actual = {name: recomputed.speeds.get(name) for name in fixed_variables}
fixed_passed = bool(fixed_variables) and all(
value is not None and abs(float(value)) <= 1e-12 for value in fixed_actual.values()
)
_check(
checks,
section="kinematic",
code="fixed_members_map_to_housing_speed_zero",
passed=fixed_passed,
message="fixed reducer members are mapped to the external housing and have zero speed",
actual={
"boundary_fixed_members": fixed_variables,
"fixed_speeds": fixed_actual,
"relation": requirement.housing_relation,
},
expected="all fixed member speeds are zero",
)
if reducer_instance.topology_family == "simple_2k_h":
_check(
checks,
section="kinematic",
code="ring_fixed_to_housing_speed_zero",
passed=fixed_passed,
message="compatibility check: fixed ring is mapped to the external housing and has zero speed",
actual={
"boundary_fixed": reducer_instance.boundary.fixed,
"ring_speed": recomputed.speeds.get("ring"),
"relation": requirement.housing_relation,
},
expected="fixed=ring and omega_ring=0",
)
output_passed = output_speed not in {None, 0.0} and abs(
abs(1.0 / float(output_speed)) - reducer_instance.derived.ratio
) <= 1e-9
_check(
checks,
section="kinematic",
code="reducer_output_maps_to_joint_output_ratio",
passed=output_passed,
message="joint output preserves reducer solved ratio",
actual={
"boundary_output": output_variable,
"output_speed": output_speed,
"ratio_from_motor_to_output": None if not output_speed else abs(1.0 / float(output_speed)),
"instance_ratio": reducer_instance.derived.ratio,
},
expected=reducer_instance.derived.ratio,
)
if reducer_instance.topology_family == "simple_2k_h":
_check(
checks,
section="kinematic",
code="carrier_maps_to_joint_output_ratio",
passed=output_passed,
message="compatibility check: joint output is carrier/output shaft and preserves reducer ratio",
actual={
"carrier_speed": output_speed,
"ratio_from_motor_to_output": None if not output_speed else abs(1.0 / float(output_speed)),
"instance_ratio": reducer_instance.derived.ratio,
},
expected=reducer_instance.derived.ratio,
)
_check(
checks,
section="kinematic",
code="kinematic_solution_file_matches_recomputed_ratio",
passed=reducer_solution is not None
and abs(abs(reducer_solution.ratio) - abs(recomputed.ratio)) <= 1e-9,
message="stored reducer kinematic_solution matches recomputed graph solution",
actual=None if reducer_solution is None else reducer_solution.ratio,
expected=None if recomputed is None else recomputed.ratio,
)
relation_ids = {relation.relation_id for relation in manifest.relations}
expected_relations = adapter.expected_relation_ids(reducer_instance, manifest)
_check(
checks,
section="assembly",
code="joint_semantic_relations_present",
passed=expected_relations <= relation_ids,
message="joint manifest declares adapter-provided input coupling, housing fix, and output relation",
actual=sorted(relation_ids),
expected=sorted(expected_relations),
)
invalid_metrics = [
{
"component_id": component.component_id,
"valid": component.placed_metrics.valid,
"volume_mm3": component.placed_metrics.volume_mm3,
}
for component in manifest.components.values()
if not component.placed_metrics.valid or component.placed_metrics.volume_mm3 <= 0.0
]
_check(
checks,
section="geometry",
code="joint_component_step_shapes_valid",
passed=not invalid_metrics,
message="all joint component STEP shapes read as valid positive-volume OCP shapes after placement",
actual=invalid_metrics,
expected=[],
)
motor_axis = _axis_for_component(manifest, "motor")
housing_axis = _axis_for_component(manifest, "housing")
try:
input_axis_component_id = adapter.input_axis_component_id(reducer_instance, manifest)
reducer_axis_component_id = adapter.reducer_axis_component_id(reducer_instance, manifest)
output_component_id = adapter.output_component_id(reducer_instance, manifest)
except Exception: # noqa: BLE001
input_axis_component_id = reducer_axis_component_id = output_component_id = ""
input_axis = _axis_for_component(manifest, input_axis_component_id) if input_axis_component_id else None
reducer_axis = _axis_for_component(manifest, reducer_axis_component_id) if reducer_axis_component_id else None
if motor_axis and housing_axis and reducer_axis and input_axis:
motor_angle = _angle_between_deg(motor_axis["direction_xyz"], input_axis["direction_xyz"])
housing_angle = _angle_between_deg(housing_axis["direction_xyz"], reducer_axis["direction_xyz"])
motor_distance = _distance_point_to_axis(
motor_axis["origin_mm"],
input_axis["origin_mm"],
input_axis["direction_xyz"],
)
housing_distance = _distance_point_to_axis(
housing_axis["origin_mm"],
reducer_axis["origin_mm"],
reducer_axis["direction_xyz"],
)
_check(
checks,
section="geometry",
code="motor_axis_coaxial_with_reducer_input",
passed=motor_angle <= requirement.axis_angle_tolerance_deg
and motor_distance <= requirement.axis_distance_tolerance_mm,
message="motor inferred axis is coaxial with reducer adapter input axis after placement",
actual={"angle_deg": motor_angle, "axis_distance_mm": motor_distance},
expected={
"angle_deg": f"<= {requirement.axis_angle_tolerance_deg}",
"axis_distance_mm": f"<= {requirement.axis_distance_tolerance_mm}",
},
)
_check(
checks,
section="geometry",
code="housing_axis_coaxial_with_reducer_axis",
passed=housing_angle <= requirement.axis_angle_tolerance_deg
and housing_distance <= requirement.axis_distance_tolerance_mm,
message="external housing inferred axis is coaxial with reducer adapter main/output axis after placement",
actual={"angle_deg": housing_angle, "axis_distance_mm": housing_distance},
expected={
"angle_deg": f"<= {requirement.axis_angle_tolerance_deg}",
"axis_distance_mm": f"<= {requirement.axis_distance_tolerance_mm}",
},
)
housing = manifest.components.get("housing")
reducer_placement = manifest.placements.get("reducer")
reducer_core_axis_min = reducer_core_axis_max = None
reducer_output_side = None
if reducer_placement is not None:
reducer_output_side = reducer_placement.details.get("reducer_output_side")
core_ids = reducer_placement.details.get("reducer_core_component_ids")
if isinstance(core_ids, list) and core_ids:
core_bboxes = [
manifest.components[component_id].placed_metrics.bbox
for component_id in core_ids
if component_id in manifest.components
]
if core_bboxes:
core_bbox = union_bbox(core_bboxes)
reducer_core_axis_min, reducer_core_axis_max = _bbox_axis_range(
core_bbox,
housing_axis["direction_xyz"],
)
if reducer_core_axis_min is not None and reducer_core_axis_max is not None:
if housing is not None:
housing_axis_min, housing_axis_max = _bbox_axis_range(
housing.placed_metrics.bbox,
housing_axis["direction_xyz"],
)
_check(
checks,
section="geometry",
code="reducer_core_inside_housing_axial_span",
passed=reducer_core_axis_min >= housing_axis_min + requirement.coupling_clearance_mm
and reducer_core_axis_max <= housing_axis_max - requirement.coupling_clearance_mm,
message="reducer core components are positioned inside the housing axial span",
actual={
"reducer_core_axis_min_mm": reducer_core_axis_min,
"reducer_core_axis_max_mm": reducer_core_axis_max,
"housing_axis_min_mm": housing_axis_min,
"housing_axis_max_mm": housing_axis_max,
},
expected={
"reducer_core_axis_min_mm": f">= housing_min + {requirement.coupling_clearance_mm}",
"reducer_core_axis_max_mm": f"<= housing_max - {requirement.coupling_clearance_mm}",
},
)
input_shaft = manifest.components.get(input_axis_component_id)
motor = manifest.components.get("motor")
motor_placement = manifest.placements.get("motor")
if adapter.input_insertion_required(reducer_instance, manifest) and input_shaft and motor and motor_placement:
shaft_min, shaft_max = _bbox_axis_range(input_shaft.placed_metrics.bbox, input_axis["direction_xyz"])
motor_min, motor_max = _bbox_axis_range(motor.placed_metrics.bbox, input_axis["direction_xyz"])
output_face = motor_placement.details.get("motor_output_bore_face_axis_value_mm")
if not isinstance(output_face, (int, float)):
output_face = motor_placement.details.get("motor_output_face_axis_value_mm")
if not isinstance(output_face, (int, float)):
output_side = motor_placement.details.get("motor_output_side", requirement.motor_output_side)
output_face = motor_min if output_side == "axis_max" else motor_max
insertion_depth = max(0.0, shaft_max - float(output_face))
bore_radius = None
if motor.raw_metrics.inferred_axis is not None:
raw_bore_radius = motor.raw_metrics.inferred_axis.details.get("motor_output_bore_radius_mm")
if isinstance(raw_bore_radius, (int, float)):
bore_radius = float(raw_bore_radius)
bore_min = None
bore_max = None
if motor.raw_metrics.inferred_axis is not None:
raw_bore_min = motor.raw_metrics.inferred_axis.details.get("motor_output_bore_axis_min_mm")
raw_bore_max = motor.raw_metrics.inferred_axis.details.get("motor_output_bore_axis_max_mm")
if isinstance(raw_bore_min, (int, float)):
bore_min = float(raw_bore_min)
if isinstance(raw_bore_max, (int, float)):
bore_max = float(raw_bore_max)
shaft_radius = max(
(input_shaft.placed_metrics.bbox[3] - input_shaft.placed_metrics.bbox[0]) / 2.0,
(input_shaft.placed_metrics.bbox[4] - input_shaft.placed_metrics.bbox[1]) / 2.0,
)
_check(
checks,
section="geometry",
code="reducer_input_shaft_inserted_into_motor_output_bore",
passed=insertion_depth >= requirement.input_shaft_insertion_depth_mm
and (bore_radius is None or shaft_radius < bore_radius),
message="reducer adapter input shaft is inserted into the inferred motor output bore by the required depth",
actual={
"input_axis_component_id": input_axis_component_id,
"shaft_axis_min_mm": shaft_min,
"shaft_axis_max_mm": shaft_max,
"motor_axis_min_mm": motor_min,
"motor_axis_max_mm": motor_max,
"motor_output_face_axis_value_mm": output_face,
"insertion_depth_mm": insertion_depth,
"shaft_radius_mm": shaft_radius,
"motor_output_bore_radius_mm": bore_radius,
"motor_output_bore_axis_min_mm": bore_min,
"motor_output_bore_axis_max_mm": bore_max,
},
expected={
"insertion_depth_mm": f">= {requirement.input_shaft_insertion_depth_mm}",
"shaft_radius_mm": "less than inferred motor output bore radius when available",
},
)
if reducer_instance.topology_family == "simple_2k_h":
_check(
checks,
section="geometry",
code="sun_input_shaft_inserted_into_motor_output_bore",
passed=insertion_depth >= requirement.input_shaft_insertion_depth_mm
and (bore_radius is None or shaft_radius < bore_radius),
message="compatibility check: sun input shaft is inserted into the inferred motor output bore by the required depth",
actual={
"shaft_axis_min_mm": shaft_min,
"shaft_axis_max_mm": shaft_max,
"motor_axis_min_mm": motor_min,
"motor_axis_max_mm": motor_max,
"motor_output_face_axis_value_mm": output_face,
"insertion_depth_mm": insertion_depth,
"shaft_radius_mm": shaft_radius,
"motor_output_bore_radius_mm": bore_radius,
"motor_output_bore_axis_min_mm": bore_min,
"motor_output_bore_axis_max_mm": bore_max,
},
expected={
"insertion_depth_mm": f">= {requirement.input_shaft_insertion_depth_mm}",
"shaft_radius_mm": "less than inferred motor output bore radius when available",
},
)
else:
_check(
checks,
section="geometry",
code="joint_axes_available",
passed=False,
message="motor, housing, reducer input, and reducer main/output axes are required for coaxial validation",
actual={
"motor": motor_axis,
"housing": housing_axis,
"input": input_axis,
"reducer_axis": reducer_axis,
},
expected="axis data on motor, housing, reducer input, and reducer axis components",
)
output = manifest.components.get(output_component_id) if output_component_id else None
housing = manifest.components.get("housing")
reducer_placement = manifest.placements.get("reducer")
reducer_output_side = None
if reducer_placement is not None:
reducer_output_side = reducer_placement.details.get("reducer_output_side")
if adapter.output_extension_required(reducer_instance, manifest) and output and housing and housing_axis and reducer_output_side in {"axis_min", "axis_max"}:
axis = housing_axis["direction_xyz"]
output_axis_min, output_axis_max = _bbox_axis_range(output.placed_metrics.bbox, axis)
housing_axis_min, housing_axis_max = _bbox_axis_range(housing.placed_metrics.bbox, axis)
output_extension = float(requirement.output_extension_mm)
if reducer_output_side == "axis_min":
extension = housing_axis_min - output_axis_min
passed = extension >= output_extension
expected = f"output_axis_min <= housing_min - {output_extension}"
else:
extension = output_axis_max - housing_axis_max
passed = extension >= output_extension
expected = f"output_axis_max >= housing_max + {output_extension}"
_check(
checks,
section="geometry",
code="carrier_output_shaft_exposes_joint_output_side",
passed=passed,
message="carrier output shaft extends beyond the external housing output side",
actual={
"output_axis_min_mm": output_axis_min,
"output_axis_max_mm": output_axis_max,
"housing_axis_min_mm": housing_axis_min,
"housing_axis_max_mm": housing_axis_max,
"output_extension_mm": extension,
"output_side": reducer_output_side,
},
expected=expected,
)
interferences = [item for item in interference_findings if item.interfering]
_check(
checks,
section="interference",
code="joint_ocp_boolean_interference",
passed=not interferences,
message="OCP Boolean Common found no positive-volume intersections for joint-level checked pairs",
actual={
"interferences": [item.model_dump(mode="json") for item in interferences],
"checked_pair_count": len(interference_findings),
"boolean_pair_count": sum(1 for item in interference_findings if item.checked_boolean),
},
expected=f"all common volumes <= {requirement.volume_tolerance_mm3} mm^3",
)
return build_joint_validation_report(run_id=manifest.run_id, checks=checks)
+334
View File
@@ -0,0 +1,334 @@
from __future__ import annotations
from math import isfinite
from .models import (
BoundaryCondition,
ConcreteRelation,
ConcreteTopology,
EquationRecord,
KinematicSolution,
MotionSample,
MotionSamples,
ReducerInstance,
ReducerParameters,
utc_now_iso,
)
from .topology_runtime import expand_template, load_default_template, resolve_concrete_topology
class KinematicSolveError(ValueError):
pass
def _direction(output_speed: float, input_speed: float) -> str:
rel = output_speed / input_speed
if abs(rel) < 1e-12:
return "zero"
return "same" if rel > 0 else "opposite"
def _speed_mode(ratio: float) -> str:
magnitude = abs(ratio)
if abs(magnitude - 1.0) <= 1e-12:
return "direct"
return "speed_reducer" if magnitude > 1.0 else "speed_increaser"
def _node_variables(topology: ConcreteTopology) -> list[str]:
variables: list[str] = []
for node in topology.nodes:
if node.kind not in {"gear", "carrier"}:
continue
name = node.template_node_id if node.repeat_index is not None else node.component_id
if name not in variables:
variables.append(name)
preferred = ["sun", "ring", "carrier", "planet"]
return sorted(
variables,
key=lambda item: (
preferred.index(item) if item in preferred else len(preferred),
item,
),
)
def _variable_for(topology: ConcreteTopology, component_id: str) -> str | None:
node = next((item for item in topology.nodes if item.component_id == component_id), None)
if node is None or node.kind not in {"gear", "carrier"}:
return None
return node.template_node_id if node.repeat_index is not None else node.component_id
def _tooth_count(parameters: ReducerParameters, template_node_id: str) -> float:
if template_node_id in parameters.gear_tooth_counts:
return float(parameters.gear_tooth_counts[template_node_id])
if parameters.stages:
stage_id, _, local_role = template_node_id.partition("_")
stage = next((item for item in parameters.stages if item.stage_id == stage_id), None)
if stage is not None:
values = {
"sun": stage.z_sun,
"planet": stage.z_planet,
"ring": stage.z_ring,
}
if local_role in values:
return float(values[local_role])
values = {
"sun": parameters.z_sun,
"planet": parameters.z_planet,
"ring": parameters.z_ring,
}
if template_node_id not in values:
raise KinematicSolveError(f"missing tooth count for node {template_node_id}")
return float(values[template_node_id])
def _add_coeff(coefficients: dict[str, float], variable: str, value: float) -> None:
coefficients[variable] = coefficients.get(variable, 0.0) + value
def _mesh_equation(
*,
relation: ConcreteRelation,
topology: ConcreteTopology,
parameters: ReducerParameters,
) -> EquationRecord:
source_var = _variable_for(topology, relation.source_component_id)
target_var = _variable_for(topology, relation.target_component_id)
carrier_var = (
_variable_for(topology, relation.carrier_component_id)
if relation.carrier_component_id
else None
)
if not source_var or not target_var or not carrier_var:
raise KinematicSolveError(f"mesh relation {relation.relation_id} is missing a motion variable")
z_source = _tooth_count(parameters, relation.source_template_node)
z_target = _tooth_count(parameters, relation.target_template_node)
coefficients: dict[str, float] = {}
if relation.relation_type == "external_mesh":
_add_coeff(coefficients, source_var, z_source)
_add_coeff(coefficients, target_var, z_target)
_add_coeff(coefficients, carrier_var, -(z_source + z_target))
expression = (
f"z_{relation.source_template_node}*(omega_{source_var}-omega_{carrier_var}) + "
f"z_{relation.target_template_node}*(omega_{target_var}-omega_{carrier_var}) = 0"
)
elif relation.relation_type == "internal_mesh":
_add_coeff(coefficients, source_var, z_source)
_add_coeff(coefficients, target_var, -z_target)
_add_coeff(coefficients, carrier_var, -(z_source - z_target))
expression = (
f"z_{relation.source_template_node}*(omega_{source_var}-omega_{carrier_var}) - "
f"z_{relation.target_template_node}*(omega_{target_var}-omega_{carrier_var}) = 0"
)
else:
raise KinematicSolveError(f"unsupported mesh relation: {relation.relation_type}")
return EquationRecord(
id=relation.relation_id,
expression=expression,
coefficients=coefficients,
rhs=0.0,
)
def _rigid_equation(relation: ConcreteRelation, topology: ConcreteTopology) -> EquationRecord:
source_var = _variable_for(topology, relation.source_component_id)
target_var = _variable_for(topology, relation.target_component_id)
if not source_var or not target_var:
raise KinematicSolveError(f"rigid relation {relation.relation_id} is missing a motion variable")
return EquationRecord(
id=relation.relation_id,
expression=f"omega_{source_var} - omega_{target_var} = 0",
coefficients={source_var: 1.0, target_var: -1.0},
rhs=0.0,
)
def build_equations(
*,
parameters: ReducerParameters,
boundary: BoundaryCondition,
topology: ConcreteTopology,
) -> list[EquationRecord]:
fixed_members = boundary.fixed_members or ([boundary.fixed] if boundary.fixed else [])
if not fixed_members:
raise KinematicSolveError("topology solve needs at least one fixed member")
equations: list[EquationRecord] = []
for relation in topology.relations:
if relation.relation_type in {"external_mesh", "internal_mesh"}:
equations.append(
_mesh_equation(
relation=relation,
topology=topology,
parameters=parameters,
)
)
elif relation.relation_type == "rigid":
equations.append(_rigid_equation(relation, topology))
boundary_equations = [(boundary.input, 1.0, "input_normalization")]
boundary_equations.extend(
(member, 0.0, f"fixed_member_{member}")
for member in fixed_members
)
for variable, value, equation_id in boundary_equations:
if variable not in _node_variables(topology):
raise KinematicSolveError(f"boundary member is not a motion variable: {variable}")
equations.append(
EquationRecord(
id=equation_id,
expression=f"omega_{variable} = {value:g}",
coefficients={variable: 1.0},
rhs=value,
)
)
return equations
class LinearSystemSolver:
def __init__(self, variables: list[str]) -> None:
self.variables = variables
def _row(self, coefficients: dict[str, float]) -> list[float]:
return [float(coefficients.get(name, 0.0)) for name in self.variables]
def solve(self, equations: list[EquationRecord]) -> tuple[dict[str, float], list[float]]:
n_vars = len(self.variables)
if not equations:
raise KinematicSolveError("kinematic system has no equations")
matrix = [
self._row(equation.coefficients) + [float(equation.rhs)]
for equation in equations
]
row_count = len(matrix)
pivot_row = 0
pivot_columns: list[int] = []
for col in range(n_vars):
pivot = max(range(pivot_row, row_count), key=lambda row: abs(matrix[row][col]))
if abs(matrix[pivot][col]) < 1e-12:
continue
matrix[pivot_row], matrix[pivot] = matrix[pivot], matrix[pivot_row]
div = matrix[pivot_row][col]
matrix[pivot_row] = [value / div for value in matrix[pivot_row]]
for row in range(row_count):
if row == pivot_row:
continue
factor = matrix[row][col]
if abs(factor) <= 1e-15:
continue
matrix[row] = [
current - factor * basis
for current, basis in zip(matrix[row], matrix[pivot_row])
]
pivot_columns.append(col)
pivot_row += 1
if pivot_row == row_count:
break
for row in matrix:
if all(abs(value) <= 1e-10 for value in row[:n_vars]) and abs(row[n_vars]) > 1e-10:
raise KinematicSolveError("over-constrained inconsistent kinematic system")
if len(pivot_columns) < n_vars:
raise KinematicSolveError("singular or under-constrained kinematic system")
values = [0.0] * n_vars
for row_index, col in enumerate(pivot_columns):
values[col] = matrix[row_index][n_vars]
if not all(isfinite(value) for value in values):
raise KinematicSolveError("non-finite speed solution")
residuals = []
for equation in equations:
row = self._row(equation.coefficients)
residuals.append(sum(a * b for a, b in zip(row, values)) - equation.rhs)
return {
name: values[index]
for index, name in enumerate(self.variables)
}, residuals
def solve_topology(
*,
run_id: str,
parameters: ReducerParameters,
boundary: BoundaryCondition,
topology: ConcreteTopology,
) -> KinematicSolution:
variables = _node_variables(topology)
equations = build_equations(parameters=parameters, boundary=boundary, topology=topology)
speeds, residuals = LinearSystemSolver(variables).solve(equations)
updated = [
equation.model_copy(update={"residual": residual})
for equation, residual in zip(equations, residuals)
]
input_speed = speeds[boundary.input]
output_speed = speeds[boundary.output]
if abs(output_speed) < 1e-12:
raise KinematicSolveError("output speed is zero")
ratio = input_speed / output_speed
return KinematicSolution(
run_id=run_id,
variables=variables,
equations=updated,
speeds=speeds,
ratio=ratio,
speed_mode=_speed_mode(ratio),
direction=_direction(output_speed, input_speed),
residual_max_abs=max(abs(value) for value in residuals),
solved_at=utc_now_iso(),
)
def solve_simple_2kh(
*,
run_id: str,
parameters: ReducerParameters,
boundary: BoundaryCondition,
) -> KinematicSolution:
topology = expand_template(
load_default_template("simple_2k_h"),
planet_count=parameters.planet_count,
boundary=boundary,
)
return solve_topology(
run_id=run_id,
parameters=parameters,
boundary=boundary,
topology=topology,
)
def solve_instance(instance: ReducerInstance) -> KinematicSolution:
return solve_topology(
run_id=instance.run_id,
parameters=instance.parameters,
boundary=instance.boundary,
topology=resolve_concrete_topology(instance),
)
def motion_samples(instance: ReducerInstance, solution: KinematicSolution) -> MotionSamples:
input_name = instance.boundary.input
input_speed = solution.speeds[input_name]
angles = [0.0, abs(solution.ratio)]
samples: list[MotionSample] = []
for input_angle in angles:
member_angles = {
name: input_angle * speed / input_speed
for name, speed in solution.speeds.items()
}
samples.append(MotionSample(input_angle_deg=input_angle, member_angles_deg=member_angles))
return MotionSamples(
run_id=instance.run_id,
samples=samples,
formula="theta_member = theta_input * omega_member / omega_input",
generated_at=utc_now_iso(),
)
+591
View File
@@ -0,0 +1,591 @@
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]
@@ -0,0 +1,240 @@
from __future__ import annotations
from dataclasses import dataclass, replace
from typing import Protocol
from ..models import MIN_GEAR_TOOTH_COUNT_EXCLUSIVE, ReducerRequirement, TopologyTemplate
@dataclass(frozen=True)
class CandidateContext:
requirement: ReducerRequirement
template: TopologyTemplate
z_sun: int
z_planet: int
z_ring: int
planet_count: int
module_mm: float
geometry: dict[str, float]
solution_ratio: float | None = None
def with_solution_ratio(self, ratio: float) -> "CandidateContext":
return replace(self, solution_ratio=ratio)
@dataclass(frozen=True)
class ConstraintResult:
code: str
passed: bool
details: dict[str, float | int | str | None]
class ConstraintRule(Protocol):
code: str
def applies_to(self, requirement: ReducerRequirement, template: TopologyTemplate) -> bool:
...
def evaluate(self, context: CandidateContext) -> ConstraintResult:
...
class BaseRule:
code: str
def applies_to(self, requirement: ReducerRequirement, template: TopologyTemplate) -> bool:
return True
def result(
self,
passed: bool,
**details: float | int | str | None,
) -> ConstraintResult:
return ConstraintResult(code=self.code, passed=passed, details=details)
class PositiveModuleRule(BaseRule):
code = "module_not_positive"
def evaluate(self, context: CandidateContext) -> ConstraintResult:
return self.result(context.module_mm > 0.0, actual=context.module_mm, expected="> 0")
class PositivePlanetCountRule(BaseRule):
code = "planet_count_not_positive"
def evaluate(self, context: CandidateContext) -> ConstraintResult:
return self.result(context.planet_count > 0, actual=context.planet_count, expected="> 0")
class CommonGearCompatibilityRule(BaseRule):
code = "common_gear_compatibility"
def evaluate(self, context: CandidateContext) -> ConstraintResult:
if context.requirement.tooth_form == "herringbone":
return self.result(False, tooth_form=context.requirement.tooth_form)
if context.requirement.tooth_form == "helical":
angle = context.requirement.helix_angle_deg or 15.0
return self.result(
5.0 <= angle <= 35.0,
tooth_form=context.requirement.tooth_form,
helix_angle_deg=angle,
)
return self.result(
context.requirement.tooth_form == "spur",
tooth_form=context.requirement.tooth_form,
)
class MinimumGearToothCountRule(BaseRule):
code = "minimum_gear_tooth_count"
def evaluate(self, context: CandidateContext) -> ConstraintResult:
tooth_counts = {
"sun": context.z_sun,
"planet": context.z_planet,
"ring": context.z_ring,
}
bad = {
role: value
for role, value in tooth_counts.items()
if value <= MIN_GEAR_TOOTH_COUNT_EXCLUSIVE
}
return self.result(
not bad,
minimum_exclusive=MIN_GEAR_TOOTH_COUNT_EXCLUSIVE,
**tooth_counts,
)
class MaxOuterDiameterRule(BaseRule):
code = "max_outer_diameter"
def evaluate(self, context: CandidateContext) -> ConstraintResult:
actual = context.geometry["ring_outer_diameter_mm"]
expected = context.requirement.constraints.max_outer_diameter_mm
return self.result(actual <= expected, actual=actual, expected=expected)
class MinNeighborClearanceRule(BaseRule):
code = "planet_neighbor_clearance"
def evaluate(self, context: CandidateContext) -> ConstraintResult:
actual = context.geometry["planet_neighbor_clearance_mm"]
expected = context.requirement.constraints.min_planet_neighbor_clearance_mm
return self.result(actual >= expected, actual=actual, expected=expected)
class KinematicRatioToleranceRule(BaseRule):
code = "ratio_tolerance"
def evaluate(self, context: CandidateContext) -> ConstraintResult:
if context.solution_ratio is None:
return self.result(False, actual=None, expected=context.requirement.target_ratio)
error = abs(context.solution_ratio - context.requirement.target_ratio)
return self.result(
error <= context.requirement.ratio_tolerance,
actual=context.solution_ratio,
expected=context.requirement.target_ratio,
error=error,
tolerance=context.requirement.ratio_tolerance,
)
class Simple2khToothClosureRule(BaseRule):
code = "simple_2kh_tooth_closure"
def applies_to(self, requirement: ReducerRequirement, template: TopologyTemplate) -> bool:
return template.family == "simple_2k_h"
def evaluate(self, context: CandidateContext) -> ConstraintResult:
expected = context.z_sun + 2 * context.z_planet
return self.result(context.z_ring == expected, actual=context.z_ring, expected=expected)
class EqualSpacingRule(BaseRule):
code = "equal_spacing_assembly_condition"
def applies_to(self, requirement: ReducerRequirement, template: TopologyTemplate) -> bool:
return template.family == "simple_2k_h"
def evaluate(self, context: CandidateContext) -> ConstraintResult:
if context.planet_count <= 0:
return self.result(False, actual=context.planet_count, expected="> 0")
value = (context.z_sun + context.z_ring) % context.planet_count
return self.result(value == 0, actual=value, expected=0)
class CenterDistanceConsistencyRule(BaseRule):
code = "center_distance_consistency"
def applies_to(self, requirement: ReducerRequirement, template: TopologyTemplate) -> bool:
return template.family == "simple_2k_h"
def evaluate(self, context: CandidateContext) -> ConstraintResult:
center_a = context.module_mm * (context.z_sun + context.z_planet) / 2.0
center_b = context.module_mm * (context.z_ring - context.z_planet) / 2.0
return self.result(
abs(center_a - center_b) <= 1e-9
and abs(center_a - context.geometry["center_distance_mm"]) <= 1e-9,
sun_planet=center_a,
ring_planet=center_b,
derived=context.geometry["center_distance_mm"],
)
class ConstraintPipeline:
def __init__(self, *, common_rules: list[ConstraintRule], family_rules: list[ConstraintRule]) -> None:
self.common_rules = common_rules
self.family_rules = family_rules
def rules_for(
self,
requirement: ReducerRequirement,
template: TopologyTemplate,
*,
include_ratio: bool,
) -> list[ConstraintRule]:
rules = [
rule
for rule in [*self.common_rules, *self.family_rules]
if rule.applies_to(requirement, template)
]
if not include_ratio:
rules = [rule for rule in rules if rule.code != KinematicRatioToleranceRule.code]
return rules
def first_failure(
self,
context: CandidateContext,
*,
include_ratio: bool,
) -> ConstraintResult | None:
for rule in self.rules_for(
context.requirement,
context.template,
include_ratio=include_ratio,
):
result = rule.evaluate(context)
if not result.passed:
return result
return None
def default_constraint_pipeline() -> ConstraintPipeline:
return ConstraintPipeline(
common_rules=[
PositiveModuleRule(),
PositivePlanetCountRule(),
CommonGearCompatibilityRule(),
MinimumGearToothCountRule(),
MaxOuterDiameterRule(),
MinNeighborClearanceRule(),
KinematicRatioToleranceRule(),
],
family_rules=[
Simple2khToothClosureRule(),
EqualSpacingRule(),
CenterDistanceConsistencyRule(),
],
)
+887
View File
@@ -0,0 +1,887 @@
from __future__ import annotations
import math
import uuid
from pathlib import Path
from .kinematics import KinematicSolveError, solve_topology
from .industrial import compute_industrial_parameters, build_industrial_assembly_topology
from .models import (
BoundaryCondition,
CascadeSearchCandidate,
MIN_GEAR_TOOTH_COUNT_EXCLUSIVE,
ParameterSearchReport,
ReducerDerived,
ReducerInstance,
ReducerParameters,
ReducerRequirement,
SearchCandidate,
StageDerived,
StageParameters,
StageRequirement,
TopologyTemplate,
utc_now_iso,
sha256_json,
)
from .parameter_constraints import CandidateContext, default_constraint_pipeline
from .configurations.ferguson_wolfrom.family import (
base_geometry as ferguson_wolfrom_geometry,
validate_equal_spacing as validate_ferguson_wolfrom_equal_spacing,
validate_tooth_closure as validate_ferguson_wolfrom_tooth_closure,
)
from .configurations.simple_2k_h_cascade.family import (
build_cascade_topology,
cascade_boundary,
stage_boundary,
stage_derived,
stage_geometry,
)
from .topology_runtime import expand_template, load_default_template
class ParameterSolveError(ValueError):
pass
def _geometry(
*,
z_sun: int,
z_planet: int,
z_ring: int,
planet_count: int,
module_mm: float,
addendum_coeff: float,
clearance_coeff: float,
ring_rim_thickness_mm: float,
) -> dict[str, float]:
sun_pitch_radius = module_mm * z_sun / 2.0
planet_pitch_radius = module_mm * z_planet / 2.0
ring_pitch_radius = module_mm * z_ring / 2.0
center_distance = module_mm * (z_sun + z_planet) / 2.0
planet_outer_diameter = module_mm * (z_planet + 2.0 * addendum_coeff)
neighbor_distance = 2.0 * center_distance * math.sin(math.pi / planet_count)
ring_outer_diameter = (
module_mm * z_ring
+ 2.0 * module_mm * (addendum_coeff + clearance_coeff)
+ 2.0 * ring_rim_thickness_mm
)
return {
"sun_pitch_radius_mm": sun_pitch_radius,
"planet_pitch_radius_mm": planet_pitch_radius,
"ring_pitch_radius_mm": ring_pitch_radius,
"sun_pitch_diameter_mm": sun_pitch_radius * 2.0,
"planet_pitch_diameter_mm": planet_pitch_radius * 2.0,
"ring_pitch_diameter_mm": ring_pitch_radius * 2.0,
"center_distance_mm": center_distance,
"planet_orbit_radius_mm": center_distance,
"ring_outer_diameter_mm": ring_outer_diameter,
"planet_outer_diameter_mm": planet_outer_diameter,
"planet_neighbor_distance_mm": neighbor_distance,
"planet_neighbor_clearance_mm": neighbor_distance - planet_outer_diameter,
}
def _score(
*,
ratio_error: float,
ring_outer_diameter_mm: float,
clearance_mm: float,
z_sun: int,
z_planet: int,
) -> float:
min_allowed = MIN_GEAR_TOOTH_COUNT_EXCLUSIVE + 1
low_tooth_penalty = max(0, min_allowed - z_sun) * 2.0 + max(0, min_allowed - z_planet) * 0.5
clearance_bonus = min(clearance_mm, 10.0) * 0.05
return ratio_error * 1_000_000.0 + ring_outer_diameter_mm + low_tooth_penalty - clearance_bonus
def _resolved_tooth_form(requirement: ReducerRequirement) -> str:
if requirement.tooth_form == "herringbone":
raise ParameterSolveError("unsupported_tooth_form: herringbone")
return requirement.tooth_form
def _resolved_helix_angle(requirement: ReducerRequirement) -> float:
if requirement.tooth_form == "spur":
return 0.0
return float(requirement.helix_angle_deg if requirement.helix_angle_deg is not None else 15.0)
def _speed_mode_from_ratio(ratio: float) -> str:
magnitude = abs(ratio)
if abs(magnitude - 1.0) <= 1e-12:
return "direct"
return "speed_reducer" if magnitude > 1.0 else "speed_increaser"
def _effective_stage_requirement(
requirement: ReducerRequirement,
stage: StageRequirement,
) -> ReducerRequirement:
constraints = stage.constraints or requirement.constraints
if constraints is None:
raise ParameterSolveError(f"stage {stage.stage_id} is missing constraints")
planet_counts = stage.planet_count_candidates or requirement.planet_count_candidates
modules = stage.module_candidates_mm or requirement.module_candidates_mm
if not planet_counts:
raise ParameterSolveError(f"stage {stage.stage_id} is missing planet_count_candidates")
if not modules:
raise ParameterSolveError(f"stage {stage.stage_id} is missing module_candidates_mm")
return ReducerRequirement(
target_ratio=requirement.target_ratio,
ratio_tolerance=requirement.ratio_tolerance,
topology_family="simple_2k_h",
input=stage.input,
fixed=stage.fixed,
output=stage.output,
tooth_form=stage.tooth_form or requirement.tooth_form,
helix_angle_deg=stage.helix_angle_deg if stage.helix_angle_deg is not None else requirement.helix_angle_deg,
helix_hand=stage.helix_hand or requirement.helix_hand,
planet_count_candidates=planet_counts,
module_candidates_mm=modules,
pressure_angle_deg=stage.pressure_angle_deg or requirement.pressure_angle_deg,
face_width_mm=stage.face_width_mm or requirement.face_width_mm,
backlash_mm=requirement.backlash_mm if stage.backlash_mm is None else stage.backlash_mm,
addendum_coeff=requirement.addendum_coeff if stage.addendum_coeff is None else stage.addendum_coeff,
clearance_coeff=requirement.clearance_coeff if stage.clearance_coeff is None else stage.clearance_coeff,
ring_rim_thickness_mm=(
requirement.ring_rim_thickness_mm
if stage.ring_rim_thickness_mm is None
else stage.ring_rim_thickness_mm
),
constraints=constraints,
)
def _stage_parameters_from_candidate(
*,
stage_id: str,
candidate: SearchCandidate,
requirement: ReducerRequirement,
) -> StageParameters:
tooth_form = _resolved_tooth_form(requirement)
helix_angle_deg = _resolved_helix_angle(requirement)
return StageParameters(
stage_id=stage_id,
z_sun=candidate.z_sun,
z_planet=candidate.z_planet,
z_ring=candidate.z_ring,
planet_count=candidate.planet_count,
module_mm=candidate.module_mm,
pressure_angle_deg=requirement.pressure_angle_deg,
face_width_mm=requirement.face_width_mm,
backlash_mm=requirement.backlash_mm,
addendum_coeff=requirement.addendum_coeff,
clearance_coeff=requirement.clearance_coeff,
ring_rim_thickness_mm=requirement.ring_rim_thickness_mm,
tooth_form=tooth_form,
helix_angle_deg=helix_angle_deg,
helix_hand=requirement.helix_hand,
)
def _enumerate_stage_candidates(
*,
run_id: str,
stage: StageRequirement,
requirement: ReducerRequirement,
template: TopologyTemplate,
) -> tuple[list[tuple[SearchCandidate, StageParameters, StageDerived]], dict[str, int], int]:
effective = _effective_stage_requirement(requirement, stage)
tooth_form = _resolved_tooth_form(effective)
helix_angle_deg = _resolved_helix_angle(effective)
pipeline = default_constraint_pipeline()
failure_counts: dict[str, int] = {}
accepted: list[tuple[SearchCandidate, StageParameters, StageDerived]] = []
evaluated_count = 0
def fail(code: str) -> None:
failure_counts[code] = failure_counts.get(code, 0) + 1
constraints = effective.constraints
if constraints is None:
raise ParameterSolveError(f"stage {stage.stage_id} is missing constraints")
for module_mm in sorted(set(effective.module_candidates_mm)):
if module_mm <= 0:
fail("module_not_positive")
continue
for planet_count in sorted(set(effective.planet_count_candidates)):
if planet_count <= 0:
fail("planet_count_not_positive")
continue
for z_sun in range(constraints.min_sun_teeth, constraints.max_sun_teeth + 1):
for z_planet in range(constraints.min_planet_teeth, constraints.max_planet_teeth + 1):
evaluated_count += 1
z_ring = z_sun + 2 * z_planet
geometry = _geometry(
z_sun=z_sun,
z_planet=z_planet,
z_ring=z_ring,
planet_count=planet_count,
module_mm=module_mm,
addendum_coeff=effective.addendum_coeff,
clearance_coeff=effective.clearance_coeff,
ring_rim_thickness_mm=effective.ring_rim_thickness_mm,
)
context = CandidateContext(
requirement=effective,
template=template,
z_sun=z_sun,
z_planet=z_planet,
z_ring=z_ring,
planet_count=planet_count,
module_mm=module_mm,
geometry=geometry,
)
pre_failure = pipeline.first_failure(context, include_ratio=False)
if pre_failure is not None:
fail(pre_failure.code)
continue
parameters = StageParameters(
stage_id=stage.stage_id,
z_sun=z_sun,
z_planet=z_planet,
z_ring=z_ring,
planet_count=planet_count,
module_mm=module_mm,
pressure_angle_deg=effective.pressure_angle_deg,
face_width_mm=effective.face_width_mm,
backlash_mm=effective.backlash_mm,
addendum_coeff=effective.addendum_coeff,
clearance_coeff=effective.clearance_coeff,
ring_rim_thickness_mm=effective.ring_rim_thickness_mm,
tooth_form=tooth_form,
helix_angle_deg=helix_angle_deg,
helix_hand=effective.helix_hand,
)
reducer_parameters = ReducerParameters(
z_sun=z_sun,
z_planet=z_planet,
z_ring=z_ring,
planet_count=planet_count,
module_mm=module_mm,
pressure_angle_deg=effective.pressure_angle_deg,
face_width_mm=effective.face_width_mm,
backlash_mm=effective.backlash_mm,
addendum_coeff=effective.addendum_coeff,
clearance_coeff=effective.clearance_coeff,
ring_rim_thickness_mm=effective.ring_rim_thickness_mm,
tooth_form=tooth_form,
helix_angle_deg=helix_angle_deg,
helix_hand=effective.helix_hand,
)
try:
solution = solve_topology(
run_id=run_id,
parameters=reducer_parameters,
boundary=BoundaryCondition(
input=stage.input,
fixed=stage.fixed,
output=stage.output,
),
topology=expand_template(
template,
planet_count=planet_count,
boundary=BoundaryCondition(
input=stage.input,
fixed=stage.fixed,
output=stage.output,
),
),
)
except KinematicSolveError:
fail("kinematic_solve_failed")
continue
candidate = SearchCandidate(
stage_id=stage.stage_id,
z_sun=z_sun,
z_planet=z_planet,
z_ring=z_ring,
planet_count=planet_count,
module_mm=module_mm,
ratio=abs(solution.ratio),
ratio_error=0.0,
ring_outer_diameter_mm=geometry["ring_outer_diameter_mm"],
planet_neighbor_clearance_mm=geometry["planet_neighbor_clearance_mm"],
score=_score(
ratio_error=0.0,
ring_outer_diameter_mm=geometry["ring_outer_diameter_mm"],
clearance_mm=geometry["planet_neighbor_clearance_mm"],
z_sun=z_sun,
z_planet=z_planet,
),
)
accepted.append(
(
candidate,
parameters,
stage_derived(
parameters=parameters,
ratio=solution.ratio,
direction=solution.direction,
speed_mode=solution.speed_mode,
z_offset_mm=0.0,
),
)
)
accepted.sort(key=lambda item: item[0].score)
return accepted, failure_counts, evaluated_count
def _solve_cascade_parameters(
*,
requirement: ReducerRequirement,
template: TopologyTemplate,
) -> tuple[ReducerInstance, ParameterSearchReport]:
if not requirement.stages or len(requirement.stages) != 2:
raise ParameterSolveError("simple_2k_h_cascade requires exactly two stages")
if requirement.tooth_form == "herringbone":
raise ParameterSolveError("unsupported_tooth_form: herringbone")
run_id = f"run_{uuid.uuid4().hex[:12]}"
requirement_hash = sha256_json(requirement.model_dump(mode="json"))
template_hash = sha256_json(template.model_dump(mode="json"))
stage_template = load_default_template("simple_2k_h")
stage_results: dict[str, list[tuple[SearchCandidate, StageParameters, StageDerived]]] = {}
failure_counts: dict[str, int] = {}
evaluated_count = 0
stage_candidate_counts: dict[str, int] = {}
for stage in requirement.stages:
results, stage_failures, stage_evaluated = _enumerate_stage_candidates(
run_id=run_id,
stage=stage,
requirement=requirement,
template=stage_template,
)
if not results:
raise ParameterSolveError(
f"stage {stage.stage_id} has no parameter candidates; failure_counts={stage_failures!r}"
)
stage_results[stage.stage_id] = results
stage_candidate_counts[stage.stage_id] = len(results)
evaluated_count += stage_evaluated
for code, count in stage_failures.items():
failure_counts[f"{stage.stage_id}.{code}"] = count
pair_evaluated_count = 0
pair_failure_counts: dict[str, int] = {}
accepted_pairs: list[tuple[CascadeSearchCandidate, list[StageParameters], list[StageDerived], object]] = []
stage_gap = requirement.cascade.stage_gap_mm if requirement.cascade else 2.0
def pair_fail(code: str) -> None:
pair_failure_counts[code] = pair_failure_counts.get(code, 0) + 1
s1_results = stage_results[requirement.stages[0].stage_id]
s2_results = stage_results[requirement.stages[1].stage_id]
for s1_candidate, s1_parameters, s1_derived in s1_results:
for s2_candidate, s2_parameters, s2_derived in s2_results:
pair_evaluated_count += 1
stage_parameters = [s1_parameters, s2_parameters]
s1_offset = 0.0
s2_offset = s1_parameters.face_width_mm + stage_gap
stage_deriveds = [
s1_derived.model_copy(update={"z_offset_mm": s1_offset}),
s2_derived.model_copy(update={"z_offset_mm": s2_offset}),
]
parameters = ReducerParameters(
z_sun=s1_parameters.z_sun,
z_planet=s1_parameters.z_planet,
z_ring=s1_parameters.z_ring,
planet_count=s1_parameters.planet_count,
module_mm=s1_parameters.module_mm,
pressure_angle_deg=s1_parameters.pressure_angle_deg,
face_width_mm=s1_parameters.face_width_mm,
backlash_mm=s1_parameters.backlash_mm,
addendum_coeff=s1_parameters.addendum_coeff,
clearance_coeff=s1_parameters.clearance_coeff,
ring_rim_thickness_mm=s1_parameters.ring_rim_thickness_mm,
tooth_form=s1_parameters.tooth_form,
helix_angle_deg=s1_parameters.helix_angle_deg,
helix_hand=s1_parameters.helix_hand,
stages=stage_parameters,
)
topology = build_cascade_topology(requirement, parameters)
boundary = cascade_boundary(requirement)
try:
solution = solve_topology(
run_id=run_id,
parameters=parameters,
boundary=boundary,
topology=topology,
)
except KinematicSolveError:
pair_fail("kinematic_solve_failed")
continue
ratio = abs(solution.ratio)
ratio_error = abs(ratio - requirement.target_ratio)
if ratio_error > requirement.ratio_tolerance:
pair_fail("ratio_tolerance")
continue
score = (
ratio_error * 1_000_000.0
+ sum(item.ring_outer_diameter_mm for item in stage_deriveds)
- min(item.planet_neighbor_clearance_mm for item in stage_deriveds) * 0.05
)
accepted_pairs.append(
(
CascadeSearchCandidate(
ratio=ratio,
ratio_error=ratio_error,
score=score,
stage_candidates=[s1_candidate, s2_candidate],
),
stage_parameters,
stage_deriveds,
solution,
)
)
if not accepted_pairs:
raise ParameterSolveError(
"no cascade parameter pair satisfies the requirement; "
f"pair_failure_counts={pair_failure_counts!r}"
)
accepted_pairs.sort(key=lambda item: item[0].score)
selected_cascade, selected_stage_parameters, selected_stage_deriveds, selected_solution = accepted_pairs[0]
selected_s1 = selected_stage_parameters[0]
axial_stack = {
"stage_offsets_mm": {
selected_stage_deriveds[0].stage_id: selected_stage_deriveds[0].z_offset_mm,
selected_stage_deriveds[1].stage_id: selected_stage_deriveds[1].z_offset_mm,
},
"stage_gap_mm": stage_gap,
"total_length_mm": selected_stage_deriveds[1].z_offset_mm + selected_stage_parameters[1].face_width_mm,
}
geometry = stage_geometry(selected_s1)
parameters = ReducerParameters(
z_sun=selected_s1.z_sun,
z_planet=selected_s1.z_planet,
z_ring=selected_s1.z_ring,
planet_count=selected_s1.planet_count,
module_mm=selected_s1.module_mm,
pressure_angle_deg=selected_s1.pressure_angle_deg,
face_width_mm=selected_s1.face_width_mm,
backlash_mm=selected_s1.backlash_mm,
addendum_coeff=selected_s1.addendum_coeff,
clearance_coeff=selected_s1.clearance_coeff,
ring_rim_thickness_mm=selected_s1.ring_rim_thickness_mm,
tooth_form=selected_s1.tooth_form,
helix_angle_deg=selected_s1.helix_angle_deg,
helix_hand=selected_s1.helix_hand,
stages=selected_stage_parameters,
)
topology = build_cascade_topology(requirement, parameters)
derived = ReducerDerived(
ratio=abs(selected_solution.ratio),
speed_mode=selected_solution.speed_mode,
direction=selected_solution.direction,
stages=selected_stage_deriveds,
axial_stack=axial_stack,
**geometry,
)
instance = ReducerInstance(
run_id=run_id,
created_at=utc_now_iso(),
requirement_hash=requirement_hash,
template_hash=template_hash,
topology_family=requirement.topology_family,
topology_template=template,
topology=topology,
requirement=requirement,
boundary=cascade_boundary(requirement),
parameters=parameters,
derived=derived,
selected_candidate=selected_cascade.stage_candidates[0],
)
report = ParameterSearchReport(
run_id=run_id,
requirement_hash=requirement_hash,
template_hash=template_hash,
evaluated_count=evaluated_count + pair_evaluated_count,
accepted_count=len(accepted_pairs),
failure_counts=failure_counts,
selected_candidate=selected_cascade.stage_candidates[0],
accepted_examples=[item[0].stage_candidates[0] for item in accepted_pairs[:20]],
stage_candidate_counts=stage_candidate_counts,
pair_evaluated_count=pair_evaluated_count,
pair_failure_counts=pair_failure_counts,
selected_stage_candidates=selected_cascade.stage_candidates,
selected_total_solution={
"ratio": selected_solution.ratio,
"speed_mode": selected_solution.speed_mode,
"direction": selected_solution.direction,
"residual_max_abs": selected_solution.residual_max_abs,
},
selected_cascade_candidate=selected_cascade,
generated_at=utc_now_iso(),
)
return instance, report
def _solve_ferguson_wolfrom_parameters(
*,
requirement: ReducerRequirement,
template: TopologyTemplate,
) -> tuple[ReducerInstance, ParameterSearchReport]:
if requirement.tooth_form == "herringbone":
raise ParameterSolveError("unsupported_tooth_form: herringbone")
if not requirement.module_candidates_mm:
raise ParameterSolveError("ferguson_wolfrom requires module_candidates_mm")
tooth_closure_errors = validate_ferguson_wolfrom_tooth_closure(requirement)
spacing_errors = validate_ferguson_wolfrom_equal_spacing(requirement)
if tooth_closure_errors or spacing_errors:
raise ParameterSolveError(
"ferguson_wolfrom requirement is invalid; "
f"tooth_closure_errors={tooth_closure_errors!r}; spacing_errors={spacing_errors!r}"
)
run_id = f"run_{uuid.uuid4().hex[:12]}"
requirement_hash = sha256_json(requirement.model_dump(mode="json"))
template_hash = sha256_json(template.model_dump(mode="json"))
tooth_form = _resolved_tooth_form(requirement)
helix_angle_deg = _resolved_helix_angle(requirement)
planet_count = sorted(set(requirement.planet_count_candidates))[0]
module_mm = sorted(set(requirement.module_candidates_mm))[0]
boundary = BoundaryCondition(
input=requirement.input,
fixed=requirement.fixed,
output=requirement.output,
)
teeth = dict(requirement.gear_tooth_counts)
parameters = ReducerParameters(
z_sun=teeth["sun1"],
z_planet=teeth["planet1"],
z_ring=teeth["ring1"],
planet_count=planet_count,
module_mm=module_mm,
pressure_angle_deg=requirement.pressure_angle_deg,
face_width_mm=requirement.face_width_mm,
backlash_mm=requirement.backlash_mm,
addendum_coeff=requirement.addendum_coeff,
clearance_coeff=requirement.clearance_coeff,
ring_rim_thickness_mm=requirement.ring_rim_thickness_mm,
tooth_form=tooth_form,
helix_angle_deg=helix_angle_deg,
helix_hand=requirement.helix_hand,
gear_tooth_counts=teeth,
)
topology = expand_template(template, planet_count=planet_count, boundary=boundary)
try:
solution = solve_topology(
run_id=run_id,
parameters=parameters,
boundary=boundary,
topology=topology,
)
except KinematicSolveError as exc:
raise ParameterSolveError(f"ferguson_wolfrom kinematic solve failed: {exc}") from exc
ratio = abs(solution.ratio)
ratio_error = abs(ratio - requirement.target_ratio)
if ratio_error > requirement.ratio_tolerance:
raise ParameterSolveError(
"ferguson_wolfrom ratio_tolerance failed; "
f"actual={ratio}, expected={requirement.target_ratio}, tolerance={requirement.ratio_tolerance}"
)
geometry = ferguson_wolfrom_geometry(
parameters,
addendum_coeff=requirement.addendum_coeff,
clearance_coeff=requirement.clearance_coeff,
ring_rim_thickness_mm=requirement.ring_rim_thickness_mm,
)
selected = SearchCandidate(
z_sun=teeth["sun1"],
z_planet=teeth["planet1"],
z_ring=teeth["ring1"],
planet_count=planet_count,
module_mm=module_mm,
ratio=ratio,
ratio_error=ratio_error,
ring_outer_diameter_mm=geometry["ring_outer_diameter_mm"],
planet_neighbor_clearance_mm=geometry["planet_neighbor_clearance_mm"],
score=_score(
ratio_error=ratio_error,
ring_outer_diameter_mm=geometry["ring_outer_diameter_mm"],
clearance_mm=geometry["planet_neighbor_clearance_mm"],
z_sun=teeth["sun1"],
z_planet=teeth["planet1"],
),
)
derived = ReducerDerived(
ratio=ratio,
speed_mode=solution.speed_mode,
direction=solution.direction,
**geometry,
)
instance = ReducerInstance(
run_id=run_id,
created_at=utc_now_iso(),
requirement_hash=requirement_hash,
template_hash=template_hash,
topology_family=requirement.topology_family,
detail_level=requirement.detail_level,
topology_template=template,
topology=topology,
requirement=requirement,
boundary=boundary,
parameters=parameters,
derived=derived,
selected_candidate=selected,
)
report = ParameterSearchReport(
run_id=run_id,
requirement_hash=requirement_hash,
template_hash=template_hash,
evaluated_count=1,
accepted_count=1,
failure_counts={},
selected_candidate=selected,
accepted_examples=[selected],
selected_total_solution={
"ratio": solution.ratio,
"speed_mode": solution.speed_mode,
"direction": solution.direction,
"residual_max_abs": solution.residual_max_abs,
},
generated_at=utc_now_iso(),
)
return instance, report
def solve_parameters(
*,
requirement: ReducerRequirement,
template: TopologyTemplate,
) -> tuple[ReducerInstance, ParameterSearchReport]:
if requirement.detail_level == "industrial_core" and requirement.topology_family != "simple_2k_h":
raise ParameterSolveError("industrial_core currently supports only single-stage simple_2k_h")
if (
requirement.detail_level == "industrial_core"
and (requirement.input, requirement.fixed, requirement.output) != ("sun", "ring", "carrier")
):
raise ParameterSolveError(
"industrial_core currently supports only sun input, ring fixed, carrier output"
)
if requirement.topology_family == "simple_2k_h_cascade":
return _solve_cascade_parameters(requirement=requirement, template=template)
if requirement.topology_family == "ferguson_wolfrom":
return _solve_ferguson_wolfrom_parameters(requirement=requirement, template=template)
tooth_form = _resolved_tooth_form(requirement)
helix_angle_deg = _resolved_helix_angle(requirement)
run_id = f"run_{uuid.uuid4().hex[:12]}"
requirement_hash = sha256_json(requirement.model_dump(mode="json"))
template_hash = sha256_json(template.model_dump(mode="json"))
boundary = BoundaryCondition(
input=requirement.input,
fixed=requirement.fixed,
output=requirement.output,
)
evaluated_count = 0
failure_counts: dict[str, int] = {}
accepted: list[SearchCandidate] = []
pipeline = default_constraint_pipeline()
topology_cache = {
count: expand_template(template, planet_count=count, boundary=boundary)
for count in sorted(set(requirement.planet_count_candidates))
if count > 0
}
def fail(code: str) -> None:
failure_counts[code] = failure_counts.get(code, 0) + 1
constraints = requirement.constraints
for module_mm in sorted(set(requirement.module_candidates_mm)):
if module_mm <= 0:
fail("module_not_positive")
continue
for planet_count in sorted(set(requirement.planet_count_candidates)):
if planet_count <= 0:
fail("planet_count_not_positive")
continue
for z_sun in range(constraints.min_sun_teeth, constraints.max_sun_teeth + 1):
for z_planet in range(
constraints.min_planet_teeth,
constraints.max_planet_teeth + 1,
):
evaluated_count += 1
z_ring = z_sun + 2 * z_planet
geometry = _geometry(
z_sun=z_sun,
z_planet=z_planet,
z_ring=z_ring,
planet_count=planet_count,
module_mm=module_mm,
addendum_coeff=requirement.addendum_coeff,
clearance_coeff=requirement.clearance_coeff,
ring_rim_thickness_mm=requirement.ring_rim_thickness_mm,
)
context = CandidateContext(
requirement=requirement,
template=template,
z_sun=z_sun,
z_planet=z_planet,
z_ring=z_ring,
planet_count=planet_count,
module_mm=module_mm,
geometry=geometry,
)
pre_failure = pipeline.first_failure(context, include_ratio=False)
if pre_failure is not None:
fail(pre_failure.code)
continue
parameters = ReducerParameters(
z_sun=z_sun,
z_planet=z_planet,
z_ring=z_ring,
planet_count=planet_count,
module_mm=module_mm,
pressure_angle_deg=requirement.pressure_angle_deg,
face_width_mm=requirement.face_width_mm,
backlash_mm=requirement.backlash_mm,
addendum_coeff=requirement.addendum_coeff,
clearance_coeff=requirement.clearance_coeff,
ring_rim_thickness_mm=requirement.ring_rim_thickness_mm,
tooth_form=tooth_form,
helix_angle_deg=helix_angle_deg,
helix_hand=requirement.helix_hand,
)
try:
solution = solve_topology(
run_id=run_id,
parameters=parameters,
boundary=boundary,
topology=topology_cache[planet_count],
)
except KinematicSolveError:
fail("kinematic_solve_failed")
continue
context = context.with_solution_ratio(abs(solution.ratio))
ratio_failure = pipeline.first_failure(context, include_ratio=True)
if ratio_failure is not None:
fail(ratio_failure.code)
continue
ratio_error = abs(abs(solution.ratio) - requirement.target_ratio)
accepted.append(
SearchCandidate(
z_sun=z_sun,
z_planet=z_planet,
z_ring=z_ring,
planet_count=planet_count,
module_mm=module_mm,
ratio=abs(solution.ratio),
ratio_error=ratio_error,
ring_outer_diameter_mm=geometry["ring_outer_diameter_mm"],
planet_neighbor_clearance_mm=geometry[
"planet_neighbor_clearance_mm"
],
score=_score(
ratio_error=ratio_error,
ring_outer_diameter_mm=geometry["ring_outer_diameter_mm"],
clearance_mm=geometry["planet_neighbor_clearance_mm"],
z_sun=z_sun,
z_planet=z_planet,
),
)
)
if not accepted:
raise ParameterSolveError(
"no parameter candidate satisfies the requirement; failure_counts="
+ repr(failure_counts)
)
accepted.sort(key=lambda candidate: candidate.score)
selected = accepted[0]
geometry = _geometry(
z_sun=selected.z_sun,
z_planet=selected.z_planet,
z_ring=selected.z_ring,
planet_count=selected.planet_count,
module_mm=selected.module_mm,
addendum_coeff=requirement.addendum_coeff,
clearance_coeff=requirement.clearance_coeff,
ring_rim_thickness_mm=requirement.ring_rim_thickness_mm,
)
parameters = ReducerParameters(
z_sun=selected.z_sun,
z_planet=selected.z_planet,
z_ring=selected.z_ring,
planet_count=selected.planet_count,
module_mm=selected.module_mm,
pressure_angle_deg=requirement.pressure_angle_deg,
face_width_mm=requirement.face_width_mm,
backlash_mm=requirement.backlash_mm,
addendum_coeff=requirement.addendum_coeff,
clearance_coeff=requirement.clearance_coeff,
ring_rim_thickness_mm=requirement.ring_rim_thickness_mm,
tooth_form=tooth_form,
helix_angle_deg=helix_angle_deg,
helix_hand=requirement.helix_hand,
)
selected_topology = expand_template(
template,
planet_count=selected.planet_count,
boundary=boundary,
)
solution = solve_topology(
run_id=run_id,
parameters=parameters,
boundary=boundary,
topology=selected_topology,
)
derived = ReducerDerived(
ratio=abs(solution.ratio),
speed_mode=solution.speed_mode,
direction=solution.direction,
**geometry,
)
instance = ReducerInstance(
run_id=run_id,
created_at=utc_now_iso(),
requirement_hash=requirement_hash,
template_hash=template_hash,
topology_family=requirement.topology_family,
detail_level=requirement.detail_level,
topology_template=template,
topology=selected_topology,
requirement=requirement,
boundary=boundary,
parameters=parameters,
derived=derived,
selected_candidate=selected,
)
if requirement.detail_level == "industrial_core":
industrial_parameters = compute_industrial_parameters(
parameters=parameters,
derived=derived,
)
instance = instance.model_copy(update={"industrial_parameters": industrial_parameters})
instance = instance.model_copy(
update={"assembly_topology": build_industrial_assembly_topology(instance)}
)
report = ParameterSearchReport(
run_id=run_id,
requirement_hash=requirement_hash,
template_hash=template_hash,
evaluated_count=evaluated_count,
accepted_count=len(accepted),
failure_counts=failure_counts,
selected_candidate=selected,
accepted_examples=accepted[:20],
generated_at=utc_now_iso(),
)
return instance, report
def load_requirement(path: Path) -> ReducerRequirement:
from .models import read_json
return ReducerRequirement.model_validate(read_json(path))
@@ -0,0 +1,11 @@
from __future__ import annotations
from .base import PhysicalRelationValidator, PhysicalValidationContext
from .registry import PHYSICAL_VALIDATOR_REGISTRY
__all__ = [
"PHYSICAL_VALIDATOR_REGISTRY",
"PhysicalRelationValidator",
"PhysicalValidationContext",
]
+180
View File
@@ -0,0 +1,180 @@
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)
+222
View File
@@ -0,0 +1,222 @@
from __future__ import annotations
from ..models import PhysicalRelation, ValidationCheck
from .base import (
BasePhysicalRelationValidator,
PhysicalValidationContext,
add_check,
add_common_relation_checks,
planet_bore_diameter_mm,
relation_code,
xy_distance,
)
class BearingRevoluteValidator(BasePhysicalRelationValidator):
relation_type = "bearing_revolute"
def validate(
self,
context: PhysicalValidationContext,
relation: PhysicalRelation,
checks: list[ValidationCheck],
) -> None:
bearing_id = relation.details.get("bearing")
extra = [bearing_id] if isinstance(bearing_id, str) else []
add_common_relation_checks(context, relation, checks, extra_components=extra)
add_check(
checks,
section="assembly",
code=relation_code(relation, "bearing_detail_exists"),
passed=isinstance(bearing_id, str) and context.component_exists(bearing_id),
message="bearing_revolute relation references an existing bearing component",
actual=bearing_id,
expected="existing bearing id in relation.details.bearing",
)
origins = {
"planet": context.placement_origin(relation.source_component_id),
"pin": context.placement_origin(relation.target_component_id),
"bearing": context.placement_origin(bearing_id) if isinstance(bearing_id, str) else None,
}
distances = {}
if origins["planet"] and origins["pin"]:
distances["planet_pin_xy_mm"] = xy_distance(origins["planet"], origins["pin"])
if origins["bearing"] and origins["pin"]:
distances["bearing_pin_xy_mm"] = xy_distance(origins["bearing"], origins["pin"])
add_check(
checks,
section="geometry",
code=relation_code(relation, "placement_consistency"),
passed=bool(distances) and all(value <= 1e-6 for value in distances.values()),
message="planet, pin, and bearing axes are coaxial in placement",
actual=distances,
expected="xy distance <= 1e-6 mm",
)
params = context.instance.industrial_parameters
if params is None:
add_check(
checks,
section="geometry",
code=relation_code(relation, "geometry_clearance_or_fit"),
passed=False,
message="industrial parameters are required for bearing fit validation",
)
return
actual = {
"pin_diameter_mm": params.planet_pin_diameter_mm,
"bearing_inner_diameter_mm": params.planet_bearing_inner_diameter_mm,
"bearing_outer_diameter_mm": params.planet_bearing_outer_diameter_mm,
"planet_bore_diameter_mm": planet_bore_diameter_mm(context),
}
passed = (
params.planet_pin_diameter_mm < params.planet_bearing_inner_diameter_mm
< params.planet_bearing_outer_diameter_mm
< actual["planet_bore_diameter_mm"]
)
add_check(
checks,
section="geometry",
code=relation_code(relation, "geometry_clearance_or_fit"),
passed=passed,
message="planet bearing fits between planet bore and pin without positive-volume overlap",
actual=actual,
expected="pin < bearing ID < bearing OD < planet bore",
)
bearing_component = context.component(bearing_id) if isinstance(bearing_id, str) else None
bearing_details = bearing_component.details if bearing_component else {}
standard_bearing_passed = (
bearing_details.get("bearing_type") == "radial_ball_bearing"
and int(bearing_details.get("ball_count", 0)) >= 6
and float(bearing_details.get("ball_diameter_mm", 0.0)) > 0.0
)
add_check(
checks,
section="geometry",
code=relation_code(relation, "standard_ball_bearing_geometry"),
passed=standard_bearing_passed,
message="planet bearing manifest describes a ball bearing with inner ring, outer ring, and rolling balls",
actual=bearing_details,
expected="bearing_type=radial_ball_bearing, ball_count>=6, ball_diameter_mm>0",
)
planet_has_revolute = any(
constraint.relation_type == "revolute_joint"
and relation.source_component_id in {constraint.component_a, constraint.component_b}
and constraint.constraint_id in context.snapshot.revolute_constraints
for constraint in context.snapshot.manifest.constraints
)
add_check(
checks,
section="assembly",
code=relation_code(relation, "planet_revolute_semantics"),
passed=planet_has_revolute,
message="planet gear has a real revolute CAD constraint for bearing rotation",
actual=relation.source_component_id,
expected="revolute_joint constraint in model",
)
class BearingSupportValidator(BasePhysicalRelationValidator):
relation_type = "bearing_support"
def validate(
self,
context: PhysicalValidationContext,
relation: PhysicalRelation,
checks: list[ValidationCheck],
) -> None:
bearing_ids = relation.details.get("bearings")
bearings = [item for item in bearing_ids if isinstance(item, str)] if isinstance(bearing_ids, list) else []
add_common_relation_checks(context, relation, checks, extra_components=bearings)
params = context.instance.industrial_parameters
support_kind = "output" if relation.source_component_id == "output_shaft" else "input"
expected_count = 2 if support_kind == "output" else 1
add_check(
checks,
section="assembly",
code=relation_code(relation, "support_bearing_count"),
passed=len(bearings) >= expected_count and all(context.component_exists(item) for item in bearings),
message="shaft support relation references the required support bearings",
actual=bearings,
expected=f">= {expected_count} bearing(s)",
)
origins = [context.placement_origin(component_id) for component_id in [relation.source_component_id, *bearings]]
origin0 = origins[0]
distances = {}
if origin0:
for component_id, origin in zip(bearings, origins[1:]):
if origin:
distances[component_id] = xy_distance(origin0, origin)
add_check(
checks,
section="geometry",
code=relation_code(relation, "placement_consistency"),
passed=bool(distances) and all(value <= 1e-6 for value in distances.values()),
message="shaft and support bearings are coaxial",
actual=distances,
expected="xy distance <= 1e-6 mm",
)
if params is None:
add_check(
checks,
section="geometry",
code=relation_code(relation, "geometry_clearance_or_fit"),
passed=False,
message="industrial parameters are required for shaft bearing support validation",
)
return
if support_kind == "output":
actual = {
"shaft_diameter_mm": params.output_shaft_diameter_mm,
"bearing_inner_diameter_mm": params.output_bearing_inner_diameter_mm,
"bearing_outer_diameter_mm": params.output_bearing_outer_diameter_mm,
"housing_inner_diameter_mm": params.housing_inner_diameter_mm,
}
else:
first_bearing = context.component(bearings[0]) if bearings else None
details = first_bearing.details if first_bearing else {}
actual = {
"shaft_diameter_mm": params.input_shaft_diameter_mm,
"bearing_inner_diameter_mm": float(details.get("inner_diameter_mm", 0.0) or 0.0),
"bearing_outer_diameter_mm": float(details.get("outer_diameter_mm", 0.0) or 0.0),
"housing_inner_diameter_mm": params.housing_inner_diameter_mm,
}
passed = (
actual["shaft_diameter_mm"] < actual["bearing_inner_diameter_mm"]
< actual["bearing_outer_diameter_mm"]
< actual["housing_inner_diameter_mm"]
)
add_check(
checks,
section="geometry",
code=relation_code(relation, "geometry_clearance_or_fit"),
passed=passed,
message="shaft support bearing dimensions fit the shaft and housing seat",
actual=actual,
expected="shaft OD < bearing ID < bearing OD < housing ID",
)
bearing_details = {
bearing_id: (context.component(bearing_id).details if context.component(bearing_id) else {})
for bearing_id in bearings
}
standard_bearing_passed = bool(bearing_details) and all(
details.get("bearing_type") == "radial_ball_bearing"
and int(details.get("ball_count", 0)) >= 6
and float(details.get("ball_diameter_mm", 0.0)) > 0.0
for details in bearing_details.values()
)
add_check(
checks,
section="geometry",
code=relation_code(relation, "standard_ball_bearing_geometry"),
passed=standard_bearing_passed,
message="shaft support bearings are represented as ball bearings with rolling elements",
actual=bearing_details,
expected="every support bearing has bearing_type=radial_ball_bearing, ball_count>=6, ball_diameter_mm>0",
)
@@ -0,0 +1,57 @@
from __future__ import annotations
from ..models import PhysicalRelation, ValidationCheck
from .base import (
BasePhysicalRelationValidator,
PhysicalValidationContext,
add_check,
add_common_relation_checks,
relation_code,
xy_distance,
)
class ClearanceValidator(BasePhysicalRelationValidator):
relation_type = "clearance"
def validate(
self,
context: PhysicalValidationContext,
relation: PhysicalRelation,
checks: list[ValidationCheck],
) -> None:
add_common_relation_checks(context, relation, checks)
params = context.instance.industrial_parameters
if params is None:
add_check(
checks,
section="geometry",
code=relation_code(relation, "geometry_clearance_or_fit"),
passed=False,
message="industrial parameters are required for clearance validation",
)
return
add_check(
checks,
section="geometry",
code=relation_code(relation, "geometry_clearance_or_fit"),
passed=params.radial_clearance_mm > 0.0 and params.axial_clearance_mm > 0.0,
message="industrial clearance relation has positive rule-computed radial and axial clearances",
actual={
"radial_clearance_mm": params.radial_clearance_mm,
"axial_clearance_mm": params.axial_clearance_mm,
},
expected="positive clearances",
)
source_origin = context.placement_origin(relation.source_component_id)
target_origin = context.placement_origin(relation.target_component_id)
distance = xy_distance(source_origin, target_origin) if source_origin and target_origin else None
add_check(
checks,
section="geometry",
code=relation_code(relation, "placement_consistency"),
passed=distance is not None,
message="clearance relation components have readable placements",
actual=distance,
expected="component placements available",
)
@@ -0,0 +1,97 @@
from __future__ import annotations
from ..models import PhysicalRelation, ValidationCheck
from .base import (
BasePhysicalRelationValidator,
PhysicalValidationContext,
add_check,
add_common_relation_checks,
relation_code,
xy_distance,
xy_radius,
)
class KeyedCouplingValidator(BasePhysicalRelationValidator):
relation_type = "keyed"
def validate(
self,
context: PhysicalValidationContext,
relation: PhysicalRelation,
checks: list[ValidationCheck],
) -> None:
key_id = relation.details.get("key")
extra = [key_id] if isinstance(key_id, str) else []
add_common_relation_checks(context, relation, checks, extra_components=extra)
add_check(
checks,
section="assembly",
code=relation_code(relation, "key_detail_exists"),
passed=isinstance(key_id, str) and context.component_exists(key_id),
message="keyed coupling relation references an existing key component",
actual=key_id,
expected="existing key id in relation.details.key",
)
shaft_origin = context.placement_origin(relation.source_component_id)
gear_origin = context.placement_origin(relation.target_component_id)
key_origin = context.placement_origin(key_id) if isinstance(key_id, str) else None
distances = {}
if shaft_origin and gear_origin:
distances["shaft_gear_xy_mm"] = xy_distance(shaft_origin, gear_origin)
add_check(
checks,
section="geometry",
code=relation_code(relation, "placement_consistency"),
passed=bool(distances) and all(value <= 1e-6 for value in distances.values()),
message="keyed shaft and gear axes are coaxial",
actual=distances,
expected="xy distance <= 1e-6 mm",
)
params = context.instance.industrial_parameters
key_component = context.component(key_id) if isinstance(key_id, str) else None
key_center = (
[float(value) for value in key_component.center_xyz_mm]
if key_component is not None
else key_origin
)
if params is None or key_center is None:
add_check(
checks,
section="geometry",
code=relation_code(relation, "geometry_clearance_or_fit"),
passed=False,
message="industrial parameters and key placement are required for keyed coupling validation",
)
return
key_radius = xy_radius(key_center)
shaft_radius = params.input_shaft_diameter_mm / 2.0
sun_bore_radius = max(0.8, min(2.2, context.instance.derived.sun_pitch_radius_mm * 0.35))
actual = {
"key_center_radius_mm": key_radius,
"input_shaft_radius_mm": shaft_radius,
"sun_bore_radius_mm": sun_bore_radius,
"sun_key_width_mm": params.sun_key_width_mm,
"sun_key_height_mm": params.sun_key_height_mm,
"sun_key_length_mm": params.sun_key_length_mm,
"face_width_mm": context.instance.parameters.face_width_mm,
}
passed = (
key_radius > shaft_radius
and key_radius < sun_bore_radius + params.sun_key_height_mm + params.bearing_seat_clearance_mm
and params.sun_key_length_mm <= context.instance.parameters.face_width_mm
and params.sun_key_width_mm > 0.0
and params.sun_key_height_mm > 0.0
)
add_check(
checks,
section="geometry",
code=relation_code(relation, "geometry_clearance_or_fit"),
passed=passed,
message="input key is sized and placed to drive the sun gear through a keyway instead of an input bearing",
actual=actual,
expected="shaft radius < key center radius < sun bore + key height, key length <= face width",
)
@@ -0,0 +1,67 @@
from __future__ import annotations
from ..models import PhysicalRelation, ValidationCheck
from .base import (
BasePhysicalRelationValidator,
PhysicalValidationContext,
add_check,
add_common_relation_checks,
relation_code,
)
class BoltedValidator(BasePhysicalRelationValidator):
relation_type = "bolted"
def validate(
self,
context: PhysicalValidationContext,
relation: PhysicalRelation,
checks: list[ValidationCheck],
) -> None:
mount_id = relation.details.get("mount")
extra = [mount_id] if isinstance(mount_id, str) else []
add_common_relation_checks(context, relation, checks, extra_components=extra)
params = context.instance.industrial_parameters
if params is None:
add_check(
checks,
section="geometry",
code=relation_code(relation, "geometry_clearance_or_fit"),
passed=False,
message="industrial parameters are required for bolted joint validation",
)
return
bolt_ids = [
component_id
for component_id in context.snapshot.manifest.components
if component_id.startswith("housing_bolt_")
]
enough_bolts = len(bolt_ids) >= params.bolt_count
bolt_circle_valid = (
params.bolt_circle_diameter_mm < params.housing_outer_diameter_mm
and params.bolt_diameter_mm < params.housing_wall_thickness_mm
)
add_check(
checks,
section="assembly",
code=relation_code(relation, "fastener_count"),
passed=enough_bolts,
message="bolted industrial joint has the rule-computed housing fastener count",
actual=len(bolt_ids),
expected=params.bolt_count,
)
add_check(
checks,
section="geometry",
code=relation_code(relation, "geometry_clearance_or_fit"),
passed=bolt_circle_valid,
message="bolt circle and bolt diameter fit within housing wall",
actual={
"bolt_circle_diameter_mm": params.bolt_circle_diameter_mm,
"housing_outer_diameter_mm": params.housing_outer_diameter_mm,
"bolt_diameter_mm": params.bolt_diameter_mm,
"housing_wall_thickness_mm": params.housing_wall_thickness_mm,
},
expected="bolt circle inside housing OD and bolt diameter < wall thickness",
)
+171
View File
@@ -0,0 +1,171 @@
from __future__ import annotations
from ..models import PhysicalRelation, ValidationCheck
from .base import (
BasePhysicalRelationValidator,
PhysicalValidationContext,
add_check,
add_common_relation_checks,
planet_bore_diameter_mm,
relation_code,
xy_distance,
)
class PressFitValidator(BasePhysicalRelationValidator):
relation_type = "press_fit"
def validate(
self,
context: PhysicalValidationContext,
relation: PhysicalRelation,
checks: list[ValidationCheck],
) -> None:
add_common_relation_checks(context, relation, checks)
pin_origin = context.placement_origin(relation.source_component_id)
carrier_origin = context.placement_origin(relation.target_component_id)
orbit_error = None
if pin_origin:
orbit_error = abs(
(pin_origin[0] ** 2 + pin_origin[1] ** 2) ** 0.5
- context.instance.derived.planet_orbit_radius_mm
)
add_check(
checks,
section="geometry",
code=relation_code(relation, "placement_consistency"),
passed=pin_origin is not None and carrier_origin is not None and orbit_error is not None and orbit_error <= 1e-6,
message="planet pin press-fit axis lies on the carrier pin orbit",
actual={"orbit_error_mm": orbit_error, "pin_origin": pin_origin, "carrier_origin": carrier_origin},
expected="orbit error <= 1e-6 mm",
)
params = context.instance.industrial_parameters
if params is None:
add_check(
checks,
section="geometry",
code=relation_code(relation, "geometry_clearance_or_fit"),
passed=False,
message="industrial parameters are required for press-fit validation",
)
return
hole_diameter = params.planet_pin_diameter_mm + 2.0 * params.bearing_seat_clearance_mm
passed = (
params.planet_pin_diameter_mm < hole_diameter
and params.carrier_pin_fit_length_mm <= params.carrier_plate_thickness_mm
and params.carrier_front_plate_thickness_mm > 0.0
)
add_check(
checks,
section="geometry",
code=relation_code(relation, "geometry_clearance_or_fit"),
passed=passed,
message="planet pin dimensions are compatible with carrier plate fit holes",
actual={
"pin_diameter_mm": params.planet_pin_diameter_mm,
"carrier_hole_diameter_mm": hole_diameter,
"carrier_pin_fit_length_mm": params.carrier_pin_fit_length_mm,
"carrier_plate_thickness_mm": params.carrier_plate_thickness_mm,
},
expected="pin diameter < modeled hole diameter and fit length <= rear plate thickness",
)
class SlipFitValidator(BasePhysicalRelationValidator):
relation_type = "slip_fit"
def validate(
self,
context: PhysicalValidationContext,
relation: PhysicalRelation,
checks: list[ValidationCheck],
) -> None:
add_common_relation_checks(context, relation, checks)
source_origin = context.placement_origin(relation.source_component_id)
target_origin = context.placement_origin(relation.target_component_id)
distance = xy_distance(source_origin, target_origin) if source_origin and target_origin else None
add_check(
checks,
section="geometry",
code=relation_code(relation, "placement_consistency"),
passed=distance is not None and distance <= 1e-6,
message="slip-fit components are coaxial in placement",
actual=distance,
expected="xy distance <= 1e-6 mm",
)
params = context.instance.industrial_parameters
if params is None:
add_check(
checks,
section="geometry",
code=relation_code(relation, "geometry_clearance_or_fit"),
passed=False,
message="industrial parameters are required for slip-fit validation",
)
return
passed = params.planet_bearing_outer_diameter_mm < planet_bore_diameter_mm(context)
add_check(
checks,
section="geometry",
code=relation_code(relation, "geometry_clearance_or_fit"),
passed=passed,
message="bearing OD fits inside planet bore without positive-volume overlap",
actual={
"bearing_outer_diameter_mm": params.planet_bearing_outer_diameter_mm,
"planet_bore_diameter_mm": planet_bore_diameter_mm(context),
},
expected="bearing OD < planet bore diameter",
)
class AxialRetentionValidator(BasePhysicalRelationValidator):
relation_type = "axial_retention"
def validate(
self,
context: PhysicalValidationContext,
relation: PhysicalRelation,
checks: list[ValidationCheck],
) -> None:
add_common_relation_checks(context, relation, checks)
planet_origin = context.placement_origin(relation.source_component_id)
retainer_origin = context.placement_origin(relation.target_component_id)
distance = xy_distance(planet_origin, retainer_origin) if planet_origin and retainer_origin else None
add_check(
checks,
section="geometry",
code=relation_code(relation, "placement_consistency"),
passed=distance is not None and distance <= 1e-6,
message="axial retainer shares the planet pin axis",
actual=distance,
expected="xy distance <= 1e-6 mm",
)
params = context.instance.industrial_parameters
if params is None:
add_check(
checks,
section="geometry",
code=relation_code(relation, "geometry_clearance_or_fit"),
passed=False,
message="industrial parameters are required for axial retention validation",
)
return
passed = (
params.planet_spacer_thickness_mm > 0
and params.planet_spacer_thickness_mm <= params.axial_clearance_mm + params.bearing_seat_clearance_mm
and params.planet_bearing_outer_diameter_mm < planet_bore_diameter_mm(context)
)
add_check(
checks,
section="geometry",
code=relation_code(relation, "geometry_clearance_or_fit"),
passed=passed,
message="planet spacer is thin enough to sit outside the gear face and retain the bearing stack",
actual={
"spacer_thickness_mm": params.planet_spacer_thickness_mm,
"axial_clearance_mm": params.axial_clearance_mm,
"bearing_outer_diameter_mm": params.planet_bearing_outer_diameter_mm,
"planet_bore_diameter_mm": planet_bore_diameter_mm(context),
},
expected="positive thin spacer outside gear face",
)
@@ -0,0 +1,23 @@
from __future__ import annotations
from .base import PhysicalRelationValidator
from .bearings import BearingRevoluteValidator, BearingSupportValidator
from .clearance import ClearanceValidator
from .couplings import KeyedCouplingValidator
from .fasteners import BoltedValidator
from .fits import AxialRetentionValidator, PressFitValidator, SlipFitValidator
from .rigid import CoaxialPhysicalValidator, RigidPhysicalValidator
PHYSICAL_VALIDATOR_REGISTRY: dict[str, PhysicalRelationValidator] = {
"bearing_revolute": BearingRevoluteValidator(),
"bearing_support": BearingSupportValidator(),
"bolted": BoltedValidator(),
"press_fit": PressFitValidator(),
"slip_fit": SlipFitValidator(),
"axial_retention": AxialRetentionValidator(),
"clearance": ClearanceValidator(),
"rigid": RigidPhysicalValidator(),
"coaxial": CoaxialPhysicalValidator(),
"keyed": KeyedCouplingValidator(),
}
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
from ..models import PhysicalRelation, ValidationCheck
from .base import (
BasePhysicalRelationValidator,
PhysicalValidationContext,
add_check,
add_common_relation_checks,
relation_code,
xy_distance,
)
class RigidPhysicalValidator(BasePhysicalRelationValidator):
relation_type = "rigid"
def validate(
self,
context: PhysicalValidationContext,
relation: PhysicalRelation,
checks: list[ValidationCheck],
) -> None:
add_common_relation_checks(context, relation, checks)
source_origin = context.placement_origin(relation.source_component_id)
target_origin = context.placement_origin(relation.target_component_id)
distance = xy_distance(source_origin, target_origin) if source_origin and target_origin else None
add_check(
checks,
section="geometry",
code=relation_code(relation, "placement_consistency"),
passed=distance is not None and distance <= context.instance.derived.planet_orbit_radius_mm + 1e-6,
message="rigidly coupled industrial components have compatible placements",
actual=distance,
expected="readable placement within assembly envelope",
)
add_check(
checks,
section="geometry",
code=relation_code(relation, "geometry_clearance_or_fit"),
passed=True,
message="rigid industrial relation is represented semantically; OCP remains the positive-volume interference authority",
actual=relation.relation_id,
expected="semantic rigid relation plus OCP validation",
)
class CoaxialPhysicalValidator(BasePhysicalRelationValidator):
relation_type = "coaxial"
def validate(
self,
context: PhysicalValidationContext,
relation: PhysicalRelation,
checks: list[ValidationCheck],
) -> None:
add_common_relation_checks(context, relation, checks)
source_origin = context.placement_origin(relation.source_component_id)
target_origin = context.placement_origin(relation.target_component_id)
distance = xy_distance(source_origin, target_origin) if source_origin and target_origin else None
add_check(
checks,
section="geometry",
code=relation_code(relation, "placement_consistency"),
passed=distance is not None and distance <= 1e-6,
message="coaxial industrial components share the main axis in placement",
actual=distance,
expected="xy distance <= 1e-6 mm",
)
add_check(
checks,
section="geometry",
code=relation_code(relation, "geometry_clearance_or_fit"),
passed=True,
message="coaxial industrial relation is represented semantically; dimensional fits are checked by related bearing/clearance rules",
actual=relation.relation_id,
expected="semantic coaxial relation",
)
+12
View File
@@ -0,0 +1,12 @@
from __future__ import annotations
from .models import ComponentPose, PlacementFrame, PlacementPlan
from .solver import PlacementSolveError, solve_reducer_placements
__all__ = [
"ComponentPose",
"PlacementFrame",
"PlacementPlan",
"PlacementSolveError",
"solve_reducer_placements",
]

Some files were not shown because too many files have changed in this diff Show More