| 位置 | 陷阱 | 正确做法 |
|---|---|---|
cell.rs |
浮点误差约 1e-15 | 断言用 < 1e-10,不能用 assert_eq! |
msd.rs FFT |
S1 − 2·S2 是两个 ~|r|² 的大数相减,解包裹坐标离原点越远越吃有效位 |
每条序列先减均值(MSD 平移不变) |
| TOR 单测 | 原子从未跨边界时 TOR 与格点视图结果相同,这种测试什么也证明不了 | 先跨一次边界、再让盒子伸缩(test_tor_does_not_follow_lattice_scaling) |
cube_density.rs |
原子坐标放格点边界会导致归属歧义 | 测试中用格点中心 (n + 0.5) / N * L |
cell.rs |
wrap_position 负数行为 |
用 rem_euclid(1.0),不要用 x - x.floor();但 rem_euclid 对 -1e-17 这类极小负数返回 1.0 本身(结果在 [0, 1],不是 [0, 1)),要 < 1 的再钳一次(box_builder::wrap_into),格点下标用 % n 或 .min(n - 1)(10-30 审查 A4-M-4) |
| 全局 | clippy snake_case | 变量名不能用单大写字母(如 L),改为 box_len 等 |
| cube_density/radius/jump/sdf.rs | ndarray Array3 仍在内部使用(分析阶段) | 在 CubeData 构造点用 .into_iter().collect() 转为 Vec<f64>;不要对外暴露 Array3 |
算法层的陷阱(ρ × V_cell 存储、dr 的 SAVE 语义、known=1/2、weight 极大值位置、
rho_grad_dir 近似、CHGCAR 循环顺序、lat_i_dist(0,0,0))以 dev/bader.md §9 为准,这里不再抄一份。
bader.md 没有的一条:ChargeGrid 不对外暴露 ndarray —— 存储用 Vec<f64>,内部计算可用局部 ndarray。
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
network/classify.rs build_nf_map |
若形成子元素同时也是配体元素(如 Si-O 且 O-O),同一原子会同时出现在 former 和 ligand 中 | 当前跳过 fa_idx == la_idx;实际体系不会出现,无需处理 |
classify_frame 形成子标签 |
桥接数来自桥氧计数,桥氧定义为 NF 邻居数 ≥ 2(含三配位氧 O_t) |
0.2.1 起由 AtomType::Former.bridging 直接携带;任何从标签字符串回推数字的写法都不要写,这正是 0.2.1 重构消除的五处缺陷 |
ferro-cli 解构 cli |
let Some(x) = cli.field 是部分移动,后续不能再借用 cli |
用 let Cli { field1, field2, .. } = cli; 一次性解构,再分别传参 |
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
连通性判据(原 find_clusters 与 AtomType::is_bridging(),分别于 2026-10-18 / 10-21 删;判据今后仍适用) |
用「配体恰好连两个形成子」当连通性判据 | 三簇配体(连三个)返回 false → 网络在连得最紧的节点处断开(实测 3 个 P 共享一个三簇氧报成 3 个团簇,应为 1 个)。连通性判据是 partners.len() >= 2,与 LigandKind::Bridging、与模块 doc 一致。ferro-core::build_network_graph 那份从一开始就是对的,两份实现不一致了很久 |
| 按元素对取截断 | 按形成子元素取 cutoffs.keys().find() 的第一个 |
按 (形成子, **该配体元素**) 取。BTreeMap 序下 ("P","F") < ("P","O"),双配体体系会拿 P-F 的 1.4 Å 去判 P-O 的 1.6 Å 键,桥氧整体判不出来。与 LinkKey 少一个配体元素维是同一类洞:参考体系只有 O,所以一直没暴露 |
cif.rs 坐标回退 |
依赖远处 ensure! 的 .unwrap() 脆弱 |
本地 else if let (Some,Some,Some) 守卫 + bail!,不变量自洽 |
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
| ferro-python 构建 | cargo build 在 macOS link 阶段报 undefined Python 符号 |
用 maturin(提供 -undefined dynamic_lookup);cargo check 可类型校验 |
Cargo.toml |
不进主 workspace,但 version.workspace=true 需成员资格 |
显式 version/edition + 空 [workspace] 表使其独立 |
| maturin 解释器 | 默认可能选到杂散 python3.9,wheel tag 不匹配当前 python | maturin build --interpreter "$(which python)" |
pyproject.toml |
readme="README.md" 缺文件 → maturin 失败;失效 python-source |
必须有 README.md;无 python 覆盖层时删 python-source |
pyproject.toml 的版本号 |
手动同步 [project] version |
已漂移过一次:0.3.0 发版时发现它还停在 0.1.7,落后四个版本,而 maturin 打 wheel 用的正是这个值——装出来的包会自称 0.1.7。已改用 dynamic = ["version"] 让 maturin 从 Cargo.toml 取,[project] 下不再写 version(2026-10-16 起 maturin 实际构建验证过) |
| CP2K 模板 | 写 MOTION/CELL_OPT/TYPE |
CP2K 2026.2 删了这个键,写了即在读输入时 ABORT(不变胞的 GEO_OPT 也中止,因为 &CELL_OPT 节总在模板里)。不要写;旧版默认值即 DIRECT_CELL_OPT。见 decisions.md「ferro.jobs」冒烟一段 |
| QE aimd 模板 | 不设 nosym |
SVR 热浴的随机速度破坏初始对称性,第 2 步 checkallsym 中止;aimd 模板加 nosym = .true. |
maturin develop |
无激活 venv/conda 时报错 | 用 maturin build + pip install <wheel>,或先激活环境 |
lib.rs 的 pyerr |
e.to_string() 转 anyhow 错误 |
只剩最外层 context:conv analyze 的状态只写 unreadable (parsing FILE),原因(1 SCF not converged)丢了。用 format!("{e:#}") 带出整条链(2026-10-17 修) |
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
rand 0.8 → 0.10 |
除 thread_rng()→rng()、gen_range()→random_range() 之外,random_range 还从 Rng trait 移到了 RngExt |
use rand::Rng 必须改成 use rand::RngExt;只改方法名会得到 no method named random_range found for struct ThreadRng |
pyo3 0.29 #[pyclass] + Clone |
隐式 FromPyObject 改为需显式选择,不选会有 deprecated 警告 |
本仓所有绑定函数按 &PyTrajectory 借用,无按值提取场景 → 选 skip_from_py_object。选 from_py_object 会让每次提取克隆整条轨迹,是不该发生的深拷贝 |
ferro-python 独立 workspace |
有自己的 Cargo.lock,cargo build/test/update --workspace 全部跳过它 → lockfile 长期漂移、编译断裂不被发现(MsdParams.fit_range 自 0.1.8 起断裂到 0.1.11 才被发现) |
每次改 ferro-analysis 等公共 API 后,额外跑 cd ferro-python && cargo check;升级依赖时额外跑 cd ferro-python && cargo update |
| 0.x 版本号语义 | Cargo 把 0.x 的次版本位当破坏性位,0.34 → 0.35 属跨主版本,cargo update 不会碰 |
用 cargo update --dry-run --verbose 看 Unchanged ... (available: ...) 行,或装 cargo-edit 后 cargo upgrade --incompatible |
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
自定义 -h(原 cmd/job.rs,已删;教训通用) |
自己声明 help 字段接管 -h/--help,却没关掉 clap 自动生成的那个。两个同名参数只在 debug 断言里报:cargo build 通过、release 正常跑,而 debug 版 ferro job 一跑就 panic(clap_builder/debug_asserts.rs:99)。测试全绿也发现不了 —— 23 个 workflow 测试测的是 builder,从不经过 clap 解析 |
#[command(disable_help_flag = true)]。2026-08-24 修。任何自定义 -h/-V 都要同时关掉 clap 的自动版;发现它的唯一途径是拿 debug 二进制真跑一次子命令 |
cargo fmt(全仓) |
代码库未用 rustfmt 管理,全仓 cargo fmt 会改动约 115 个文件、近 1900 处(2026-10-21 实测),淹没特性 diff |
只对改动文件 cargo fmt -- <file>,或跳过格式化手工对齐周边风格;用 cargo build/cargo clippy 验证。若已误跑全仓,先提交真实改动再 git checkout -- . 丢弃格式化 |
| CLI 的包名 | 目录叫 ferro-cli,包名是 ferro:cargo build -p ferro-cli 报错而 target/ 里的旧二进制照跑,端到端检查拿旧结果当新的(2026-10-14 实测踩到) |
cargo build -p ferro / cargo test -p ferro;端到端前看一眼二进制时间戳 |
| Claude Code 的 shell 与非 ASCII 字符(2026-10-14 / 15 实测) | 命令行里的中文与「—」偶发被截成非法 UTF-8:python - <<'PY' 报 Non-UTF-8 code;git commit -m 提交成功但信息是乱码(git 只警告);sed -i 's/…—…/' 把坏字节写进 changelog,因手册经 include_str! 编进二进制,下一次 cargo build 才报 not valid utf-8 |
命令行只放 ASCII:提交信息用 Write 写文件再 git commit -F;含非 ASCII 的替换用 Edit 工具或 Write 写的脚本 |
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
gr.rs calc_gr |
默认 r_max=10.005 对小盒子超过 MIC 上界,近邻格每轴退化为 1 个 cell,最小镜像失效,g(r) 尾部被错误压向 0 | calc_gr 内把有效 r_max clamp 到 Cell::minimum_image_cutoff()(最小面间距的一半,非最短边长),并写回 result.params.r_max;with_auto_rmax 现由 ferro-python 使用,同样走该上界 |
box_builder.rs CellList::neighbors |
27-bin 遍历缺去重,小盒子(n_bins ≤ 2)同一 bin 被多次映射 → 近邻重复计入、弛豫斥力成倍累加 | 加 seen bin 去重(分析层已改用 neighbor.rs 的 NeighborList) |
bader_grid.rs bader_ongrid |
曾有第一遍梯度上升结果被 volnum=vec![0;..] 立即清零重算(开发遗留),运行时翻倍 |
已删,只保留全路径追踪的单遍循环;改 on-grid 时勿再引入双重计算 |
七项中六项已修(0.1.10–0.1.13、0.2.1),教训分别落在下方各节与
network 重构(0.2.1)编码陷阱。两条仍需知道的:
angle.rsneighbors_of每对被枚举两次后靠j<=i丢一半,2× 计算浪费但不影响 正确性 —— 决定不处理sq.rs的 Faber-Ziman 权重曾全 n² 遍历 +factor=2.0,异种对被加两次。实测examples/lammps/70Z30P00A_NVT.lammpstrj大 q 处total_xrd旧/新 = 1.588、total_neutron= 1.524,修后尾部收敛到 1.007。改权重公式后必须验大 q 是否趋于 1
病根是口径不一致,不是局部笔误。 参考实现(code1/gr.c:139-146、
code2/dump2sq.c:392-406)是逐帧归一化/变换后时间平均;Ferro 移植时把体积提到了
循环外(⟨hist⟩·⟨V⟩),只对时间平均后的 g 做一次变换。NVT 下两者严格恒等
(V 恒定,运算线性可交换);NPT 下每处交换留下一个协方差项。
关键恒等式:ρ·g 中体积自行抵消
ρ_f · g_ij,f = (N_f/V_f) · ni·hist_f·V_f/(4πr²dr·N_A·N_B')
= ni·N·hist_f/(4πr²dr·N_A·N_B') ← V_f 消去
⟨ρ_f(g_f−1)⟩ = rho_g − rho, rho_g := ⟨ρ_f·g_f⟩(纯计数),rho := N·⟨1/V⟩
所以逐帧变换的结果可由两个时间平均量精确重构,无须每帧各做一次 FT。
GrResult 因此有 rho_g 字段;fold 维护两份直方图 —— plain(Σcount,供 CN 与
rho_g)与 vol_w(Σcount·V_f,供 g(r))。不是近似:两个测试与字面逐帧实现对拍到
1e-10 以内。
| 轨迹 | g(r) 最大绝对差 | S(q) 最大绝对差 |
|---|---|---|
70Z30P00A_NVT.lammpstrj(V 恒定) |
0(逐字节相同) | 0(逐字节相同) |
43Z43P15A_NPT.lammpstrj(σ_V/⟨V⟩=0.641%) |
3.06e-3(相对峰值 0.0088%) | 1.06e-2(相对峰值 0.82%) |
S(q) 的偏差比 g(r) 大约 100 倍,主体来自 ρ 的口径(N/⟨V⟩ → N·⟨1/V⟩,本例相对差
3.9e-5,与 ⟨V⟩·⟨1/V⟩−1 = 0.003927% 吻合)而非 g 本身。结论:这次修复真正值钱的是
S(q) 一侧;g(r) 一侧基本是正确性洁癖。 0.1.12 及更早跑出的 NPT .sq 与新版会有约
1e-2 的差异,NVT 结果完全不受影响。
- 粒子数守恒校验:归一化把 N_A/N_B/N 当常数提出求和号,逐帧比对计数,不符即
Err(原先else { continue }会把首帧没见过的类型静默丢弃) volume_std用两遍算法:⟨V²⟩−⟨V⟩²在 V≈6e4 时抵消误差放大到 1e-3,定容轨迹也 报不出 0;改Σ(v−⟨V⟩)²后降到 1 ulp(7e-12),测试改用相对容差判定「恒容」- 文件头
# volume = <mean> +/- <std> Ang^3,用户可直接判断轨迹是否 NPT
cube_density.rs:188的ref_cell.volume()是同类病,但它的 NPT 问题不止体积 (格点在逐帧变形的盒子里含义就不同),需先定义「NPT 下 3D 密度图指什么」,单独排期- 变粒子数(GCMC / 反应 MD):会让各 partial 来自不同系综,
Σ w_ij·S_ij = total这条 0.1.10 刚立起来的恒等式失效,需独占一次「total 怎么定义」的讨论
曾断言「标签分组与元素分组的加权总 S(q) 逐点严格相等」。不成立:同种对 g_AA 的
归一化用 N_A(N_A−1),而标签层求和重构出的是 N_A²,差一个 O(1/N) 的自排除项。
纯改名(每元素一个标签)是精确相等;位点细分则留有随 N 缩小的有限尺寸差。
测试 test_label_rename_gives_identical_totals /
test_label_subdivision_differs_only_by_finite_size_term 分别钉住这两种情形。
calc_gr / calc_angle 默认读 atom.element,GroupBy::Label 时读 atom.label
(未设则回退 element)。除此之外 atom.label 的消费者是 writer:cif 写 _atom_site_label、
extxyz 写 label:S:1 列、dump 经 dump_name 把 <元素>_<后缀> 形式的 label 折进 element 列。
各 reader 的分配(「字母前缀」= readers/util.rs::element_from_label):
| Reader | element 来源 |
label |
|---|---|---|
xyz.rs |
第 1 列;是原子序数时转成元素符号 | None |
extxyz.rs |
species 列;是原子序数时同样转换 |
label 列(有则读) |
pdb.rs parse_atom_record |
第 77–78 列(大小写规范成 Fe);空或行短时从原子名按列对齐推(13 列空格 → 14 列单字母,否则 13–14 列,非元素退 13 列单字母),推不出报错 |
None |
vasp.rs |
POSCAR 第 6 行元素符号 + 计数展开;没有元素行(VASP 4)报错 | None |
lammps_data.rs |
Masses 段注释 # Fe,无注释则按质量反查 |
None |
lammps_dump.rs |
element 列,没有时用 typelabel 列,按第一个下划线拆分取元素前缀;两列都没有 → X{type}(X1/X2…),或按 --type-map |
有下划线且前缀合法时为整串(O_b_P_P),否则 None |
cif.rs |
_atom_site_type_symbol,缺失则取 label 的字母前缀 |
_atom_site_label 原样 |
cp2k.rs |
&KIND 映射,或 label 的字母前缀 |
坐标段第 1 列原样 |
qe.rs / qe_out.rs |
ATOMIC_SPECIES label 的字母前缀 |
label 原样 |
陷阱:
| 位置 | 陷阱 | 说明 |
|---|---|---|
lammps_dump.rs |
dump 缺 element 列时元素名为 X1/X2,symbol_to_z 全部返回 255 |
S(q) 的 XRD 系数与中子散射长度查表全部失败,total_xrd/total_neutron 无意义。0.1.11 起 calc_sq_from_gr 会打印告警(此前静默)。用 -m sq 前仍应确认 ITEM: ATOMS 头含 element |
| 两套元素提取 | symbol_to_z(精确→两字节→首字节)与 readers/util.rs::element_from_label(字母前缀 + 首字母大写,CP2K / QE / CIF 共用)规则不一致 |
对 ZnA:前者 → Zn,后者 → Zna。下划线拆分规则只作用于 LAMMPS dump,不动 element_from_label(它处理 Fe1/O2 原生命名,字母前缀规则对其正确) |
symbol_to_z 贪婪匹配 |
Pb→铅(82)、Po→钋(84)、Os→锇(76) 会盖过"P bridging"这类伪标签意图 |
这是选择「按第一个下划线拆分」而非贪婪前缀匹配的主要理由 |
typing.rs |
apply_type_labels 把类型标签写进 element 字段而非 labellabel;折进 element 的是 dump writer 的 dump_name(2026-10-18 由 structure 的 fold_labels 移入 io,凡写 dump 都折) |
伪元素轨迹即由 dump_name 产生;elem_z 的前缀匹配就是为它服务的 |
cif.rs 位点 |
Fe1/Fe2 的 element 均为 Fe,g(r) 无法分辨不等价位点 |
用 -x/-y 按 label 选(CIF reader 已填 label)。注意 Fe1 不合 <元素>_<后缀> 约定,导出 LAMMPS dump 时不会被折进 element 列(0.2.1 的守卫),走 extxyz 的 label:S:1 列才无损 |
ferro traj 的 gr / sq / angle 以 private/code1(dump2analysis)与 private/code2
(dump2sq)为参考实现。三处差异全部定位,其中一处是参考程序的真 bug。下次有人问
"为什么 ferro 的 S(q) 和 dump2sq 对不上",先看这一节。
dump2sq.c:InitializeType 只在第 0 帧写入 data[i].type_new;
input.c:ReadDataLammpstrj 的 sscanf 此后只更新 id/type/elem/x/y/z,从不更新
type_new。于是原子类型按数组下标而非实际元素归类。LAMMPS 未加 dump_modify sort id
时原子书写顺序随 domain decomposition 变化,第 1 帧起归类即失效。
实测对照:
| 轨迹 | 帧间同位置同 id | partial 平均互相关 (q>2) | XRD vs ND 相关 | fe 与 d2sq 的 max|Δ| |
|---|---|---|---|---|
examples/lammps/43Z43P15A_NPT.lammpstrj(未排序) |
210/2004 | 0.959 | 0.9986 | 0.31 / 0.14 |
tests/70Z30P00A_NVT.lammpstrj(已排序) |
5003/5003 | −0.118 | 0.4280 | 0.0008 / 0.0027 |
决定性验证:.gr 的 Total 列不经过 type_new,是全文件唯一干净的一列。拿它自己做
傅里叶变换,与 dump2sq 输出的两条总曲线相关 0.99868(XRD)/ 0.99993(ND)——
dump2sq 的加权总 S(q) 实际就是总 g(r) 的傅里叶变换,Faber-Ziman 加权被完全冲掉。
处置:不复现。scripts/trajcheck.py 集中记录成因与数据,compare_sq.py 与
compare_sq_experiment.py 每次运行自检并在终端与图上警示。dump2analysis 每帧按元素
重新选原子,不受影响。
| ferro | dump2analysis | |
|---|---|---|
| 计数 | 每个几何角一次(PO₄ 给 6 个 O-P-O) | 有序端对,两端同元素时数两次;不同元素时一次 |
| bin k 覆盖 | [kΔθ, (k+1)Δθ) |
[0.05+kΔθ, 0.15+kΔθ) |
计数因子不影响 p 与峰位(ferro 不报 mean / std,见 decisions.md「angle 不报平均键角」)。半格偏移源自 angle.c:OutputAngle 的
min_angle = 0.05;那套分箱会把 <0.05° 的角写到 a[-1](越界),上界也延到
180.05°,故不采作默认。0.1.15 起 --angle-min / --angle-max 可调,
--angle-min 0.05 --angle-max 180.05 --d-angle 0.1 可逐点复现——实测 O-P-O 与
O-Al-O 各 1800 个 bin,补上 ×2 后整数计数零差。
dump2sq.c:CalcSq 只算 4πρ/q · Σ_r r[g−1]sin(qr)Δr,不含定义里的 +1。实测残差在
q>15 处为 1.00031(XRD)/ 1.00018(ND),标准差 0.014 / 0.002,确认是纯常数。比较
脚本默认给 dump2sq 加 1(--raw 可关),因为 S(q) 定义要求大 q 趋于 1,ferro 已是
标准形式。
compare_sq.py早先把上面第一条描述成"dump2sq 的 g(r) 有伪峰"——伪峰是症状, 成因是 type_new 陈旧。compare_angle.py早先把 bin 偏移写成"横轴标注约定不同、同一索引覆盖同一区间" ——错的,区间真的错开半格。
--r-cut-ab / --r-cut-bc 原先按原子序数派发给两端,静默忽略用户在 -a/-c
写的顺序(-a Zn -b O -c P --r-cut-ab 2.5 会把 2.5 给 P,因 Z=15 < 30),与意图相反。
两个 cutoff 相等时不显现,所以一直没暴露。
修法:AngleParams.ends: Option<(String,String)>,CLI 点名三元组时把 -a/-c 原样
传下去;未点名时(扫全部三元组)无用户顺序可依,仍用规范 (Z, 符号) 顺序。
两端同类型时 ends 不生效,一律取 min(r_cut_ab, r_cut_bc) —— 谁被排成 lo 端
取决于邻居枚举顺序,只有取 min 才可复现(测试 test_symmetric_ends_ignore_order)。
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
Table 的缺失值 |
列并集下某输入缺某列,补 0 会被当成「测到了 0」 | Column::Num 存 f64::NAN,cell() 渲染为空字段,pandas 读回 NaN。空 ≠ 零,这条是列并集能被接受的全部理由 |
Summary 的数值 |
用 Column::Num 会让「5 帧」写成 5.000000e0 |
[inputs] 是给人看的注释块,值预格式化成文本(fmt_compact:整数保持整数,其余四位有效数字,非有限值写 -) |
meta_lines() |
把逐文件才有意义的量(组成、clamp 后的 r_max)写进共享参数块,多文件下第一个文件的组成会被当成全局事实 | 共享参数留 meta_lines(),逐文件的走 Summary::note() 进 [inputs] 清单 |
| 按输入数量分派单/批两条路 | 产物形态会取决于 glob 当天匹配到几个文件;平时 5 个走批处理、某天只剩 1 个就悄悄换形态 | 单一代码路径,N=1 是 N 的特例。两条路还意味着两套命名、两套错误处理 |
expand_inputs 零匹配 |
静默跑零个文件是最难查的失败,尤其在脚本里 | 零匹配报错并回显模式 |
| 批内失败 | 只打印不改退出码,shell 里 ferro traj gr ... && next 会把失败当成功 |
跳过 + [inputs] 留原因 + 退出码 1;参数级错误仍在读第一个文件前快速失败 |
多输入下的 .cube |
文件名不带 stem 时第二个输入直接覆盖第一个 | ferro map 的文件名必须掺输入 stem(density_<stem>.cube) |
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
-o 的语义 |
把批次标记塞进 -o。0.3.2 及更早它是文件名后缀,subprocess.run([... "-o", str(outdir/"x")]) 会写出 gr_<绝对路径>.csv;0.3.3 起 -o 是输出目录,同一句话变成默默写进一棵新目录树 |
批次标记走 -s;仍以 outdir 为工作目录调用(cwd=outdir,故连 -o 都不必给),输入路径转绝对;产物名自己拼 <mode>[_<表>]_<后缀>.csv |
| 脚本里缺目录 | 指望 ferro 自己建 | 0.3.3 起目录不存在时会询问,而脚本里没人能答 —— 非交互环境不给 --mkdir 直接报错退出。要么先 mkdir,要么传 --mkdir。convert 例外(2026-10-12 用户定):它只写用户逐字写出的一个目标,缺失目录直接建,没有 --mkdir |
| 复跑的残留 | 上一轮的产物若还在,新一轮参数写错导致 ferro 没写文件时,脚本会拿旧文件当本轮结果,数字全对但结论是错的 | 调用前 unlink() 预期产物,再验存在 |
| 退出码 | dump2analysis / dump2sq 参数校验失败走 exit(0),退出码不可信;ferro 的可信(批内失败也置 1) |
两种检查分开:C 程序只验产物,ferro 同时查 returncode |
| 读长表 | 按列名取列的老写法在长表下取不到东西;直接 df.iloc[:, 4] 又会在列顺序变化时静默错位 |
按数据列选行(center / neighbor / end_a…),选空时把该表实际有的组合打印出来 |
file 列 |
单输入脚本遇到多输入产物时静默取第一个,后面所有数字都对不上且图上看不出来 | 断言 df.file.nunique() == 1 后再丢列 |
| 产物名 | 在调用点手写 f"gr_{tag}.csv" |
用 ferrocmp.product_name() 拼(batch::product_name 的镜像)。2026-08-13 加 label 段时,三个脚本各写各的名字,同时断掉 |
-o 与 label 重复 |
把配对同时塞进 -o(-o P-O → gr_P-O_P-O.csv) |
配对已由 label 段进文件名,-o 只留批次标记(cmp)。这正是加 label 段换来的东西 |
--ferro 传相对路径 |
直接交给 subprocess |
run_ferro 以 outdir 为 cwd,../target/release/ferro 会 FileNotFoundError。含分隔符时先 resolve() |
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
| 删 clap 参数 | 只改函数体、不删结构体字段 | 两处都要改。run_sq 不再读 c.select 之后,traj sq -a P -b O 仍被接受并静默产出 sq.csv;删掉 SqCmd 的 #[command(flatten)] select 之后 clap 才报 unexpected argument。「参数没人用了」不等于「参数没了」 |
| 有序选择 vs 集合选择 | 用同一条规则拼名 | 分开:gr/angle 按写的顺序(CN 有向,-a P -b O 与 -a O -b P 是两份不同数据,必须两个文件);--elements 排序去重(是集合,O,P 与 P,O 选中同一批原子,同一份数据不能落到两个文件名下)。统一成任何一种都会错一半 |
| 用户串进文件名 | 直接拼 | 先校验 [A-Za-z0-9_+-],在读第一个文件之前报错。选中的串会成为路径的一段,-a 'P/2' 不校验就写到别处去了 |
| 非法字符的处理 | 替换成下划线 | 报错。替换会让 -a P/2 与 -a P_2 静默写进同一个文件 —— 与补零冒充「测到了 0」同一类错误 |
输出目录(今 -o)的创建时机 |
写第一个文件时才建 | Output::prepare() 在读第一个输入之前调用,与「参数级错误快速失败」一致。否则跑完一小时分析才发现路径打不开 |
rotcorr 的「无选择」分支 |
以为 --center/--neighbor 可省 |
两者都是必填(Option 只为「不带 -i 时打帮助」,随后 bail)。它恒有 label,rotcorr_all.csv 走不到,别为它写代码 |
write_all 的参数个数 |
继续加位置参数 | 加到第 8 个时收进 Output { dir, label, suffix }。三个字段总是一起走,分开传只会让调用点一串 None, Some(x), None |
样式栈是 ['science','vibrant'] + usetex。三条实测结论先摆前面:
| 事 | 结论 |
|---|---|
| 字符串转义 | 不需要。matplotlib 的 usetex 前导会加载 underscore 宏包([strings]),43Z43P15A_NPT_5、P_3、O_b、50% Zn 全部原样通过 |
| 仍会炸的字符 | &、#、^ —— 让整张图渲染失败(LaTeX 报错,不是画歪)。元素符号与位点标签不可能含它们,只有自定义文件名会碰上 |
| usetex 速度 | 一张图约 0.5 s,不是瓶颈 |
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
science 样式 + 双轴 |
ytick.right: True 会在右侧画主轴的镜像刻度,跟 twinx 的 CN 刻度叠在一起 |
主轴显式 ax.tick_params(right=False),把右边让给 CN 轴 |
| 堆积柱的「段」 | 把 csv 的每一行当一个段 | 一个段是一个物种。qn_partner 里同一个 (qn, m_<X>…) 组合在每个成分各有一行,不归并的话明度档按行号分配 —— 颜色就不再编码 m 而是编码「第几行」,第二个成分整根柱子变淡 |
m_<X> 列的 NaN |
照 == key 匹配 |
先 fillna(0)。列并集下某成分缺整列 m_Al(那个体系没有 Al),空字段读回是 NaN,匹配不上就整根柱子静默消失。一般不该把 NaN 当 0,这里 NaN 确实等于 0(没有 X 就没有连向 X 的桥),是有明确语义的例外 |
| 多子图共享图例 | 每格各自 color_map(该格的类别) |
颜色必须跨全图按类别值分配。否则 Al 格的 cn=4 与 Zn 格的 cn=3 都拿到色序第一个颜色,一份共享图例就是在撒谎。段标签也不能带元素名(Al_4),否则 Zn 格里同一个橙色挂着 Al 的名字 |
| 明度档数 | 按第一组的段数算 | 取所有格、所有组的最大值。不同成分下伙伴分解行数不同(某成分一个 Q2(m_Al=2) 都没有),只按第一组数会在别的组上索引越界 |
| 图例位置 | ax.legend(bbox_to_anchor=(0.5,-0.28)) |
挂到 fig.legend 上。成分名是旋转的长标签,挂在 ax 上会被它们顶穿 |
| 多组并排时的标题 | 默认 pad | 组标签(CMD/MLMD)竖排在柱顶会顶到标题,n_grp > 1 时给 title_pad |
| 固定类别的配色(2026-09-28) | 按「本批出现过的类别」调 color_map |
缺一类(没有 O_f)整排颜色错一位,两张图同一类不同色。类别集合是固定的(配体四类),就按全集分色 —— build_figure 的 palette_keys |
| 类别过多的共享图例 | 两格类别合起来进一份 fig 图例 | 超过 vibrant 的 7 色就循环复用,同一颜色在一份图例里出现两次。各格类别互不相同时(bridge)改逐格图例、逐格分色 |
| 热图的轴 | 按元素跨图共用一根轴 | P–O–P 图里 Q0 不可能出现(参与 P–O–P 的 P 至少 n=1),共用会多出一整排必空的格。逐图逐端取全部成分的并集,各成分仍对齐 |
| 同种元素热图的上三角 | 以为 linkage 的规范序保证 v_a ≤ v_b |
规范序按 (元素, qn, cn) 排,而 Al 的轴用 cn —— qn 小但 cn 大的 Al 会落在下三角。同种元素一律折成 (min, max) |
imshow(aspect="equal") 多格排版 |
width_ratios 里混进边距 |
各格的格子不再一样大。宽度比例只按格子数给,边距另算进 figsize |
set_anchor("N") 顶端对齐 |
在画每格时设 | fig.colorbar(ax=...) 从那一格借位时会重设 anchor。全部画完再统一设一遍 |
| 行名挂在首列 ylabel 上 | 首列可能是只有两排的小图 | 行名比图长就顶出画布。按整行格位居中放 fig.text |
| 非零小值的一位小数 | f"{v:.1f}" |
0.03 % 显示成 0.0,与真正的 0 混。写 <0.1 |
| g(r) 的 CN 右轴 | 用默认自动范围 | r 到 8 Å 时 CN 已到 110,把一壳层平台压成一条线。这正是 CFG["cn_ylim"] / xlim 存在的理由,出图时按体系设 |
ferro-io 只依赖 ferro-core,而 write_gr 的入参 GrResult 在 ferro-analysis
—— 直接把 writer 搬进 io 会让分层图出现横边,违反「中间层不得互相依赖」。
成熟工具无一例外走「中立载体」:ASE 的结果对象自带 write() 走通用 jsonio;
pymatgen 的 MSONable + MontyEncoder;OVITO 的 DataTable 被所有 modifier 产出、
被所有 exporter 消费。ASE 与 pymatgen 是 Python,能写出循环依赖却依然不写,
说明这是设计选择而非语言约束。
结论:规则不用改,缺的是反方向的共享类型。补上 Table 之后
io --Trajectory--> analysis 与 analysis --Table--> io 对称,GrResult 继续留在
analysis(io 没理由认识它),跨层的只有它的可序列化投影。
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
cmd/net.rs |
--P-O=2.3 把元素对写在参数名里,clap 无法建模成固定 flag 集 |
main 在 Cli::parse_from 之前用 split_pair_args 从 argv 剥离,剩下的交给 clap |
| 子命令帮助 | clap 的 --help 派生格式塞不下「输出列结构」「长表为什么长」这类段落 |
保留 disable_help_flag 风格的自定义 help.rs;ferro traj gr 无 -i 时打印富文本 |
| 三级帮助的第一级 | clap 不支持给子命令分组显示 | 帮助本来就是手写的,print_overview() 自己排版分类 |
已知限制清单见
progress.md。一条容易混淆的留在这里:ferro traj sq的--q-min只影响输出 q 网格,g(r) 恒从 0 积分到--r-max。
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
| 需求表述有歧义时 | 挑一种读法直接实现 | 「每个 system 对应一个 set」既可读作「恒为一个」也可读作「不跨 system 地切」,两次改动才落到后者。含「每个 X 一个 Y」这类量词的需求,先把边界情形(X 比 Y 大很多时怎么办)问清楚再动手 |
| 分组依据 | 按目录名(*.train、init.011) |
名字说明不了里面装的是什么。按逐原子的元素序列分组,成分相同才合并;顺带让异成分体系自动分开 |
| 原子重排 | 只换 type.raw 不动数据 |
逐原子数组(coord、force)必须跟同一个置换走,而与原子编号无关的量(box、energy、virial)不能跟着动。漏掉 force 会让力对错原子,数值全在、量级正常、完全看不出来 |
| 规范序的选择 | 随手选一个 | ferro 取 (Z, 符号)(与 collect 的 type_map、traj gr 的分组序一致),dpdata 取字母序。两者都靠 type_map.raw 自描述所以都对,但同一份数据经 ferro 与经 dpdata 合并,type.raw 的数字不同 —— 混用两条工具链时别拿旧的 type.raw 配新的 type_map.raw |
| by-source 的语义 | 拼完再按 --set-size 均匀切 |
切分要在每个 system 内部进行:set 不横跨 system,每个 set 仍可追溯到单一条件;--set-size 照常生效。writer 因此需要一个显式边界的入口 —— 全局均匀切表达不了「边界落在 system 边界上」 |
| 切分的余数 | 留在最后一个 set | 500 帧按 400 切要给 250+250 而不是 400+100。一大一小对训练负载和「拿一个 set 当验证集」都更差;这条对两种模式都适用 |
| 打乱的可复现性 | 用系统随机源 | seed 有默认值(666),不给 --seed 也能复现。数据集是要归档的,「这一版是怎么打乱的」必须答得出来 |
| 后缀继承 | 无条件加 .train |
只在组内全部输入共享同一个划分后缀时继承;混合后缀或无后缀(dpgen collect 那种)就不加,否则等于凭空断言这是训练集 |
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
| 筛选判据放哪 | 「阈值比较只有三行,放 CLI 就行」 | 代码量不是分层判据。项目所有分析方法都在 ferro-analysis,判据放 CLI 会让 cmd/dataset.rs 成为唯一自带算法的子命令。更要命的是连带效应:判据在 CLI 时,承载类型看起来只被 io 产出、cli 消费,于是「不下沉 core」的结论也跟着错 —— 判据回到 analysis,Trajectory 立刻满足「两个以上中间层要叫出它的名字」 |
| 数据集的承载类型 | 造一个 DeepmdSystem 放 ferro-io |
analysis 的筛选函数要收它,analysis → io 就是横向依赖,直接违反铁律。而且类型带格式名,等 GPUMD 的 extxyz 进来就是第二个类型。用已下沉的 Trajectory:extxyz reader 本来就产出它,npy reader 也产出它,中立性自动成立 |
| 区间的基准 | 作用在原始帧号上 | 作用在存活序列上。--start 10 是「通过质量判据的第 10 帧」—— 在丢掉未知多少帧之后,这是唯一还讲得通的语义。原始索引由 keep 带出,保留帧仍可追溯 |
| 判据贡献的度量 | 看漏斗每步剔掉多少 | 漏斗每步只在上一步的存活帧上报数,于是一个只会重复抓别人已抓帧的判据看着也很能干。要逐帧对全部帧算判定,再统计「独占抓到」多少。实测 -f 3 -s 0.2:force 判坏 1978 帧、独占 0,漏斗(2000→22→0)完全看不出它冗余 |
| 表里的计数 | 用 Column::Num |
会把「2000 帧」渲染成 2.000000e3。整数计数走 push_text 预格式化 —— 与 Summary 那条是同一个坑,只是换了个地方再踩一次 |
| set 切分的余数 | chunks(set_size) 朴素切 |
410 帧按 400 切给出 400+10,那个 10 帧的 set 当验证集没有统计意义。余数应摊进各 set(205+205) |
| 重写 system 目录 | 直接覆盖各 npy | 帧数变少时旧的 set.001、set.002 会留在原地,读回来帧数还是旧的。写之前先清掉全部 set.* |
expand_inputs |
拿它展开目录 | 它只收 is_file(),给目录会报 "no file matches"。DeePMD 的 system 是目录,用 expand_dirs |
| npy 的 dtype | 只按自己写出的 float64 读 | dpdata 默认写 float32。两种都要收,别的 dtype 报错而不是猜 |
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
| 文本锚点 | 字面匹配(starts_with(" MD| Step number")) |
按 token 序列匹配(["MD|","Step","number"],split_whitespace 后逐词比)。CP2K 每个版本都会动列宽与对齐,字面匹配把解析器钉死在一个版本的空白上;而空白正是 split_whitespace 已经扔掉的东西。所有 tag 收进 mod tag 一张表,每个 tag 是候选列表,支持某版本改了措辞就是加一行,不是在扫描器里加分支 |
| 数值行的列 | 写死下标(f[2..5]、fields[2..11]、恰好 12 字段) |
从尾部取或按可解析性筛。应力块靠「这一行能解出三个浮点」自动跳过表头与 1/3 Trace、Determinant 摘要行,不必知道有几行表头;cell 行末位是体积、其前九个是晶胞,从尾部取则前缀增减一列都不影响 |
| xyz 块的注释行 | 要求它以 i = 开头 |
注释行的措辞不是 xyz 格式的约定,版本间无保证。判据改成结构性的:一行原子数、一行解析不成原子行的(这才叫注释)、然后那么多行能解析的原子行。首尾各验一行即可,中间留给真正读取时逐行解析 |
nalgebra::Matrix3 落盘 |
用 as_slice() 取九个数 |
它是列优先(无开关),给出的是转置;而 npy 的 box/virial、extxyz 的 Lattice、GPUMD 的 lattice 全要行优先。走 matrix3_row_major()。这个错误有一半是静默的:stress 对称,转置后数值不变,于是你会在 cell 上发现 bug、修掉、以为 stress 也对了。测试必须用非对称矩阵 |
| CP2K 的应力单位 | 按版本查表推断 | STRESS_UNIT 是 &PRINT &STRESS_TENSOR 下的输入关键字,同一个二进制能打 bar / GPa / atm —— 单位不是版本的函数,版本表天生解不了。从 STRESS| Analytical stress tensor [bar] 的方括号自读,认不出报错。dpdata 硬编码 / GPa(formats/cp2k/output.py),遇到 bar 输出静默错 5 个数量级 |
| 力的单位 | 也想从文本读 | 力是唯一没有单位标注的量(xyz 块的注释行只有 i = …, time = …, E = …),只能按 a.u.(Hartree/Bohr)兜底。这是「文本没写时才回落」的唯一实例 |
| 坐标块 vs 力块 | 靠内容区分 | 两个块逐字同构:同原子数、同 i = … 注释行、同元素列。只有顺序能分辨(坐标在前),所以扫描必须有上界 —— 限定在 [锚点_i, 锚点_{i+1}) 内。没有上界时,某帧缺块会让扫描跨界抓到下一帧的数据,数值完全正常只是整体错位一帧 |
| 块定位 | 用固定行偏移(A + 10 这类) |
偏移随 ensemble 变(NVT/NPT_I/NPT_F 的坐标块偏移是 10/18/20),CP2K 多打一行(MD| Estimated peak process memory 已经在输出里了)整张表就错位,而错位读到的是别的块的数字、不报错。改为从锚点扫描特征行;偏移只用来校验(与首帧不同则告警) |
STRESS| 与 MD| Pressure |
当成同一个量 | STRESS| 是势能部分,MD| Pressure 含动能项。实测(total.out 第 1 帧):(2/3)E_kin/V = 14134 bar,(1/3)Tr σ = 2345 bar,两者相加 16479 ≈ MD| Pressure 的 16480.9。训练集只能用 STRESS| —— 用错等于把动能烘进势函数,别的温度下系统性错压 |
| stress 的符号 | 以为 CP2K 与 ASE 同号 | CP2K / VASP / QE 三者一致,且都与 ASE 相反(正 = 压缩)。证据:ase/calculators/cp2k.py:363 的 -1.0 * stress # cp2k uses the opposite sign、ase/io/vasp.py:630 的 stress *= -0.1*GPa、ase/io/espresso.py:2098 的 # sign convention is opposite of ase。ferro 存原样符号,故 virial = +stress×V 不变号;导出 GPUMD 的 stress= 时要变号(那个关键字是 ASE 约定),virial= 不用 |
| 重启拼接的 out | 按序号取第 k 个 ENERGY| / STRESS| |
重启时会重打一次初始值:total.out 有 2002 个 ENERGY| 但只有 2000 帧,按序号取在重启点之后整体错位一位。用「从锚点向上找最近的」,天然不受影响。段的标志是 MD_INI| MD initialization(GO CP2K GO 在新版本里根本不打印,dpdata 的段分隔符不可用) |
| 初始构型 | 以为会混进数据集 | CP2K 的初始构型打印在第一个 MD| Step number 之前,按锚点定位天然排除;且重启不重打构型,故不存在重复帧。实测 2000 个锚点区间内各恰好 2 个 xyz 块,无一例外 |
| DeePMD npy 的形状 | 照文档写 (nframes, natoms, 3) |
磁盘上一律二维:dpdata 落盘前统一 reshape([nframes, -1]),文档里的三维是逻辑形状。energy.npy 是 (nframes, 1) 而非 (nframes,) |
| npy 的精度 | 跟 dpdata 默认的 float32 | collect 是流水线的头,filter 与 merge 都读它;头上有损无法在下游还原。写 float64,降精度留给喂训练框架那一步 |
| 一个 system 的组成 | 把多条轨迹并进一个目录 | type.raw 一个目录只写一次,故各帧的原子数与类型序列必须逐项相同。合并是 merge 的职责,collect 恒为一输入一目录 |
| 输出目录名 | 用文件 stem | CP2K 日志常常全叫 total.out,靠目录区分。stem 撞车时改 <父目录>_<stem>,仍撞则报错 —— 静默覆盖会让你以为收集了两条轨迹 |
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
| 多输入的产物命名 | 把多级路径用分隔符压平(a_md) |
保留目录结构(a/md)。filter 的 -o 本来就按相对路径重建,find_systems 与 merge 也都递归下钻 —— 压平会是这条链上唯一的例外。命名规则取「剥掉公共祖先后剩下的层级」:公共前缀按定义不携带区分信息,剩下的必然唯一 |
| 撞名 | 报错要求用户改名 | 分组单位选对了,撞名就是「该合并」的定义。collect 按目录分组之后,同目录的两个 out 本来就该进一个 system,原来那条 bail! 整条消失。这也意味着命名与分组不能拆成两个提交 —— 中间态是「撞名报错但名字已变」,没法对拍 |
| 分组键 | 用路径原样 | a/x.out 与 ./a/y.out 是同一个目录却分成两组。键取 canonicalize,但显示与消息用用户写下的路径 —— 规范化后是绝对路径,刷屏且认不出 |
| 合并多文件的排序 | 逐帧按 step 全局排序 | 看着最彻底,但重启若不从 checkpoint 而是新起一个 job,step 会从 0 重新计数,全局排序会把两段真实轨迹交错洗牌,比不排序更糟。按文件的首 step 排、文件内保持原序:正常重启下与全局排序等价,重号下退化成「按文件拼」,最坏情况不比现状差 |
| 重叠帧 | 一定要去重 | 重启只重跑 checkpoint 以来的几步,位置速度相同则能量力也相同,不稀释结果。不去重,但把每个源文件的 step 区间打出来 —— 这个前提只有区间可见时才检验得了 |
| 一个目录里成分不一致 | 当作坏帧丢掉(复用既有的 composition changed 计数) |
报错并指名两个文件与各自组成。「把两个体系放进了一个目录」是人的错误,「这一帧的元素列坏了」是数据的问题,两者的处理动作完全不同;渲染成后者会让人去查 SCF 设置 |
| 合并语义下的单文件失败 | 整个目录失败 | 跳过坏文件、用剩下的建 system。但 system 目录看着完全正常而帧数少了,所以那份跳过清单要在最后的汇总里再说一遍,不能只在中间滚过去 |
| 失败计数(2026-10-23) | 文件失败记一次,所在目录因「无可用文件」再记一次 | VASP 单点一目录一个 OUTCAR,105 个没跑完的作业报成 210 个失败。只按输入文件计;目录级条目只带前面没列过的文件,原因随条目收集、跑完统一列一次 |
| 产物为目录树的命令 | -o 给个 . 的默认值 |
默认 . 会把 npy 撒进正在工作的目录;且与「名字为空时写进 -o 本身」叠加后,不给 -o 的那条路会被 --overwrite 恒定拦下,等于一条走不通的路。当时的结论「改必填」已于 2026-09-21 被推翻 —— 反对的是 . 这个值,不是「有默认值」本身;跟着输入走的默认值(AIMD 目录同级)撒不到别处去。现行判据见下方 2026-09-21 那一节 |
| 报告文件放哪 | 塞进 <outdir>/report/ 子目录显得整齐 |
平铺。expand_dirs 只收 is_dir(),平铺的 csv 会被后续 merge -i clean/* 自动滤掉,而 report/ 子目录反倒会被收进去当 system 候选 |
| 堆叠多 system 的行标签 | 用 label_of(取 stem) |
嵌套结构下 a/md 与 b/md 的叶子名相同,堆起来分不出是谁。用相对路径。batch::Summary 因此加了 failed_one 与 into_table_named |
| 「诊断只在只读模式算」这类省时优化 | 凭直觉认定它贵 | 先量。实测 1110 帧 / 302 原子挂钟 0.23 s(带诊断)vs 0.28 s(不带)—— rayon 跑满六核,差别在噪声里。这个优化的代价是那几张表永远落不了盘 |
| 批内失败的提示语 | 承诺失败原因在某个文件里 | [inputs] 块只有分析命令的 csv 才有,dataset 的产物是目录。指错地方比不指路更糟 —— 用户会照着去找一个不存在的东西。原因每个 SKIP 行都已说过,这句的唯一职责是解释退出码 |
| clap 定义与手写帮助页 | 靠人记得两边都改 | 加测试。main.rs 的 mod help_sync 正向查「每个长选项都在页里出现」、反向查「页里的 --xxx 真实存在」。上线当场抓到 19 处真漏,而提出它的起因只有 1 处(--shuffle) |
| 那个测试的判据 | 一次写对 | 收紧了三次。① 页里写短名(-a ELEM)也算写了 —— 要断言的是参数被交代过,不是拼法;② 允许交叉引用别的命令的参数(ferro net --export-traj 出现在 gr 页里是对的),只抓哪儿都不存在的;③ 跳过「there is no --from / --to flag」这类否定陈述 —— 那句话正是在做对的事 |
| 共享参数组的未文档化项 | 一律要求写进帮助 | SelectArgs 由 gr 与 angle 共用,gr 因此接受 -c/-z 却对它无意义。有意不写要进白名单并写明理由,而不是放宽断言 —— 放宽等于把这个检查关掉 |
| 帮助页文本怎么拿到测试里 | 把 25 个 print_* 改成返回 &str |
比这个检查本身还大的改动。include_str!("help.rs") 按函数名切出函数体即可;函数名写错会当场 panic(找不到那一页),所以映射表自己不会烂 |
collect 改成「一目录一 system」时只改了 docs/src/dataset/collect.md,
事后审出四处还写着旧语义或缺新事实:
| 位置 | 漏了什么 | 为什么容易漏 |
|---|---|---|
docs/src/cli-reference.md § collect |
还写「一个输入一个 system 目录,目录名取 stem,撞车改 <父目录>_<stem>」 |
手册两处讲同一个命令:专页讲「为什么」,参考页讲「参数与语义」。改了专页很容易以为完了 |
docs/src/cli-reference.md § filter |
还写「报告三张表只打印不落盘」 | 同上 |
CLAUDE.md 代码导航 |
缺 doc.rs、缺 help_sync |
新增文件时导航表不在改动路径上 |
README.md / cli-reference.md / dev/overview.md 的命令树 |
三处都缺 ferro doc |
同一张命令树抄在三个文件里 |
教训:改命令语义时要 grep 的不只是它的专页。这几处的共同点是「同一份事实 被抄在多个地方」——命令树三份、命令语义两份。防漂测试只盯 clap 与帮助页那一对, 盯不到文档之间。
当前没有更好的办法:把命令树做成生成的(像 supported_formats() 那样)
需要 markdown 侧有注入点,mdBook 的预处理器能做但要多一个构建步骤。记在这里,
下次再改命令树时三处一起改。
- 自读自写的回路验证不了符号。读侧与写侧同时漏掉变号时,往返测试逐位相同、
全绿。符号的依据必须来自外部实物:fixture 由 ASE 3.29.0 生成,期望值由
dpdata 1.0.2 的换算式算出,两者独立落到同一个数。这与
array_order.rs用 非对称矩阵测行优先是同一类防护 —— 对称张量下转置静默,自洽回路下符号静默 stress与virial是两个量,不是两个名字。差一个体积因子(virial 是 eV, stress 是 eV/ų),且相对「正 = 压缩」这个内部约定符号相反:extxyz 的stress=随 ASE(正 = 拉伸,读时变号),virial=随 QUIP/GPUMD/DeePMD (正 = 压缩,除体积、不变号)。三个独立来源一致:extxyz 规格写virial -> stress是乘-1/cell_vol;GPUMD 手册明写两个键的正负各代表什么; dpdata 1.0.2 的virials = -volume * stress_ase- 行优先还是列优先:不要站队,检查对称性。ASE 文档说这九个数是 Fortran order
(列优先),GPUMD 手册把它拼成
vxx vxy vxz vyx ...(行优先)—— 同一个格式被两家 描述成相反的顺序,却从没人报过 bug,因为规格要求这个张量对称("fail if not symmetric"),对称下两种读法逐位相同。所以 reader 检查对称性而不是赌一家。 注意Lattice不在此列:它的九个数是三个晶格矢量依次排开,两家描述一致,Ferro 原本就是对的,别顺手一起改 - 6 分量 Voigt 一律拒收。规格允许这个形式,但组件顺序不是普适的:规格与 ASE 是
xx yy zz yz xz xy,VASP 的in kB行与 GPUMD 自己的stress_*.out是xx yy zz xy yz zx(同一个程序在两种文件里用两种顺序),而文件里没有任何字段 说明产出者。六个独立浮点数下任何排列都自洽,从数据里检测不出来,故拒收而非 猜一种再告警 force与forces两种拼法都要收。GPUMD 的train.xyz用单数,ASE 用复数, 两者都是合法的Properties列名。只认复数时读 NEP 数据集会一声不吭丢掉全部 受力(find("forces")返回None,不报错)- 报错消息里也不许用
as_slice()。它是列优先,会把张量打成转置,读日志的人 据此判断「哪一侧错了」时会被带偏。走matrix3_row_major
<field>表头也是文本。vasprun 的<array name="atoms">里,<field type="string">element</field>会被当成元素符号,于是 297 个原子读出 298 个物种、每帧判 incomplete、整个文件读成 0 帧。只取<c>里的文字。 这个 bug 是实际写的时候踩到的,症状(空轨迹)离原因(多了一个物种)很远- vasprun 的
<energy>每个<scstep>都有一份。第一个是未收敛的 SCF 迭代值 (实测 33072.96 vs 收敛的 −1929.96),收敛值是 calculation 层的最后一个。 取错不会报错,只是全部标签都错 in kB的顺序是XX YY ZZ XY YZ ZX,不是 extxyz 规格的XX YY ZZ YZ XZ XY。 搞混只换 xy 与 yz,对角线看不出来。三个独立来源一致(dpdata 的下标映射、private/dp_makedataliu.py的[[0,3,5,3,1,4,5,4,2]]、ASE 的[[0,1,2,4,5,3]])- 符号不变。VASP 的
in kB与Frame::stress都是正 = 压缩;ASE 读同一行时 变号,是因为它自己的约定相反。别看到 ASE 变号就跟着变 - 体积要用
|det|。参考脚本用cell[0,0]*cell[1,1]*cell[2,2],只对正交胞成立; 三斜胞下静默给出错的 virial - 晶胞不能继承上一帧。定胞运行里继承与不继承逐位相同,所以这个 bug 在现有数据 上完全测不出来,只会在第一次跑变胞时显形。缺胞块的帧宁可丢
- 能量取
free energy TOTEN,不是energy(sigma->0)。力是自由能对坐标的导数, 配sigma->0等于给模型两半不同的泛函。(plan.md曾写「dpdata 取 sigma->0」, 是错的,已更正) - 别用固定行偏移。dpdata 从
FORCE on cell往下数 14 行取in kB,而它对 ML_FF 用的是 4 —— 偏移是某一种排版的属性,不是格式的属性 - 同目录混格式会静默翻倍。真实 VASP 目录里
OUTCAR与vasprun.xml记同一批 帧,而 collect 把同目录的文件当作一次运行的分段拼接。成分一致、两边都读得通, 没有任何别的症状
with_extension会吃掉.train。输出基名常是sysA.train,with_extension("xyz")把它变成sysA.xyz—— 划分后缀被当成扩展名换掉, 三个部分互相覆盖。拼字符串:format!("{}.xyz", base.display())- 划分不能取尾巴。按帧序切最后 10% 的话,test 全是轨迹末尾那一段连续状态,
与训练集分布不同却又高度自相关,两头都不像。成员取自
shuffle_order, 各部分内部再排回帧序(只有成员是随机的,产物仍逐字节可复现) - 比例向上取到至少 1 帧。
(n * share).round()在 n 小时给 0 —— 用户要了 验证集,命令成功返回,而验证集不存在。0 帧的部分直接不写,非 0 比例则保底 1 帧; train 会被挤空时报错并指名是哪个 system --mode by-source与帧级划分互斥。by-source 的全部产物就是「set 边界落在 system 边界上」,帧级随机划分正好打碎它。同理 by-source +--type nep: extxyz 没有 set,边界无处安放。两条都在读第一个文件前失败- 输入已带
.train再划分要拦。merge -i 'data/*.train' --ratio 9:1看着很合理,实际是把别人已经划好的数据集再划一次,产出X.train.test。 只看路径就能判,故在读第一条轨迹之前拦 -0.0不是 bug 但看着像。变号之后写出的stress="-0.0000000000 ..."会让 读日志的人怀疑符号逻辑。fmt里把-0.0归一成0.0
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
| 精简前的范围估计 | 按「页数」估 | 按超标页数估。23 页里只有 8 页超 40 行,其余 16 页只需补一行手册指针。「其余 22 页」这个说法把补一行和重写一页算成了同一件事 |
| 「没有手册就得新写」 | 看 docs/src/ 有没有同名文件 |
cli-reference.md(863 行)已按命令分节覆盖了全部参数。查覆盖要把它算作回落,否则会误判成「要新写三页手册」 |
| 指针指向大页 | ferro doc convert → 整本 cli-reference |
让 ferro doc 支持按小节寻址(Page.section,取该 ## 到下一个同级标题)。另一条路是把参考拆成一堆按命令的小文件,那会让「唯一一份完整参考」碎掉 |
| 小节标题写错 | 输出空白 | 找不到就回落到整页,另有测试钉住每个 section 在其页里真实存在、且解析出的内容多于 5 行 |
| 防漂测试的覆盖面 | 只扫 help.rs |
ferro net 的帮助页在 cmd/net.rs(HELP_EXTRA,紧挨它需要的 argv 剥离逻辑),于是它是唯一没被检查的命令,而它有 10 个选项。测试要显式把那个文件也 include_str! 进来 |
--P-O=2.4 这类参数 |
断言它在 clap 上存在 | net 的配对参数把元素对写在参数名里,clap 建模不了,main 在解析前就从 argv 剥离了 —— clap 永远不知道它。判据取那个 =(别处的参数从不这么写) |
| 40 行上限 | 当成硬约束,砍参数表凑数 | 参数表(含值域枚举)是唯一必须完整的一段。不知道 --functional 收哪些值就没法敲命令。收尾时 4 页超标,大头都是参数表,不该再砍 |
convert 的行数 |
按总行数判断超标 | 其中 27 行是 supported_formats() 生成的格式表 —— 那正是这页存在的理由。手写部分只有 32 行 |
| zsh 里量行数 | for c in "traj gr"; do $F $c; done |
zsh 默认不对未加引号的变量做分词(与 bash 相反),ferro "traj gr" 成了一个参数,量出来全是 7 行的 clap 报错。要写 ${=c} |
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
| 「只给目录」的判据 | 看 -o 指向的路径是不是已存在的目录 |
只认用户写的尾部分隔符。按磁盘状态判,同一条命令行今天写 gr_out.csv、明天写进 out/,而且不报错 |
Path 与尾部分隔符 |
以为 Path 保留它 |
Path::new("out/").file_name() 是 Some("out") —— 与 pathlib 一样被吃掉。要判断只能看原始字符串(to_string_lossy().ends_with(...)) |
| 裸文件名的父目录 | p.parent().unwrap_or(".") |
Path::new("a.out").parent() 是 Some("") 不是 None,unwrap_or 不触发;canonicalize("") 报错。空路径须显式当 .。2026-10-17 collect 因此把产物甩出 -o(7735386),测试全用带目录的路径,没覆盖到 |
| 非交互环境下缺目录 | 自动创建 | 报错并指明 --mkdir。脚本里没人能回答 [y/N],而静默创建会把拼错的路径变成一次看着成功的运行。convert 除外(见上表「脚本里缺目录」) |
convert 的 -o |
按磁盘状态判 -o 是不是目录 |
2026-10-23 起 -o 恒为目录、--format-out 必填,不再有文件模式(原「给 --format 才是目录、产物 frame[_NNNN].EXT」已被推翻,见 decisions.md)。产物 <输入名>[_<-s>].EXT、按帧拆分为 <输入名>_NNNN.EXT;尚不存在且带已知格式扩展名的 -o(out.extxyz)报错。陷阱见本文件「convert 目录 -o 审查修复」 |
| 确认提示写到哪 | stdout | stderr。ferro ... > out.txt 时提示仍要看得见,而 stdout 可能正被管道接走 |
bader 的默认输出位置 |
跟其余命令一样默认当前目录 | 默认输入文件所在目录。VASP 的电荷密度一律叫 CHGCAR,默认 cwd 时两个体系必然写同一个 CHGCAR_ACF.dat。这是唯一的例外,帮助页与手册都写明 |
job 的 -o 解析函数 |
收 &JobCmd |
收 Option<&Path>。run 里 if let Some(m) = args.method 已经把 args 部分移动,再借整个结构体就是 E0507(与 0.2.0 那次同一族) |
改 path: &str 为 &Path |
以为只是换类型 | 消息里的 {path} 全部要改 —— Path 没有 Display。本轮 24 处,靠 let path_ = path.display(); 统一 |
| 只改 writer 不改 reader | 「plan 里写的是 writer」 | 半边改完之后 io_dispatch 读侧仍要 to_str(),crate 里两种签名并存。一起改 |
| dataset 的默认后缀 | 无条件追加 .train |
名字已以划分后缀结尾时不加(否则 sysA.train.train);划分时先剥掉 .train 再给三部分命名 |
| merge 的后缀继承 | 用 Option<String>(共享则 Some) |
None 同时表示「都没有」与「混着不同部分」。默认值变成 .train 之后这两者必须分开:都没有→.train,混着→报错,否则测试数据会被标成训练集。改为三态枚举 |
| 「已划分过的输入不许再划」 | 一律拒绝带后缀的输入 | 默认后缀是 .train 之后,几乎所有产物都带后缀,一律拒绝等于禁掉「filter 之后 merge 时再划分」。.train 放行(剥掉再命名),.valid/.test 才拒绝 —— 从留出集里切训练集会毁掉它被留出的理由 |
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
writers/lammps_dump.rs 三斜 |
六个数写 xlo/xhi |
是 *_bound(倾斜后的外接盒):xlo_b = xlo + MIN(0,xy,xz,xy+xz)、xhi_b = xhi + MAX(...)。写错的产物喂给 OVITO / ASE / LAMMPS read_dump 得到的盒子偏小 |
| 同上,reader 侧 | 用 hi - lo 当边长 |
lx = (xhi_b − xlo_b) − |xy| − |xz|、ly = (yhi_b − ylo_b) − |yz|(与 ase/io/lammpsrun.py::construct_cell 一致;该式与规格的 MAX−MIN 差在四个象限上恒等)。读写两侧一致地错时往返测试全绿 —— 与 extxyz 应力符号同一个陷阱,所以 writer 的测试必须断言文本而不是读回来比 |
| 正交胞 | 拿它验三斜修复 | 三个倾斜量为 0 时两种写法逐位相同,正交轨迹上这个 bug 完全不可见。tests/ 原本一条三斜 fixture 都没有,只能合成一条 |
| 脚本的倾斜折叠 | 照抄 | 参考脚本写 xz -= round(xz/ly)*ly、yz -= round(yz/lz)*lz,而 LAMMPS 的约束是 |xz| ≤ lx/2、|yz| ≤ ly/2 —— 那两个除数是错的。Ferro 不做折叠 |
MD| Temperature [K] |
取行末那个数 | 该行有两个:瞬时、累计平均。取第二个不报错,只会给出一条越来越平的曲线。tests/cp2k_md_3frames.out 选这三帧正因为第 2、3 帧两列不等 |
| OUTCAR 的温度 | 以为 (temperature X K) 是晶格温度 |
它印在 EKIN_LAT 行的括号里,但那是离子温度:实测该行 EKIN_LAT 恒为 0 而括号里是 111.55,后者由 EKIN=4.268142 / N=296 按 2E/(3N·k_B) 得来。按 token 取 (temperature 之后那个浮点,别取行内第一个(那是 EKIN_LAT 的 0) |
| vasprun 的温度 | 用 <i name="TEBEG"> |
那是输入参数(恒温器设定点),不是瞬时值。vasprun 不打印温度,只能由 <i name="kinetic"> 反算,除数是 3N 不是 3N−3(依据是 OUTCAR 那一对数)。导出量要在表头注明,否则同一次运行换个来源、数字对不上却没人解释得了 |
| CP2K 的逐帧 step | 用与 anchors 并行的那个 steps Vec |
它只在解析成功时 push,某个锚点的步号解析不出就整体错位一位,而错位读到的是别帧的步号,不报错。从锚点行 lines[a] 直接取 |
collect 的默认输出位置 |
默认 . |
0.3.2 因此定了「-o 必填」。那条反对的是 . 这个值,不是「有默认值」本身 —— 跟着输入走的默认值(AIMD 目录同级)撒不到别处去。判据更正为:默认值必须跟着输入走,不能是 . |
| 默认路径怎么拼 | canonicalize |
规范化后是绝对路径,刷屏且认不出(issues.md 早有同一条)。with_file_name 换掉末级组件,同级正是要的位置;只有 . / .. 这类没有末级名字的才回落到规范化 |
.db 后缀 |
塞进 SPLIT_SUFFIXES |
.train/.valid/.test 说的是划分的哪一部分,.db 说的是数据从哪来 —— 不是一个维度。进去会让「.valid 不许再划分」那条守卫连 .db 一起拒掉。它在 write_split 入口被剥一次,于是 md.db 筛成 md.train;留着会叠成 md.db.train,而 merge 的共享后缀检查只看末尾一段,两段后缀让它失去唯一解 |
| inspect 产物的位置 | 放进 -o 树,或塞进 system 目录 |
放进 AIMD 目录自己的 ferro_inspect/。CP2K 日志常常全叫 total.out、靠目录区分(与 bader 报告默认写输入旁边同一条理由);塞进 system 目录则会混进 DeePMD 的训练数据目录 |
--type inspect 收到 -o |
静默忽略 | 报错,在读第一个文件之前。-o 的含义是「数据集写到哪」,而这条路一个 npy 都不写 —— 忽略会让用户回头去那个目录找一个从未写过的数据集 |
collect 的 --type |
复用 filter/merge 的 OutType |
单开 CollectType。复用会把 inspect 列进那两个命令的 --help(敲了才被运行时拦下),而 help_sync 查的是参数名、不查值域,这种不诚实它抓不到 |
| 逐帧标量的缺失值 | 补 0 | 渲染成空字段。「这条路读不出步号」不等于「第 0 步」,与列并集补零冒充「测到了 0」是同一类错误 |
inspect 的 .data 取哪一帧 |
第 0 帧(参考脚本的默认) | 末帧。第 0 帧是喂进 CP2K 的初始构型,用户手上本来就有;末帧才是这次 AIMD 产出的新东西 |
| 逐帧体积 | cell[0,0]*cell[1,1]*cell[2,2] |
|det|。前者只对正交胞成立,三斜胞下静默给出错的密度 —— 参考脚本两处都是前者,与 VASP 那轮记过的是同一个坑 |
用 python - <<'PY' 批量改 Rust 文件时,Rust 字符串里的续行反斜杠会被 Python
吃掉:写在 Python 三引号串里的
"...its three \
files always go to..."
到 Rust 文件里变成了同一行上的 its three files,编译通过、
测试全绿,只有运行时的错误消息里多出一串空格。另外 bail! / format! 的
格式串必须是单个字面量,相邻字面量拼接("a" "b")在宏里不合法。
按两个锚点切片搬代码(s[:a] + s[b:])时,先断言 a < b(2026-10-09 实测踩到):
第二个锚点在文件里出现了两次,index 取到的是前一处,b < a,于是 s[:a] + s[b:]
不是删掉一段而是把 b..a 复制了一遍,编译照过。锚点文字要唯一(count == 1),
搬完看一眼 git diff --stat,增删行数对不上就是切错了。
bader_weight.rs 把 1 索引数组的 volchg[nvols] 当真空电荷。现象、实测数字与修法见
plan/suspended.md「bader weight 的真空电荷取错」(Bader 改写组)。ferro-cli/tests/bader_reports.rs
的真空断言因此只跑 ongrid / neargrid,注释里指名了这条。
两轮审计的判据不同:第一轮找「死代码 / 重复」,第二轮按 CLAUDE.md 的
R1–R6 逐个函数过。这里只放下次改代码时会再踩的判据。 已落地的改动翻
git 历史(8a8fe75..443d45c),未定的留在 plan/zero-call.md。
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
| 全局 | 拿 cargo clippy 零警告当「没有死代码」 |
库 crate 里 pub 项不触发 dead_code lint,零调用的那几百行全部躲过了它,其中两个整文件还被 lib.rs 的 pub use ...::* 再导出。查零调用要按名字逐个 grep 全仓,不能靠编译器 |
| 全局 | 看见两处相似就合并 | Rule of Three:两处不动,第三处才评估,判据是「是否同一个变化原因」。2026-09-12 那轮按此把六条 ×2 的重复判为有意保留(见下) |
| 「零调用 = 死代码」 | 直接删 | 本仓多数功能还没进入实际使用,「需求确定但 CLI 层没接」与「开发遗留」在代码里长得一样。cube_jump 就是前者(429 行 + 10 测试 + 手册页齐全,只缺 CLI 入口)。判据要靠人,清单见 plan/zero-call.md |
readers/ 的 floats 辅助 |
看见同名函数就全合成一个 | 合并后的 readers/util.rs::floats 是 map_while(遇到非数字即停,让行尾注释不中断解析)。vasp_outcar.rs:57 那个是 filter_map(跳过非数字继续,为了 in kB 这类行首带标签的行),语义相反,不能并。另:第四份曾藏在 cube.rs 叫 parse_floats,按名字 grep 找不到 |
ml/geometry.rs:43 min_image vs Cell::minimum_image |
语义相同,想合 | 前者把矩阵 transpose 与 inverse 提到 O(N²) 内循环之外。合并等于把 try_inverse() 放回内循环。2026-10-18 已合:两边都改走构造时预存逆矩阵的 PeriodicCell,见文末「周期几何」节 |
md/sq.rs elem_z_local |
symbol_to_z 的薄包装,想换成直调 |
未知符号给 0(sq 用 Z 当电子数,symbol_to_z 的 255 是荒谬值)。gr 原有的 elem_z(给 255 排到末尾)已随 canonical_cmp 删去(13.6) |
md/cube_sdf.rs:149 signature |
当成第三套化学式渲染 | 它拼的是原子类型当 HashMap key(Ob:6|P_2:1),不是化学式。骨架(BTreeMap 计数 → 拼串)像而已 |
| 化学式渲染两套 | 当成不一致要统一 | group_name 省略计数 1(目录名,ls 里当名字读),formula_of 不省略(报错里与第二个化学式上下叠着读,补齐的下标才对得上列)。用途相反,都对。两处已互相指名 |
md/mod.rs 的 pub use 与 lib.rs 的 pub use md::{...} |
以为加了模块就导出了 | 两处手写同一份清单。cube_jump 在 md/mod.rs 导出了、在 lib.rs 漏了。加分析模块时两处都要看 |
build_avg_frame 的跨周期边界平均是真 bug(原子被平均到盒子正中央),但修法
有两个,只有一个是纯修 bug:
- 各帧用自己的 cell 做 cart→frac:NPT 下实测让 2004 个原子全部变动
(中位 0.74 Bohr)—— 因为分数平均 ≠ 笛卡尔平均,这等于顺带改掉了「NPT 下
时间平均帧指什么」,而那个口径
progress.md记着未定义、需先定义再动 - 一律用参考帧的 cell:固定矩阵下「分数平均再映射回来」与「笛卡尔平均」 严格恒等,于是没跨边界的原子逐位不变,实测 1932/2004 未动、72 个跨边界者 移动 11.7–46.8 Bohr
取后者。判据是:bug 修复的 diff 要能一眼说清「变的都该变」;顺带改口径会让 golden master 的差异失去唯一解释。
产物里没有时间戳、没有绝对路径,唯一的非确定源是产物头里的
ferro v{CARGO_PKG_VERSION},重构期间版本号不动。所以 R5 的逐字节 diff
不需要任何屏蔽 —— 而「需要屏蔽的东西太多」正是 golden master 最常见的失败原因。
两次跑同一版二进制自比对零差异,93 个产物(含 npy 二进制)全部确定。
盖不到的(无速度、无 cube fixture 的几个分析)登记在 plan/io.md「测试缺口」与「带速度的测试 fixture」。
bader 已补上(2026-09-20):tests/CHGCAR_2atoms 是 8×8×8 的合成网格,
三种方法 × 三份报告都能端到端对拍。它的密度处处高于真空阈值,故 vacchg 恒为 0
—— 真空路径仍未覆盖,见 plan/suspended.md 的 weight 缺陷一条。
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
Former.qn |
当成「总桥氧数」 | 它是同元素连接数(P–O–P),文献 Qⁿ_m 的 n。总桥数 = n + Σm,而桥氧个数是第三个量(n_bo):三簇氧算 1 个桥氧但 2 个连接,三者只在无三簇氧时才相等 |
bridges_to |
只统计「恰好两个形成子」的配体 | 统计全部 nf.len()>=2,除自己外每个形成子各算一个连接。文献数的是 connections(arXiv 2510.13545: "the number of aluminum and boron connections with phosphate"),按桥氧记会让 Σm 亏空、无法与 n 相加 |
qn_partner 的列 |
把形成子自身元素也出成 m_<自己> 列 |
排除。新口径下 m_P ≡ qn,是恒等重复列;文献 Pⁿ_mAl,xB 也没给自己留下标位。去掉不影响闭合——m_P 由 n 唯一决定,不是独立分组维度 |
linkage 的端点列 |
列名说桥氧、值填连接数 | 端点数字列必须与 label() 渲染的数字同源,否则同一行里 P_2 和数字列会对不上。故改名 qn_a/qn_b 并填同元素连接数 |
qn 表 sd 全为 0 |
当成累加器坏了 | 可能是真的:新口径下 P–O–P 骨架若逐帧不变,sd 就是 0。参考轨迹 5 帧全是 93/207/71/1(独立 Python 复算一致),涨落全在 P–O–Al 与配位数一侧。旧口径把刚性与涨落混在一个数里,所以从前 sd 非零 |
| 共边多面体 | 假设网络全部共角 | 传统 Qn 的三条前提是「全四面体、全共角、无三键氧」。共边下一个邻居贡献两个桥氧而数字看着正常,故加 n_edge_sharing 检测。参考体系 3589 对全共角、零共边;实测把截断放大到 3.2 Å 会报出 1117 对——共边非零时先怀疑截断 |
AtomType 的消费 |
从 label() 的字符串反解析数字 |
直接读字段。0.2.1 前有五处这样的解析器,散在四个 crate,改标签格式要同步改六处,漏一处即静默错数据(extract_qn 对 "P_3" 解析失败后 unwrap_or(0),所有 P 变 Q0) |
Moments 的 sd |
用 Σf² − N·mean² 求方差 |
用 Welford。恒定序列下两遍求和给出 ~2e-10 的抵消噪声,而 sd 列里的假抖动会被读者当成物理。rayon 是归并不是顺序折叠,故需 Chan/Golub/LeVeque 成对合并 |
Moments 的缺席帧 |
只累加出现过的帧 | 缺席帧按 f = 0 计入,在 finish(n_frames) 里按同一条合并公式补进来。否则 5 帧里只出现 1 次的 bin 会被当成「比例 1.0」 |
Accumulator::new 预置 |
按参数预置每个元素的空条目 | 参数里点名但轨迹里没有的元素必须在 finalize 丢掉,否则均值算成 0.0,把「不存在」伪装成「平均值为零」(实测 70Z30P00A 的 mean 表里冒出 Al=0.00) |
write_lammps_dump 的 type 列 |
在帧循环内按首见顺序重建 | 整条轨迹确定一次。元素只有几种时逐帧碰巧稳定,写位点标签后某帧恰好没有 P_4,同一编号就在不同帧指向不同的东西 |
| label 折进 element 列 | 无条件折叠 | 只折 <element>_… 形式的标签。CIF/CP2K/QE 的位点名(O1、Fe1)折进去后读回来就是不存在的元素 O1;不合约定的要跳过并计数告警 |
| 按标签跑 g(r) | 以为标注轨迹能直接多帧分析 | 只在单帧成立。calc_gr 要求逐类型粒子数守恒,而标签 population 逐帧在变(实测 P_3:149/152/150/150/150)。多帧请按元素选 |
| linkage 的规范半边 | 按行求和当作某位点的总参与度 | 每对只存一次(两端按 (元素, 桥接数, 配位数) 排序),行和不是总参与度;要算参与度得把 _a 与 _b 两列都数一遍 |
bridges_to 与三配位配体 |
期待 Σm == qn |
三配位配体计入 qn 但没有唯一对端,不进 bridges_to,故 Σm ≤ qn,差额即三配位桥数 |
bridging 与 cn |
当成同一个数 | 两个不同的量:cn 数截断内全部配体,bridging 只数连了 ≥2 个形成子的。参考轨迹的 Al 不带非桥氧(ligand_type 无 O_n, Al 行),故逐原子恰好相等 —— 这是该体系的性质,不是定义,换个体系立刻分叉 |
| 非 Qn 形成子的 label | 以为数字一律是桥接数 | Qn 形成子(默认 B,P,Si)取 Qn,其余取配位数。Al_4 是四配位 Al。约定在分类时写进 AtomType::Former.qn,不在渲染时查静态表 —— --qn 能在运行时覆盖它 |
LinkKey 的维度 |
只存两端位点状态 | 必须含配体元素。双配体体系(--Al-O + --Al-F)里 Al-O-P 与 Al-F-P 是两种桥,合并计数无法与实验对应。参考体系只有 O,所以这个洞在 0.2.1 里一直没暴露 |
batch::write_all 的 meta |
table.meta = meta.clone() 覆盖 |
拼接:公共块 + 该表自述。分析侧用 Table::meta_line 写的逐列说明会被覆盖掉;Table::concat_union 也要保留各部分 meta(取第一份),否则说明在 stack 阶段就丢了 |
composition 的配体行 |
把 ligand_type 三行的 sd 相加 |
逐帧重新累加。相关项之和的方差不等于方差之和 —— 与 qn/qn_partner 那次同一个坑。参考数据里恰好相等,因为三个分量有两个方差为 0(常数不改变和的方差),不能当作「相加也对」的证据 |
配体 fraction 的分母 |
全体配体原子 | 该配体元素的原子数。「桥氧占全部氧」是文献 BO fraction,「占 O+F」不对应任何常用量。单配体体系看不出区别 |
Qn 标签写成 Q2 |
以为够短够清楚 | 带元素前缀 P-Q2。双 Qn 形成子体系(B+P、Si+Al)里 Q0…Q4 各出现两遍,跨元素重名没有相邻列能解 |
| 把伙伴分解写进 label | Q2(1Al) 这种文献写法 |
不写。Q^n(mX) 表达不了「该桥的配体连了三个形成子」,参考数据里 (qn=2,m_Al=1,m_P=0)(Σm 亏空 1)与 (qn=2,m_Al=1,m_P=1) 会渲染成同一个标签 |
| 单元词汇与原子词汇混用 | 让 linkage 也写 P-Q2 |
桥联连的是原子,Qn 命名的是含多个原子的单元;导出轨迹更必须用原子形式,否则 dump reader 拆不出 element |
| 空分布表 | 照写只有表头的 CSV | 跳过并打印原因。只有表头的 network_qn.csv 读起来像「测了,Qn 是零」,而实情是压根没测 —— 与 Accumulator 那条 Al=0.00 是同一类错误 |
CELL|必须精确匹配,不能starts_with("CELL")。 2025 的输出里同时有CELL_TOP|、CELL|、CELL_REF|三个同形块,前缀匹配会拿到最后一个。三者 在正常运行里数值相同,所以这个错误在自己的数据上永远不发作ENERGY|行的单位不能取"第一个括号"。 行里更早还有一个( QS ),取第一个 括号会把单位读成QS。取"数值前一个 token"再剥括号,三种写法 ((a.u.):/[a.u.]:/[hartree])一次覆盖- 坐标表的 Element 列其实是两个字段(
Fe 26,符号 + 原子序数),整行 9 个 字段。按"符号前一列是 kind"去数列会错位,kind 名得从ATOMIC KIND INFORMATION块按索引查表 - 应力块尾部的特征向量行是三列裸浮点,与矩阵行一样能解析出三个数。行首必须
限定为
STRESS|或行标X/Y/Z,否则读到的是特征向量不是应力张量 - 缺应力与缺力后果完全不同。 缺应力只是没有 virial,帧照样能训练;缺力则
这一帧进不了 DeePMD system(
force.npy不是可选项)。把两者归成一类处理, 要么白丢一批好数据,要么写出一个 force 全零的数据集 - 截断落在力表中途时,力的条数少于原子数而不是零条。 判据是"力数 == 原子数", 不是"有没有力块"
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
collect 的成分比较 |
先拼接再 sort_atoms |
逐文件各自排完再拼。置换取自第 0 帧再套到全部帧,先拼后排会拿第一个文件的原子顺序去重排第二个文件的原子 —— 产物看着完全正常,数值全错 |
| 成分不符的报错 | 比较逐原子序列、却按计数渲染两个化学式 | 两边打出来会一模一样(Al2O303P100Zn50 vs Al2O303P100Zn50),消息无法自证。比较规范序列之后,剩下的差异必然是计数差异,点名到元素 |
| 「重排原子」的实现 | 只置换 forces |
Frame 上有三个逐原子旁路数组:forces、velocities、bonds(后者存的是原子编号,要反向映射 old→new)。dpdata 的 sort_atom_types 遍历 DTYPES 表、凡带 NATOMS 轴的一律置换,漏不掉;ferro 是手写的三个字段,只能靠测试钉住 |
convert 的原子重排 |
整条轨迹调一次 sort_atoms |
逐帧各调一次(2026-10-17 起 sort_atoms 本身逐帧取置换,此条与上一条只剩历史意义)。sort_atoms 取第 0 帧的置换套全部帧,只对帧间原子顺序恒定的轨迹成立;LAMMPS dump 读侧按 id 排过,但 xyz / extxyz 没有 id,帧间顺序可变。逐帧排对顺序本就恒定的轨迹结果相同(稳定排序) |
| 磁盘上的原子顺序 | 以为会影响训练 | 不影响:DeePMD-kit 加载时自己按类型稳定排序(deepmd/utils/data.py:1026,默认 sort_atoms=True,文档写明非 mixed type 的 descriptor 要求如此)。但 mixed type 是例外,那条路上不排序,将来做 mixed type 时不能无脑套用 |
--format-in 与嗅探 |
给了 --format-in 就不嗅探了 |
嗅探照跑。它换来两样东西:不一致时的一行 NOTE:(静默服从会让手滑写错与有意覆盖长得一样,而前者的下一个症状是解析器在一份好文件上报锚点缺失),以及那条「同目录 OUTCAR + vasprun.xml」守卫的比较对象 |
| 那条守卫 | 继续比较 stats.format |
--format-in 一给,stats.format 就恒等于它,守卫恒不触发。改为比较嗅探结果。守卫挡的错误没有任何其他症状:数据集看着完全正常,只是每帧躺了两遍 |
| 「版本多、易错,把嗅探删掉」 | 照做 | 先量一下:sniff 只有 40 行且不含任何版本知识,担心的那部分全在四个 reader 的块匹配里,删掉它一行都减不掉 |
| CP2K 读不到 `GLOBAL | Run type` | 默认按 MD 走 |
| 精简帮助页 | 直接删段落 | 先逐条核对手册里有没有。这次 collect 删掉的 59 行在 docs/src/dataset/collect.md 里逐条命中,所以是删不是搬;不核对就会丢事实 |
| 帮助页的小节名 | 随手起 | job -s cp2k 原本有一个叫 Output: 的参数分组(--cube/--molden),加产物段时撞名。一页里两个同名小节,读者无从分辨 |
println! 里的花括号 |
直接写 {coord,box}.npy |
会被当成格式占位符,编译报 "python's numeric grouping" 这种毫不相干的错。改写成 `coord |
| 统计帮助页行数 | `ferro | wc -l` |
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
| 力块 | 找到 atom … force = 就读 |
verbosity = 'high' 时总力后跟七组同格式分量(非局域、离子、局域、核修正、Hubbard、SCF 修正、DFT-D3),总力只是 Forces acting on atoms 下第一组 nat 行 |
| md / vc-md 的帧配对 | 把 Entering Dynamics 之后的 ATOMIC_POSITIONS 当作这一步的结构 |
那是下一步的结构:第 k 步的结构印在第 k−1 步末尾,第 0 步取输出头;最后一步之后的卡片丢弃。错一位时能量、力配着错的坐标,没有任何报错 |
Entering Dynamics 的识别 |
按 token Dynamics 匹配 |
md 写 Dynamics:、vc-md 写 Dynamics;,token 都带标点 |
| 输出头的晶胞 | 取 lattice parameter (alat) |
只有四位小数;celldm(1) 六位 |
| 应力 | 取 kbar 列(dpdata 如此) | kbar 只两位小数,Ry/bohr³ 列八位;dpdata 的 virial 因此与 ferro 差约 1e-4 eV |
| 拿 ASE 核对能量 | ASE 的值当真 | ASE 的 espresso reader 写死 create_units('2006'),而 QE 7.5 constants.f90 用 CODATA 2018:ASE 的能量相对偏低 1.2e-6(262 Ry 处 3.1e-4 eV)。能量对 dpdata(scipy 2018) |
| 没开 tprnfor 的 scf | 帧照收,只是没力 | collect 会写出没有 force.npy 的 system。力必需,同 cp2k_sp;报错点名 tprnfor |
| 没收敛的单点 | 返回空轨迹 | collect 拿空组成和同目录别的文件比,整个目录报「组成不同」。一帧不剩就报错,collect 据此 SKIP(同 cp2k_sp) |
| GIPAW / pp.x 输出 | 只认 Program PWSCF,别的报「认不出」 |
它们横幅格式相同,点名「这是 QE 的 GIPAW 输出,只读 pw.x」 |
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
writers/deepmd.rs 的 nopbc |
按「帧有没有 cell」决定写不写 | dpdata 的非周期 system 照写 box.npy,读进来有 cell、pbc 全 false。按「无胞或三向非周期」决定;writer 还会删掉旧标记,判错就静默把团簇写成周期体系 |
| deepmd 往返测试 | 往返一致就当布局对 | 读写两边同时转置照样往返一致。另读盘上 npy 查 [0][1];应力要非对称 |
| 拿 dpdata 对比 nopbc system 的盒子 | 以为 cells 是 box.npy 原值 |
dpdata 读 nopbc system 时把 cells 置零;比盒子直接读 npy |
| 「同一 AIMD 文件帧间原子顺序变化」 | 想在 collect 层造端到端用例 | 四种 AIMD 格式的元素列表都是逐文件的,造不出来;逐帧情形只能在 sort_atoms 层测 |
cp2k_md.rs 读不了 CP2K ≤7.1:能量行写 energy (a.u.):(圆括号),而
unit_in_brackets() 只找方括号,于是整份文件 bail 在 unknown energy unit `[]` 。
同一批里还有一条更隐蔽的:≤7.1 的应力块是 STRESS TENSOR [GPa],没有
STRESS| 前缀,匹配不上的后果不是报错而是 frame.stress 恒为 None ——
帧照样读出来、数据集照样写出来,只是少一个 virial.npy,全程零错误零告警。
这两条和 extxyz 的 stress 符号、LAMMPS dump 的三斜盒子是同一类: 测试只覆盖了自己产生的数据。仓里的 CP2K fixture 全是 2025.2,而 2025.2 的 两个块都是新写法,所以老写法这条路从来没被走过。
处置:把 cp2kdata 的一份真实 6.1 输出整份收进 tests/cp2k_sp_v61.out,断言数值
取自 cp2kdata 自己的 answer/ 目录——跟独立实现对表,不跟自己的输出对表。
用户最初要求"白名单 + 不支持的版本直接报错"。查证后改成"照常解析 + 每文件打一行
NOTE:",理由三条:
- 硬拒绝会挡掉没见过但格式没变的将来版本。 CP2K 大多数发布并不动日志布局
cp2k_md.rs的模块文档里早就写着相反的判断:token 序列匹配而非版本表, 且STRESS_UNIT是 CP2K 的输入关键字、不是版本的函数,版本表答不了这个 问题。加白名单等于推翻一条已经写下并被测试钉住的判断- 白名单本身装着两种布局。 查完 cp2kdata 全部 28 份 fixture,格式断代在 7.1 与 8.1 之间、2024 与 2025 之间各有一次,「2023–2026」这一段里横跨一次断裂
提示文本里必须点名版本号。只说"版本未验证"的提示,用户下一步还得自己去 grep 文件头才知道是哪一版。
最终口径(2026-09-22 用户定):明确实现两代——2023–2024 与
2025–2026。2025–2026 静默,2024 及之前打提示后照常提取。7.1 及更早"过老,
暂不考虑",但既有的解析代码与 cp2k_sp_v61.out 保留:它是 ATOMIC FORCES 块
唯一的真实文件样例,而那个块 2023–2024 也在用。
deepmd 环境里原本是 cp2kdata 0.7.3(PyPI 最新发布),它的上限是 2024.1, 读不了 CP2K 2025 的输出。逐块看:
| 0.7.3 | private/ 的 0.7.4 |
|
|---|---|---|
| MD cell 版本表 | 9.1 2022.2 2023.1 2023.2 2024.1 + 7.1 |
另加 2024.2 2025.1 2025.2 |
| 能量 | energy\s\S{6}: —— 只中 (a.u.): 与 [a.u.]: |
另加 [hartree](无冒号) |
| 力 | 只有 ATOMIC FORCES in [a.u.] |
另加 FORCES| Atomic forces |
| 应力 | STRESS TENSOR [GPa]、STRESS| Analytical … [GPa] |
另加 [bar] |
2025 的输出在 0.7.3 下失败形式是 parse_energies_list 返回 None,然后在
dpdata 插件里炸成 TypeError: unsupported operand type(s) for *: 'NoneType' and 'float' —— 看起来像 dpdata 的错,其实是版本不够。
已按用户授权把 private/cp2kdata(0.7.4,HEAD eb1d2a8)装进 deepmd 环境
(pip install --no-deps --no-build-isolation .),所以对表不再需要
PYTHONPATH。用户已用 dpgen 验过这一版。
注意 0.7.4 的 MD cell 版本表里仍然没有 2026,届时会 raise NotImplementedError;ferro 这边不依赖那张表。另外它的版本号正则是
\d{1,4}\.\d,小数点后只收一位。
与 dpdata 的逐项对拍结果写在手册里,不在这儿:
docs/src/dataset/collect.md 的 "Checked against dpdata"(ferro doc dataset collect)。那是给用户看的证据,重复一份只会两处分岔。
对拍时唯一的操作陷阱:atom_names 的顺序两边不同(dpdata 按首次出现,
ferro 按 (Z, 符号),见 writers/deepmd.rs),所以比对逐原子的量之前必须先
配对,不能直接按下标比。
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
| 列宽计算 | 着色后的字符串含 SGR 转义码,chars().count() 会把它们算进宽度,表格边框被挤歪 |
折行按 shown()(未着色文本)计宽,已着色的行用 screen_len 跳过 ESC[...m。测试 rich_table_aligns_on_screen_columns_not_bytes |
| 朴素样式 | 任何一处无条件输出 • │ ─ 或希腊字母,Windows 老控制台上就是乱码 |
所有非 ASCII 字符都经 style.unicode 分支;plain_style_is_pure_ascii 断言整段输出 is_ascii() |
| 内容保全测试 | 屏幕逐行读折行的表格,相邻列片段交错,逐字符比顺序必然失败 | 宽度给足时比顺序,窄宽度比多重集;unicode 样式不进这条测试(公式本就该变),由 latex 的用例钉住 |
| 在 macOS 上量行宽 | BSD awk 的 length 按字节计,一个框线字符算 3,报出几百条「超宽行」 |
用 Python 按字符数量 |
用 script 伪造 tty |
在非交互 shell 里 script 抱怨 tcgetattr;看起来像失败,其实输出照常 |
加 </dev/null 并检查输出行数,别只看「超宽 0」—— 空输出也是 0 |
19 条发现的处置见 decisions.md「2026-10-01 全库审查」。这里只记下次写 reader /
writer 时会再踩的判据:
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
| reader 遇到截断文件 | 行数不足就 continue / unwrap_or(&""),残帧照收 |
MD 被中断而留下半帧是常态不是异常。不足 n 行报错,或丢末帧并告警;lines[i] 一律 get + bail。残帧的下游后果分三种:panic(msd、bondlife 按第 0 帧原子数索引)、静默算错(net、angle、map)、被守卫拦下(只有 gr)—— 前两种都比报错更糟 |
| 时间相关分析 | 假设各帧原子数相同 | 只有 calc_gr 有粒子数守恒守卫。读侧不拦,分析侧就要拦;守卫该在 core 共享一份,不是每个分析各写一份 |
| writer 换坐标系 | 只变换坐标 | 任何对坐标的变换(转到 LAMMPS 规范胞、按元素重排原子)都要对全部逐原子旁路数组做同一变换:速度、力,以及按原子编号的 bonds。与 sort_atoms 漏 velocities/bonds(2026-09-22)是同一族,这次在 writer 侧又出现两处 |
| 输入格式的关键字写法 | 只认测试里写过的那一种({bohr}、.TRUE.) |
查格式自己的解析规则:QE 卡片选项可写 bohr、{bohr}、(bohr),大小写不敏感;CP2K 逻辑值单写关键字即真,T TRUE .TRUE. YES ON 均真。认不出的写法报错,绝不回落到一个默认单位 —— 回落 Å 的结果是键长 0.43 Å 的结构,全程零告警 |
| 扩展名分派 | .xyz 就是纯 XYZ |
ASE、GPUMD、CP2K 与 ferro 自己的 --type nep 都把 extxyz 写成 .xyz。按扩展名分派只能定「哪一族」,族内要看内容(第 2 行有无 Lattice= / Properties=) |
| 多输入的产物名 | 只用 stem | runs/*/prod.lammpstrj 是常见布局。collect 已改为按目录分组,其余命令同名依次加 _2、_3 并打 Note(batch.rs 的批内标签,plan 10-03 审查 S7);撞名必须在读第一个文件前判,事后覆盖是静默的 |
| 周期网格的搜索窗 | rem_euclid 折叠后直接累加 |
窗口 2s+1 超过网格点数时多个偏移折到同一格,重复计数。与 box_builder CellList 缺去重同一个洞 —— 第三次出现,下次写周期网格遍历先想这一条。修法(M4):每轴先算下标列表,窗口 ≥ 网格点数时取全部 0..n,否则折回(此时互不相同)—— 比 seen 去重便宜,且窗口小于网格时与原遍历逐位一致。现存的 cube_radius.rs 已处理(angle 已改用 NeighborList) |
StdRng |
当作可复现的 RNG | rand 文档明写不可移植、将来版本可换算法。要「同 seed 可归档复现」就直接用具体算法的 crate(chacha20)并固定版本 |
| POSCAR 的速度块 | 跳过空行后把剩余行当速度 | 坐标之后恒有一行模式行(空行也算一行):空行或 C/K 开头 = Cartesian Å/fs,其余 = Direct(需 POTIM,报错);首字符 L 是 NPT CONTCAR 的晶格速度块(再跳 8 行)。滤掉空行会把 Cartesian 那一行当成第一条速度。ASE 的 write_vasp(sort=True) 也有「坐标重排、速度原序」的 bug —— 照抄参考实现前先看它有没有同病 |
| ASE 当参考实现 | 以为它覆盖全部合法输入 | ASE 3.29.0 的 read_espresso_in 读不了 A=(namelist 键已转小写、却按 'A' 查)与 1.026D+01 这类 namelist 值,也不支持「无 celldm 时坐标 alat 取 |a1|」。对拍时 ASE 报错的情形回 pw.x 源码(Modules/cell_base.f90)手算,并在测试注释里写明来源 |
| 格式的「可选提示」 | 按 Atoms # full 注释或列数推 LAMMPS data 的 style |
注释在 LAMMPS 里可选,列数有歧义(charge 与 molecular 同为 6 列;8 列的 atomic+image flag 按 full 读会把 image flag 当坐标)。用户裁定(2026-10-02):style 必须显式给出,注释即使存在也不看;对不上的列数报错 |
| 截断 vs 写错 | reader 把一帧读不成一律当「末帧被截断」丢掉 | 截断只会切在文件最后一行;别处的缺列、坏字段、缺坐标列是文件写错了,截断解释不了,恒报错。单帧文件尤其要分清:按截断处理会读出 0 帧且不报错(M2 修时实测踩到)。2026-10-20 统一(扫描 B-2,用户定):最后一行「列数不足或数字读不出」才算截断,整行完整但内容错(原子序数查不到元素)恒报错;截断后一帧不剩改为报错。xyz / extxyz / pdb / dump 共用 readers/util.rs::drop_truncated,新 reader 照用 |
| 缩放坐标 | xs 直接 s·L |
LAMMPS 定义 xs = (x - lo)/L,还原要加真实 lo(三斜时由 *_bound 反推)。ASE 3.29 漏了 lo,与它自己的 x 列不自洽,此处不以 ASE 为准 |
| 参数取值检查 | if v <= 0.0 { 报错 },或只在计算里、读完文件才查 |
写成「合法则放行,否则报错」:NaN 与任何数比较为假,落到报错分支(反着写会放过 --dr nan;clippy 也不让写 !(v > 0.0))。步长、截断还要 is_finite,inf 的步长得 0 个 bin。判据放 XxxParams::validate,CLI 在建 -o、读第一个文件前调,calc_* 入口再调一次给 Python |
返回 Option 的计算 |
失败原因揉成一个 None,调用者去猜 |
angle、vanhove 以前的报错「empty trajectory?」「trajectory too short?」都是猜错的。返回 Result,每种失败各自报 |
| 坐标「规范化」(平移原点、折回盒内) | 以为周期性配对了,平移与折回不影响任何分析 | 单帧分析(gr、angle、net)确实不受影响;NPT 的时间相关分析会。LAMMPS 绕盒心缩放,xlo(t) = c - L(t)/2 每帧在变:按帧减 lo 等于每帧整体平移 ΔL/2,5 帧 NPT 测试轨迹 lag-1 MSD +57%;只折回到 [0,L) 也有 0.03–0.2%(TOR 下额外折回留 ΔL 残差)。NVT 两者都不变。MDAnalysis 的 dump reader 正是按帧减 lo(2.4.0 起),其 NoJump 是 Bullerjahn 2023 判为病态的格点(LAT)方案。用户裁定(2026-10-02):Ferro 一律保留读到的原始坐标,只有 cube 的参考结构折回(md/util.rs::cube_from_grid,标为特例;原在 cube_density::cube_reference_frame,第 5 批 5.3 并入)。Frame::wrap_all 已改为按轴尊重 pbc、只动越界原子,供这类特例用 |
| 「解析失败」 | parse().unwrap_or(0.0) |
与列并集补零同一类:把「读不出来」伪装成「测到了 0」。报错并点名行号 |
数据表 ferro-io/src/readers/spacegroups.dat 由 scripts/gen_spacegroups.py 从 spglib 2.7.0
生成(530 条 Hall 设置、7388 个操作),include_str! 编进二进制。查表在 readers/spacegroup.rs。
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
| 数据来源 | 直接抄 ASE 的 spacegroup.dat
|
ASE 是 LGPL-2.1+,ferro 是 MIT,编进二进制有静态链接义务。spglib 是 BSD-3,且 setting 显式标注(530 条,ASE 只有 274 条)。署名在数据文件头与 docs/src/introduction.md
|
| 同一符号多个设置 | 默认取第一个(ASE 的做法:无 setting 标签即取 1) | 选错原点原子数照样合理、位置全错:F d -3 m 上 (0,0,0)+(½,½,½) 两个位点,原点 1 得 16 个原子、原点 2 得 32 个。一律报错并给修法(:1/:2、Hall 符号、显式操作)。67 号 C m m e 的 abc 与 ba-c 两个设置差一个原点平移,新符号不分轴序,同样报错 —— ASE 在这里静默取标准设置,是 ferro 与 ASE 唯一的对拍差异 |
| 单斜唯一轴、R 群 H/R 胞 | 用特例代码按符号判 |
度规筛选:保留旋转满足 |
| H-M 简写 | 先比去掉占位 1 的形式 |
P 3 1 2(149)与 P 3 2 1(150)去 1 后撞名。先精确比设置符号与全符号,都不中再比简写(P 21/c = P 1 21/c 1) |
| 旧符号(e 滑移面前) | 映射到新符号(ASE 的 old_spacegroup_names) |
67、68 号的新符号不分轴序(Cmma、Cmmb 都成 Cmme),映射到新符号反而制造歧义。旧符号映射到 (IT 号, 轴序) |
| spglib 的数据 | 当作无误 | spglib 2.7.0 的 Hall 331(68 号 bca 原点 2)设置符号写成 B b c b,同轴序原点 1 与 ITA 都是 B b e b。不改则 B b e b :2 只剩 a-cb 一个候选、被静默选中。生成脚本对它单独改写并断言;其余「同号同轴序、原点 1/2 符号不同」的情形已扫过,只此一处 |
| 写坏的对称操作 |
filter_map(.ok()) 跳过 |
以前静默丢弃,全坏时退回 P1。报错并点名该操作 |
对拍(2026-10-03,scripts/crosscheck_spacegroups.py,非预期差异时退出码 1):一般位置单原子、按晶系给相容晶胞,
spglib 530 条 Hall 符号全部一致;530 条 H-M 设置符号 518 条一致、12 条按设计报歧义
(67/68 号);ASE 独立数据表 274 个 (IT 号, setting) 中 273 个一致,唯一差异即上面的 C m m e。
| 陷阱 | 正确做法 |
|---|---|
| 取「第一个最小 bin」 | 零平台上会贴在峰尾。tests/cp2k_md_3frames 的 Al-O 在 2.051 为 0、2.071 有一个样本(0.32)、2.091 起才是真空隙,取首格得 2.05 Å(审查 C-D5) |
| 改成「最长零平台中点」就以为够了 | 帧少时峰后是稀疏采样的低尾巴,里面零星有恰好为 0 的格子。43Z43P15A(5 帧)的 Al-O 从 2.0 拖到 3.0 Å(g ≈ 0.02–0.2),最长零段只有 2 格,g_min 报 0.000 冒充干净空隙。故先做 0.10 Å 滑动平均(用户 2026-10-07 选定)再找,g_min 变 0.027。真空隙平滑后仍是 0,P-O 不受影响 |
以为 g_min ≈ 0 就说明截断无所谓 |
只有平滑后仍为 0 才是。Al-O 那条尾巴里 CN(r) 一直在涨:4.08 / 4.12 / 4.15(2.2 / 2.4 / 2.6 Å),auto 取 2.591 Å 时还冒出 3 对共边告警(2.4 Å 时为 0)。这是体系性质,不是算法错;手册已写明「先看 g_min」 |
| 截断按两位小数打印 | bin 中心是 2.111 这类值,打 2.11 用户照抄复跑对不上。打三位,手填回去六张表逐字节相同 |
| auto 对在输入里不存在就让文件失败 | 定值截断对缺元素的输入照常分析(Accumulator 丢掉缺席元素),auto 必须一致,否则按成分扫描时不含 Al 的样品整个失败。截断置 0 而不留 NaN:NaN 在 total_cmp 下最大,会进最小镜像上界检查 |
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
Table::meta 经 concat_union 堆叠 |
只留第一份 meta(见上「network 重构」一节,那是为保住表的自述)。往表头写逐输入的数(O–O 分位、自动截断),多输入时第一个的值冒充整批(C-D6、B-7) | 表头只放与输入无关的说明;逐输入的数走 Summary 的列,进 [inputs]。同 CLAUDE.md 的 meta_lines 规则,只是落在 Table::meta 上 |
| 峰值折叠 | fold(0.0, f64::max):f64::max 遇 NaN 返回另一个数,NaN 被吞,再 m > f_max 对 NaN 为假,坏帧通过(C-D4) |
折叠时显式传播 NaN,判据写 m.is_nan() || m > max(clippy 不让写 !(m <= max)) |
| 只在 CLI 做的检查 | 帧间隔检查在 cmd/traj.rs 私有,Python msd 绕过,且默认 dt=1.0(D-M1) |
判据下沉到分析库(md::check_frame_spacing),CLI 与 Python 调同一个;会静默缩放时间轴的参数不给默认值 |
| 公开函数里的最小镜像 | 内部 minimum_image(..).expect("non-singular"),CLI 入口查过,库的其他调用方(如 classify_frame)直调即 panic(D-M17) |
公开入口查一次(interplanar_spacings()? 恰在奇异时报错),返回 Result;内部 expect 写明由谁查过。只返回 Option 的(chg_sdf)入口返回 None 防 panic。2026-10-18 起入口改为构造 PeriodicCell(见文末「周期几何」节) |
| 倾斜胞的拼接 | 沿晶格矢量量 gap、沿另一块自己的单位矢量平移(D-M2):正交胞上逐位正确,倾角一不同就错位且 gap 变小 | 高度用面间距(法向投影),平移在目标胞的坐标系里算;正交胞上新旧逐位相同,所以必须有倾斜胞的测试 |
| clap 默认值 | doc 注释手写 [default: x] 又给 default_value_t,短帮助显示两遍 |
有 default_value(_t) 就不手写;help_sync::a_clap_default_is_not_also_written_by_hand 盯着 |
| argv 预处理 | --X-Y=v 的剥离对所有子命令生效,traj gr --P-O=2.3 被吞、退出码 0(D-M16) |
预处理只对需要它的子命令(判据写在 split_pair_args 里) |
| 进文件名的用户字符串 | -s 不校验,a/../../x 写到 -o 之外(D-M13) |
一律过 label_char_ok,在读第一个输入前查 |
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
Cell.origin |
Cell::from_matrix / from_lengths_angles 一律把原点置 0。从旧胞造新胞(超胞、加真空)时用构造器,LAMMPS 的 lo 就静默丢了 —— 正是 A-12 本身 |
由旧胞派生的新胞写 Cell { matrix, origin: cell.origin };origin 只给认原点的格式和需要盒子真实位置的结构操作用,分析一律不用 |
| 非规范取向胞的 LAMMPS 写出 | 坐标经「分数 → 规范胞」线性变换,原点不为 0 时平移那段也被转走 | 绕原点转:to_lammps(pos − o) + o;速度、力是矢量不减原点 |
| 对称展开的测试坐标 | 想测「特殊位置上的像合并」,写了 (0.0004, 0.25, 0.25) 配反演 —— y、z 在反演下变 0.75,根本不是不动点,测试按错因失败 |
特殊位置的其余分量取该操作的不动值(反演取 0 或 0.5) |
| CLI 参数的「默认」与「给了」 | default_value_t 之后 derive 结构里分不清用户给没给(D-4 要据此决定关判据还是报错) |
改 Option<T> + 方法取默认;doc 注释手写 [default: …](无 default_value 时 help_sync 允许) |
| 第三个同构 xyz 块 | CP2K MD 按「第一块坐标、第二块力」取,VELOCITIES 也打到 stdout 且排在力前时,速度当力存、全程无错 | 两块之后继续扫到下一锚点,再见同原子数的块整份报错 |
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
| 盒子里量距离 | 直接拿 frame.cell 算:pbc 不是 TTT 的帧被当体相(A-7),Cell::minimum_image 每调一次求一次逆 |
分析入口 check::periodic_cells / all_periodic_cells 逐帧建 PeriodicCell(拒非 TTT、奇异、非有限,按截断查上界),循环里用它。core 的分类器内部取 PeriodicCell::of(frame)。Cell::minimum_image 只剩 Bader 两处(挂起) |
NeighborList 的结果 |
当成「严格在截断内」直接用 | 它是超集:含边界并留 1e-9 相对余量。调用方按自己的截断、用自己的算法再筛(gr 按 bin、angle 按 >=、bondlife <=、分类器 <)。要逐字节保持的量(rotcorr 求和、分类距离)用 pc.minimum_image 重算,不用列表里的矢量 —— 两者末位不同 |
| 邻居顺序 | 以为顺序无所谓 | neighbors_of 按下标排序,与全扫顺序相同,浮点求和才不变。别为省一次排序去掉它 |
| 测试 fixture | Frame { cell: Some(..), ..Frame::default() } 的 pbc 是默认的 FFF,以前照样被当周期体系算,现在被拒 |
有胞的 fixture 用 Frame::with_cell(cell, [true; 3]) 或显式写 pbc |
NeighborList::build 的截断 |
截断为 0(没有任何截断对)时 bin 数是 1/0 → usize::MAX,分配爆掉 |
先判截断 > 0(分类器在没有截断时不建列表) |
| box_builder 的 cell list | 看见两份 cell list 想合 | 不合(R6):它只服务立方盒、每步弛豫重建、力的累加依赖访问顺序;两处注释互指 |
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
| 加格式 | 在 CLI 或 Python 里加扩展名分支 | 只改 ferro-io/src/format.rs:Format 加变体,ALL / name / detected_by / can_write / multi_frame / from_name / read_as / write_one 由编译器与 match 逼着补。CLI 的格式表由 Format::ALL 生成,Python 的 read / write 走同一张表 |
| reader 里想提示用户 | eprintln! |
返回 Notice(reader 返回 (Trajectory, Vec<Notice>),经 read 进 ReadReport.warnings),CLI 经 report.rs 打成 <来源>[:<行号>]: FerroWarning: …,Python 发 ferro.FerroWarning / FerroNote(第 11 批)。告警文本不写 CLI 选项名(Python 用户没有 -a/-b/-c) |
| 带参数名的报错 | 搬进 io 后文案只剩一种说法 | --atom-style / --type-map 与 Python 的 atom_style= / type_map= 各自在读前检查、用自己的参数名;io 的 read_as 只留中性的防御性报错 |
| 名字不说明格式的文件 | 一律报 Unsupported | 走 sniff 认横幅;.out / .xml / .log / 无扩展名时 sniff 的报错原样给出,其余扩展名才加「Unsupported input format」前缀 |
| 往 dict / 结构里补字段 | 新键与已有键同名,静默覆盖(read_output 的 steps 计数被首末步号元组覆盖,59d3469 修) |
补字段前 grep 已有键;测试断言旧键的类型,不只断言新键存在 |
| 读文本 | read_to_string / BufRead::lines |
readers::util::read_text / text_lines:坏字节报文件、行号、列号;打开失败才说 cannot open |
dataset collect 的输入 |
改 expand_inputs 让它收目录 |
收 DeePMD 目录的是 expand_trajectories(traj / map / net);collect 仍只收文件 |
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
| 构造帧 | 先 set_forces 再 add_atom |
add_atom 在已有力 / 速度时 panic(会少一个);先加原子再设力。set_atoms 会丢掉旧的力与速度 |
| 改元素 / 标签 | 以为改的是这一帧 | sites_mut() 在位点表被别的帧共享时先复制(copy-on-write),只改这一帧;要改整条轨迹就逐帧改 |
自己拼 Trajectory { frames, .. } |
位点表各帧一份,内存回到逐帧 | 用 add_frame,或拼完调 share_sites();ferro_io::read 出口已统一调 |
| 重排原子 | 只排坐标,漏了力 / 速度 / 电荷 / 键 | Frame::permuted(order) 一次全带走(issues.md 两次「重排漏了 bonds」就是这个) |
AtomRef 的字段 |
&*a.element、a.label.as_deref() 再从闭包返回 |
字段本身已是 &str / Option<&str>,直接用;经局部变量再借一次会把生命周期缩到闭包内 |
| 批量机械替换 | 惰性正则 `(?:. | \n)*? 跨语句吞代码;ARGS + "?"只让末尾)` 可选 |
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
| 新加公开项 | 在子模块里 pub fn,外部却够不着 |
analysis / io 的子模块已私有(A-12),新公开项要在 crate 根 pub use;没导出又没调用的,dead_code 会报 —— 那正是它的用途。只导出仓外真有调用的项:ferro-io 根上曾再导出 30 个仓外零调用的 reader / writer,dead_code 因此形同虚设(A-2,P3 收口);按格式的 reader 一律经 read 走,不上根 |
| 库报错 | 把 CLI 语境写进库的错误(「this analysis follows atoms by index」放进 core) | core 只陈述事实(哪一帧、哪个原子),「为什么要紧」由调用方补:analysis 走 check::same_atoms,deepmd writer 自己补「一个 system 一种成分」 |
| 闭包返回借用 | 把 |p| (&p.0, &p.1) 先绑到变量再传给 impl Fn(&T) -> (&Path, …),报 lifetime may not live long enough |
闭包直接写在参数位置让签名从约束推断,或改写成 fn |
| 重排 / 排序 | 以为排序在 ml::merge |
规范序是 ferro_core::data::elements::canonical_cmp,排原子是 Trajectory::canonically_sorted;type_map、gr 列序、merge 同一条规则 |
| 维里 | 手写 stress * volume / virial / volume |
Frame::virial() / Cell::stress_from_virial(),零体积统一报错 |
| shell 里的非 ASCII | 第 5 – 7 步又犯过五次(heredoc、sed 参数带中文) | 规则见「构建 / 工具链陷阱」与 memory.md「中文不要经 Bash」 |
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
| 写出格式 | 拼好 DIR/<输入名>.<EXT> 再交给 write / for_output 从文件名推断 |
Format::from_name 先认 POSCAR / CONTCAR 前缀、后看扩展名:-i POSCAR --format-out extxyz 写出的是 POSCAR,--format nosuchfmt 也照样通过。用户给的格式只经 Format::for_extension 解析,再经 write_numbered(…, format, …) 显式传入。Python write 同理,format 必填(判据见 decisions.md「写出格式一律由用户显式给出」) |
| 产物名取 stem | input.file_stem() |
. / .. / ../ 没有 file_name,退回整条路径会拼出隐藏文件、甚至逃出 -o;DeePMD 目录的 data.000 被当扩展名剥掉。先 canonicalize,目录取 file_name、文件取 file_stem |
| 同文件检查 | 在 -o 建出来之前比较 resolved 路径 |
-o new/.. 尚不存在时 canonicalize 失败、退回原文比较而漏过,输入被覆盖。建目录之后再比;按帧拆分时产物是 x_NNNN,不比 |
| 建目录时机 | 不询问就建的目录放在读输入之前 | 输错 -i 或选不到帧会留下空目录。convert 在选出帧之后才 ensure_dir(询问式的命令仍在读之前,见 outpath.rs 模块文档) |
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
| 测试里手写的程序输出 | 照记忆写一行「像」的措辞(" SCF run NOT converged") |
CP2K 真实写法是 *** SCF run converged … ***、*** WARNING in qs_scf.F:630 :: SCF run NOT converged ***,行首匹配从未命中,真实文件上 SCF 判定从没生效过,测试却一直绿 —— 「同一份数据两条路都错」的又一例(见上)。锚点措辞从真实输出或程序源码(examples/external/cp2k-2026.2/src/)里 grep,不手写 |
| 跨步的状态变量 | 每步读后不清(last_scf) |
某步缺判定行时沿用上一步的结论。锚点处 take() |
| 格式嗅探 | 嗅探自写一套切词 | 与 reader 的解析规则漂移(Lattice = "..." 解析认、嗅探不认,静默走普通 reader)。嗅探调 reader 自己的 tokenizer |
| 模板继承 | 生成器只在「需要改」时写结构相关键 | 模板(别的体系测过的输入)里的同名键原样留下并生效:VASP 的 NELECT / NUPDOWN。不写时也要处理模板里已有的(本仓注释掉,用户定) |
| 位置 | 陷阱 | 正确做法 |
|---|---|---|
读前参数检查(check_read_args、Python read) |
只看 Format::from_name(path) |
按有效格式 format_in.or_else(|| from_name(path)) 判;否则 -i x.txt --format-in lammps/data 越过 --atom-style 检查,落到 io 的中性报错 |
格式表的 --format-in 列 |
按 Format::ALL 的变体逐行查 LABELS |
CP2K md / sp 两个变体共一行(ALL 只放 Cp2kMd),按 name() 归组才能两名同列 |
| 加格式 | 只补 ALL 与各 match |
LABELS 是数组,不受 match 穷尽检查:新变体须手动加一个名字;测试只查名字不重复 |
build 的产物格式(OutArgs::ext) |
只看文件名,或在读前预检里直接 Format::detect(p)? |
有效格式 = format_in → detect → from_name。detect 要打开文件,预检时它的 IO 错误回落到文件名,不能 ? 中止整批 —— 坏输入应在自己那一步读失败、被跳过(审查 2026-10-30 H6) |