Skip to content

Commit 520fa05

Browse files
authored
Add files via upload
1 parent e7e2a62 commit 520fa05

3 files changed

Lines changed: 205 additions & 4 deletions

File tree

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import numpy as np
2+
from scipy.interpolate import make_interp_spline, PPoly
3+
from scipy.integrate import cumulative_trapezoid
4+
from scipy.optimize import brentq
5+
6+
def _fit_bspline_energy_path(s_vals, E_vals, gradient_norms=None, k=3):
7+
"""
8+
Fits the energy profile using B-spline basis functions (Ritz approximation).
9+
Automatically handles boundary conditions for Cubic (k=3) and Quintic (k=5) splines.
10+
11+
"""
12+
bc_type = None
13+
if gradient_norms is not None:
14+
start_bc = [(1, gradient_norms[0])]
15+
end_bc = [(1, gradient_norms[-1])]
16+
17+
# Consistent with higher order derivatives requirements
18+
if k >= 5:
19+
start_bc.append((2, 0.0))
20+
end_bc.append((2, 0.0))
21+
22+
bc_type = (start_bc, end_bc)
23+
24+
spline = make_interp_spline(s_vals, E_vals, k=k, bc_type=bc_type)
25+
return spline
26+
27+
def _find_spline_maxima(spline, s_min=0.05, s_max=0.95):
28+
"""
29+
Finds local maxima on the B-spline curve.
30+
Uses a robust fallback (Grid Search + Brent's method) if
31+
BSpline.roots() is unavailable in the environment.
32+
"""
33+
# 1. Calculate derivative spline E'(s)
34+
deriv_spline = spline.derivative(nu=1)
35+
36+
roots = []
37+
38+
# --- Attempt 1: Native .roots() method (Newer SciPy) ---
39+
try:
40+
roots = deriv_spline.roots()
41+
except AttributeError:
42+
# --- Attempt 2: Fallback for older SciPy (Grid Search + Brentq) ---
43+
# Create a grid to detect sign changes
44+
grid_points = 200
45+
s_grid = np.linspace(0.0, 1.0, grid_points)
46+
y_grid = deriv_spline(s_grid)
47+
48+
for i in range(len(s_grid) - 1):
49+
y1 = y_grid[i]
50+
y2 = y_grid[i+1]
51+
52+
# Check for sign change
53+
if y1 * y2 < 0:
54+
try:
55+
# Find exact root in this interval
56+
root = brentq(deriv_spline, s_grid[i], s_grid[i+1])
57+
roots.append(root)
58+
except ValueError:
59+
continue
60+
61+
# 3. Filter valid roots within range and check for maximum (E''(s) < 0)
62+
valid_maxima = []
63+
second_deriv_spline = spline.derivative(nu=2)
64+
65+
for r in roots:
66+
# Check if root is real and within range
67+
if np.isreal(r) and s_min <= r.real <= s_max:
68+
r_real = r.real
69+
# Check concavity (Second derivative must be negative for a maximum)
70+
curvature = second_deriv_spline(r_real)
71+
if curvature < -1e-6: # Concave down -> Maximum
72+
energy = spline(r_real)
73+
valid_maxima.append((r_real, energy))
74+
75+
return valid_maxima
76+
77+
def distribute_geometry_bspline_ritz(
78+
geometry_list,
79+
energy_list,
80+
gradient_list=None,
81+
n_points=None,
82+
spline_degree=3,
83+
use_gradient_corrections=True,
84+
concentration_factor=0.0
85+
):
86+
"""
87+
Redistributes geometry nodes based on B-spline Ritz approximation.
88+
89+
Features:
90+
1. Fits continuous path using B-splines.
91+
2. Identifies the exact TS location on the spline.
92+
3. Optionally concentrates nodes around the high-energy region.
93+
94+
Args:
95+
concentration_factor (float):
96+
Controls how much nodes gather around the peak energy.
97+
0.0 = Uniform spacing (arc-length).
98+
> 0.0 = Higher density at high energy regions (mimicking MaxFlux behavior).
99+
Recommended values: 0.0 to 5.0.
100+
"""
101+
geom = np.asarray(geometry_list, dtype=float)
102+
energies = np.asarray(energy_list, dtype=float)
103+
n_old = len(geom)
104+
105+
if n_points is None:
106+
n_points = n_old
107+
108+
# 1. Path Parameterization (Normalized Arc Length s in [0, 1])
109+
geom_flat = geom.reshape(n_old, -1)
110+
diffs = np.diff(geom_flat, axis=0)
111+
seg_lengths = np.linalg.norm(diffs, axis=1)
112+
s_cum = np.concatenate([[0.0], np.cumsum(seg_lengths)])
113+
total_length = s_cum[-1]
114+
115+
if total_length < 1e-12 or n_old < 4:
116+
return geom.copy()
117+
118+
s_norm = s_cum / total_length
119+
120+
# 2. Fit Geometry Spline X(s) (Cubic for smooth path)
121+
geom_spline = make_interp_spline(s_norm, geom_flat, k=3)
122+
123+
# 3. Fit Energy Spline E(s) (Ritz Ansatz)
124+
# Calculate projected gradients dE/ds
125+
grad_proj = None
126+
if gradient_list is not None and use_gradient_corrections:
127+
grads = np.asarray(gradient_list).reshape(n_old, -1)
128+
tangents = np.gradient(geom_flat, s_cum, axis=0)
129+
t_norms = np.linalg.norm(tangents, axis=1, keepdims=True)
130+
t_norms[t_norms < 1e-12] = 1.0
131+
# Project and scale by L (since s is normalized)
132+
grad_proj = np.sum(grads * (tangents / t_norms), axis=1) * total_length
133+
134+
energy_spline = _fit_bspline_energy_path(
135+
s_norm,
136+
energies,
137+
gradient_norms=grad_proj,
138+
k=spline_degree
139+
)
140+
141+
# 4. Determine New Node Distribution
142+
# Create a fine grid to evaluate the "Density Function"
143+
s_fine = np.linspace(0, 1, 1000)
144+
E_fine = energy_spline(s_fine)
145+
146+
if concentration_factor > 1e-3:
147+
# --- Weighted Distribution (High density at high energy) ---
148+
# Ref: "Evaluation points gather near the transition state"
149+
150+
# Weight function W(s) ~ 1 + alpha * normalized_Energy(s)
151+
E_min = np.min(E_fine)
152+
E_max = np.max(E_fine)
153+
if E_max - E_min > 1e-6:
154+
E_scaled = (E_fine - E_min) / (E_max - E_min)
155+
# Use exponential weighting similar to Flux ~ exp(beta*E) but milder for stability
156+
weights = 1.0 + concentration_factor * (np.exp(2.0 * E_scaled) - 1.0)
157+
else:
158+
weights = np.ones_like(E_fine)
159+
160+
# Calculate Cumulative Density Function (CDF)
161+
cdf = cumulative_trapezoid(weights, s_fine, initial=0)
162+
cdf /= cdf[-1] # Normalize to [0, 1]
163+
164+
# Inverse Transform Sampling to find new s values
165+
# We want uniform steps in CDF space -> translates to variable steps in s space
166+
target_cdf = np.linspace(0, 1, n_points)
167+
s_new = np.interp(target_cdf, cdf, s_fine)
168+
169+
else:
170+
# --- Align Peak only (Previous Logic) ---
171+
# Find exact TS
172+
maxima = _find_spline_maxima(energy_spline)
173+
if maxima:
174+
s_ts, _ = max(maxima, key=lambda x: x[1])
175+
else:
176+
s_ts = s_norm[np.argmax(energies)]
177+
178+
# Align closest integer node to TS
179+
target_ts_idx = max(1, min(n_points - 2, int(round(s_ts * (n_points - 1)))))
180+
181+
# Piecewise linear distribution ensuring TS hit
182+
s_new_1 = np.linspace(0.0, s_ts, target_ts_idx + 1)
183+
s_new_2 = np.linspace(s_ts, 1.0, n_points - target_ts_idx)
184+
s_new = np.concatenate([s_new_1[:-1], s_new_2])
185+
186+
# 5. Generate New Geometry
187+
new_flat_geom = geom_spline(s_new)
188+
new_geom = new_flat_geom.reshape(n_points, geom.shape[1], 3)
189+
190+
# Fix endpoints
191+
new_geom[0] = geom[0]
192+
new_geom[-1] = geom[-1]
193+
194+
return new_geom

multioptpy/interface.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ def call_nebparser(parser):
267267
parser.add_argument("-ad", "--align_distances", type=int, default=0, help='distribute images at equal intervals on the reaction coordinate')
268268
parser.add_argument("-adene", "--align_distances_energy", type=int, default=0, help='distribute images at energy-weighted intervals on the reaction coordinate')
269269
parser.add_argument("-adpred", "--align_distances_energy_predicted", type=int, default=0, help='distribute images at intervals on the reaction coordinate using cubic predicted interpolation')
270+
parser.add_argument("-adrpred", "--align_distances_ritz_energy_predicted", type=int, default=0, help='distribute images at intervals on the reaction coordinate using Ritz method')
270271
parser.add_argument("-ads", "--align_distances_spline", type=int, default=0, help='distribute images at equal intervals on the reaction coordinate using spline interpolation')
271272
parser.add_argument("-ads2", "--align_distances_spline_ver2", type=int, default=0, help='distribute images at equal intervals on the reaction coordinate using spline interpolation ver.2')
272273
parser.add_argument("-adg", "--align_distances_geodesic", type=int, default=0, help='distribute images at equal intervals on the reaction coordinate using geodesic interpolation')

multioptpy/neb.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
from multioptpy.Interpolation.linear_interpolation import distribute_geometry, distribute_geometry_by_length, distribute_geometry_by_energy, distribute_geometry_by_predicted_energy
6969
from multioptpy.Interpolation.savitzky_golay_interpolation import savitzky_golay_interpolation, distribute_geometry_by_length_savgol
7070
from multioptpy.Interpolation.adaptive_interpolation import adaptive_geometry_energy_interpolation
71+
from multioptpy.Interpolation.ritz_interpolation import distribute_geometry_bspline_ritz
7172

7273

7374

@@ -149,6 +150,7 @@ def __init__(self, args):
149150
self.align_distances = args.align_distances
150151
self.align_distances_energy = args.align_distances_energy
151152
self.align_distances_energy_predicted = args.align_distances_energy_predicted
153+
self.align_distances_ritz_energy_predicted = args.align_distances_ritz_energy_predicted
152154
self.align_distances_spline = args.align_distances_spline
153155
self.align_distances_spline_ver2 = args.align_distances_spline_ver2
154156
self.align_distances_geodesic = args.align_distances_geodesic
@@ -495,8 +497,7 @@ def execute(self):
495497
self.make_traj_file(file_directory)
496498

497499
# Calculate energy and gradients
498-
energy_list, gradient_list, geometry_num_list, pre_total_velocity = \
499-
self.calculation_engine.calculate(file_directory, adaptive_neb_count,
500+
energy_list, gradient_list, geometry_num_list, pre_total_velocity = self.calculation_engine.calculate(file_directory, adaptive_neb_count,
500501
pre_total_velocity, self.config)
501502

502503
if adaptive_neb_count == 0:
@@ -542,8 +543,7 @@ def execute(self):
542543
geometry_num_list, biased_energy_list, biased_gradient_list, adaptive_neb_count, element_list)
543544

544545
# Calculate analysis metrics
545-
cos_list, tot_force_rms_list, tot_force_max_list, bias_force_rms_list, path_length_list = \
546-
self._calculate_analysis_metrics(total_force, biased_gradient_list, geometry_num_list)
546+
cos_list, tot_force_rms_list, tot_force_max_list, bias_force_rms_list, path_length_list = self._calculate_analysis_metrics(total_force, biased_gradient_list, geometry_num_list)
547547

548548
# Save analysis data and create plots
549549
self._save_analysis_data(cos_list, tot_force_rms_list, tot_force_max_list,
@@ -739,6 +739,12 @@ def update_geometry_in_place(result_geometry):
739739
"Aligning geometries using energy-weighted predicted interpolation...",
740740
lambda: {'energy_list': energy_list, 'gradient_list': gradient_list}
741741
),
742+
(
743+
'align_distances_ritz_energy_predicted',
744+
distribute_geometry_bspline_ritz,
745+
"Aligning geometries using energy-weighted predicted interpolation and Ritz method ...",
746+
lambda: {'energy_list': energy_list, 'gradient_list': gradient_list}
747+
),
742748

743749
]
744750

0 commit comments

Comments
 (0)