nebcert 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nebcert/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ """
2
+ NEBCert: quality checks for NEB reaction paths, transition states and
3
+ tunnelling-corrected rate constants.
4
+ """
5
+
6
+ __version__ = "1.2.0"
7
+ __author__ = "Andres Monreal-Hernández"
8
+ __license__ = "MIT"
9
+
10
+ from nebcert.core.neb_profile import calculate_neb_profile_analysis, NEBProfileResult
11
+ from nebcert.core.ts_frequency import verify_ts_frequency_and_irc, TSFrequencyResult
12
+ from nebcert.core.tst_kinetics import calculate_eyring_tst_rates, TSTKineticsResult
13
+ from nebcert.core.tunneling import calculate_quantum_tunneling_corrections, TunnelingResult
14
+ from nebcert.core.scoring import assess_reaction_pathway_quality, ReactionPathwayReport
15
+
16
+ __all__ = [
17
+ "__version__",
18
+ "calculate_neb_profile_analysis",
19
+ "NEBProfileResult",
20
+ "verify_ts_frequency_and_irc",
21
+ "TSFrequencyResult",
22
+ "calculate_eyring_tst_rates",
23
+ "TSTKineticsResult",
24
+ "calculate_quantum_tunneling_corrections",
25
+ "TunnelingResult",
26
+ "assess_reaction_pathway_quality",
27
+ "ReactionPathwayReport"
28
+ ]
nebcert/citation.py ADDED
@@ -0,0 +1,22 @@
1
+ """
2
+ Citation strings for NEBCert (concept DOI: resolves to the latest version on Zenodo).
3
+ """
4
+
5
+ from nebcert import __version__
6
+
7
+ CONCEPT_DOI = "10.5281/zenodo.22217586"
8
+ TITLE = "NEBCert: quality checks for NEB paths, transition states and tunnelling-corrected rate constants"
9
+
10
+ BIBTEX = (
11
+ "@software{monreal2026nebcert,\n"
12
+ " author = {Monreal-Hern{\\'a}ndez, Andr{\\'e}s},\n"
13
+ f" title = {{{{{TITLE}}}}},\n"
14
+ " year = {2026},\n"
15
+ f" version = {{{__version__}}},\n"
16
+ " publisher = {Zenodo},\n"
17
+ f" doi = {{{CONCEPT_DOI}}},\n"
18
+ " url = {https://github.com/sircalch/nebcert}\n"
19
+ "}\n"
20
+ )
21
+
22
+ APA = f"Monreal-Hernández, A. (2026). {TITLE} (v{__version__}). Zenodo. https://doi.org/{CONCEPT_DOI}"
nebcert/cli.py ADDED
@@ -0,0 +1,323 @@
1
+ """
2
+ Command Line Interface (CLI) for NEBCert.
3
+ """
4
+
5
+ import sys
6
+ import os
7
+ import argparse
8
+ import numpy as np
9
+
10
+ from nebcert import __version__
11
+ from nebcert.citation import APA, BIBTEX
12
+ from nebcert.parsers.vasp_neb import parse_vasp_neb_dat
13
+ from nebcert.parsers.orca_neb import parse_orca_neb_output
14
+ from nebcert.parsers.gaussian_irc import parse_gaussian_irc_output
15
+ from nebcert.parsers.generic_mep_csv import parse_mep_csv
16
+
17
+ from nebcert.core.neb_profile import calculate_neb_profile_analysis
18
+ from nebcert.core.ts_frequency import verify_ts_frequency_and_irc
19
+ from nebcert.core.tst_kinetics import calculate_eyring_tst_rates
20
+ from nebcert.core.tunneling import calculate_quantum_tunneling_corrections
21
+ from nebcert.core.scoring import assess_reaction_pathway_quality
22
+
23
+ from nebcert.reporters.plot_generator import generate_nebcert_figures
24
+ from nebcert.reporters.manuscript_prep import generate_nebcert_manuscript_assets
25
+ from nebcert.reporters.html_report import generate_nebcert_html_report
26
+
27
+
28
+ def print_banner():
29
+ banner = rf"""
30
+ _ _ ______ _____ _____ _
31
+ | \ | | ____| _ \ / ____| | |
32
+ | \| | |__ | |_) | | ___ _ __| |_
33
+ | . ` | __| | _ <| | / _ \ '__| __|
34
+ | |\ | |____| |_) | |___| __/ | | |_
35
+ |_| \_|______|____/ \_____\___|_| \__| v{__version__}
36
+
37
+ Transition State, NEB Reaction Pathways & Quantum Tunneling Toolkit
38
+ Monreal-Hernández et al., 2026
39
+ """
40
+ print(banner)
41
+
42
+
43
+ def run_demo(output_dir: str = "nebcert_demo_output"):
44
+ """
45
+ Executes a benchmark demonstration evaluating a hydrogen atom abstraction reaction:
46
+ CH4 + OH* -> CH3* + H2O with 9-image NEB band, 1st-order saddle point TS (nu_imag = -1250 cm^-1),
47
+ Eyring TST kinetics, and asymmetric Eckart quantum tunneling.
48
+ """
49
+ print(f"\n[NEBCert] Running demonstration benchmark on Reaction Pathway (CH4 + OH* -> CH3* + H2O)...")
50
+ os.makedirs(output_dir, exist_ok=True)
51
+
52
+ metadata = {
53
+ "reaction": "CH4 + OH* -> [TS]* -> CH3* + H2O (Hydrogen Abstraction)",
54
+ "functional": "wB97X-D3 / def2-TZVP",
55
+ "software": "SYNTHETIC DEMO DATA (ORCA-like CI-NEB / NumFreq; not a real calculation)"
56
+ }
57
+
58
+ # 1. 9-image NEB reaction energy profile (eV)
59
+ # Reactant = 0.0, Peak TS (img 4) = 0.225 eV (5.19 kcal/mol), Product (img 8) = -0.620 eV (-14.30 kcal/mol)
60
+ s_coords = [0.0, 0.35, 0.70, 1.05, 1.40, 1.75, 2.10, 2.45, 2.80]
61
+ energies_rel_ev = [0.000, 0.045, 0.120, 0.195, 0.225, 0.110, -0.220, -0.510, -0.620]
62
+ tangent_forces = [0.00, 0.02, 0.03, 0.02, 0.01, 0.03, 0.02, 0.02, 0.00]
63
+
64
+ print(" -> Analyzing NEB Minimum Energy Path, fitting cubic spline, and auditing CI-NEB forces...")
65
+ neb_res = calculate_neb_profile_analysis(
66
+ energies_ev=energies_rel_ev,
67
+ coordinates_s_ang=s_coords,
68
+ tangent_forces_ev_ang=tangent_forces
69
+ )
70
+
71
+ # 2. Transition state imaginary frequency
72
+ print(" -> Verifying 1st-order saddle point vibrational frequencies (nu_imag = -1250.0 cm^-1)...")
73
+ sample_freqs = [-1250.0, 120.0, 250.0, 480.0, 850.0, 1100.0, 1450.0, 2900.0, 3100.0, 3650.0]
74
+ ts_res = verify_ts_frequency_and_irc(
75
+ frequencies_cm1=sample_freqs,
76
+ min_significant_freq_cm1=50.0,
77
+ irc_confirmed=True
78
+ )
79
+
80
+ # 3. Eyring TST rate constants
81
+ print(" -> Computing Eyring-Polanyi TST rate constants k(T) and Arrhenius parameters...")
82
+ tst_res = calculate_eyring_tst_rates(
83
+ e_activation_ev=neb_res.e_forward_barrier_ev
84
+ )
85
+
86
+ # 4. Quantum tunneling corrections (Eckart & Wigner)
87
+ print(" -> Computing asymmetric Eckart quantum tunneling transmission factor kappa(T)...")
88
+ tun_res = calculate_quantum_tunneling_corrections(
89
+ imaginary_freq_cm1=1250.0,
90
+ e_forward_barrier_ev=neb_res.e_forward_barrier_ev,
91
+ e_reverse_barrier_ev=neb_res.e_reverse_barrier_ev
92
+ )
93
+
94
+ report = assess_reaction_pathway_quality(
95
+ metadata=metadata,
96
+ neb_res=neb_res,
97
+ ts_freq_res=ts_res,
98
+ tst_res=tst_res,
99
+ tunneling_res=tun_res
100
+ )
101
+
102
+ print(" -> Generating publication-ready vector figures (NEB MEP Profile, Arrhenius Plot, Tunneling Curve)...")
103
+ generate_nebcert_figures(report, output_dir)
104
+
105
+ print(" -> Drafting manuscript Methods text snippet, summary LaTeX tables, and BibTeX citations...")
106
+ assets = generate_nebcert_manuscript_assets(report, output_dir)
107
+
108
+ with open(assets["methods_text"], "r", encoding="utf-8") as f:
109
+ methods_txt = f.read()
110
+ with open(assets["citation_bib"], "r", encoding="utf-8") as f:
111
+ bib_txt = f.read()
112
+
113
+ html_p = os.path.join(output_dir, "report.html")
114
+ print(f" -> Writing interactive report to {html_p}...")
115
+ generate_nebcert_html_report(report, html_p, methods_text=methods_txt, citation_bib=bib_txt)
116
+
117
+ print("\n" + "="*70)
118
+ print(f" [RESULT] Overall status: {report.overall_status}")
119
+ print(f" [SCORE] {report.validation_score}")
120
+ print("="*70)
121
+ print(f" * Reaction Target : {report.metadata['reaction']}")
122
+ print(f" * Activation Barrier: E_a^fwd = {report.neb_profile.e_forward_barrier_kcal_mol:.2f} kcal/mol ({report.neb_profile.e_forward_barrier_ev:.3f} eV) | Delta E_rxn = {report.neb_profile.delta_e_reaction_kcal_mol:.2f} kcal/mol")
123
+ print(f" * Transition State : {report.ts_frequency.imaginary_frequency_cm1:.1f} cm^-1 | Status: {report.ts_frequency.status}")
124
+ print(f" * Eyring TST Rate : k(298 K) = {report.tst_kinetics.k_298_s_minus_1:.2e} s^-1 (Arrhenius A = {report.tst_kinetics.arrhenius_pre_exponential_a_s_minus_1:.2e} s^-1, E_a = {report.tst_kinetics.arrhenius_e_activation_kcal_mol:.2f} kcal/mol)")
125
+ print(f" * Quantum Tunneling: kappa_Eckart(298 K) = {report.tunneling.kappa_eckart_298:.2f}")
126
+ print("="*70)
127
+ print(f"\nAll outputs successfully saved to: {os.path.abspath(output_dir)}/")
128
+ print(f"Open {os.path.abspath(html_p)} in your browser to inspect the full report.\n")
129
+
130
+
131
+ def run_assess(args):
132
+ """
133
+ Evaluates user-provided MEP/NEB files or frequencies.
134
+ """
135
+ output_dir = args.output
136
+ os.makedirs(output_dir, exist_ok=True)
137
+
138
+ neb_res = None
139
+ ts_res = None
140
+ tst_res = None
141
+ tun_res = None
142
+
143
+ # 1. Parse NEB / MEP input
144
+ orca_neb = None
145
+ if args.input_neb:
146
+ print(f"\n[NEBCert] Parsing NEB data from: {args.input_neb}...")
147
+ lower = args.input_neb.lower()
148
+ if lower.endswith(".dat"):
149
+ try:
150
+ m_data = parse_vasp_neb_dat(args.input_neb)
151
+ except Exception:
152
+ m_data = parse_mep_csv(args.input_neb)
153
+ elif lower.endswith((".out", ".log")):
154
+ m_data = parse_orca_neb_output(args.input_neb)
155
+ if not m_data["energies_ev"]:
156
+ print("[Error] No ORCA NEB 'PATH SUMMARY' table found in the file.", file=sys.stderr)
157
+ sys.exit(1)
158
+ if m_data["n_images_without_energy"]:
159
+ print(f"[Error] {m_data['n_images_without_energy']} of {m_data['n_images']} images in the last "
160
+ f"'PATH SUMMARY' table have no energy (E = 0): the image calculations failed or never ran; see the ORCA errors above the table.",
161
+ file=sys.stderr)
162
+ sys.exit(1)
163
+ orca_neb = m_data
164
+ print(f" -> ORCA NEB: {m_data['n_images']} images, NEB converged: {m_data['neb_converged']}, "
165
+ f"climbing image: {m_data['ci_index']}")
166
+ if m_data.get("ts"):
167
+ print(f" -> NEB-TS transition state: E_a = {m_data['ts']['barrier_fwd_kcal']:.2f} kcal/mol, "
168
+ f"TS optimisation converged: {m_data['ts']['converged']}")
169
+ else:
170
+ m_data = parse_mep_csv(args.input_neb)
171
+
172
+ ts_data = orca_neb.get("ts") if orca_neb else None
173
+ neb_res = calculate_neb_profile_analysis(
174
+ energies_ev=m_data["energies_ev"],
175
+ coordinates_s_ang=m_data.get("coordinates_s_ang"),
176
+ tangent_forces_ev_ang=m_data.get("tangent_forces"),
177
+ band_converged=orca_neb["neb_converged"] if orca_neb else None,
178
+ ts_energy_rel_ev=ts_data["barrier_fwd_ev"] if ts_data else None,
179
+ ts_optimisation_converged=ts_data["converged"] if ts_data else None,
180
+ climbing_image_index=orca_neb["ci_index"] if orca_neb else None
181
+ )
182
+
183
+ # 2. Parse / evaluate TS frequencies
184
+ if args.frequencies:
185
+ freq_list = [float(x) for x in args.frequencies.split(",")]
186
+ ts_res = verify_ts_frequency_and_irc(
187
+ frequencies_cm1=freq_list,
188
+ irc_confirmed=args.irc
189
+ )
190
+ elif orca_neb and orca_neb.get("frequencies"):
191
+ ts_res = verify_ts_frequency_and_irc(
192
+ frequencies_cm1=orca_neb["frequencies"],
193
+ irc_confirmed=args.irc
194
+ )
195
+
196
+ # 3. Calculate kinetics and tunneling
197
+ # Electronic barriers (forward and reverse) for the Eckart barrier; the band already holds the
198
+ # optimised NEB-TS energy when there is one.
199
+ ea_val = None
200
+ ea_rev = neb_res.e_reverse_barrier_ev if neb_res else None
201
+ if neb_res:
202
+ ea_val = neb_res.e_forward_barrier_ev
203
+ elif args.barrier_ev:
204
+ ea_val = float(args.barrier_ev)
205
+ elif args.barrier_kcal:
206
+ ea_val = float(args.barrier_kcal) / 23.06054887
207
+
208
+ if args.gibbs_barrier_kcal is not None:
209
+ tst_res = calculate_eyring_tst_rates(e_activation_ev=float(args.gibbs_barrier_kcal) / 23.06054887,
210
+ barrier_type="gibbs")
211
+ elif ea_val is not None:
212
+ tst_res = calculate_eyring_tst_rates(e_activation_ev=ea_val, barrier_type="electronic")
213
+ if ea_val is not None and ts_res and ts_res.imaginary_frequency_cm1:
214
+ tun_res = calculate_quantum_tunneling_corrections(
215
+ imaginary_freq_cm1=ts_res.imaginary_frequency_cm1,
216
+ e_forward_barrier_ev=ea_val,
217
+ e_reverse_barrier_ev=ea_rev
218
+ )
219
+
220
+ meta = {
221
+ "reaction": args.reaction or "the reaction",
222
+ "functional": args.functional or "an unstated level of theory",
223
+ "software": args.software or ("ORCA" if orca_neb else "an unstated program")
224
+ }
225
+
226
+ report = assess_reaction_pathway_quality(
227
+ metadata=meta,
228
+ neb_res=neb_res,
229
+ ts_freq_res=ts_res,
230
+ tst_res=tst_res,
231
+ tunneling_res=tun_res
232
+ )
233
+
234
+ print(" -> Generating publication figures...")
235
+ generate_nebcert_figures(report, output_dir)
236
+
237
+ print(" -> Generating manuscript text, LaTeX summary table, and BibTeX citations...")
238
+ assets = generate_nebcert_manuscript_assets(report, output_dir)
239
+
240
+ with open(assets["methods_text"], "r", encoding="utf-8") as f:
241
+ methods_txt = f.read()
242
+ with open(assets["citation_bib"], "r", encoding="utf-8") as f:
243
+ bib_txt = f.read()
244
+
245
+ html_p = os.path.join(output_dir, "report.html")
246
+ print(f" -> Writing HTML quality report to {html_p}...")
247
+ generate_nebcert_html_report(report, html_p, methods_text=methods_txt, citation_bib=bib_txt)
248
+
249
+ print("\n" + "="*70)
250
+ print(f" [RESULT] Overall status: {report.overall_status}")
251
+ print(f" [SCORE] {report.validation_score}")
252
+ print("="*70)
253
+ if report.neb_profile:
254
+ print(f" * Barrier (E_a^fwd): {report.neb_profile.e_forward_barrier_kcal_mol:.2f} kcal/mol ({report.neb_profile.e_forward_barrier_ev:.3f} eV)")
255
+ if report.tst_kinetics:
256
+ print(f" * TST Rate (298 K) : k = {report.tst_kinetics.k_298_s_minus_1:.2e} s^-1")
257
+ print("="*70)
258
+ print(f"\nReport ready at: {os.path.abspath(html_p)}\n")
259
+
260
+
261
+ def print_citation():
262
+ print()
263
+ print("If you use NEBCert in your publications, please cite:")
264
+ print()
265
+ print("APA Style:")
266
+ print(APA)
267
+ print()
268
+ print("BibTeX:")
269
+ print(BIBTEX)
270
+
271
+
272
+ def main():
273
+ parser = argparse.ArgumentParser(
274
+ prog="nebcert",
275
+ description="NEBCert: Transition State, NEB Reaction Pathways, Quantum Tunneling, and TST Kinetics Certification."
276
+ )
277
+ parser.add_argument("-v", "--version", action="version", version=f"nebcert {__version__}")
278
+
279
+ subparsers = parser.add_subparsers(dest="command", help="Available subcommands")
280
+
281
+ # Assess command
282
+ assess_parser = subparsers.add_parser("assess", help="Assess NEB pathway, TS frequencies, and reaction kinetics")
283
+ assess_parser.add_argument("-i", "--input-neb", default=None, help="Path to NEB file: VASP neb.dat, ORCA NEB/NEB-CI/NEB-TS output (.out/.log), or CSV/TSV table of image energies")
284
+ assess_parser.add_argument("--frequencies", default=None, help="Comma-separated vibrational frequencies in cm^-1 (e.g. '-1250,150,300,800')")
285
+ assess_parser.add_argument("--barrier-ev", type=float, default=None, help="Activation energy in eV (if not using NEB profile)")
286
+ assess_parser.add_argument("--barrier-kcal", type=float, default=None, help="Activation energy in kcal/mol")
287
+ assess_parser.add_argument("--gibbs-barrier-kcal", type=float, default=None, help="Gibbs free energy of activation (kcal/mol) for the Eyring rate constant; without it the rate uses the electronic barrier and is flagged")
288
+ assess_parser.add_argument("--irc", action="store_const", const=True, default=None, help="Declare that an IRC connected the intended minima (not checked by NEBCert); omit when no IRC was run")
289
+ assess_parser.add_argument("-o", "--output", default="nebcert_output", help="Directory for output report (default: nebcert_output)")
290
+ assess_parser.add_argument("--reaction", default=None, help="Reaction description (e.g. 'CH4 + OH -> CH3 + H2O')")
291
+ assess_parser.add_argument("--functional", default=None, help="DFT functional / level of theory")
292
+ assess_parser.add_argument("--software", default=None, help="Software code (e.g. 'VASP', 'ORCA', 'Gaussian')")
293
+
294
+ # Demo command
295
+ demo_parser = subparsers.add_parser("demo", help="Run benchmark demonstration (H-abstraction 9-image NEB + TS + TST + Tunneling)")
296
+ demo_parser.add_argument("-o", "--output", default="nebcert_demo_output", help="Output directory (default: nebcert_demo_output)")
297
+
298
+ # Cite command
299
+ subparsers.add_parser("cite", help="Display BibTeX and APA citation details")
300
+
301
+ if len(sys.argv) == 1:
302
+ print_banner()
303
+ parser.print_help()
304
+ sys.exit(0)
305
+
306
+ args = parser.parse_args()
307
+
308
+ if args.command == "assess":
309
+ print_banner()
310
+ run_assess(args)
311
+ elif args.command == "demo":
312
+ print_banner()
313
+ run_demo(args.output)
314
+ elif args.command == "cite":
315
+ print_banner()
316
+ print_citation()
317
+ else:
318
+ parser.print_help()
319
+
320
+
321
+ if __name__ == "__main__":
322
+ main()
323
+
@@ -0,0 +1,22 @@
1
+ """
2
+ Core kinetics, NEB spline analysis, and quantum tunneling engines for NEBCert.
3
+ """
4
+
5
+ from nebcert.core.neb_profile import calculate_neb_profile_analysis, NEBProfileResult
6
+ from nebcert.core.ts_frequency import verify_ts_frequency_and_irc, TSFrequencyResult
7
+ from nebcert.core.tst_kinetics import calculate_eyring_tst_rates, TSTKineticsResult
8
+ from nebcert.core.tunneling import calculate_quantum_tunneling_corrections, TunnelingResult
9
+ from nebcert.core.scoring import assess_reaction_pathway_quality, ReactionPathwayReport
10
+
11
+ __all__ = [
12
+ "calculate_neb_profile_analysis",
13
+ "NEBProfileResult",
14
+ "verify_ts_frequency_and_irc",
15
+ "TSFrequencyResult",
16
+ "calculate_eyring_tst_rates",
17
+ "TSTKineticsResult",
18
+ "calculate_quantum_tunneling_corrections",
19
+ "TunnelingResult",
20
+ "assess_reaction_pathway_quality",
21
+ "ReactionPathwayReport"
22
+ ]
@@ -0,0 +1,240 @@
1
+ """
2
+ Nudged Elastic Band (NEB / CI-NEB) profile analysis, spline interpolation, and MEP force audit.
3
+ """
4
+
5
+ from typing import List, Dict, Any, Optional, Tuple
6
+ from dataclasses import dataclass
7
+ import numpy as np
8
+ from scipy.interpolate import CubicSpline
9
+
10
+ EV_TO_KCAL_MOL = 23.06054887
11
+
12
+
13
+ @dataclass
14
+ class NEBImagePoint:
15
+ image_index: int
16
+ reaction_coordinate_s_ang: float
17
+ energy_ev: float
18
+ relative_energy_ev: float
19
+ relative_energy_kcal_mol: float
20
+ tangent_force_ev_ang: Optional[float]
21
+
22
+
23
+ @dataclass
24
+ class NEBProfileResult:
25
+ n_images: int
26
+ total_path_length_ang: float
27
+ ts_image_index: int
28
+ ts_position_s_ang: float
29
+ e_forward_barrier_ev: float
30
+ e_forward_barrier_kcal_mol: float
31
+ e_reverse_barrier_ev: float
32
+ e_reverse_barrier_kcal_mol: float
33
+ delta_e_reaction_ev: float
34
+ delta_e_reaction_kcal_mol: float
35
+ max_tangent_force_ev_ang: Optional[float]
36
+ is_climbing_image_converged: bool
37
+ has_intermediate_minimum: bool
38
+ interpolated_s: List[float]
39
+ interpolated_e_rel_ev: List[float]
40
+ image_points: List[NEBImagePoint]
41
+ status: str # 'PASS', 'WARNING', 'FAIL'
42
+ diagnostic_message: str
43
+ e_forward_barrier_spline_ev: Optional[float] = None
44
+ barrier_source: str = "spline"
45
+ band_converged: Optional[bool] = None
46
+ ts_optimisation_converged: Optional[bool] = None
47
+ deepest_intermediate_well_kcal_mol: float = 0.0
48
+
49
+
50
+ def calculate_neb_profile_analysis(
51
+ energies_ev: List[float],
52
+ coordinates_s_ang: Optional[List[float]] = None,
53
+ tangent_forces_ev_ang: Optional[List[float]] = None,
54
+ force_convergence_threshold: float = 0.05, # eV/Å
55
+ warning_force_threshold: float = 0.10,
56
+ band_converged: Optional[bool] = None,
57
+ ts_energy_rel_ev: Optional[float] = None,
58
+ ts_optimisation_converged: Optional[bool] = None,
59
+ min_well_depth_kcal_mol: float = 0.5,
60
+ min_path_length_ang: float = 0.05,
61
+ climbing_image_index: Optional[int] = None
62
+ ) -> NEBProfileResult:
63
+ """
64
+ Analyzes NEB minimum energy path, fits cubic spline interpolation, calculates
65
+ forward and reverse activation barriers, and audits force convergence and continuity.
66
+
67
+ Parameters
68
+ ----------
69
+ energies_ev : list of float
70
+ Total DFT energies for all images along the band (eV).
71
+ coordinates_s_ang : list of float, optional
72
+ Cumulative reaction coordinates s (Å). If None, uniform spacing 0..1 is used.
73
+ tangent_forces_ev_ang : list of float, optional
74
+ Parallel / residual forces along band (eV/Å).
75
+ force_convergence_threshold : float, default 0.05 eV/Å
76
+ Limit on the force at the image nearest the barrier.
77
+ warning_force_threshold : float, default 0.10 eV/Å
78
+ Unused; kept for backward compatibility.
79
+ band_converged : bool, optional
80
+ Convergence flag of the band as printed by the program (ORCA: "THE NEB OPTIMIZATION HAS
81
+ CONVERGED"). False gives FAIL: an unconverged band does not locate the barrier.
82
+ ts_energy_rel_ev : float, optional
83
+ Energy of an optimised transition state (e.g. ORCA NEB-TS) relative to image 0. When given it
84
+ replaces the spline maximum as the forward barrier; the spline value is kept for comparison.
85
+ ts_optimisation_converged : bool, optional
86
+ False gives FAIL (the TS energy is then not a saddle-point energy). When True, the force check on the
87
+ band is skipped: NEB-TS converges the band only loosely by design and the TS optimisation refines it.
88
+ climbing_image_index : int, optional
89
+ Index of the climbing image. When the band converged and no optimised TS is given, the climbing-image
90
+ energy is the barrier (a converged climbing image sits at the saddle point; a spline through a coarse
91
+ band does not).
92
+ min_well_depth_kcal_mol : float, default 0.5
93
+ An interior image lower than both neighbours by more than this is reported as an intermediate
94
+ minimum; shallower dips are treated as noise of the band.
95
+ min_path_length_ang : float, default 0.05
96
+ When coordinates_s_ang are distances along the path (Å), a shorter path means that both end points
97
+ are the same structure (e.g. one end point slid into the other during pre-optimisation): FAIL.
98
+
99
+ Returns
100
+ -------
101
+ result : NEBProfileResult
102
+ """
103
+ e_arr = np.asarray(energies_ev, dtype=float)
104
+ n_img = len(e_arr)
105
+ if n_img < 3:
106
+ raise ValueError("NEB reaction path requires at least 3 images (Reactant, TS, Product).")
107
+
108
+ if coordinates_s_ang is not None and len(coordinates_s_ang) == n_img:
109
+ s_arr = np.asarray(coordinates_s_ang, dtype=float)
110
+ else:
111
+ s_arr = np.linspace(0.0, float(n_img - 1), n_img)
112
+
113
+ # Reference energies relative to Reactant (image 0)
114
+ e_rel = e_arr - e_arr[0]
115
+ total_length = float(s_arr[-1] - s_arr[0])
116
+ # Distances printed with few decimals can repeat (e.g. a band whose end points coincide); the spline
117
+ # then needs a strictly increasing abscissa, so it falls back to the image index.
118
+ if not np.all(np.diff(s_arr) > 0):
119
+ s_arr = np.linspace(0.0, float(n_img - 1), n_img)
120
+
121
+ # Fit natural cubic spline
122
+ spline = CubicSpline(s_arr, e_rel, bc_type='natural')
123
+ s_fine = np.linspace(s_arr[0], s_arr[-1], 300)
124
+ e_fine = spline(s_fine)
125
+
126
+ # Find peak (Transition State candidate)
127
+ ts_fine_idx = int(np.argmax(e_fine))
128
+ ts_s_pos = float(s_fine[ts_fine_idx])
129
+ ts_peak_e_rel = float(e_fine[ts_fine_idx])
130
+
131
+ # Nearest discrete image to TS peak
132
+ ts_img_idx = int(np.argmin(np.abs(s_arr - ts_s_pos)))
133
+
134
+ spline_barrier_ev = max(0.0, ts_peak_e_rel)
135
+ barrier_source = "spline"
136
+ if ts_energy_rel_ev is not None:
137
+ ts_peak_e_rel = float(ts_energy_rel_ev)
138
+ barrier_source = "optimised TS"
139
+ elif climbing_image_index is not None and band_converged and 0 < climbing_image_index < n_img - 1:
140
+ ts_peak_e_rel = float(e_rel[climbing_image_index])
141
+ barrier_source = "climbing image"
142
+ e_fwd_barrier_ev = max(0.0, ts_peak_e_rel)
143
+ e_fwd_barrier_kcal = float(e_fwd_barrier_ev * EV_TO_KCAL_MOL)
144
+
145
+ e_product_rel_ev = float(e_rel[-1])
146
+ e_rev_barrier_ev = max(0.0, ts_peak_e_rel - e_product_rel_ev)
147
+ e_rev_barrier_kcal = float(e_rev_barrier_ev * EV_TO_KCAL_MOL)
148
+
149
+ delta_e_rxn_ev = e_product_rel_ev
150
+ delta_e_rxn_kcal = float(delta_e_rxn_ev * EV_TO_KCAL_MOL)
151
+
152
+ # Check for intermediate minima (valleys along MEP)
153
+ # Check if there is an internal local minimum between 0 and -1
154
+ well = 0.0
155
+ for i in range(1, n_img - 1):
156
+ if e_rel[i] < e_rel[i-1] and e_rel[i] < e_rel[i+1]:
157
+ # depth below the lower of the two barriers that bound it
158
+ depth = min(np.max(e_rel[:i]), np.max(e_rel[i+1:])) - e_rel[i]
159
+ well = max(well, float(depth * EV_TO_KCAL_MOL))
160
+ has_interm_min = well > min_well_depth_kcal_mol
161
+
162
+ # Force audit
163
+ max_f = None
164
+ ts_f = None
165
+ is_f_conv = True
166
+ if tangent_forces_ev_ang is not None and len(tangent_forces_ev_ang) == n_img:
167
+ # Check force specifically at climbing image
168
+ f_arr = np.abs(np.asarray(tangent_forces_ev_ang, dtype=float))
169
+ max_f = float(np.max(f_arr))
170
+ ts_f = float(f_arr[ts_img_idx])
171
+ if ts_f > force_convergence_threshold and ts_optimisation_converged is not True:
172
+ is_f_conv = False
173
+
174
+ # Image points
175
+ img_points = []
176
+ for i in range(n_img):
177
+ f_val = float(tangent_forces_ev_ang[i]) if tangent_forces_ev_ang is not None and i < len(tangent_forces_ev_ang) else None
178
+ img_points.append(NEBImagePoint(
179
+ image_index=i,
180
+ reaction_coordinate_s_ang=float(s_arr[i]),
181
+ energy_ev=float(e_arr[i]),
182
+ relative_energy_ev=float(e_rel[i]),
183
+ relative_energy_kcal_mol=float(e_rel[i] * EV_TO_KCAL_MOL),
184
+ tangent_force_ev_ang=f_val
185
+ ))
186
+
187
+ # Decision logic (first matching condition wins)
188
+ bar = f"E_a^fwd = {e_fwd_barrier_kcal:.2f} kcal/mol ({barrier_source})"
189
+ if coordinates_s_ang is not None and total_length < min_path_length_ang:
190
+ status = "FAIL"
191
+ diag = (f"The path is {total_length:.4f} Å long: both end points are the same structure, so the band "
192
+ f"describes no reaction. Check the end-point optimisations.")
193
+ elif band_converged is False:
194
+ status = "FAIL"
195
+ diag = f"The band did not converge; the highest image does not locate the barrier ({bar})."
196
+ elif ts_optimisation_converged is False:
197
+ status = "FAIL"
198
+ diag = f"The transition-state optimisation did not converge ({bar})."
199
+ elif e_fwd_barrier_ev < 1e-4 and abs(delta_e_rxn_ev) < 1e-4:
200
+ status = "FAIL"
201
+ diag = "Flat energy profile (barrier and reaction energy < 1e-4 eV). Check the end points."
202
+ elif not is_f_conv:
203
+ status = "WARNING"
204
+ diag = (f"Force at the image nearest the barrier is {ts_f:.3f} eV/Å > {force_convergence_threshold:.2f} "
205
+ f"eV/Å ({bar}).")
206
+ elif has_interm_min:
207
+ status = "WARNING"
208
+ diag = (f"Intermediate minimum {well:.2f} kcal/mol deep along the band: the path has more than one "
209
+ f"step, which should be located separately ({bar}).")
210
+ else:
211
+ status = "PASS"
212
+ conv = " Band convergence not reported." if band_converged is None else ""
213
+ diag = (f"No problem found in the band: {bar}, Delta E_rxn = {delta_e_rxn_kcal:.2f} kcal/mol, "
214
+ f"no intermediate minimum deeper than {min_well_depth_kcal_mol:.1f} kcal/mol.{conv}")
215
+
216
+ return NEBProfileResult(
217
+ n_images=n_img,
218
+ total_path_length_ang=total_length,
219
+ ts_image_index=ts_img_idx,
220
+ ts_position_s_ang=ts_s_pos,
221
+ e_forward_barrier_ev=e_fwd_barrier_ev,
222
+ e_forward_barrier_kcal_mol=e_fwd_barrier_kcal,
223
+ e_reverse_barrier_ev=e_rev_barrier_ev,
224
+ e_reverse_barrier_kcal_mol=e_rev_barrier_kcal,
225
+ delta_e_reaction_ev=delta_e_rxn_ev,
226
+ delta_e_reaction_kcal_mol=delta_e_rxn_kcal,
227
+ max_tangent_force_ev_ang=max_f,
228
+ is_climbing_image_converged=is_f_conv,
229
+ has_intermediate_minimum=has_interm_min,
230
+ interpolated_s=s_fine.tolist(),
231
+ interpolated_e_rel_ev=e_fine.tolist(),
232
+ image_points=img_points,
233
+ status=status,
234
+ diagnostic_message=diag,
235
+ e_forward_barrier_spline_ev=float(spline_barrier_ev),
236
+ barrier_source=barrier_source,
237
+ band_converged=band_converged,
238
+ ts_optimisation_converged=ts_optimisation_converged,
239
+ deepest_intermediate_well_kcal_mol=well
240
+ )