Split project into frontend and backend
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
backend/.pytest_cache/
|
||||
backend/output/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
+26
@@ -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
|
||||
}
|
||||
}
|
||||
+28
@@ -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
|
||||
}
|
||||
}
|
||||
+26
@@ -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
|
||||
}
|
||||
}
|
||||
+44
@@ -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
|
||||
}
|
||||
}
|
||||
+46
@@ -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
|
||||
}
|
||||
}
|
||||
+44
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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 缺失,报告环境错误。不要跳过后声称成功。
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Planetary reducer formula/generation/validation demo."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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"),),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
]
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Joint module generation and validation helpers."""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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(),
|
||||
],
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
Reference in New Issue
Block a user