Fixed bug in CPUNEP implementation of qNEP

Fixes CPUNEP forces for qNEP (charge_mode 1/2) models, which did not match a finite-difference derivative of the reported energy.

Root cause: charge neutrality is enforced via

Q_{\text{used},i} = Q_{\text{raw},i} - \overline{Q_{\text{raw}}}

a global correction depending on all atoms, not just local descriptors. The force chain rule through that shift is

\frac{\partial E_{\text{coulomb}}}{\partial R_{n_1}}
= \sum_i D_{\text{real},i} \frac{\partial Q_{\text{used},i}}{\partial R_{n_1}}
= \sum_i \left( D_{\text{real},i} - \overline{D_{\text{real}}} \right) \frac{\partial Q_{\text{raw},i}}{\partial R_{n_1}}

so the charge-response derivative D_real (\partial E_{\text{coulomb}}/\partial Q) must also be shifted by -\overline{D_{\text{real}}} before being used to assemble forces. This term exists in GPUMD's nep_charge.cu (zero_mean_D_real) but was missing from src/nepy/nep.cpp, so the unshifted, purely local D_{\text{real},i} term was used instead. The resulting force error was small on large systems (diluted by 1/N) but sizable on small ones (e.g. ~9% relative error on a 3-atom water molecule).

Adds zero_mean_D_real next to the existing zero_total_charge, and a regression test (test_qnep_forces_agree_with_finite_differences in tests/test_cpunep.py) using the repo's existing nep_cm1.txt/nep_cm2.txt fixtures.

Merge request reports

Loading