Skip to content

Latest commit

 

History

History
954 lines (770 loc) · 115 KB

issues.md

File metadata and controls

954 lines (770 loc) · 115 KB

已知问题 / 编码陷阱

编码陷阱

位置 陷阱 正确做法
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 |


Bader 实现陷阱(来自 Fortran 源码逆向分析)

算法层的陷阱(ρ × 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_type / typing 编码陷阱

位置 陷阱 正确做法
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; 一次性解构,再分别传参

cluster / 审计 编码陷阱

位置 陷阱 正确做法
连通性判据(原 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 编码陷阱

位置 陷阱 正确做法
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 修)

依赖升级陷阱(2026-08-08)

位置 陷阱 正确做法
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 写的脚本

g(r) / box_builder / Bader 编码陷阱

位置 陷阱 正确做法
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 时勿再引入双重计算

g(r) / S(q) 审查项处置(2026-08-08)

七项中六项已修(0.1.10–0.1.13、0.2.1),教训分别落在下方各节与 network 重构(0.2.1)编码陷阱。两条仍需知道的:

  • angle.rs neighbors_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

NPT 逐帧体积归一化(2026-08-08 已修,0.1.13)

病根是口径不一致,不是局部笔误。 参考实现(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 以内。

实测差异(完整轨迹,-a P -b O)

轨迹 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 分别钉住这两种情形。


element / label 分配规则(2026-08-08 查证)

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 字段而非 label 已过时(2026-10-03 核实):现在写 label;折进 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 列才无损

外部参考工具的差异与缺陷(2026-08-09 查证)

ferro traj 的 gr / sq / angle 以 private/code1(dump2analysis)与 private/code2 (dump2sq)为参考实现。三处差异全部定位,其中一处是参考程序的真 bug。下次有人问 "为什么 ferro 的 S(q) 和 dump2sq 对不上",先看这一节。

dump2sq 的 partial 按数组下标归类(对方的 bug,不复现)

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 每帧按元素 重新选原子,不受影响。

angle 的两处约定差(保留 ferro 现有行为)

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 后整数计数零差。

S(q) 缺 +1(在比较脚本侧补偿)

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 偏移写成"横轴标注约定不同、同一索引覆盖同一区间" ——错的,区间真的错开半格。

angle cutoff 归属(2026-08-09 已修,0.1.15)

--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 / 批处理编码陷阱(2026-08-09)

位置 陷阱 正确做法
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)

外部脚本调用 ferro 的陷阱(2026-08-11,scripts/ferrocmp.py)

位置 陷阱 正确做法
-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()

产物命名 / --outdir 编码陷阱(2026-08-13)

位置 陷阱 正确做法
删 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

发表级绘图编码陷阱(2026-08-13;原 scripts/plot_*.py,2026-10-20 迁入 ferro-python/python/ferro/plot/)

样式栈是 ['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(2026-08-09 定案)

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 没理由认识它),跨层的只有它的可序列化投影。


单二进制重构陷阱(2026-08-09,0.2.0)

位置 陷阱 正确做法
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。

数据集 merge 编码陷阱(2026-08-25)

位置 陷阱 正确做法
需求表述有歧义时 挑一种读法直接实现 「每个 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 那种)就不加,否则等于凭空断言这是训练集

数据集 filter 编码陷阱(2026-08-25)

位置 陷阱 正确做法
筛选判据放哪 「阈值比较只有三行,放 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 报错而不是猜

CP2K out 解析 / DeePMD 数据集编码陷阱(2026-08-25)

位置 陷阱 正确做法
文本锚点 字面匹配(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>,仍撞则报错 —— 静默覆盖会让你以为收集了两条轨迹

collect 目录语义 / 帮助防漂编码陷阱(2026-08-26)

位置 陷阱 正确做法
多输入的产物命名 把多级路径用分隔符压平(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(找不到那一页),所以映射表自己不会烂

一处语义改了、四处文档跟不上(2026-08-26)

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 的预处理器能做但要多一个构建步骤。记在这里, 下次再改命令树时三处一起改。

extxyz 的 stress/virial 编码陷阱(2026-08-26)

  • 自读自写的回路验证不了符号。读侧与写侧同时漏掉变号时,往返测试逐位相同、 全绿。符号的依据必须来自外部实物: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

VASP AIMD 读取编码陷阱(2026-08-27)

  • <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 把同目录的文件当作一次运行的分段拼接。成分一致、两边都读得通, 没有任何别的症状

dataset 划分 / --type 编码陷阱(2026-08-26)

  • 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

帮助页精简 / ferro doc 小节寻址编码陷阱(2026-08-26)

位置 陷阱 正确做法
精简前的范围估计 按「页数」估 按超标页数估。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 语义统一 / 路径类型编码陷阱(2026-09-20)

位置 陷阱 正确做法
「只给目录」的判据 看 -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 才拒绝 —— 从留出集里切训练集会毁掉它被留出的理由

LAMMPS 三斜盒子 / collect 产物位置 / 逐帧标量编码陷阱(2026-09-21)

位置 陷阱 正确做法
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 heredoc 改 Rust 源码时(2026-09-21 实测踩到)

用 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 方法的真空电荷取错(2026-09-20 发现,未修)

bader_weight.rs 把 1 索引数组的 volchg[nvols] 当真空电荷。现象、实测数字与修法见 plan/suspended.md「bader weight 的真空电荷取错」(Bader 改写组)。ferro-cli/tests/bader_reports.rs 的真空断言因此只跑 ongrid / neargrid,注释里指名了这条。

全库简化审计发现的陷阱(2026-09-03 提出,2026-09-12 按 Rule of Three 重做)

两轮审计的判据不同:第一轮找「死代码 / 重复」,第二轮按 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 漏了。加分析模块时两处都要看

修 bug 时顺手改口径(2026-09-12 实测踩到)

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 的差异失去唯一解释。

golden master 在本仓可以逐字节做(2026-09-12 实测)

产物里没有时间戳、没有绝对路径,唯一的非确定源是产物头里的 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 缺陷一条。

network 重构(0.2.1)编码陷阱

位置 陷阱 正确做法
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 是同一类错误

CP2K 单点解析编码陷阱(2026-09-22)

  • 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 全零的数据集
  • 截断落在力表中途时,力的条数少于原子数而不是零条。 判据是"力数 == 原子数", 不是"有没有力块"

原子顺序 / --format / 帮助页精简编码陷阱(2026-09-22) (现 --format-out)

位置 陷阱 正确做法
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`

QE pw.x 输出编码陷阱(2026-10-17)

位置 陷阱 正确做法
力块 找到 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」

dataset 审查编码陷阱(2026-10-17)

位置 陷阱 正确做法
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 层测

同一份数据两条路都错、往返测试照样绿(第三例,2026-09-22)

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/ 目录——跟独立实现对表,不跟自己的输出对表。

版本闸门:为什么改成提示而不是拒绝(2026-09-22 定案)

用户最初要求"白名单 + 不支持的版本直接报错"。查证后改成"照常解析 + 每文件打一行 NOTE:",理由三条:

  1. 硬拒绝会挡掉没见过但格式没变的将来版本。 CP2K 大多数发布并不动日志布局
  2. cp2k_md.rs 的模块文档里早就写着相反的判断:token 序列匹配而非版本表, 且 STRESS_UNIT 是 CP2K 的输入关键字、不是版本的函数,版本表答不了这个 问题。加白名单等于推翻一条已经写下并被测试钉住的判断
  3. 白名单本身装着两种布局。 查完 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 也在用。

cp2kdata 的版本上限(2026-09-22 查证,已装新版)

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),所以比对逐原子的量之前必须先 配对,不能直接按下标比。

ferro doc 渲染器编码陷阱(2026-09-24)

位置 陷阱 正确做法
列宽计算 着色后的字符串含 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

2026-10-01 审查:读写两侧的静默错误

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」。报错并点名行号

CIF 空间群符号展开(2026-10-03,审查 A1)

数据表 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 胞 用特例代码按符号判 度规筛选:保留旋转满足 $W^T G W = G$ 的候选,由晶胞几何自动判定。容差取宽(相对 1e-2),宁可留多个候选走报错,也不因 CIF 晶胞参数的舍入把正确设置筛掉
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。

g(r) 第一极小的自动定位(2026-10-07,md/shell.rs)

陷阱 正确做法
取「第一个最小 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 下最大,会进最小镜像上界检查

2026-10-03 审查 B–D 组修复的编码陷阱(2026-10-07 – 10-09)

位置 陷阱 正确做法
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,在读第一个输入前查

2026-10-11 审查修复的编码陷阱(2026-10-14)

位置 陷阱 正确做法
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 且排在力前时,速度当力存、全程无错 两块之后继续扫到下一锚点,再见同原子数的块整份报错

周期几何编码陷阱(2026-10-18,审查第 2 步)

位置 陷阱 正确做法
盒子里量距离 直接拿 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):它只服务立方盒、每步弛豫重建、力的累加依赖访问顺序;两处注释互指

格式表 / 告警通道编码陷阱(2026-10-18,审查第 3 步)

位置 陷阱 正确做法
加格式 在 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 仍只收文件

拓扑 / 坐标分离存储编码陷阱(2026-10-18,审查第 4 步)

位置 陷阱 正确做法
构造帧 先 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 + "?"只让末尾)` 可选

职责归位编码陷阱(2026-10-18,审查第 5 步)

位置 陷阱 正确做法
新加公开项 在子模块里 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」

convert 目录 -o 审查修复的编码陷阱(2026-10-24)

位置 陷阱 正确做法
写出格式 拼好 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 模块文档)

全库 code review 高级四条的编码陷阱(2026-10-25)

位置 陷阱 正确做法
测试里手写的程序输出 照记忆写一行「像」的措辞(" 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。不写时也要处理模板里已有的(本仓注释掉,用户定)

--format-in / --format-out 编码陷阱(2026-10-29)

位置 陷阱 正确做法
读前参数检查(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)