votca 2026-dev
Loading...
Searching...
No Matches
ewaldsolvers.h
Go to the documentation of this file.
1/*
2 * Copyright 2009-2026 The VOTCA Development Team
3 * (http://www.votca.org)
4 *
5 * Licensed under the Apache License, Version 2.0 (the "License")
6 *
7 * You may not use this file except in compliance with the License.
8 * You may obtain a copy of the License at
9 *
10 * http://www.apache.org/licenses/LICENSE-2.0
11 *
12 * Unless required by applicable law or agreed to in writing, software
13 * distributed under the License is distributed on an "AS IS" BASIS,
14 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 * See the License for the specific language governing permissions and
16 * limitations under the License.
17 *
18 */
19
20#pragma once
21#ifndef VOTCA_XTP_EWALDSOLVERS_H
22#define VOTCA_XTP_EWALDSOLVERS_H
23
24// Standard includes
25#include <chrono>
26#include <cmath>
27#include <limits>
28
29// Third party includes
30#include <Eigen/Eigenvalues>
31#include <boost/format.hpp>
32
33// Local VOTCA includes
35#include "votca/xtp/eigen.h"
38#include "votca/xtp/logger.h"
39
64
65namespace votca {
66namespace xtp {
67
68// Runs the standard preconditioned CG algorithm directly (rather than via
69// Eigen::ConjugateGradient) so every iteration's own curvature,
70// p.dot(A*p), can be inspected. For a genuinely SPD operator this is
71// mathematically guaranteed positive for every nonzero p, at every
72// iteration -- CG's own convergence theory depends on it. If it is ever
73// <= 0, that is a direct proof (not an inference from residual behavior)
74// that the operator is NOT positive-definite, and this stops immediately
75// with a clear diagnostic rather than continue computing meaningless
76// iterations. This exists specifically to distinguish, for a real
77// divergence found this session (residual growing past its own starting
78// value with a plain PCG solve, on a system legacy's own SOR handles
79// fine at the same physics), between two genuinely different
80// explanations that could not be told apart from residual behavior
81// alone: the true global operator being indefinite (this check would
82// fire), versus a merely poorly-preconditioned but still SPD system
83// (this check would never fire, even on a slowly-converging or stalled
84// run). Templated on the preconditioner type for the same reason
85// EwaldBackground::Evaluate's own PCG solve needs two separate branches
86// below: the preconditioner type is a compile-time choice here too, and
87// this avoids writing the same loop out twice.
89 Eigen::VectorXd x;
91 double residual = 0.0;
92 bool converged = false;
93 // -1 if the operator was never found indefinite; otherwise the
94 // 1-based iteration number at which p.dot(A*p) <= 0 was first found.
95 // This is a real, unambiguous certificate whenever it fires (CG's own
96 // convergence theory strictly requires p.dot(A*p) > 0 for every
97 // nonzero p if the operator is genuinely SPD), but it can miss real
98 // indefiniteness for a "lucky" (or, from this test's own point of
99 // view, unlucky) b that happens not to excite the negative-eigenvalue
100 // direction at all -- confirmed directly with a deliberately
101 // constructed 3x3 counterexample (a genuinely indefinite matrix,
102 // eigenvalues -1/3/5, where b=[1,1,1] is EXACTLY orthogonal to the
103 // eigenvector for -1, letting CG converge to the exact right answer
104 // in 2 iterations without curvature ever going non-positive). See
105 // lanczos_min_eigenvalue below for a real (if still b-dependent, just
106 // less narrowly so) improvement on this.
109 // The smallest eigenvalue of the (small, m x m, m = iterations run)
110 // Lanczos tridiagonal matrix built from every alpha_k/beta_k this CG
111 // run itself computed -- a standard result (the CG-Lanczos
112 // correspondence; verified directly here against known eigenvalues
113 // for both an SPD test matrix, exact match to 1e-14, and a genuinely
114 // indefinite one with a GENERIC right-hand side, exact match there
115 // too) that these Ritz values approximate the extreme eigenvalues of
116 // the (preconditioned) operator using the WHOLE accumulated Krylov
117 // subspace, not one iteration's own snapshot the way
118 // indefinite_at_iteration is. Still not a b-independent guarantee (the
119 // same 3x3 counterexample above, with its own adversarially exact
120 // orthogonality, defeats this too -- confirmed directly) but
121 // meaningfully more robust for any b that is not exactly orthogonal to
122 // the relevant eigenvector, which a genuine physical field vector,
123 // not an adversarially constructed one, essentially never is exactly.
124 // NaN if fewer than 2 iterations ran (need at least 2 alphas for a
125 // meaningful 2x2 tridiagonal matrix).
126 double lanczos_min_eigenvalue = std::numeric_limits<double>::quiet_NaN();
127};
128
129template <typename Preconditioner>
131 const EwaldPeriodicDipoleOperator& op, Preconditioner& precond,
132 const Eigen::VectorXd& b, Index max_iter, double tol, Logger& log,
133 std::chrono::steady_clock::time_point t_start) {
134 auto elapsed_s = [&]() {
135 return std::chrono::duration<double>(std::chrono::steady_clock::now() -
136 t_start)
137 .count();
138 };
139
141 Eigen::VectorXd x = Eigen::VectorXd::Zero(b.size());
142 Eigen::VectorXd residual_vec = b - op * x;
143 const double rhs_norm2 = b.squaredNorm();
144 const double threshold =
145 std::max(tol * tol * rhs_norm2, std::numeric_limits<double>::min());
146 double residual_norm2 = residual_vec.squaredNorm();
147 // rhs = 0 is a legitimate system, not a degenerate one: a background
148 // whose permanent moments are all zero has nothing to induce, and
149 // x = 0 solves it exactly. The convergence test just below already
150 // gets that right -- residual_norm2 = 0 is under threshold, which is
151 // max(tol^2 * 0, DBL_MIN) = DBL_MIN -- so the solve returns
152 // immediately with the correct answer and zero iterations.
153 //
154 // Only the REPORTED relative residual is 0/0. Reported as 0 rather
155 // than NaN because the absolute residual genuinely is zero, and a NaN
156 // here does more than look untidy: it reaches the log as "residual
157 // nan", and the !std::isnan guard downstream then suppresses the
158 // Lanczos eigenvalue line, so a perfectly correct solve reads like a
159 // failed one. Seen for real on a zeroed-multipole background.
160 //
161 // The in-loop recomputation needs no such guard: with rhs_norm2 = 0
162 // the loop below never runs.
163 double tol_error =
164 (rhs_norm2 > 0.0) ? std::sqrt(residual_norm2 / rhs_norm2) : 0.0;
165 bool converged = (residual_norm2 < threshold);
166 // Every alpha_k/beta_k this run computes, in order -- see
167 // lanczos_min_eigenvalue's own documentation for what these build.
168 std::vector<double> alphas;
169 std::vector<double> betas;
170
171 if (!converged) {
172 Eigen::VectorXd p = precond.solve(residual_vec);
173 Eigen::VectorXd z(b.size()), tmp(b.size());
174 double abs_new = residual_vec.dot(p);
175 Index i = 0;
176 while (i < max_iter) {
177 tmp.noalias() = op * p;
178 const double curvature = p.dot(tmp);
179 XTP_LOG(Log::info, log) << TimeStamp() << " PCG iter " << (i + 1)
180 << ": curvature p.A.p=" << curvature << " ("
181 << elapsed_s() << "s)" << std::flush;
182 if (curvature <= 0.0) {
183 result.indefinite_at_iteration = i + 1;
184 result.indefinite_curvature = curvature;
185 XTP_LOG(Log::info, log)
186 << TimeStamp() << " PCG iter " << (i + 1)
187 << ": p.A.p = " << curvature
188 << " <= 0 -- the operator is NOT positive-definite (this is a "
189 "direct algebraic certificate, not an inference from "
190 "residual behavior). Stopping here rather than continue "
191 "computing iterations that CG's own convergence theory no "
192 "longer covers."
193 << std::flush;
194 break;
195 }
196 const double alpha = abs_new / curvature;
197 alphas.push_back(alpha);
198 x += alpha * p;
199 residual_vec -= alpha * tmp;
200
201 residual_norm2 = residual_vec.squaredNorm();
202 tol_error = std::sqrt(residual_norm2 / rhs_norm2);
203 if (residual_norm2 < threshold) {
204 converged = true;
205 ++i;
206 break;
207 }
208
209 z = precond.solve(residual_vec);
210 const double abs_old = abs_new;
211 abs_new = residual_vec.dot(z);
212 const double beta = abs_new / abs_old;
213 betas.push_back(beta);
214 p = z + beta * p;
215 ++i;
216 }
217 result.iterations = i;
218 } else {
219 result.iterations = 0;
220 }
221
222 // Lanczos tridiagonal matrix from alphas/betas -- see
223 // lanczos_min_eigenvalue's own documentation for the derivation and
224 // its own direct numerical verification (both SPD and indefinite test
225 // cases, exact match to the true eigenvalues in both). Needs at least
226 // 2 alphas (i.e. 1 beta) for a meaningful 2x2 matrix; a 1x1 "matrix"
227 // is just 1/alphas[0], not a real eigenvalue ESTIMATE of anything.
228 if (alphas.size() >= 2) {
229 const std::size_t m = alphas.size();
230 Eigen::MatrixXd T = Eigen::MatrixXd::Zero(m, m);
231 T(0, 0) = 1.0 / alphas[0];
232 for (std::size_t i = 1; i < m; ++i) {
233 T(i, i) = 1.0 / alphas[i] + betas[i - 1] / alphas[i - 1];
234 const double off = std::sqrt(betas[i - 1]) / alphas[i - 1];
235 T(i, i - 1) = off;
236 T(i - 1, i) = off;
237 }
238 Eigen::SelfAdjointEigenSolver<Eigen::MatrixXd> es(T);
239 result.lanczos_min_eigenvalue = es.eigenvalues()(0);
240 }
241
242 result.x = x;
243 result.residual = tol_error;
244 result.converged = converged;
245 return result;
246}
247
248// Per-site polarizability blocks (P, not P^-1), used for the plain
249// Jacobi-Over-Relaxation (JOR) iteration below. Confirmed directly
250// against legacy PolarBackground's own source (not re-derived from
251// theory alone) that this, not EwaldBlockJacobiPreconditioner's own
252// larger (intramolecular-Thole-coupled) block, is the exact match to
253// legacy's own per-site update: APolarSite::Induce computes
254// mu_new = (1-wSOR)*mu_old + wSOR*(-P)*(F_perm+F_induced)
255// which is a textbook weighted-Jacobi step once D = P^-1 is recognized
256// as this system's own diagonal block (EwaldPeriodicDipoleOperator's
257// own diagonal is built from getPInv() exactly) -- D^-1 = P follows
258// directly, with no re-derivation needed of the specific field-based
259// expression legacy itself uses internally.
260//
261// Also confirmed directly (by tracing legacy's own PolarBackground::
262// Evaluate) that legacy's own "SOR" is Jacobi-style, not sequential
263// Gauss-Seidel-based SOR despite the shared name: legacy computes EVERY
264// site's own field in full (step III.B, "(Re-)generate induction
265// fields") before updating ANY site's own dipole (step III.C, "Induce
266// again") -- every field used in a given iteration is built entirely
267// from the PREVIOUS iteration's own induced dipoles.
269 public:
271 const std::vector<Index>& ids) {
272 for (Index id : ids) {
273 const PolarSegment& segment = registry.Get(id, EwaldChargeState::Neutral);
274 for (const PolarSite& site : segment) {
275 // getPInv() is this codebase's own stored quantity (see
276 // EwaldPeriodicDipoleOperator's own diagonal); P itself is not
277 // separately exposed anywhere, so recovering it needs a real
278 // 3x3 inverse here -- cheap and exact for a non-singular
279 // polarizability tensor.
280 blocks_.push_back(site.getPInv().inverse());
281 }
282 }
283 }
284
285 Index size() const { return 3 * Index(blocks_.size()); }
286
287 // Exposes the per-site P block directly.
288 const Eigen::Matrix3d& GetBlock(Index n) const { return blocks_[n]; }
289
290 // Returns P * v, applied per 3-DOF site block -- the D^-1 * v the
291 // JOR iteration below needs (see this class's own documentation for
292 // why D^-1 = P here specifically). Site ordering matches
293 // EwaldPeriodicDipoleOperator's own targets_ layout exactly (same
294 // registry, same ids, same per-segment site iteration order), so no
295 // separate offset bookkeeping is needed here: site n's own 3 DOF are
296 // always at [3n, 3n+3) for both this class and that operator.
297 Eigen::VectorXd Apply(const Eigen::VectorXd& v) const {
298 Eigen::VectorXd result(size());
299 for (std::size_t n = 0; n < blocks_.size(); ++n) {
300 result.segment<3>(3 * Index(n)) = blocks_[n] * v.segment<3>(3 * Index(n));
301 }
302 return result;
303 }
304
305 private:
306 std::vector<Eigen::Matrix3d> blocks_;
307};
308
309struct JorResult {
310 Eigen::VectorXd x;
312 double residual = 0.0;
313 double max_dU = 0.0;
314 double avg_dU = 0.0;
315 bool converged = false;
316};
317
318// Mirrors legacy APolarSite::HistdU() exactly (confirmed directly
319// against its own source, apolarsite.cc): per-site relative change in
320// induced dipole moment between successive iterates, with a small-
321// magnitude fallback so a near-zero dipole (e.g. this site's own first
322// iteration, starting from x=0) doesn't divide by ~zero. Legacy's own
323// "small" constant is documented there as "1e-10 e*nm" -- this
324// codebase uses atomic units (bohr) throughout, not nm (see e.g.
325// EwaldRealSpaceSum's own class documentation), so using legacy's raw
326// 1e-10 unconverted would apply the wrong ABSOLUTE threshold (1e-10 nm
327// and 1e-10 bohr are very different physical dipole magnitudes) --
328// tools::conv::nm2bohr is this codebase's own existing conversion
329// constant (already used elsewhere in this file for r_min), reused
330// here rather than a separately hardcoded factor. epstol itself (see
331// SolveWithJOR below) needs no such conversion: it's a ratio of two
332// same-unit quantities, genuinely dimensionless regardless of which
333// unit system U0/U1 happen to be expressed in.
334double SiteRelativeDipoleChange(const Eigen::Vector3d& U0,
335 const Eigen::Vector3d& U1) {
336 const double small = 1e-10 * tools::conv::nm2bohr;
337 const double abs_U0 = U0.norm();
338 const double abs_U1 = U1.norm();
339 const double abs_dU = (U1 - U0).norm();
340 const double abs_U = (abs_U1 > abs_U0 && small > abs_U0) ? abs_U1 : abs_U0;
341 if (small > abs_U) {
342 return abs_U;
343 }
344 return abs_dU / abs_U;
345}
346
347// Plain weighted-Jacobi iteration for A*x=b (A = EwaldPeriodicDipoleOperator),
348// matching legacy PolarBackground's own SOR exactly in structure (see
349// EwaldSitePolarizabilityBlocks's own documentation for the direct
350// confirmation against legacy's own source). Iteration, in the general
351// weighted-Jacobi form for A = D + R:
352// x_new = x_old + omega * D^-1 * (b - A*x_old)
353// Unlike PCG, this makes no positive-definiteness assumption anywhere
354// in its own derivation -- its convergence instead depends on the
355// spectral radius of the iteration matrix (I - omega*D^-1*A) staying
356// below 1, a genuinely different (and empirically, for this kind of
357// system, more forgiving) condition. Verified directly, before writing
358// any of this, against a small SPD test system (exact match to the
359// direct solve) and against a genuinely indefinite one (the same 3x3
360// counterexample used to validate this session's own indefiniteness
361// checks) -- JOR converges on the indefinite case where CG structurally
362// cannot, matching the entire reason for using it here.
363//
364// Convergence criterion matches legacy PolarBackground's own exactly
365// (confirmed directly against its own source, step III.D) -- NOT the
366// global relative residual ||b-Ax||/||b|| this function's own first
367// version used (borrowed, incorrectly, from the PCG code path's own
368// stopping rule): legacy checks a per-site relative change in induced
369// dipole moment between successive iterates (SiteRelativeDipoleChange
370// above) against epstol=1e-3 (legacy's own hardcoded value, not this
371// codebase's own pcg_tolerance_) for EVERY site, with a looser
372// avgdU < epstol*0.1 fallback that can force convergence even if a few
373// individual sites remain just above epstol. The global residual is
374// still computed and logged every iteration (informational only -- it
375// is a genuinely different quantity, not proportional to max_dU/avg_dU
376// in general, and no longer decides when this function itself stops).
377//
378// match_legacy_first_step, when true, uses omega=1.0 (fully unrelaxed)
379// for iteration 0 only, then the configured omega for every iteration
380// after -- literally replicating legacy's own two-phase structure
381// (InduceDirect(), fully unrelaxed, THEN the main loop's own wSOR-
382// relaxed Induce() calls) rather than applying the same omega-relaxed
383// step from x=0 that legacy never does. Added after this session's own
384// attempt to algebraically relate the two codebases' differing
385// recursions (legacy: mu2 = mu1 - wSOR*P*FU(mu1), coefficient 1 on
386// mu1, since legacy's own mu1 is already fully unrelaxed; this
387// function's own un-adjusted recursion: x2 = (2-omega)*x1 +
388// omega*P*C*x1, a different coefficient, purely because THIS x1 was
389// only omega-relaxed to begin with) turned out to rest on an
390// unverified assumption (that C, this class's own leftover-from-D
391// coupling operator, equals legacy's own FU field) that was never
392// independently confirmed -- this flag sidesteps that assumption
393// entirely by making the two codebases' own x1/mu1 and x2/mu2 directly,
394// literally comparable (same relaxation history on both sides), so a
395// comparison needs only the same simple bohr<->nm dipole conversion
396// already validated for x1 vs mu1, not any field-specific conversion
397// or unverified operator decomposition.
399 const EwaldSitePolarizabilityBlocks& site_p,
400 const Eigen::VectorXd& b, Index max_iter, double omega,
401 Logger& log,
402 std::chrono::steady_clock::time_point t_start,
403 bool match_legacy_first_step) {
404 auto elapsed_s = [&]() {
405 return std::chrono::duration<double>(std::chrono::steady_clock::now() -
406 t_start)
407 .count();
408 };
409
410 const double kEpsTol = 1e-3; // legacy's own hardcoded value
411
412 JorResult result;
413 Eigen::VectorXd x = Eigen::VectorXd::Zero(b.size());
414 const double rhs_norm = b.norm();
415 const Index n_sites = b.size() / 3;
416
417 Index i = 0;
418 for (; i < max_iter; ++i) {
419 Eigen::VectorXd residual_vec = b - op * x;
420 const double residual_norm = residual_vec.norm();
421 const Eigen::VectorXd x_old = x;
422 const double this_step_omega =
423 (i == 0 && match_legacy_first_step) ? 1.0 : omega;
424 x = x_old + this_step_omega * site_p.Apply(residual_vec);
425
426 double max_dU = -1.0;
427 double avg_dU = 0.0;
428 for (Index n = 0; n < n_sites; ++n) {
429 const double dU = SiteRelativeDipoleChange(x_old.segment<3>(3 * n),
430 x.segment<3>(3 * n));
431 avg_dU += dU;
432 if (dU > max_dU) {
433 max_dU = dU;
434 }
435 }
436 avg_dU /= double(n_sites);
437
438 XTP_LOG(Log::info, log)
439 << TimeStamp() << " JOR iter " << (i + 1) << ": max_dU=" << max_dU
440 << " avg_dU=" << avg_dU << " (residual=" << residual_norm / rhs_norm
441 << ", informational only -- see this function's own documentation "
442 "for why max_dU/avg_dU, not this, is the actual stopping "
443 "criterion) ("
444 << elapsed_s() << "s)" << std::flush;
445
446 result.residual = residual_norm / rhs_norm;
447 result.max_dU = max_dU;
448 result.avg_dU = avg_dU;
449
450 bool converged = (max_dU <= kEpsTol);
451 if (avg_dU < kEpsTol * 0.1) {
452 converged = true;
453 }
454 if (converged) {
455 result.converged = true;
456 ++i;
457 break;
458 }
459 }
460 result.iterations = i;
461 result.x = x;
462 return result;
463}
464
465} // namespace xtp
466} // namespace votca
467
468#endif // VOTCA_XTP_EWALDSOLVERS_H
const PolarSegment & Get(Index id, EwaldChargeState state) const
const Eigen::Matrix3d & GetBlock(Index n) const
Eigen::VectorXd Apply(const Eigen::VectorXd &v) const
std::vector< Eigen::Matrix3d > blocks_
EwaldSitePolarizabilityBlocks(const EwaldRegistry &registry, const std::vector< Index > &ids)
Logger is used for thread-safe output of messages.
Definition logger.h:164
Class to represent Atom/Site in electrostatic+polarization.
Definition polarsite.h:36
Timestamp returns the current time as a string Example: cout << TimeStamp().
Definition logger.h:224
#define XTP_LOG(level, log)
Definition logger.h:40
const double nm2bohr
Definition constants.h:47
JorResult SolveWithJOR(const EwaldPeriodicDipoleOperator &op, const EwaldSitePolarizabilityBlocks &site_p, const Eigen::VectorXd &b, Index max_iter, double omega, Logger &log, std::chrono::steady_clock::time_point t_start, bool match_legacy_first_step)
ClassicalSegment< PolarSite > PolarSegment
PcgIndefinitenessResult SolveWithIndefinitenessCheck(const EwaldPeriodicDipoleOperator &op, Preconditioner &precond, const Eigen::VectorXd &b, Index max_iter, double tol, Logger &log, std::chrono::steady_clock::time_point t_start)
double SiteRelativeDipoleChange(const Eigen::Vector3d &U0, const Eigen::Vector3d &U1)
Provides a means for comparing floating point numbers.
Definition basebead.h:33
Eigen::Index Index
Definition types.h:26
Eigen::VectorXd x