1-
21import os
32import shutil
43import numpy as np
@@ -277,6 +276,7 @@ def _run_step4_irc_and_opt(self, ts_final_files):
277276 # Get TS energy (as per Q2, this is set before IRC runs)
278277 ts_e = irc_instance .final_energy
279278 ts_bias_e = irc_instance .final_bias_energy
279+ ts_g = self ._extract_free_energy (irc_instance )
280280
281281 # Get IRC endpoint paths
282282 endpoint_paths = irc_instance .irc_terminal_struct_paths
@@ -327,6 +327,7 @@ def _run_step4_irc_and_opt(self, ts_final_files):
327327 "path" : final_opt_path ,
328328 "e" : opt_instance .final_energy ,
329329 "bias_e" : opt_instance .final_bias_energy ,
330+ "g" : self ._extract_free_energy (opt_instance ),
330331 "label" : f"Endpoint_{ j + 1 } "
331332 })
332333
@@ -345,7 +346,7 @@ def _run_step4_irc_and_opt(self, ts_final_files):
345346 os .makedirs (result_dir , exist_ok = True )
346347
347348 e_profile = {
348- "TS" : {"e" : ts_e , "bias_e" : ts_bias_e , "path" : ts_path },
349+ "TS" : {"e" : ts_e , "bias_e" : ts_bias_e , "g" : ts_g , " path" : ts_path },
349350 }
350351 if len (endpoint_results ) >= 1 :
351352 e_profile ["End1" ] = endpoint_results [0 ]
@@ -372,103 +373,247 @@ def _run_step4_irc_and_opt(self, ts_final_files):
372373
373374 print ("\n --- STEP 4: IRC & EQ OPTIMIZATION COMPLETE ---" )
374375
376+ @staticmethod
377+ def _extract_free_energy (instance ):
378+ """Return G_tot (Hartree, float) from an optimizer instance, or None.
379+
380+ Looks up ``instance.thermochemistry_results["G_tot"][0]``. Returns
381+ ``None`` silently when vibrational analysis was not performed or when
382+ the key is absent, so callers can treat ``None`` as "not available".
383+ """
384+ try :
385+ thermo = getattr (instance , "thermochemistry_results" , None )
386+ if thermo is None :
387+ return None
388+ return thermo ["G_tot" ][0 ]
389+ except (KeyError , TypeError , IndexError ):
390+ return None
391+
375392 def _create_energy_profile_plot (self , e_profile , output_path , title_name ):
376- """Generates an energy profile plot using matplotlib."""
393+ """Generate an energy profile plot, optionally with a free-energy panel.
394+
395+ When at least one structure in *e_profile* carries a ``"g"`` key with a
396+ non-None value the figure is split into two vertically stacked subplots:
397+
398+ * **Top panel** – electronic / bias energy profile (existing behaviour).
399+ * **Bottom panel** – Gibbs free energy profile (``G_tot`` from
400+ ``thermochemistry_results``).
401+
402+ Structures whose ``"g"`` value is ``None`` are shown as gaps on the
403+ free-energy panel with an ``"N/A"`` annotation so the reader knows the
404+ data was not available rather than zero.
405+
406+ When *no* structure has a ``"g"`` value the plot falls back to the
407+ original single-panel layout.
408+ """
377409 if not MATPLOTLIB_AVAILABLE :
378410 print (f" Skipping plot generation: matplotlib not installed." )
379411 return
380-
412+
381413 try :
382414 labels = []
383415 energies = []
384416 biased_energies = []
385-
386-
417+ free_energies = [] # None where not available
418+
387419 if "End1" in e_profile :
388420 labels .append ("End1" )
389421 energies .append (e_profile ["End1" ]["e" ])
390422 biased_energies .append (e_profile ["End1" ]["bias_e" ])
391-
423+ free_energies .append (e_profile ["End1" ].get ("g" ))
424+
392425 labels .append ("TS" )
393426 energies .append (e_profile ["TS" ]["e" ])
394427 biased_energies .append (e_profile ["TS" ]["bias_e" ])
395-
428+ free_energies .append (e_profile ["TS" ].get ("g" ))
429+
396430 if "End2" in e_profile :
397431 labels .append ("End2" )
398432 energies .append (e_profile ["End2" ]["e" ])
399433 biased_energies .append (e_profile ["End2" ]["bias_e" ])
400-
401-
434+ free_energies . append ( e_profile [ "End2" ]. get ( "g" ))
435+
402436 if not energies :
403437 print (" Warning: No energies found to plot." )
404438 return
405-
406- # Convert to relative kcal/mol
439+
440+ # Determine whether any free-energy data is present
441+ has_free_energy = any (g is not None for g in free_energies )
442+
443+ # --- Convert electronic/bias energies to relative kcal/mol ---
407444 min_e = min (energies )
408445 min_bias_e = min (biased_energies )
409-
410446 rel_energies = (np .array (energies ) - min_e ) * 627.509
411447 rel_biased_energies = (np .array (biased_energies ) - min_bias_e ) * 627.509
412-
413- x = list (range (len (labels )))
414- plt .figure (figsize = (8 , 6 ))
415-
416- # Plot both energies as requested in Q1
417- plt .plot (x , rel_energies , 'o-' , c = 'blue' , label = 'Energy (E_final)' )
418- plt .plot (x , rel_biased_energies , 'o--' , c = 'red' , label = 'Bias Energy (E_bias_final)' )
419-
420- plt .xticks (x , labels )
421- plt .ylabel ("Relative Energy (kcal/mol)" )
422- plt .title (f"Reaction Profile for { title_name } " )
423- plt .legend ()
424- plt .grid (axis = 'y' , linestyle = '--' , alpha = 0.7 )
425- plt .savefig (output_path , dpi = 300 )
448+ x = list (range (len (labels )))
449+
450+ if has_free_energy :
451+ # Two-panel figure: electronic energy (top) + free energy (bottom)
452+ fig , (ax_e , ax_g ) = plt .subplots (
453+ 2 , 1 , figsize = (8 , 10 ),
454+ sharex = True ,
455+ gridspec_kw = {"hspace" : 0.35 },
456+ )
457+
458+ # --- Top panel: electronic / bias energies ---
459+ ax_e .plot (x , rel_energies , "o-" , c = "blue" , label = "Energy (E_final)" )
460+ ax_e .plot (x , rel_biased_energies , "o--" , c = "red" , label = "Bias Energy (E_bias_final)" )
461+ ax_e .set_xticks (x )
462+ ax_e .set_xticklabels (labels )
463+ ax_e .set_ylabel ("Relative Energy (kcal/mol)" )
464+ ax_e .set_title (f"Electronic Energy Profile – { title_name } " )
465+ ax_e .legend ()
466+ ax_e .grid (axis = "y" , linestyle = "--" , alpha = 0.7 )
467+
468+ # --- Bottom panel: Gibbs free energy ---
469+ # Build arrays; use NaN for missing points so matplotlib breaks
470+ # the line but still plots the available segments.
471+ g_values_ha = [g if g is not None else float ("nan" ) for g in free_energies ]
472+ g_arr = np .array (g_values_ha , dtype = float )
473+
474+ # Compute reference using the minimum of available (non-NaN) values
475+ valid_g = g_arr [~ np .isnan (g_arr )]
476+ if valid_g .size > 0 :
477+ min_g = valid_g .min ()
478+ rel_g = (g_arr - min_g ) * 627.509
479+ else :
480+ rel_g = g_arr # All NaN – panel will be empty
481+
482+ ax_g .plot (x , rel_g , "o-" , c = "green" , label = "Free Energy (G_tot)" )
483+
484+ # Annotate N/A for unavailable points
485+ for xi , (g_val , lbl ) in enumerate (zip (free_energies , labels )):
486+ if g_val is None :
487+ ax_g .annotate (
488+ "N/A" ,
489+ xy = (xi , 0 ),
490+ xytext = (xi , 0 ),
491+ ha = "center" ,
492+ va = "bottom" ,
493+ color = "gray" ,
494+ fontsize = 9 ,
495+ )
496+ print (
497+ f" Warning: Free energy not available for '{ lbl } ' "
498+ f"(vibrational analysis may not have been performed)."
499+ )
500+
501+ ax_g .set_xticks (x )
502+ ax_g .set_xticklabels (labels )
503+ ax_g .set_ylabel ("Relative Free Energy (kcal/mol)" )
504+ ax_g .set_title (f"Gibbs Free Energy Profile – { title_name } " )
505+ ax_g .legend ()
506+ ax_g .grid (axis = "y" , linestyle = "--" , alpha = 0.7 )
507+
508+ else :
509+ # Original single-panel layout (no free-energy data at all)
510+ fig , ax_e = plt .subplots (figsize = (8 , 6 ))
511+ ax_e .plot (x , rel_energies , "o-" , c = "blue" , label = "Energy (E_final)" )
512+ ax_e .plot (x , rel_biased_energies , "o--" , c = "red" , label = "Bias Energy (E_bias_final)" )
513+ ax_e .set_xticks (x )
514+ ax_e .set_xticklabels (labels )
515+ ax_e .set_ylabel ("Relative Energy (kcal/mol)" )
516+ ax_e .set_title (f"Reaction Profile for { title_name } " )
517+ ax_e .legend ()
518+ ax_e .grid (axis = "y" , linestyle = "--" , alpha = 0.7 )
519+
520+ plt .savefig (output_path , dpi = 300 , bbox_inches = "tight" )
426521 plt .close ()
427522 print (f" Generated energy plot: { output_path } " )
428-
523+
429524 except Exception as e :
430525 print (f" Warning: Failed to generate energy plot: { e } " )
431526
432527 def _write_energy_profile_text (self , e_profile , output_path , title_name ):
433- """Writes the final energies to a text file."""
528+ """Write final energies to a text file.
529+
530+ The existing electronic-energy table is preserved unchanged. When at
531+ least one structure carries a ``"g"`` key a dedicated free-energy
532+ section is appended after the existing content, including:
533+
534+ * A table of absolute ``G_tot`` values (Hartree).
535+ * Activation free energies (ΔG‡) and reaction free energy (ΔG_rxn)
536+ in kcal/mol, or ``N/A`` where the data is missing.
537+ """
538+ KCAL = 627.509 # Hartree → kcal/mol
539+
434540 try :
435- with open (output_path , 'w' ) as f :
541+ with open (output_path , "w" ) as f :
542+ # ----------------------------------------------------------------
543+ # Existing electronic-energy section (unchanged)
544+ # ----------------------------------------------------------------
436545 f .write (f"# Energy Profile for { title_name } \n " )
437546 f .write ("# All energies in Hartree\n " )
438547 f .write ("# -------------------------------------------------------------------\n " )
439- f .write (f"Structure, File_Path, Final_Energy, Final_Bias_Energy\n " )
440-
441-
442- ts_total = e_profile ['TS' ]['bias_e' ]
548+ f .write ("Structure, File_Path, Final_Energy, Final_Bias_Energy\n " )
549+
550+ ts_total = e_profile ["TS" ]["bias_e" ]
443551 f .write (f"TS, { e_profile ['TS' ]['path' ]} , { e_profile ['TS' ]['e' ]:.12f} , { e_profile ['TS' ]['bias_e' ]:.12f} \n " )
444552
445553 end1_total = None
446554 if "End1" in e_profile :
447- end1_total = e_profile [' End1' ][ ' bias_e' ]
555+ end1_total = e_profile [" End1" ][ " bias_e" ]
448556 f .write (f"Endpoint_1, { e_profile ['End1' ]['path' ]} , { e_profile ['End1' ]['e' ]:.12f} , { e_profile ['End1' ]['bias_e' ]:.12f} \n " )
449-
557+
450558 end2_total = None
451559 if "End2" in e_profile :
452- end2_total = e_profile [' End2' ][ ' bias_e' ]
560+ end2_total = e_profile [" End2" ][ " bias_e" ]
453561 f .write (f"Endpoint_2, { e_profile ['End2' ]['path' ]} , { e_profile ['End2' ]['e' ]:.12f} , { e_profile ['End2' ]['bias_e' ]:.12f} \n " )
454-
562+
455563 f .write ("# -------------------------------------------------------------------\n " )
456- # Calculate and write barriers and reaction energies if possible
457564 if end1_total is not None :
458- barrier_1 = (ts_total - end1_total ) * 627.509
565+ barrier_1 = (ts_total - end1_total ) * KCAL
459566 f .write (f"Activation Energy (End1 -> TS): { barrier_1 : .2f} kcal/mol\n " )
460-
461567 if end2_total is not None :
462- barrier_2 = (ts_total - end2_total ) * 627.509
568+ barrier_2 = (ts_total - end2_total ) * KCAL
463569 f .write (f"Activation Energy (End2 -> TS): { barrier_2 : .2f} kcal/mol\n " )
464-
465570 if end1_total is not None and end2_total is not None :
466- reaction_e = (end2_total - end1_total ) * 627.509
571+ reaction_e = (end2_total - end1_total ) * KCAL
467572 f .write (f"Reaction Energy (End1 -> End2): { reaction_e : .2f} kcal/mol\n " )
468- elif end1_total is not None :
469- pass
470-
471-
573+
574+ # ----------------------------------------------------------------
575+ # Free-energy section – appended when any G_tot data is present
576+ # ----------------------------------------------------------------
577+ ts_g = e_profile ["TS" ].get ("g" )
578+ end1_g = e_profile ["End1" ].get ("g" ) if "End1" in e_profile else None
579+ end2_g = e_profile ["End2" ].get ("g" ) if "End2" in e_profile else None
580+
581+ if any (v is not None for v in [ts_g , end1_g , end2_g ]):
582+ f .write ("\n " )
583+ f .write ("# ===================================================================\n " )
584+ f .write ("# FREE ENERGY SECTION (G_tot from thermochemistry analysis)\n " )
585+ f .write ("# ===================================================================\n " )
586+ f .write ("# All values in Hartree unless noted otherwise.\n " )
587+ f .write ("# 'N/A' indicates vibrational analysis was not performed for that\n " )
588+ f .write ("# structure; results in that row should be interpreted with caution.\n " )
589+ f .write ("# -------------------------------------------------------------------\n " )
590+ f .write ("Structure, G_tot [Hartree]\n " )
591+
592+ def _fmt_g (val ):
593+ return f"{ val :.12f} " if val is not None else "N/A"
594+
595+ f .write (f"TS, { _fmt_g (ts_g )} \n " )
596+ if "End1" in e_profile :
597+ f .write (f"Endpoint_1, { _fmt_g (end1_g )} \n " )
598+ if "End2" in e_profile :
599+ f .write (f"Endpoint_2, { _fmt_g (end2_g )} \n " )
600+
601+ f .write ("# -------------------------------------------------------------------\n " )
602+
603+ # ΔG‡ and ΔG_rxn – only when both operands are available
604+ def _delta_kcal (a , b ):
605+ """Return (a - b) * KCAL as formatted string, or 'N/A'."""
606+ if a is None or b is None :
607+ return "N/A"
608+ return f"{ (a - b ) * KCAL : .2f} kcal/mol"
609+
610+ if "End1" in e_profile :
611+ f .write (f"Activation Free Energy (End1 -> TS): { _delta_kcal (ts_g , end1_g )} \n " )
612+ if "End2" in e_profile :
613+ f .write (f"Activation Free Energy (End2 -> TS): { _delta_kcal (ts_g , end2_g )} \n " )
614+ if "End1" in e_profile and "End2" in e_profile :
615+ f .write (f"Reaction Free Energy (End1 -> End2): { _delta_kcal (end2_g , end1_g )} \n " )
616+
472617 print (f" Generated energy text file: { output_path } " )
473618 except Exception as e :
474619 print (f" Warning: Failed to write energy text file: { e } " )
@@ -1167,6 +1312,7 @@ def _run_step4(self, settings, input_data, run_index=0):
11671312
11681313 ts_e = irc_instance .final_energy
11691314 ts_bias_e = irc_instance .final_bias_energy
1315+ ts_g = self ._extract_free_energy (irc_instance )
11701316 endpoint_paths = irc_instance .irc_terminal_struct_paths
11711317
11721318 if not endpoint_paths or len (endpoint_paths ) != 2 :
@@ -1203,6 +1349,7 @@ def _run_step4(self, settings, input_data, run_index=0):
12031349 "path" : final_opt_path ,
12041350 "e" : opt_instance .final_energy ,
12051351 "bias_e" : opt_instance .final_bias_energy ,
1352+ "g" : self ._extract_free_energy (opt_instance ),
12061353 })
12071354
12081355 if not endpoint_results :
@@ -1213,7 +1360,7 @@ def _run_step4(self, settings, input_data, run_index=0):
12131360 result_dir = f"{ ts_name_base } _Step4_Profile"
12141361 os .makedirs (result_dir , exist_ok = True )
12151362
1216- e_profile = {"TS" : {"e" : ts_e , "bias_e" : ts_bias_e , "path" : ts_path }}
1363+ e_profile = {"TS" : {"e" : ts_e , "bias_e" : ts_bias_e , "g" : ts_g , " path" : ts_path }}
12171364 if len (endpoint_results ) >= 1 : e_profile ["End1" ] = endpoint_results [0 ]
12181365 if len (endpoint_results ) >= 2 : e_profile ["End2" ] = endpoint_results [1 ]
12191366
@@ -1234,6 +1381,4 @@ def _run_step4(self, settings, input_data, run_index=0):
12341381 # --- FIX: Store relative path ---
12351382 profile_dirs .append (result_dir )
12361383
1237- return {"profile_dirs" : profile_dirs }
1238-
1239-
1384+ return {"profile_dirs" : profile_dirs }
0 commit comments