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