votca 2026-dev
Loading...
Searching...
No Matches
ewaldperiodicdipoleoperator.cc
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// Standard includes
21#include <algorithm>
22#include <array>
23#include <chrono>
24#include <fstream>
25#include <stdexcept>
26
27// Local VOTCA includes
29
30namespace votca {
31namespace xtp {
32
34 EwaldRegistry& registry, const EwaldRealSpaceSum& real_sum,
35 const EwaldReciprocalSpaceSum& recip_sum, const EwaldShapeCorrection& shape,
36 std::vector<Index> ids, double alpha_ewald, double thole_a)
37 : registry_(registry),
38 real_sum_(real_sum),
39 recip_sum_(recip_sum),
40 shape_(shape),
41 ids_(std::move(ids)),
42 // Real thole_a now, not a placeholder -- AddIntraSegmentCoupling
43 // genuinely calls ComputeThole. See this class's own constructor
44 // documentation and AddIntraSegmentCoupling's own documentation
45 // for why.
46 intra_interactor_(alpha_ewald, thole_a),
47 self_field_matrix_(recip_sum.SelfFieldMatrix()) {
48 offsets_.reserve(ids_.size() + 1);
49 offsets_.push_back(0);
50 for (Index id : ids_) {
52 throw std::runtime_error(
53 "EwaldPeriodicDipoleOperator: segment id not registered at "
54 "EwaldChargeState::Neutral");
55 }
56 Index n_sites = registry_.Get(id, EwaldChargeState::Neutral).size();
57 offsets_.push_back(offsets_.back() + 3 * n_sites);
58 }
59 size_ = offsets_.back();
60 // RawMultiply(v) includes a constant "leak" -- the permanent field from
61 // every registered segment *not* in ids_ (e.g. a fixed external
62 // background) -- since EwaldRealSpaceSum::AddFieldAt /
63 // EwaldReciprocalSpaceSum::AddFieldAtMany sum over every other
64 // registered segment, not just other ids_ members. That leak is
65 // independent of v (RawMultiply(0) already contains all of it, since at
66 // v=0 every ids_ site's own induced dipole is zero and so contributes
67 // nothing to it), so it is captured once here and subtracted in every
68 // multiply() call. Without this, multiply(0) != 0, which silently
69 // breaks the linearity ConjugateGradient requires -- confirmed the hard
70 // way, by a failing test (see test_ewaldperiodicdipoleoperator.cc).
71 // Build every target's real-space neighbour list up front, serially.
72 // RawMultiply parallelizes over targets, and the cache (plus its
73 // statistics counters) is written only while a list is being built --
74 // so this pass is what makes that parallel loop safe. Done here rather
75 // than lazily on first use so there is exactly one place where the
76 // ordering guarantee lives. See
77 // EwaldRealSpaceSum::PrepareNeighborCache.
78 {
79 std::vector<std::pair<Index, PolarSite*>> cache_targets;
80 cache_targets.reserve(std::size_t(size_ / 3));
81 for (std::size_t n = 0; n < ids_.size(); ++n) {
83 for (Index s = 0; s < segment.size(); ++s) {
84 cache_targets.push_back({ids_[n], &segment[s]});
85 }
86 }
87 real_sum_.PrepareNeighborCache(cache_targets, EwaldChargeState::Neutral);
88 }
89
90 baseline_ = RawMultiply(Eigen::VectorXd::Zero(size_));
91}
92
93std::pair<Index, Index> EwaldPeriodicDipoleOperator::LocateSite(Index i) const {
94 // upper_bound finds the first offset strictly greater than i; the
95 // segment i belongs to is the one just before that.
96 auto it = std::upper_bound(offsets_.begin(), offsets_.end(), i);
97 Index seg_idx = Index(it - offsets_.begin()) - 1;
98 Index site_idx = (i - offsets_[std::size_t(seg_idx)]) / 3;
99 return {seg_idx, site_idx};
100}
101
103 auto [seg1, site1] = LocateSite(i);
104 auto [seg2, site2] = LocateSite(j);
105 Index xyz1 = (i - offsets_[std::size_t(seg1)]) % 3;
106 Index xyz2 = (j - offsets_[std::size_t(seg2)]) % 3;
107
108 if (seg1 == seg2 && site1 == site2) {
109 const PolarSegment& segment =
110 registry_.Get(ids_[std::size_t(seg1)], EwaldChargeState::Neutral);
111 return segment[site1].getPInv()(xyz1, xyz2);
112 }
113 // Cross-site (including two different sites of the SAME segment -- see
114 // class documentation): never actually read by DiagonalPreconditioner
115 // (which only keeps entries where row()==col()); 0.0 here rather than
116 // the real (expensive, for a cross-segment pair) periodic tensor entry.
117 return 0.0;
118}
119
121 const Eigen::VectorXd& v) const {
122 // Phase timers -- see RawMultiplyTimings' own declaration. clk() is
123 // only called a handful of times per matvec, against ~1e8 pair
124 // evaluations inside it, so the instrumentation itself is far below
125 // the resolution of what it measures.
126 using clock = std::chrono::steady_clock;
127 auto clk = []() { return clock::now(); };
128 auto secs = [](clock::time_point a, clock::time_point b) {
129 return std::chrono::duration<double>(b - a).count();
130 };
131 ++timings_.n_calls;
132 auto t_phase = clk();
133
134 std::vector<std::pair<Index, PolarSite*>> targets;
135 targets.reserve(std::size_t(size_ / 3));
136
137 for (std::size_t n = 0; n < ids_.size(); ++n) {
139 Index base = offsets_[n];
140 for (Index s = 0; s < segment.size(); ++s) {
141 PolarSite& site = segment[s];
142 site.setInduced_Dipole(v.segment<3>(base + 3 * s));
143 site.Reset();
144 targets.push_back({ids_[n], &site});
145 }
146 }
147
148 timings_.setup += secs(t_phase, clk());
149 t_phase = clk();
150
151 // Parallel over targets. Safe because each iteration touches only its
152 // own target's accumulators, and real_sum_'s neighbour cache was built
153 // in full by PrepareNeighborCache in this class's own constructor, so
154 // AddFieldAt is read-only with respect to real_sum_ here. Without that
155 // prepass the first call would race on the cache and its counters.
156 //
157 // include_static = false: the permanent-multipole field is independent
158 // of v, so it is identical here and in baseline_ = RawMultiply(0), and
159 // cancels exactly in multiply().
160 {
161 const Index n_targets = Index(targets.size());
162#pragma omp parallel for schedule(dynamic, 16)
163 for (Index i = 0; i < n_targets; ++i) {
164 real_sum_.AddFieldAt<Estatic::V>(targets[std::size_t(i)].first,
165 *targets[std::size_t(i)].second,
167 }
168 }
169 // EwaldReciprocalSpaceSum no longer takes a segment id at all -- it
170 // never excludes anything (see its own class documentation, and this
171 // class's own documentation for why that matters here specifically) --
172 timings_.real_space += secs(t_phase, clk());
173 t_phase = clk();
174
175 // so only the bare site pointers are needed for this call.
176 std::vector<PolarSite*> recip_targets;
177 recip_targets.reserve(targets.size());
178 for (const auto& entry : targets) {
179 recip_targets.push_back(entry.second);
180 }
181 recip_sum_.AddFieldAtMany<Estatic::V>(recip_targets,
183 timings_.reciprocal += secs(t_phase, clk());
184 t_phase = clk();
185
186 // Shape/surface correction: legacy applies this unconditionally every
187 // induction iteration (FU12_ShapeField_At_By, using each site's own
188 // CURRENT induced dipole -- see this class's own constructor
189 // documentation for the fuller account of why this was missing
190 // entirely before). EwaldShapeCorrection::TotalDipoleMoment already
191 // sums every registered segment's charge + static dipole + induced
192 // dipole together, so the v-dependent part here comes entirely from
193 // the induced dipoles just set above (every ids_ site's own, via
194 // setInduced_Dipole, plus any other registered segment's induced
195 // dipole if set from elsewhere) -- the v-independent part (charges,
196 // static dipoles) contributes identically at every call, including
197 // v=0, so it is captured once in baseline_ exactly like the real- and
198 // reciprocal-space terms above, not something this method needs to
199 // handle specially.
200 //
201 // The shape/surface field does not depend on the target at all -- it
202 // is -(4*pi/3V)*M for every site, with M the registry's own total
203 // dipole moment. EwaldShapeCorrection::AddFieldAt recomputes that
204 // whole-system sum per target, so calling it once per site made this
205 // O(N^2): 5000 sums over 5000 sites to produce 5000 copies of one
206 // vector, measured at 0.451s per matvec. Computed once here instead
207 // and added directly.
208 {
209 PolarSite shape_probe(-1, "X", Eigen::Vector3d::Zero());
210 shape_.AddFieldAt<Estatic::V>(shape_probe, EwaldChargeState::Neutral);
211 const Eigen::Vector3d shape_field = shape_probe.V();
212 for (const auto& entry : targets) {
213 entry.second->V() += shape_field;
214 }
215 }
216
217 timings_.shape += secs(t_phase, clk());
218 t_phase = clk();
219
220 Eigen::VectorXd result(size_);
221 for (std::size_t n = 0; n < ids_.size(); ++n) {
222 const PolarSegment& segment =
224 Index base = offsets_[n];
225 for (Index s = 0; s < segment.size(); ++s) {
226 const PolarSite& site = segment[s];
227 // BUG FIX (this session): the coupling field enters with a MINUS
228 // here. The system being solved is mu = P*(F_perm + FU(mu)), i.e.
229 // (P^-1 - C)*mu = F_perm where C*mu is the induced-coupling field
230 // FU. This used to build P^-1*v + V + M*v, i.e. the operator
231 // P^-1 + C, which is the wrong sign on the coupling block.
232 //
233 // It went unnoticed for a long time because it cannot show up at
234 // the first iteration: x starts at zero, so the first JOR update
235 // is x1 = omega * P * b with no coupling term involved at all, and
236 // x1 (and F_perm, and P) all matched legacy. The sign only bites
237 // from the second iteration onward. Measured directly: legacy
238 // satisfies mu2 = mu1 + 0.35*P*FU to 2e-15, while this code was
239 // producing mu2 = mu1 - 0.35*P*FU -- and feeding this code's OWN
240 // mu1 and FU into the correct (+) rule reproduced legacy's mu2 to
241 // 1.3e-6, confirming every computed ingredient was already right
242 // and only this sign was wrong.
243 //
244 // Both terms flip together: site.V() and the self-field correction
245 // are two parts of the same coupling field (the correction cancels
246 // a spurious self-term inside site.V(), see below), so they must
247 // carry the same sign.
248 //
249 // site.V() already includes the true (negative) self-field
250 // -self_field_matrix_*v_i, via recip_sum_'s own unconditional
251 // (nothing-excluded) sum -- the self_field_matrix_*v_i term below
252 // cancels that leak, matching legacy's own atomic self-interaction
253 // correction (see self_field_matrix_'s own class documentation).
254 result.segment<3>(base + 3 * s) =
255 site.getPInv() * v.segment<3>(base + 3 * s) - site.V() -
256 self_field_matrix_ * v.segment<3>(base + 3 * s);
257 }
258 }
259 // Intramolecular coupling is part of the same C block as site.V()
260 // above and carries the same minus (see the sign-fix note there).
261 // Subtracted here rather than by changing AddIntraSegmentCoupling's
262 // own convention, because DumpStagedCoupling uses that method
263 // directly to build its FUa stage and needs it to keep producing the
264 // field itself, with legacy's own sign, not the operator block.
265 timings_.assemble += secs(t_phase, clk());
266 t_phase = clk();
267
268 Eigen::VectorXd intra = Eigen::VectorXd::Zero(size_);
269 AddIntraSegmentCoupling(v, intra);
270 result -= intra;
271
272 timings_.intra += secs(t_phase, clk());
273 return result;
274}
275
277 const Eigen::VectorXd& v, Eigen::VectorXd& result) const {
278 // See class documentation for the fuller history: an earlier version
279 // of this method used eeInteractor::FillTholeInteraction (a real
280 // mistake, since PolarRegion is aperiodic and its "Thole-only"
281 // convention was never a considered choice about intramolecular
282 // coupling specifically). A LATER version corrected that to erfc-only,
283 // UNDAMPED by Thole -- matching, it seemed, legacy's own permanent-
284 // field intramolecular treatment (which genuinely has no real-space
285 // loop at all, only a reciprocal-space erf compensation -- see
286 // EwaldRealSpaceInteractor::ApplyErfStaticFieldCorrection). That
287 // second version was ALSO wrong, for the induced case specifically:
288 // legacy's own FU12_ERFC_At_By -- the SAME method used for both
289 // intermolecular (confirmed at PolarBackground's own line ~954) and
290 // intramolecular (confirmed at its own line ~453-454) induced-induced
291 // real-space pairs -- applies Thole damping unconditionally via its
292 // own l3/l5 mechanism at both call sites, with no special-casing for
293 // same-segment pairs at all. The static and induced cases are
294 // genuinely different in legacy's own code (no real-space
295 // intramolecular loop at all for statics; a real one, Thole-damped,
296 // for induction) -- generalizing from one to the other, twice, in two
297 // different directions, was the actual mistake both previous versions
298 // made. This version applies ComputeThole exactly as ApplyInducedField
299 // itself does, rather than assuming l3=l5=1.0 as prior versions did.
300 for (std::size_t n = 0; n < ids_.size(); ++n) {
301 const PolarSegment& segment =
303 Index n_sites = segment.size();
304 if (n_sites < 2) {
305 continue;
306 }
307 Index base = offsets_[n];
308 for (Index i = 0; i < n_sites; ++i) {
309 const PolarSite& site_i = segment[i];
310 for (Index j = i + 1; j < n_sites; ++j) {
311 const PolarSite& site_j = segment[j];
312 // r_vec points from site_j (source) to site_i (target), matching
313 // ApplyInducedField's own r_vec = site2.getPos() - site1.getPos()
314 // convention (site1=source, site2=target) with site_j as source,
315 // site_i as target.
316 const Eigen::Vector3d r_vec = site_i.getPos() - site_j.getPos();
317 const double r = r_vec.norm();
319 intra_interactor_.ComputeB(r);
321 intra_interactor_.ComputeErfB(r);
323 intra_interactor_.ComputeThole(r, site_j, site_i);
324 // Same l3*B1 / l5*B2 combination ApplyInducedField itself uses
325 // (see that method's own comment on why there is no separate
326 // 3.0* factor here -- B2 already carries it). Not symmetric in
327 // general now that Thole damping is site-pair-dependent (unlike
328 // the earlier undamped version, where r_vec (x) r_vec and
329 // Identity being individually symmetric made block.transpose()
330 // == block trivially) -- ComputeThole(r, site_j, site_i) and
331 // ComputeThole(r, site_i, site_j) would give the same l3/l5
332 // here specifically (same r, same two sites, order-independent
333 // eigendamp product), so the block itself is still symmetric in
334 // this case, but that's a property of ComputeThole's own
335 // symmetric input, not assumed structurally the way it was
336 // before.
337 //
338 // ALPHA-INDEPENDENCE: l*B_erfc is NOT what this term must
339 // contribute. An intramolecular pair is absent from real_sum_
340 // but PRESENT, undamped, in recip_sum_, so site.V() already
341 // carries its B_erf; adding l*B_erfc gives l*B_erfc + B_erf
342 // where the alpha-independent target is l*B_bare. The
343 // difference, (l-1)*B_erf, vanishes only where damping is
344 // inactive -- and intramolecular separations are where l < 1.
345 // It grows with alpha and contaminates the converged dipoles:
346 // found as a 0.43% drift of the [fg permanent x bg induced]
347 // channel over alpha = 1.5 ... 3.0 1/nm, visible even in its
348 // shape term, which contains no alpha at all.
349 //
350 // So this term supplies the damped FULL interaction minus the
351 // undamped erf piece already added:
352 //
353 // l*B_bare - B_erf == l*B_erfc + (l-1)*B_erf
354 //
355 // Second form, so l = 1 is visibly unchanged and no large bare
356 // terms cancel at small r.
357 const double c3 = t.l3 * b.B1 + (t.l3 - 1.0) * berf.B1;
358 const double c5 = t.l5 * b.B2 + (t.l5 - 1.0) * berf.B2;
359 Eigen::Matrix3d block =
360 c5 * (r_vec * r_vec.transpose()) - c3 * Eigen::Matrix3d::Identity();
361 result.segment<3>(base + 3 * i) += block * v.segment<3>(base + 3 * j);
362 result.segment<3>(base + 3 * j) +=
363 block.transpose() * v.segment<3>(base + 3 * i);
364 }
365 }
366 }
367}
368
370 const Eigen::VectorXd& v) const {
371 return RawMultiply(v) - baseline_;
372}
373
374} // namespace xtp
375} // namespace votca
std::pair< Index, Index > LocateSite(Index i) const
void AddIntraSegmentCoupling(const Eigen::VectorXd &v, Eigen::VectorXd &result) const
Eigen::VectorXd RawMultiply(const Eigen::VectorXd &v) const
EwaldPeriodicDipoleOperator(EwaldRegistry &registry, const EwaldRealSpaceSum &real_sum, const EwaldReciprocalSpaceSum &recip_sum, const EwaldShapeCorrection &shape, std::vector< Index > ids, double alpha_ewald, double thole_a)
Eigen::VectorXd multiply(const Eigen::VectorXd &v) const
Class to represent Atom/Site in electrostatic+polarization.
Definition polarsite.h:36
void setInduced_Dipole(const Eigen::Vector3d &induced_dipole)
Definition polarsite.h:93
const Eigen::Matrix3d & getPInv() const
Definition polarsite.h:54
const Eigen::Vector3d & V() const
Definition polarsite.h:68
const Eigen::Vector3d & getPos() const
Definition staticsite.h:80
STL namespace.
Charge transport classes.
Definition ERIs.h:28
ClassicalSegment< PolarSite > PolarSegment
Provides a means for comparing floating point numbers.
Definition basebead.h:33
Eigen::Index Index
Definition types.h:26