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
0 commit comments