Loading...
Searching...
No Matches
NEBOcinebController.cpp
Go to the documentation of this file.
1/*
2** This file is part of eOn.
3**
4** SPDX-License-Identifier: BSD-3-Clause
5**
6** Copyright (c) 2010--present, eOn Development Team
7** All rights reserved.
8**
9** Repo:
10** https://github.com/TheochemUI/eOn
11*/
13#include "eon/EonLogger.h"
16#include "eon/eonExceptions.hpp"
17#include <algorithm>
18#include <cmath>
19
20namespace eonc::neb {
21
24 auto &ci = params.neb_options().climbing_image;
25 auto &r = ci.ocineb;
26 return Config{
27 r.use_mmf,
28 r.trigger_force,
29 r.trigger_factor,
30 r.max_steps,
31 r.ci_stability_count,
32 r.angle_tol,
34 r.restore_unhelpful,
35 };
36}
37
40
41void OCINEBController::initBaseline(double baseline_force) {
42 baseline_force_ = baseline_force;
43 current_threshold_ = std::max(baseline_force_ * cfg_.trigger_factor,
44 2.0 * cfg_.force_tolerance);
45}
46
47bool OCINEBController::shouldTrigger(double convForce, bool ci_active,
48 long climbingImage, long numImages,
49 int ciStabilityCounter) const {
50 if (!cfg_.use_mmf || !ci_active)
51 return false;
52 if (climbingImage <= 0 || climbingImage > numImages)
53 return false;
54 if (ciStabilityCounter <= static_cast<int>(cfg_.ci_stability_count))
55 return false;
56 if (convForce <= cfg_.force_tolerance)
57 return false;
58 return convForce < current_threshold_;
59}
60
61void OCINEBController::updateStability(long climbingImage) {
62 if (climbingImage == previousClimbingImage_) {
64 } else {
66 previousClimbingImage_ = climbingImage;
67 has_cached_mode_ = false;
68 }
69}
70
72 double convForce) {
73 auto *log = eonc::log::get();
74
75 QUILL_LOG_DEBUG(log,
76 "Triggering MMF. Force: {:.4f}, Threshold: {:.4f} "
77 "({:.2f}x baseline)",
78 convForce, current_threshold_,
80
81 // The dimer moves this image. updateForces() may then hop
82 // climbingImage to a neighbor; band CI force is that neighbor.
83 const long walked = neb.climbingImage;
84 AtomMatrix savedPositions = neb.path[walked]->getPositions();
85 // The saved geometry is evaluated already; a restore puts its forces
86 // back instead of asking the potential again.
87 const AtomMatrix savedForces = neb.path[walked]->getForcesRaw();
88 const double savedEnergy = neb.path[walked]->getPotentialEnergy();
89
90 double alignment = 0.0;
91 int mmfResult = runDimer(neb, alignment);
92
93 // Always update forces after MMF
94 neb.movedAfterForceCall = true;
95 neb.updateForces();
96 double newForce = neb.convergenceForce();
97
98 // A positive-curvature walk can put the band force under tolerance
99 // while the climbing image sits in a well. Restore that image
100 // before accepting the band.
101 if (convergedClimb(newForce, cfg_.force_tolerance, mmfResult)) {
102 QUILL_LOG_DEBUG(log, "NEB converged after MMF. Force: {:.4f}", newForce);
103 return {newForce, true, false};
104 }
105
106 const double walkedForce = neb.path[walked]->getForcesFreeV().norm();
107 const bool mmfHelped = walkHelped(walkedForce, convForce, mmfResult);
108
109 if (mmfHelped) {
110 updateThresholdSuccess(convForce, walkedForce);
111 QUILL_LOG_DEBUG(log,
112 "MMF helped (status={}, walked={}, now_ci={}). "
113 "Walked force: {:.4f} -> {:.4f} ({:.2f}x baseline). "
114 "Band CI force: {:.4f}. New threshold: {:.4f}",
115 mmfResult, walked, neb.climbingImage, convForce,
116 walkedForce, walkedForce / baseline_force_, newForce,
118 } else {
119 // Frontiers OCI-NEB (doi:10.3389/fchem.2026.1807063, Algorithm 1)
120 // restores only on positive curvature (status -2). Alignment
121 // failure keeps the walked CI and applies the linear penalty.
122 // restore_unhelpful also restores on alignment reject / force
123 // increase on the walked image. Default false: the paper setting.
124 const bool restore = cfg_.restore_unhelpful || mmfResult == -2;
125 if (restore) {
126 neb.path[walked]->setPositions(savedPositions);
127 neb.path[walked]->setEvaluation(savedForces, savedEnergy);
128 neb.movedAfterForceCall = true;
129 has_cached_mode_ = false;
130 newForce = convForce;
131 }
132 updateThresholdBackoff(alignment);
133 QUILL_LOG_DEBUG(
134 log,
135 "MMF backoff (status={}, walked={}, now_ci={}). "
136 "Walked force: {:.4f} -> {:.4f}, band CI: {:.4f}, "
137 "Alignment: {:.3f}. {}New threshold: {:.4f} ({:.2f}x baseline)",
138 mmfResult, walked, neb.climbingImage, convForce, walkedForce, newForce,
139 alignment, restore ? "Restored CI. " : "", current_threshold_,
141 }
142
143 bool shouldReset =
144 (savedPositions - neb.path[walked]->getPositions()).norm() >
145 neb.params.optimizer_options().max_move *
146 neb.params.neb_options().image_count;
147
148 if (shouldReset) {
149 QUILL_LOG_DEBUG(log, "Resetting optimization history.");
150 }
151
153
154 return {newForce, false, shouldReset};
155}
156
158 double &alignment) {
159 auto *log = eonc::log::get();
160 alignment = 0.0;
161
162 if (neb.climbingImage <= 0 || neb.climbingImage > neb.numImages) {
163 QUILL_LOG_WARNING(log, "Invalid climbing image for MMF: {}",
164 neb.climbingImage);
165 return -1;
166 }
167
168 AtomMatrix initialMode;
169 if (has_cached_mode_) {
170 initialMode = cached_mode_;
171 } else {
172 initialMode = *neb.tangent[neb.climbingImage];
173 }
174 double tangentNorm = initialMode.norm();
175 if (tangentNorm < 1e-8) {
176 QUILL_LOG_WARNING(log, "Tangent too small for MMF initialization");
177 return -1;
178 }
179 initialMode /= tangentNorm;
180
181 auto tempMinModeSearch = std::make_shared<MinModeSaddleSearch>(
182 neb.path[neb.climbingImage], initialMode,
183 neb.path[neb.climbingImage]->getPotentialEnergy(), neb.params, neb.pot);
184
185 int minModeStatus;
186 try {
187 minModeStatus = tempMinModeSearch->run(cfg_.max_steps);
188 } catch (const eonc::DimerModeRestoredException &) {
190 QUILL_LOG_DEBUG(log, "MMF: Dimer restored to best state");
191 } catch (const eonc::DimerModeLostException &) {
193 QUILL_LOG_WARNING(log, "Dimer lost mode during MMF refinement");
194 }
195
196 mmf_iterations_used_ += tempMinModeSearch->iteration;
197
198 double eigenvalue = tempMinModeSearch->getEigenvalue();
199 if (eigenvalue > 0.0) {
200 QUILL_LOG_WARNING(log,
201 "MMF skipped: Positive curvature detected (eig={:.4f}).",
202 eigenvalue);
203 return -2;
204 }
205
206 AtomMatrix finalModeMatrix = tempMinModeSearch->getEigenvector();
207 VectorXd finalMode =
208 VectorXd::Map(finalModeMatrix.data(), finalModeMatrix.size());
209 VectorXd currentTangent =
210 VectorXd::Map(neb.tangent[neb.climbingImage]->data(),
211 neb.tangent[neb.climbingImage]->size());
212 if (finalMode.size() != currentTangent.size()) {
213 QUILL_LOG_WARNING(log,
214 "MMF mode size {} != tangent size {}; skip alignment",
215 finalMode.size(), currentTangent.size());
216 alignment = 0.0;
217 } else if (finalMode.norm() == 0.0 || currentTangent.norm() == 0.0) {
218 alignment = 0.0;
219 } else {
220 alignment =
221 std::abs(finalMode.normalized().dot(currentTangent.normalized()));
222 }
223
224 if (minModeStatus == MinModeSaddleSearch::STATUS_GOOD ||
226 if (alignment < cfg_.angle_tol) {
227 QUILL_LOG_WARNING(
228 log,
229 "MMF converged/restored but mode drifted (alignment={:.3f} < {:.3f})",
230 alignment, cfg_.angle_tol);
231 return -1;
232 }
233 cached_mode_ = finalModeMatrix;
234 has_cached_mode_ = true;
235 return 0;
236 } else if (minModeStatus == MinModeSaddleSearch::STATUS_BAD_MAX_ITERATIONS) {
237 return 1;
238 } else {
239 QUILL_LOG_WARNING(log, "MMF failed. Mode-tangent alignment: {:.3f}",
240 alignment);
241 return -1;
242 }
243}
244
246 double newForce) {
247 current_threshold_ = newForce * (0.5 + 0.4 * (newForce / convForce));
248 double max_threshold = std::max(baseline_force_ * cfg_.trigger_factor,
249 2.0 * cfg_.force_tolerance);
250 current_threshold_ = std::min(current_threshold_, max_threshold);
251}
252
254 double alpha = std::clamp(alignment, 0.0, 1.0);
255 double penalty_factor = 0.5 + 0.5 * alpha;
256 current_threshold_ = baseline_force_ * cfg_.trigger_factor * penalty_factor;
257 // Lower bound on the MMF trigger threshold. Scaled by force_tolerance so
258 // the MMF gate never collapses to zero when the NEB is already near
259 // convergence, but also capped by the trigger_factor envelope so a
260 // loose force_tolerance cannot push min_threshold above the cap and
261 // starve MMF activation.
262 double min_threshold = 2.0 * cfg_.force_tolerance;
263 current_threshold_ = std::max(current_threshold_, min_threshold);
264}
265
266} // namespace eonc::neb
Eigen::Matrix< double, Eigen::Dynamic, 3, eOnStorageOrder > AtomMatrix
Definition Eigen.h:37
const neb_options_t & neb_options() const
static bool walkHelped(double walkedForce, double convForce, int mmfStatus)
bool shouldTrigger(double convForce, bool ci_active, long climbingImage, long numImages, int ciStabilityCounter) const
static Config fromParams(const Parameters &params)
void updateThresholdSuccess(double convForce, double newForce)
void updateStability(long climbingImage)
MMFResult run(eonc::NudgedElasticBand &neb, double convForce)
int runDimer(eonc::NudgedElasticBand &neb, double &alignment)
void initBaseline(double baseline_force)
void updateThresholdBackoff(double alignment)
static bool convergedClimb(double bandForce, double forceTolerance, int mmfStatus)
quill::Logger * get() noexcept
Get or create the default "combi" logger.
Definition EonLogger.h:44
struct eonc::neb_options_t::climbing_image_options_t::hybrid_dimer_t ocineb
struct eonc::neb_options_t::climbing_image_options_t climbing_image