votca 2026-dev
Loading...
Searching...
No Matches
qp_solver_utils.h
Go to the documentation of this file.
1/*
2 * Copyright 2009-2023 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_QP_SOLVER_UTILS_H
22#define VOTCA_XTP_QP_SOLVER_UTILS_H
23
24#include <algorithm>
25#include <cmath>
26#include <cstddef>
27#include <limits>
28#include <stdexcept>
29#include <vector>
30
31#include <boost/optional.hpp>
32
33namespace votca {
34namespace xtp {
35namespace qp_solver {
36
38
65
67 double omega = 0.0;
68 double residual = 0.0;
69 double deriv = 0.0;
70 double Z = 0.0;
71 double distance_to_ref = 0.0;
72 bool accepted = false;
73};
74
82
84 double g_sc_limit = 1e-5;
86
87 // The new grid-search design separates four previously entangled concerns:
88 // (1) full QP search extent
89 // (2) dense sign-change detection spacing
90 // (3) adaptive shell spacing / shell count
91 // (4) interval refinement method (configured elsewhere)
92 // This makes the search numerics easier to reason about and allows robust
93 // dense fallback without forcing the adaptive scan to inherit the same mesh.
94 //
95 // qp_full_window_half_width defines the full symmetric interval around the
96 // current reference frequency that can be searched when no narrower window
97 // is imposed by the caller.
99
100 // qp_dense_spacing is only for the robust dense scan that detects sign
101 // changes before interval refinement. It is intentionally independent from
102 // the adaptive search spacing.
103 double qp_dense_spacing = 0.002;
104
105 // The adaptive search starts at the reference energy and expands outward in
106 // shells. The shell spacing can be set directly or derived from a requested
107 // number of shells per half-window. This search is meant to find the most
108 // relevant root early, while the dense scan remains the robust fallback.
110 Index qp_adaptive_shell_count = 0; // 0 -> use width
111
112 double min_accepted_Z = 0.05;
113 double max_accepted_Z = 1.5;
114};
115
116// Legacy mapping helpers preserve the old qp_grid_steps / qp_grid_spacing
117// behavior for existing XML files and tests. They are translation utilities
118// only; the actual search code below works exclusively with the decoupled
119// canonical options.
120template <typename Opt>
121inline double LegacyFullWindowHalfWidth(const Opt& opt) {
122 if (opt.qp_grid_steps <= 1 || opt.qp_grid_spacing <= 0.0) {
123 return -1.0;
124 }
125 return 0.5 * opt.qp_grid_spacing * double(opt.qp_grid_steps - 1);
126}
127
128template <typename Opt>
129inline double LegacyAdaptiveShellWidth(const Opt& opt) {
130 if (opt.qp_grid_steps <= 1 || opt.qp_grid_spacing <= 0.0) {
131 return -1.0;
132 }
133
134 const double full_window_width =
135 opt.qp_grid_spacing * double(opt.qp_grid_steps - 1);
136
137 // Reconstruct the historical "coarse" spacing as closely as possible.
138 // The hard-coded 21-point floor is legacy behavior from the original
139 // implementation and intentionally remains confined to this compatibility
140 // path instead of the new adaptive search itself.
141 const Index base_coarse_steps = std::max<Index>(21, opt.qp_grid_steps / 4);
142
143 if (base_coarse_steps <= 1) {
144 return 4.0 * opt.qp_grid_spacing;
145 }
146
147 return full_window_width / double(base_coarse_steps - 1);
148}
149
150// NormalizeGridSearchOptions is the single entry point that converts a mix of
151// new and legacy settings into one canonical set of search controls. New-style
152// options always take precedence; legacy qp_grid_steps / qp_grid_spacing only
153// fill values that were left unset by the caller.
154template <typename Opt>
155inline void NormalizeGridSearchOptions(Opt& opt) {
156 const bool has_legacy = (opt.qp_grid_steps > 1 && opt.qp_grid_spacing > 0.0);
157
158 if (opt.qp_full_window_half_width <= 0.0) {
159 opt.qp_full_window_half_width =
160 has_legacy ? LegacyFullWindowHalfWidth(opt) : 0.75;
161 }
162
163 if (opt.qp_dense_spacing <= 0.0) {
164 opt.qp_dense_spacing = has_legacy ? opt.qp_grid_spacing : 0.002;
165 }
166
167 if (opt.qp_adaptive_shell_count <= 0 && opt.qp_adaptive_shell_width <= 0.0) {
168 opt.qp_adaptive_shell_width =
169 has_legacy ? LegacyAdaptiveShellWidth(opt) : 0.025;
170 }
171
172 if (opt.qp_full_window_half_width <= 0.0) {
173 throw std::runtime_error(
174 "Invalid QP search setup: qp_full_window_half_width must be > 0");
175 }
176
177 if (opt.qp_dense_spacing <= 0.0) {
178 throw std::runtime_error(
179 "Invalid QP search setup: qp_dense_spacing must be > 0");
180 }
181
182 if (opt.qp_adaptive_shell_count <= 0 && opt.qp_adaptive_shell_width <= 0.0) {
183 throw std::runtime_error(
184 "Invalid QP search setup: need qp_adaptive_shell_width > 0 or "
185 "qp_adaptive_shell_count > 0");
186 }
187}
188
189// Convert either an explicit shell width or an explicit shell count into the
190// actual shell spacing used by the adaptive outward scan.
192 if (opt.qp_adaptive_shell_count > 0) {
193 return opt.qp_full_window_half_width /
194 static_cast<double>(opt.qp_adaptive_shell_count);
195 }
196 return opt.qp_adaptive_shell_width;
197}
198
199template <typename QPFunc>
200double SolveQP_Bisection(double lowerbound, double f_lowerbound,
201 double upperbound, double f_upperbound,
202 const QPFunc& f, const SolverOptions& opt) {
203 if (f_lowerbound * f_upperbound > 0.0) {
204 throw std::runtime_error(
205 "Bisection needs a positive and negative function value");
206 }
207
208 while (true) {
209 const double c = 0.5 * (lowerbound + upperbound);
210 if (std::abs(upperbound - lowerbound) < opt.g_sc_limit) {
211 return c;
212 }
213
214 const double y_c = f.value(c, EvalStage::Refine);
215 if (std::abs(y_c) < opt.g_sc_limit) {
216 return c;
217 }
218
219 if (y_c * f_lowerbound > 0.0) {
220 lowerbound = c;
221 f_lowerbound = y_c;
222 } else {
223 upperbound = c;
224 f_upperbound = y_c;
225 }
226 }
227}
228
229template <typename QPFunc>
230double SolveQP_Brent(double lowerbound, double f_lowerbound, double upperbound,
231 double f_upperbound, const QPFunc& f,
232 const SolverOptions& opt) {
233 if (f_lowerbound * f_upperbound > 0.0) {
234 throw std::runtime_error(
235 "Brent needs a positive and negative function value");
236 }
237
238 double a = lowerbound;
239 double b = upperbound;
240 double fa = f_lowerbound;
241 double fb = f_upperbound;
242
243 // c is the previous bracket endpoint
244 double c = a;
245 double fc = fa;
246
247 // d and e track the last and second-last step sizes
248 double d = b - a;
249 double e = d;
250
251 for (Index iter = 0; iter < opt.qp_bisection_max_iter; ++iter) {
252
253 // Ensure that b is the best current estimate
254 if ((fb > 0.0 && fc > 0.0) || (fb < 0.0 && fc < 0.0)) {
255 c = a;
256 fc = fa;
257 d = b - a;
258 e = d;
259 }
260
261 if (std::abs(fc) < std::abs(fb)) {
262 a = b;
263 b = c;
264 c = a;
265 fa = fb;
266 fb = fc;
267 fc = fa;
268 }
269
270 const double tol = opt.g_sc_limit;
271 const double m = 0.5 * (c - b);
272
273 if (std::abs(m) < tol || std::abs(fb) < opt.g_sc_limit) {
274 return b;
275 }
276
277 if (std::abs(e) >= tol && std::abs(fa) > std::abs(fb)) {
278 // Attempt inverse interpolation
279 double s = fb / fa;
280 double p = 0.0;
281 double q = 0.0;
282
283 if (a == c) {
284 // Secant step
285 p = 2.0 * m * s;
286 q = 1.0 - s;
287 } else {
288 // Inverse quadratic interpolation
289 double q1 = fa / fc;
290 double r = fb / fc;
291 p = s * (2.0 * m * q1 * (q1 - r) - (b - a) * (r - 1.0));
292 q = (q1 - 1.0) * (r - 1.0) * (s - 1.0);
293 }
294
295 if (p > 0.0) {
296 q = -q;
297 }
298 p = std::abs(p);
299
300 // Accept interpolation only if it is safe
301 if (q != 0.0 && 2.0 * p < std::min(3.0 * m * q - std::abs(tol * q),
302 std::abs(e * q))) {
303 e = d;
304 d = p / q;
305 } else {
306 d = m;
307 e = m;
308 }
309 } else {
310 d = m;
311 e = m;
312 }
313
314 a = b;
315 fa = fb;
316
317 if (std::abs(d) > tol) {
318 b += d;
319 } else {
320 b += (m > 0.0 ? tol : -tol);
321 }
322
323 fb = f.value(b, EvalStage::Refine);
324 }
325
326 throw std::runtime_error(
327 "Brent did not converge within qp_bisection_max_iter");
328}
329
330inline bool AcceptRoot(const RootCandidate& cand, const SolverOptions& opt) {
331 if (!std::isfinite(cand.omega) || !std::isfinite(cand.Z)) {
332 return false;
333 }
334
335 if (std::abs(cand.residual) > opt.g_sc_limit) {
336 return false;
337 }
338
339 if (cand.Z <= 0.0) {
340 return false;
341 }
342
343 if (cand.Z < opt.min_accepted_Z) {
344 return false;
345 }
346
347 if (cand.Z > opt.max_accepted_Z) {
348 return false;
349 }
350
351 return true;
352}
353
354inline double ScoreRoot(const RootCandidate& cand) {
355 return cand.Z - 0.1 * cand.distance_to_ref;
356}
357
358template <typename QPFunc>
359boost::optional<RootCandidate> RefineQPInterval(
360 double lowerbound, double f_lowerbound, double upperbound,
361 double f_upperbound, const QPFunc& f, double reference,
362 const SolverOptions& opt, bool use_brent) {
363 RootCandidate cand;
364
365 cand.omega = use_brent ? SolveQP_Brent(lowerbound, f_lowerbound, upperbound,
366 f_upperbound, f, opt)
367 : SolveQP_Bisection(lowerbound, f_lowerbound,
368 upperbound, f_upperbound, f, opt);
369
370 cand.residual = f.value(cand.omega, EvalStage::Refine);
371 cand.deriv = f.deriv(cand.omega);
372
373 if (std::abs(cand.deriv) > 1e-14) {
374 cand.Z = -1.0 / cand.deriv;
375 } else {
376 cand.Z = std::numeric_limits<double>::infinity();
377 }
378
379 cand.distance_to_ref = std::abs(cand.omega - reference);
380 cand.accepted = AcceptRoot(cand, opt);
381
382 cand.accepted = AcceptRoot(cand, opt);
383
384 /*std::cout << "DEBUG ROOT "
385 << "omega=" << cand.omega
386 << " residual=" << cand.residual
387 << " deriv=" << cand.deriv
388 << " Z=" << cand.Z
389 << " accepted=" << cand.accepted
390 << std::endl;*/
391 return cand;
392}
393
394template <typename QPFunc>
395boost::optional<double> SolveQP_Grid_Windowed(
396 QPFunc& fqp, double frequency0, double left_limit, double right_limit,
397 Index gw_sc_iteration, const SolverOptions& opt,
398 WindowDiagnostics* wdiag = nullptr,
399 std::vector<RootCandidate>* accepted_roots_out = nullptr,
400 std::vector<RootCandidate>* rejected_roots_out = nullptr,
401 bool use_brent = false) {
402 struct SamplePoIndex {
403 double omega = 0.0;
404 double fval = 0.0;
405 };
406
407 WindowDiagnostics local_diag;
408 std::vector<RootCandidate> accepted_roots;
409 std::vector<RootCandidate> rejected_roots;
410
411 if (left_limit >= right_limit) {
412 if (wdiag != nullptr) {
413 *wdiag = local_diag;
414 }
415 if (accepted_roots_out != nullptr) {
416 *accepted_roots_out = accepted_roots;
417 }
418 if (rejected_roots_out != nullptr) {
419 *rejected_roots_out = rejected_roots;
420 }
421 return boost::none;
422 }
423
424 const double shell_width = EffectiveAdaptiveShellWidth(opt);
425
426 // The adaptive search is centered on the current reference frequency. On
427 // the first GW iteration, a local linearized estimate is used when it stays
428 // inside the requested window; later iterations keep the current frequency as
429 // the center. From that center, the scan expands symmetrically in shells.
430 double center = frequency0;
431
432 if (gw_sc_iteration == 0) {
433 const double f0 = fqp.value(frequency0, EvalStage::Other);
434 const double df0 = fqp.deriv(frequency0);
435 if (std::isfinite(f0) && std::isfinite(df0) && std::abs(df0) > 1e-6) {
436 const double w_lin = frequency0 - f0 / df0;
437 if (std::isfinite(w_lin) && w_lin >= left_limit && w_lin <= right_limit) {
438 center = w_lin;
439 }
440 }
441 }
442
443 center = std::max(left_limit, std::min(right_limit, center));
444
445 // Compute how many shells are needed to reach both limits from the chosen
446 // center. Unlike the legacy implementation, this depends only on the
447 // explicit adaptive shell control, not on the dense scan spacing.
448 const double max_shell_reach =
449 std::max(center - left_limit, right_limit - center);
450 const Index n_shells =
451 static_cast<Index>(std::ceil(max_shell_reach / shell_width));
452
453 auto refine_and_store = [&](double a, double fa, double b, double fb,
454 Index shell_idx) {
455 if (b < a) {
456 std::swap(a, b);
457 std::swap(fa, fb);
458 }
459
460 // Deterministic local mini-scan inside the coarse sign-change interval.
461 // This restores much of the old dense-grid robustness while keeping the
462 // fast coarse search globally.
463 struct LocalBracket {
464 double left = 0.0;
465 double f_left = 0.0;
466 double right = 0.0;
467 double f_right = 0.0;
468 double midpoint() const { return 0.5 * (left + right); }
469 };
470
471 const Index local_substeps = 12;
472 std::vector<LocalBracket> local_brackets;
473 local_brackets.reserve(static_cast<std::size_t>(local_substeps));
474
475 if (b > a) {
476 const double dx = (b - a) / static_cast<double>(local_substeps);
477
478 double x_prev = a;
479 double f_prev = fa;
480
481 for (Index i = 1; i <= local_substeps; ++i) {
482 const double x_curr =
483 (i == local_substeps) ? b : (a + static_cast<double>(i) * dx);
484 const double f_curr =
485 (i == local_substeps) ? fb : fqp.value(x_curr, EvalStage::Scan);
486
487 // Standard sign change
488 if ((f_prev < 0.0 && f_curr > 0.0) || (f_prev > 0.0 && f_curr < 0.0)) {
489 local_brackets.push_back({x_prev, f_prev, x_curr, f_curr});
490 }
491
492 // Near-exact zero at the left node
493 if (std::abs(f_prev) <= opt.g_sc_limit && x_prev < x_curr) {
494 local_brackets.push_back({x_prev, f_prev, x_curr, f_curr});
495 }
496
497 // Near-exact zero at the right node
498 if (std::abs(f_curr) <= opt.g_sc_limit && x_prev < x_curr) {
499 local_brackets.push_back({x_prev, f_prev, x_curr, f_curr});
500 }
501
502 x_prev = x_curr;
503 f_prev = f_curr;
504 }
505 }
506
507 // Choose the local bracket deterministically:
508 // midpoint closest to the search center, tie-broken by lower left edge.
509 if (!local_brackets.empty()) {
510 auto best_it = local_brackets.begin();
511 double best_dist = std::abs(best_it->midpoint() - center);
512
513 for (auto it = local_brackets.begin() + 1; it != local_brackets.end();
514 ++it) {
515 const double dist = std::abs(it->midpoint() - center);
516 if (dist < best_dist - 1e-14 ||
517 (std::abs(dist - best_dist) <= 1e-14 && it->left < best_it->left)) {
518 best_it = it;
519 best_dist = dist;
520 }
521 }
522
523 a = best_it->left;
524 fa = best_it->f_left;
525 b = best_it->right;
526 fb = best_it->f_right;
527 }
528
529 auto cand_opt =
530 RefineQPInterval(a, fa, b, fb, fqp, frequency0, opt, use_brent);
531 if (!cand_opt) {
532 return;
533 }
534
535 if (local_diag.first_interval_shell < 0) {
536 local_diag.first_interval_shell = shell_idx;
537 }
538 ++local_diag.intervals_found;
539
540 const RootCandidate& cand = cand_opt.value();
541 if (cand.accepted) {
542 if (local_diag.first_accepted_shell < 0) {
543 local_diag.first_accepted_shell = shell_idx;
544 }
545 accepted_roots.push_back(cand);
546 } else {
547 rejected_roots.push_back(cand);
548 }
549 };
550
551 SamplePoIndex center_pt{center, fqp.value(center, EvalStage::Scan)};
552
553 bool left_active = true;
554 bool right_active = true;
555 SamplePoIndex left_prev = center_pt;
556 SamplePoIndex right_prev = center_pt;
557
558 for (Index shell = 1; shell <= n_shells; ++shell) {
559 local_diag.shells_explored = shell;
560 bool added_this_shell = false;
561 const double delta = double(shell) * shell_width;
562
563 // Each shell adds one point to the left and one to the right of the
564 // center. Whenever a sign change is detected between neighboring samples,
565 // that interval is refined by bisection or Brent and the resulting root is
566 // classified as accepted or rejected using the Z-based acceptance test.
567
568 if (left_active) {
569 const double omega_left = center - delta;
570 if (omega_left >= left_limit) {
571 SamplePoIndex left_curr{omega_left,
572 fqp.value(omega_left, EvalStage::Scan)};
573 added_this_shell = true;
574
575 if (left_prev.fval * left_curr.fval < 0.0) {
576 refine_and_store(left_curr.omega, left_curr.fval, left_prev.omega,
577 left_prev.fval, shell);
578 }
579 left_prev = left_curr;
580 } else {
581 left_active = false;
582 }
583 }
584
585 if (right_active) {
586 const double omega_right = center + delta;
587 if (omega_right <= right_limit) {
588 SamplePoIndex right_curr{omega_right,
589 fqp.value(omega_right, EvalStage::Scan)};
590 added_this_shell = true;
591
592 if (right_prev.fval * right_curr.fval < 0.0) {
593 refine_and_store(right_prev.omega, right_prev.fval, right_curr.omega,
594 right_curr.fval, shell);
595 }
596 right_prev = right_curr;
597 } else {
598 right_active = false;
599 }
600 }
601
602 if (!added_this_shell && !left_active && !right_active) {
603 break;
604 }
605 }
606
607 if (left_prev.omega > left_limit + 1e-12) {
608 SamplePoIndex left_end{left_limit, fqp.value(left_limit, EvalStage::Scan)};
609 if (left_end.fval * left_prev.fval < 0.0) {
610 refine_and_store(left_end.omega, left_end.fval, left_prev.omega,
611 left_prev.fval, local_diag.shells_explored + 1);
612 }
613 }
614
615 if (right_prev.omega < right_limit - 1e-12) {
616 SamplePoIndex right_end{right_limit,
617 fqp.value(right_limit, EvalStage::Scan)};
618 if (right_prev.fval * right_end.fval < 0.0) {
619 refine_and_store(right_prev.omega, right_prev.fval, right_end.omega,
620 right_end.fval, local_diag.shells_explored + 1);
621 }
622 }
623
624 // Preference order for the adaptive search:
625 // 1. return the best accepted root found in the scanned shells
626 // 2. if the caller allows it, return the best rejected root
627 // The outer GW driver uses this to enforce that restricted-window searches
628 // only succeed on accepted roots; otherwise it escalates to a full dense
629 // scan.
630 if (!accepted_roots.empty()) {
631 auto best =
632 std::max_element(accepted_roots.begin(), accepted_roots.end(),
633 [](const RootCandidate& a, const RootCandidate& b) {
634 return ScoreRoot(a) < ScoreRoot(b);
635 });
636
637 local_diag.chosen_shell = static_cast<int>(
638 std::llround(std::abs(best->omega - center) / shell_width));
639
640 if (wdiag != nullptr) {
641 *wdiag = local_diag;
642 }
643 if (accepted_roots_out != nullptr) {
644 *accepted_roots_out = accepted_roots;
645 }
646 if (rejected_roots_out != nullptr) {
647 *rejected_roots_out = rejected_roots;
648 }
649 return best->omega;
650 }
651
652 if (!rejected_roots.empty()) {
653 auto least_bad =
654 std::max_element(rejected_roots.begin(), rejected_roots.end(),
655 [](const RootCandidate& a, const RootCandidate& b) {
656 return ScoreRoot(a) < ScoreRoot(b);
657 });
658
659 local_diag.chosen_shell = static_cast<int>(
660 std::llround(std::abs(least_bad->omega - center) / shell_width));
661
662 if (wdiag != nullptr) {
663 *wdiag = local_diag;
664 }
665 if (accepted_roots_out != nullptr) {
666 *accepted_roots_out = accepted_roots;
667 }
668 if (rejected_roots_out != nullptr) {
669 *rejected_roots_out = rejected_roots;
670 }
671 return least_bad->omega;
672 }
673
674 if (wdiag != nullptr) {
675 *wdiag = local_diag;
676 }
677 if (accepted_roots_out != nullptr) {
678 *accepted_roots_out = accepted_roots;
679 }
680 if (rejected_roots_out != nullptr) {
681 *rejected_roots_out = rejected_roots;
682 }
683 return boost::none;
684}
685
686} // namespace qp_solver
687} // namespace xtp
688} // namespace votca
689
690#endif
double LegacyAdaptiveShellWidth(const Opt &opt)
boost::optional< double > SolveQP_Grid_Windowed(QPFunc &fqp, double frequency0, double left_limit, double right_limit, Index gw_sc_iteration, const SolverOptions &opt, WindowDiagnostics *wdiag=nullptr, std::vector< RootCandidate > *accepted_roots_out=nullptr, std::vector< RootCandidate > *rejected_roots_out=nullptr, bool use_brent=false)
bool AcceptRoot(const RootCandidate &cand, const SolverOptions &opt)
double ScoreRoot(const RootCandidate &cand)
void NormalizeGridSearchOptions(Opt &opt)
double SolveQP_Brent(double lowerbound, double f_lowerbound, double upperbound, double f_upperbound, const QPFunc &f, const SolverOptions &opt)
double SolveQP_Bisection(double lowerbound, double f_lowerbound, double upperbound, double f_upperbound, const QPFunc &f, const SolverOptions &opt)
boost::optional< RootCandidate > RefineQPInterval(double lowerbound, double f_lowerbound, double upperbound, double f_upperbound, const QPFunc &f, double reference, const SolverOptions &opt, bool use_brent)
double LegacyFullWindowHalfWidth(const Opt &opt)
double EffectiveAdaptiveShellWidth(const SolverOptions &opt)
Provides a means for comparing floating point numbers.
Definition basebead.h:33
Eigen::Index Index
Definition types.h:26
void Add(const Stats &other)
std::size_t TotalSigmaCalls() const