docs: 综合三方工具分析,输出最终优化方案 workbuddy_v1.md

对比 WorkBuddy/Claude/Codex 三款 AI 工具对同一代码库的
优化建议,以表格形式评价各自优劣(Bug 发现/代码质量/战略
思维/代码示例),最终整合为 6 阶段实施计划(11 人天)。
This commit is contained in:
2026-06-12 15:39:31 +08:00
parent 782422e800
commit d930fb558c
4 changed files with 1358 additions and 0 deletions
+450
View File
@@ -0,0 +1,450 @@
# Dynamics 项目优化建议
> 分析日期:2026-06-12
> 分析范围:`D:\Share\Data\aliyun-gitea\dynamics` 完整代码库
> 当前算例:`examples/case06`120 原子一维链横波,NT=100,000 步,C 引擎)
> 另见:[`optimization/workbuddy.md`](workbuddy.md)(架构拆分、全局变量封装、测试等宏观建议)
本文档聚焦以下四类问题,不与 workbuddy.md 重复:
1. **已确认的 Bug**(可能导致运行崩溃或结果错误)
2. **Python 引擎性能**(向量化等具体代码改动)
3. **引擎一致性问题**C/C++/Fortran 行为差异)
4. **小的代码质量问题**(错误文本、重复代码等)
---
## 一、已确认的 Bug
### B1. `run_simulation()` 内引用了不在作用域的 `config` 变量 ⚠️ 严重
**文件**[`compute.py:1548-1550`](../compute.py)
```python
# 问题代码(run_simulation 函数内部,config 未传入)
"camera_distance": str(config.get("camera_distance", 40.0)),
"camera_elevation": str(config.get("camera_elevation", 0)),
"camera_azimuth": str(config.get("camera_azimuth", 0)),
```
`run_simulation()` 是独立函数,不接收 `config` 参数,但函数内部直接使用了 `run_from_config()` 的局部变量 `config`。只要使用 Python 引擎(`engine: python`)就会触发 `NameError`
**修复方案**:这三个字段的值已在 `run_from_config` 里读取并存入全局变量(`camera_keyframes_raw` 已有),
`camera_distance/elevation/azimuth` 同样声明为全局变量并在 `run_from_config` 中赋值:
```python
# 在模块顶部全局变量区添加(compute.py ~65 行附近)
camera_distance = 40.0
camera_elevation = 0
camera_azimuth = 0
# 在 run_from_config 中赋值(~756 行附近,已有 use_marker 赋值的位置)
camera_distance = float(config.get("camera_distance", 40.0))
camera_elevation = float(config.get("camera_elevation", 0))
camera_azimuth = float(config.get("camera_azimuth", 0))
# run_simulation 内改为读全局变量
"camera_distance": str(camera_distance),
"camera_elevation": str(camera_elevation),
"camera_azimuth": str(camera_azimuth),
```
---
### B2. `dynamics.py` 绘图块引用未定义的变量 ⚠️ 严重
**文件**[`dynamics.py:330-451`](../dynamics.py)
```python
# step_plot=1 时触发,但这些变量从未赋值
time_arr = np.arange(NT) * DT # NT、DT 在此作用域未定义
n_atoms = all_x.shape[1] # all_x 不存在
atom_ids_list = data.get("atom_ids", ...) # data 不存在
```
这段绘图代码写于旧版(`run_simulation` 返回完整轨迹时),现在 `run_from_config` 已不再在此作用域填充这些变量,整块代码是失效的死代码。当前所有案例恰好设置 `step_plot: 0` 所以不触发,但一旦用户设为 1 就崩溃。
**修复方案**:从 `run_from_config` 的返回值读取数据重构绘图代码,或暂时添加保护:
```python
# 临时保护(快速修复)
if not no_plot and config.get("step_plot", 1):
print("[run] 警告: step_plot 功能暂未实现,已跳过")
```
---
### B3. `plot_wave.py` 使用旧 JSON 格式字段读取新格式 display.txt ⚠️ 严重
**文件**[`plot_wave.py:22-27`](../plot_wave.py) 和 [`plot_wave.py:96-111`](../plot_wave.py)
```python
# plot_wave.py 的 load_disp_data()
def load_disp_data(output_dir):
return compute.load_text_data(disp_path) # 调用了 JSON 格式的加载函数!
# 然后访问旧 JSON 字段名
n_frames = int(data["n_frames"]) # 新格式没有这个键
x = np.array(data["disp_all_x"]) # 新格式没有这个键
masses = np.array(data["atom_masses"]) # 新格式没有这个键
```
`display.txt` 现在已是新文本格式(由 `save_display_txt` 写出),正确的加载函数是 `compute.load_display_txt()`,且字段名完全不同(`frames_x` 而非 `disp_all_x`)。
**影响**`step_plot_wave: 1` 时必然崩溃,case05/case06 的波形动画功能完全失效。
**修复方案**:将 `load_disp_data` 改为调用 `load_display_txt`,并适配字段名:
```python
def load_disp_data(output_dir):
disp_path = os.path.join(output_dir, "display.txt")
if not os.path.exists(disp_path):
raise FileNotFoundError(f"找不到 {disp_path}")
return compute.load_display_txt(disp_path) # 改这里
# 后续访问字段名也需同步修改:
# data["disp_all_x"] → disp_data["frames_x"]
# data["n_frames"] → disp_data["frames_x"].shape[0]
# data["atom_masses"] → 从 header_fields 读取或从 bond/coord 文件读
```
> **注**`plot_wave.py` 依赖 `atom_masses`、`bond_pairs` 等物理量,这些在新的 display.txt 格式中
> 通过 `header_fields` 字符串存储,需要额外解析。较彻底的修复需要在 display.txt header 中保留
> `atom_masses` 序列(目前 `atom_radii` 已以逗号分隔字符串保存,可仿照此方式)。
---
### B4. `run_dynamics.py`case06)描述文字错误
**文件**[`examples/case06/run_dynamics.py:34`](../examples/case06/run_dynamics.py)
```python
# 当前(错误)
parser = argparse.ArgumentParser(description="运行 Dynamics 示例案例 case01")
# 应为
parser = argparse.ArgumentParser(description="运行 Dynamics 示例案例 case06")
```
---
### B5. `draw.py` 裸 `except` 吞掉所有异常
**文件**[`draw.py:97`](../draw.py)
```python
try:
alpha_list = [float(x) for x in raw_alpha.split(",")]
if len(alpha_list) != 6:
alpha_list = alpha_list * 6
except: # 裸 except:连 KeyboardInterrupt 都会捕获
alpha_list = [float(raw_alpha)] * 6
```
**修复**:改为 `except (ValueError, AttributeError):`,并加 `print` 提示。
---
## 二、Python 引擎性能优化
> **背景**:case06 使用 C 引擎,以下优化主要影响 `engine: python` 路径。
> 当前 Python vs C 速度比约为 6-8 倍(100,000 步,120 原子)。
### P1. 弹簧力计算向量化(最大收益)
**文件**[`compute.py:1253-1277`](../compute.py)
```python
# 当前:Python for 循环,119 键 × 100,000 步 ≈ 1200 万次迭代
for bond_idx, (idx_1, idx_2) in enumerate(BOND_PAIRS):
dx = x[idx_2] - x[idx_1]
dy = y[idx_2] - y[idx_1]
dz = z[idx_2] - z[idx_1]
dist = np.sqrt(dx * dx + dy * dy + dz * dz)
if dist <= 1e-12:
continue
stretch = dist - BOND_REST_LENGTHS[bond_idx]
force_mag = BOND_STIFFNESS[bond_idx] * stretch
...
```
**向量化版本**
```python
if ELASTIC_FORCE and BOND_PAIRS is not None and len(BOND_PAIRS) > 0:
i_arr = BOND_PAIRS[:, 0] # (n_bonds,)
j_arr = BOND_PAIRS[:, 1]
dx = x[j_arr] - x[i_arr]
dy = y[j_arr] - y[i_arr]
dz = z[j_arr] - z[i_arr]
dist = np.sqrt(dx * dx + dy * dy + dz * dz)
valid = dist > 1e-12
inv_dist = np.where(valid, 1.0 / np.maximum(dist, 1e-12), 0.0)
stretch = dist - BOND_REST_LENGTHS
force_mag = BOND_STIFFNESS * stretch * inv_dist # 归一化后的分量力幅
fx_bond = force_mag * dx
fy_bond = force_mag * dy
fz_bond = force_mag * dz
np.add.at(fx, i_arr, fx_bond)
np.add.at(fx, j_arr, -fx_bond)
np.add.at(fy, i_arr, fy_bond)
np.add.at(fy, j_arr, -fy_bond)
np.add.at(fz, i_arr, fz_bond)
np.add.at(fz, j_arr, -fz_bond)
```
**预期加速**Python 引擎整体 **5-15 倍**(对于链状键合体系,弹簧力是瓶颈)。
> `np.add.at` 支持重复索引的原子累加,对有分叉键的复杂体系也正确。
> 若键无重复端点(链状),可改为 `fx[i_arr] += fx_bond; fx[j_arr] -= fx_bond` 更快。
---
### P2. `apply_fixed_constraints()` 减少临时数组分配
**文件**[`compute.py:1408-1418`](../compute.py)
```python
# 当前:每步创建 2 个 (n_atoms, 3) 的临时数组
positions = np.column_stack((x, y, z))
velocities = np.column_stack((vx, vy, vz))
positions = np.where(fixed, ATOM_POSITIONS, positions)
velocities = np.where(fixed, 0.0, velocities)
return positions[:, 0], positions[:, 1], positions[:, 2], ...
```
对于 case06(x/y 全部固定,z 自由),每步都在做 120 原子 × 3 列的 `column_stack`,但实际上只有 z 方向的原子可以运动。
**优化版本**(预先计算掩码,直接原地修改):
```python
# 在模块初始化时(run_from_config 中)预计算固定掩码
_FIXED_MASK_X = ATOM_FIXED[:, 0] != 0 # bool 数组,提前缓存
_FIXED_MASK_Y = ATOM_FIXED[:, 1] != 0
_FIXED_MASK_Z = ATOM_FIXED[:, 2] != 0
def apply_fixed_constraints(x, y, z, vx, vy, vz):
if np.any(_FIXED_MASK_X):
x[_FIXED_MASK_X] = ATOM_POSITIONS[_FIXED_MASK_X, 0]
vx[_FIXED_MASK_X] = 0.0
if np.any(_FIXED_MASK_Y):
y[_FIXED_MASK_Y] = ATOM_POSITIONS[_FIXED_MASK_Y, 1]
vy[_FIXED_MASK_Y] = 0.0
if np.any(_FIXED_MASK_Z):
z[_FIXED_MASK_Z] = ATOM_POSITIONS[_FIXED_MASK_Z, 2]
vz[_FIXED_MASK_Z] = 0.0
return x, y, z, vx, vy, vz
```
---
### P3. `run_simulation()` 中冗余的 `frame_indices` 列表
**文件**[`compute.py:1470,1510`](../compute.py)
```python
frame_indices = []
...
frame_indices.append(step) # 每 NSTEP 步追加一次,仅用于最后计算 n_frames_actual
# 实际上等价于:
n_frames_actual = record_steps // NSTEP # 直接计算,O(1)
```
100,000 步中约有 1,000 次 `append`,开销小但无必要。删除 `frame_indices` 列表,用计数器替代。
---
### P4. `apply_driving_force()` 内部创建临时 `t_vec`
**文件**[`compute.py:630`](../compute.py)
```python
# 每次调用都分配一个 3 元素 array
t_vec = np.array([t, t, t], dtype=np.float64)
pos_drive = d["amp"] * np.cos(2.0 * np.pi * d["freq"] * t_vec + d["phi"])
```
`t` 是标量,`d["freq"]``d["phi"]` 是 (3,) 数组,直接用标量广播即可:
```python
pos_drive = d["amp"] * np.cos(2.0 * np.pi * d["freq"] * t + d["phi"])
vel_drive = -d["amp"] * 2.0 * np.pi * d["freq"] * np.sin(2.0 * np.pi * d["freq"] * t + d["phi"])
```
---
### P5. `GRAVITY_INTERACTION` 的 O(N²) 双重循环
**文件**[`compute.py:1279-1297`](../compute.py)
```python
# 当前:纯 Python 双重循环
for i in range(n):
for j in range(i + 1, n):
...
```
**向量化方案**(适用于 N 不太大时):
```python
if GRAVITY_INTERACTION:
# 构造所有原子对的差向量
xi = x[:, np.newaxis]; xj = x[np.newaxis, :]
yi = y[:, np.newaxis]; yj = y[np.newaxis, :]
zi = z[:, np.newaxis]; zj = z[np.newaxis, :]
r2 = (xj - xi)**2 + (yj - yi)**2 + (zj - zi)**2
np.fill_diagonal(r2, np.inf) # 排除自身
r = np.sqrt(r2)
mi = m[:, np.newaxis]; mj = m[np.newaxis, :]
# 力幅(标量,上三角)
f_mag = GRAVITY_STRENGTH * mi * mj / r2
fx_ij = f_mag * (xj - xi) / r # (n, n) 矩阵
fy_ij = f_mag * (yj - yi) / r
fz_ij = f_mag * (zj - zi) / r
fx += np.sum(fx_ij - fx_ij.T, axis=1) * 0.5 # 利用反对称性
fy += np.sum(fy_ij - fy_ij.T, axis=1) * 0.5
fz += np.sum(fz_ij - fz_ij.T, axis=1) * 0.5
```
> 此向量化在 N ≤ ~500 时比 Python 循环快约 20 倍;N > 1000 时内存占用高(N² 矩阵),
> 需要分块或使用 Barnes-Hut 树算法。
---
## 三、引擎一致性问题
### E1. 弹性力计算:Python 与 C 引擎的差异
**文件**[`compute.py:1273-1277`](../compute.py) vs [`engines/c/main.c`](../engines/c/main.c)
Python 引擎将整根键的弹性势能 `½k(d-d₀)²` 记给原子 `i`(不均分),而 C/C++ 引擎均分给两端。
这不影响力的计算(力相同),但如果未来基于势能做分析,两个引擎的势能分配会不同。
建议注释中明确说明约定。
### E2. C++ 引擎 `save_trajectory` 默认值为 1
**文件**[`engines/cpp/param.json`](../engines/cpp/param.json)
```json
// engines/c/param.json: "save_trajectory": 0
// engines/cpp/param.json: "save_trajectory": 1 ← 与 C 不一致
```
用户切换引擎时行为不同,可能意外生成大文件。建议统一为 0。
### E3. Fortran 引擎不支持 `save_trajectory=0`
**文件**[`engines/fortran/main.f90`](../engines/fortran/main.f90)
Fortran 引擎总是输出 JSON 格式的 `trajectory.txt`,不支持直接写 `display.txt`
workbuddy.md 中已将此列为 P0,此处确认影响范围:
- case 使用 `engine: fortran` 时必然崩溃(Python 侧期待 `display.txt`
- 应参照 C 引擎的 `write_display_txt` 函数实现
### E4. 外部引擎每次运行都重新校准
**文件**[`compute.py:924-948`](../compute.py)
每次运行前跑 `min(1000, NT//10)` 步测速。对于 `NT=100,000` 的 case06,校准跑 10,000 步,
占总运算量的 10%。
**快速修复**(跳过短运行的校准):
```python
# 当总步数 < 5000 时跳过校准,直接用默认估计
if total_steps < 5000:
_step_time = 1e-5 # 合理的保守估计
_est_total = _step_time * total_steps
else:
# 现有校准逻辑...
```
**彻底修复**:缓存校准结果到 `engines/{engine}/_calib_cache.json`key 为 `(n_atoms, method)`
命中时跳过校准(workbuddy.md §2.1 有详细代码示例)。
---
## 四、代码质量问题
### Q1. 相机路径解析函数重复
[`dynamics.py:35-69`](../dynamics.py) 的 `_load_camera_kf()`
[`compute.py:90-136`](../compute.py) 的 `_load_camera_motion()` 是功能完全相同的函数,
仅变量名略有差异。
**建议**:保留 `compute.py` 中的版本,`dynamics.py` 改为从 `compute` 导入:
```python
# dynamics.py
from compute import _load_camera_motion as _load_camera_kf
```
### Q2. `load_parameters()` 函数已废弃但仍保留
**文件**[`compute.py:1123-1199`](../compute.py)
这是老版通过 `key = value` 文本格式读参数的函数,现在所有案例已改用 YAML。
只有 `compute.py``main()` 还调用它,而 `compute.main()` 本身也已被 `dynamics.py` 取代。
整个函数(77 行)加上 `main()`25 行)都可以安全删除,或移入 `tools/` 目录存档。
### Q3. 边界条件逻辑不一致
**文件**[`compute.py:1380-1387`](../compute.py) vs [`compute.py:1420-1428`](../compute.py)
```python
def Limit_in_box(a, amin, amax, va):
# 反弹:碰壁时速度反向(弹性碰撞)
va = np.where(over | under, -va, va)
def wrap_position(x, y, z):
# 周期性边界:从一侧出去从另一侧进入
x = np.where(x > X_MAX, X_MIN, x)
```
`run_simulation` 中两者都被调用:先通过 `apply_motion_update()` 执行 `Limit_in_box`(反弹),
再执行 `wrap_position()`(周期)。顺序执行时,`Limit_in_box` 已把粒子限制在盒内,
`wrap_position` 实际上永远不会触发——但这一逻辑关系无文档说明,容易误导读者。
**建议**:在 `run_simulation` 中加一行注释说明两者的分工,或删除其中一个。
### Q4. 案例说明文字未更新(case06)
**文件**[`examples/case06/run_dynamics.py:34`](../examples/case06/run_dynamics.py)
见 B4,在此重申。
---
## 五、总结与优先级
| 编号 | 问题 | 优先级 | 预估工作量 |
|------|------|--------|-----------|
| B1 | `run_simulation``config` 未定义 | 🔴 立即 | 15 min |
| B2 | `dynamics.py` 绘图块死代码(NameError | 🔴 立即 | 30 min |
| B3 | `plot_wave.py` 使用旧格式加载新 display.txt | 🔴 立即 | 1-2 h |
| B4 | case06 描述文字写着 case01 | 🟡 低 | 1 min |
| B5 | `draw.py` 裸 except | 🟡 低 | 5 min |
| P1 | 弹簧力向量化(Python 引擎) | 🟢 中 | 1 h |
| P2 | `apply_fixed_constraints` 减少临时数组 | 🟢 低 | 30 min |
| P3 | `frame_indices` 列表改计数器 | 🟢 低 | 5 min |
| P4 | `apply_driving_force` 去掉 `t_vec` | 🟢 低 | 5 min |
| P5 | `GRAVITY_INTERACTION` O(N²) 向量化 | 🟢 中 | 1 h |
| E1 | 势能归属约定加注释 | 🟡 低 | 10 min |
| E2 | C++ `save_trajectory` 默认值统一 | 🟡 低 | 5 min |
| E3 | Fortran 引擎支持 display.txt 输出 | 🔴 高 | 2-3 h |
| E4 | 外部引擎校准缓存 | 🟡 中 | 1-2 h |
| Q1 | 相机解析函数去重 | 🟡 低 | 15 min |
| Q2 | 废弃的 `load_parameters` + `main()` 删除 | 🟡 低 | 15 min |
| Q3 | 边界条件逻辑加注释 | 🟡 低 | 10 min |
| Q4 | case06 描述文字 | 🟢 低 | 1 min |
**最高回报的三件事(按投入产出比排序)**
1. **B3**`plot_wave.py` 格式不匹配):修复后波形动画功能恢复,影响 case05/case06
2. **B1**`config` 未定义):修复后 Python 引擎可完整运行 case06
3. **P1**(弹簧力向量化):修复后 Python 引擎速度提升 5-15 倍,适合大规模测试和教学演示
---
*本文档由 Claude Sonnet 4.6 自动生成,基于代码静态分析,未实际运行测试验证。建议在实施前先运行相关案例确认 Bug 现象。*