votca 2026-dev
Loading...
Searching...
No Matches
fragmentsaturator.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// VOTCA includes
21#include <algorithm>
22#include <cmath>
23#include <iostream>
26
27// OpenBabel includes
28#include <openbabel/atom.h>
29#include <openbabel/forcefield.h>
30#include <openbabel/mol.h>
31
32// Local VOTCA includes
34
35namespace votca {
36namespace xtp {
37
39 const QMMolecule& mol, double bond_length_angstrom) {
40 QMMolecule result(mol.getType(), mol.getId());
41
42 // All of mol's own, original atoms are copied first, entirely
43 // unchanged, at their own, original indices -- new H atoms are only
44 // ever appended after all of these, so every original index remains
45 // valid, matching the same, direct requirement already confirmed,
46 // earlier this session, for fragment_A/fragment_B atom-index
47 // definitions computed before this saturation step.
48 for (const QMAtom& atom : mol) {
49 result.push_back(atom);
50 }
51
52 // -1 (unset) for every original atom -- see SaturationResult's own
53 // header comment for exactly what this means/is for.
54 std::vector<Index> new_atom_parent_ids(mol.size(), -1);
55
56 double bond_length_bohr = bond_length_angstrom * tools::conv::ang2bohr;
57 Index new_index = mol.size();
58 for (const QMAtom& atom : mol) {
59 if (!atom.hasExternalBond()) {
60 continue;
61 }
62 // getExternalBondDirection() is already normalized (confirmed
63 // directly: QMAtom::setExternalBondDirection's own .normalize()
64 // call, and a pure rotation, both preserve unit length exactly --
65 // SegmentMapper's own transform never renormalizes this
66 // separately, since it does not need to).
67 Eigen::Vector3d new_pos =
68 atom.getPos() + bond_length_bohr * atom.getExternalBondDirection();
69 QMAtom new_h(new_index, "H", new_pos);
70 // Real, direct fix for a real, direct, separate bug -- confirmed
71 // directly from the user's own real, direct report: the new H
72 // atom used to be left with its own, default bonded_partner_ids_
73 // (all -1, unset) -- meaning RelaxNewAtoms's own OpenBabel-based
74 // connectivity building (fragmentsaturator.cc,
75 // getBondedPartnerIds()-driven) never knew this new H was bonded
76 // to anything at all, and relaxed it as a fully isolated,
77 // unbonded atom -- explaining the real, direct, badly wrong
78 // final position the user observed (van der Waals repulsion
79 // alone, no real bond-stretch term holding it near its own real
80 // parent at all).
81 //
82 // Fixed by recording this real, direct, single bond on both
83 // sides: the new H's own single real partner is its own parent
84 // (new_h.AddBondedPartner, starting from an entirely empty,
85 // fresh array, so this is always its own first, and only, real
86 // partner) -- and the parent's own, real, existing
87 // bonded_partner_ids_ (its own real MD-level connectivity, e.g.
88 // ring neighbors, already correctly set well before this point)
89 // needs the new H appended too, on result's own copy of it
90 // specifically (result[atom.getId()], not the original, const
91 // mol's own atom at all, which cannot be mutated here) -- found
92 // at the exact same index as its own original id, since every
93 // original atom is copied first, entirely unchanged, at its own,
94 // original index, directly above.
95 new_h.AddBondedPartner(atom.getId());
96 result[atom.getId()].AddBondedPartner(new_index);
97 result.push_back(new_h);
98 new_atom_parent_ids.push_back(atom.getId());
99 new_index++;
100 }
101
102 return SaturationResult{result, new_atom_parent_ids};
103}
104
106 Index n_original_atoms,
107 Index n_steps) {
108 // Build a real OBMol directly from mol's own atoms and real, known
109 // bond connectivity (QMAtom::getBondedPartnerIds(), populated by
110 // the SegmentMapper/Md2QmEngine pipeline already built earlier this
111 // session) -- OBMol::PerceiveBondOrders(), below, needs this
112 // connectivity as real input, it does not itself guess which atoms
113 // are bonded at all (confirmed directly, earlier this session, by
114 // reading its own source), and geometry-only bond perception (no
115 // known connectivity at all) was separately confirmed, this same
116 // session, to be genuinely unreliable for exactly this kind of
117 // situation (a newly-added atom), via a real, direct RDKit test
118 // that gave a chemically wrong result for an analogous case.
119 OpenBabel::OBMol obmol;
120 obmol.BeginModify();
121 tools::Elements elements;
122 for (const QMAtom& atom : mol) {
123 OpenBabel::OBAtom* obatom = obmol.NewAtom();
124 obatom->SetAtomicNum(int(elements.getNucCrg(atom.getElement())));
125 // Bohr -> Angstrom -- OpenBabel's own, standard internal unit
126 // (confirmed directly, this session, from its own official
127 // examples), unlike xtp's own, internal Bohr convention
128 // (QMAtom::getPos()'s own header comment, qmatom.h).
129 Eigen::Vector3d pos_angstrom = atom.getPos() / tools::conv::ang2bohr;
130 obatom->SetVector(pos_angstrom.x(), pos_angstrom.y(), pos_angstrom.z());
131 }
132
133 // OBMol::AddBond's own atom indices are 1-based (confirmed
134 // directly, from OpenBabel's own official examples), unlike
135 // QMAtom::getId()'s own 0-based indices, so +1 is applied here.
136 // Bond order itself is passed as a placeholder single bond (1) for
137 // every bond -- PerceiveBondOrders(), below, derives the real bond
138 // order from this connectivity plus real geometry; this initial
139 // value is never trusted directly.
140 for (const QMAtom& atom : mol) {
141 const Index* partners = atom.getBondedPartnerIds();
142 for (Index i = 0; i < QMAtom::kMaxBondedPartners; i++) {
143 Index partner_id = partners[i];
144 // Only add each real bond once (from the lower-ID side) --
145 // getBondedPartnerIds() records both directions of every real
146 // bond (confirmed directly, earlier this session, by
147 // Md2QmEngine::map()'s own symmetric AddBondedPartner calls,
148 // once for each atom on either side of a given bond), so
149 // without this check, every real bond would be added twice.
150 if (partner_id == -1 || partner_id <= atom.getId()) {
151 continue;
152 }
153 obmol.AddBond(int(atom.getId()) + 1, int(partner_id) + 1, 1);
154 }
155 }
156 obmol.EndModify();
157 obmol.PerceiveBondOrders();
158
159 OpenBabel::OBForceField* pFF =
160 OpenBabel::OBForceField::FindForceField("MMFF94");
161 if (pFF == nullptr || !pFF->Setup(obmol)) {
162 // MMFF94 atom typing genuinely can fail for some real structures
163 // (confirmed directly, this session, via a real, documented,
164 // still-open upstream OpenBabel issue around aromatic-ring
165 // kekulization, openbabel/openbabel #2567) -- UFF is a real,
166 // established, more general-purpose fallback for exactly this
167 // situation.
168 pFF = OpenBabel::OBForceField::FindForceField("UFF");
169 if (pFF == nullptr || !pFF->Setup(obmol)) {
170 throw std::runtime_error(
171 "FragmentSaturator::RelaxNewAtoms: could not set up either "
172 "MMFF94 or UFF for this fragment.");
173 }
174 }
175
176 // Fix every original atom in place -- only the new H atom(s)
177 // SaturateExternalBonds appended (index >= n_original_atoms, per
178 // its own, documented convention of appending strictly after all
179 // original atoms) are free to move. Matches the official, direct
180 // OBFFConstraints/Setup(mol, constraints) pattern documented
181 // directly in OpenBabel's own forcefield.cpp, rather than
182 // OBForceField::SetFixAtom() called directly -- confirmed this is
183 // the officially-documented way, before writing this, rather than
184 // guessed.
185 OpenBabel::OBFFConstraints constraints;
186 for (Index i = 0; i < n_original_atoms; i++) {
187 constraints.AddAtomConstraint(int(i) + 1);
188 }
189 if (!pFF->Setup(obmol, constraints)) {
190 throw std::runtime_error(
191 "FragmentSaturator::RelaxNewAtoms: could not set up "
192 "constraints.");
193 }
194
195 // Real, direct fix for a real, genuine, confirmed root cause: a
196 // single pFF->ConjugateGradients(n_steps) call used to be made here
197 // directly -- but OpenBabel's own econv convergence-criterion
198 // argument is genuinely, confirmedly ignored by both
199 // ConjugateGradients() and ConjugateGradientsInitialize() (two,
200 // real, still-open upstream OpenBabel issues, confirmed directly
201 // before writing this: openbabel/openbabel#1366,
202 // openbabel/openbabel#2804) -- meaning the full n_steps count always
203 // ran, unconditionally, regardless of whether the geometry had
204 // already genuinely converged much earlier. Confirmed directly, on
205 // the user's own real machine: this is the real, genuine root cause
206 // of a real cross-platform coupling discrepancy this session --
207 // running the exact same starting geometry through DFT+PODCoupling
208 // on both platforms reproduced the exact same coupling values,
209 // isolating the divergence entirely to non-deterministic,
210 // platform-dependent floating-point rounding compounding over many
211 // real, wasted, post-convergence relaxation steps.
212 //
213 // Fixed by replicating OpenBabel's own, real, internal energy-
214 // convergence criterion (IsNear(e_n2, e_n1, econv), confirmed
215 // directly by reading ConjugateGradientsInitialize's own real
216 // source, forcefield.cpp) manually here instead, checking Energy()
217 // directly and stopping the real relaxation as soon as it
218 // genuinely converges -- rather than always continuing to the full
219 // n_steps regardless. econv itself kept at OpenBabel's own real,
220 // documented default (1e-6, ConjugateGradientsInitialize's own
221 // header comment, forcefield.h) for consistency with what this
222 // code always intended to use anyway.
223 double econv = 1e-6;
224 pFF->ConjugateGradientsInitialize(int(n_steps), econv);
225 double e_prev = pFF->Energy();
226 // Checked every 10 real steps, rather than every single one --
227 // Energy() itself is a real, non-trivial recomputation, so checking
228 // this often (not every step) keeps the real convergence-check
229 // overhead itself small relative to the real steps it can now skip.
230 const Index check_interval = 10;
231 bool still_running = true;
232 Index step = 0;
233 bool converged = false;
234 for (; step < n_steps && still_running; step += check_interval) {
235 Index steps_this_round = std::min(check_interval, n_steps - step);
236 still_running = pFF->ConjugateGradientsTakeNSteps(int(steps_this_round));
237 double e_now = pFF->Energy();
238 if (std::abs(e_now - e_prev) < econv) {
239 converged = true;
240 step += steps_this_round;
241 break;
242 }
243 e_prev = e_now;
244 }
245 // Real, direct, temporary diagnostic, worked through directly with
246 // the user -- RelaxNewAtoms is static (no Logger available at all,
247 // confirmed directly by reading its own real header declaration),
248 // so std::cerr is used directly here instead of XTP_LOG. Confirmed
249 // directly, from the user's own real CI failure log, that this
250 // test's own stdout/stderr is already visible in CI output
251 // regardless of pass/fail (the log already showed real, direct
252 // XTP_LOG-style "PODCoupling diagnostic" lines from the same, real,
253 // passing Run test) -- so this should show up there the same way.
254 std::cerr << "[RelaxNewAtoms] took " << step << "/" << n_steps
255 << " conjugate-gradient steps ("
256 << (converged ? "converged early"
257 : (still_running ? "exhausted full budget"
258 : "OpenBabel itself stopped"))
259 << ")" << std::endl;
260 pFF->GetCoordinates(obmol);
261
262 // Build the resulting, relaxed QMMolecule -- same element/ID for
263 // every atom; only the position itself potentially changes (for the
264 // free, new H atom(s) -- every fixed, original atom's own position
265 // should come back essentially unchanged, modulo floating-point
266 // noise, though this is not separately re-verified here).
267 QMMolecule result(mol.getType(), mol.getId());
268 Index idx = 0;
269 for (const QMAtom& atom : mol) {
270 OpenBabel::OBAtom* obatom = obmol.GetAtom(int(idx) + 1);
271 Eigen::Vector3d pos_bohr =
272 Eigen::Vector3d(obatom->GetX(), obatom->GetY(), obatom->GetZ()) *
274 QMAtom new_atom(atom.getId(), atom.getElement(), pos_bohr);
275 result.push_back(new_atom);
276 idx++;
277 }
278
279 return result;
280}
281
282} // namespace xtp
283} // namespace votca
information about an element
Definition elements.h:42
Index getNucCrg(std::string name)
Return the Nuclear charges of each atom. H - 1, He - 2, Na - 3 etc...
Definition elements.cc:36
const std::string & getType() const
void push_back(const T &atom)
static QMMolecule RelaxNewAtoms(const QMMolecule &mol, Index n_original_atoms, Index n_steps=500)
static SaturationResult SaturateExternalBonds(const QMMolecule &mol, double bond_length_angstrom=kDefaultCHBondLengthAngstrom)
container for QM atoms
Definition qmatom.h:37
void AddBondedPartner(Index partner_id)
Definition qmatom.h:161
static constexpr Index kMaxBondedPartners
Definition qmatom.h:43
const double ang2bohr
Definition constants.h:48
Charge transport classes.
Definition ERIs.h:28
Provides a means for comparing floating point numbers.
Definition basebead.h:33
Eigen::Index Index
Definition types.h:26