@@ -546,11 +546,11 @@ def test_get_bond_length_changes_baseline_and_std(self):
546546 normal_mode_disp = normal_mode_disp [0 ],
547547 weights = weights ,
548548 )
549- baseline_max , std = nmd .get_bond_length_changes_baseline_and_std (
549+ baseline , std = nmd .get_bond_length_changes_baseline_and_std (
550550 non_reactive_bonds = [(0 , 1 ), (1 , 2 ), (4 , 7 ), (4 , 8 ), (3 , 6 ), (3 , 4 )],
551551 xyzs = xyzs )
552- self .assertAlmostEqual (baseline_max , 0.0913286 , 4 )
553- self .assertAlmostEqual (std , 0.1657833 , 4 )
552+ self .assertAlmostEqual (baseline , 0.0204022 , 4 )
553+ self .assertAlmostEqual (std , 0.0254036 , 4 )
554554
555555 def test_get_bond_length_in_reaction (self ):
556556 """Test the get_bond_length_in_reaction() function."""
@@ -639,11 +639,11 @@ def test_classic_intra_h_migration_through_all_major_functions(self):
639639 xyzs = xyzs ,
640640 weights = weights ,
641641 )
642- self .assertAlmostEqual (baseline , 0.10390012 , 4 )
643- self .assertAlmostEqual (std , 0.1217205 , 4 )
642+ self .assertAlmostEqual (baseline , 0.0700999 , 4 )
643+ self .assertAlmostEqual (std , 0.0921143 , 4 )
644644 min_reactive_bond_diff = np .min (reactive_bonds_diffs )
645645 sigma = (min_reactive_bond_diff - baseline ) / std
646- self .assertAlmostEqual (float (sigma ), 10.914223252 , 4 )
646+ self .assertAlmostEqual (float (sigma ), 14.7891 , 4 )
647647
648648 def test_get_displaced_xyzs (self ):
649649 """Test the get_displaced_xyzs() function."""
@@ -759,6 +759,136 @@ def test_fingerprint_atom(self):
759759 fp = nmd .fingerprint_atom (atom_index = 2 , molecule = c2h5no2_mol , depth = 5 )
760760 self .assertEqual (fp , [8 , [7 , [6 , [1 ], [1 ], [6 , [1 ], [1 ], [1 ]]], [8 ]]])
761761
762+ def test_check_bond_directionality_valid_h_abstraction (self ):
763+ """Test check_bond_directionality() with a real H-abstraction TS (CH4 + OH -> CH3 + H2O)."""
764+ freq_log = os .path .join (ARC_TESTING_PATH , 'freq' , 'TS_CH4_OH.log' )
765+ freqs , normal_mode_disp = parse_normal_mode_displacement (log_file_path = freq_log )
766+ weights = nmd .get_weights_from_xyz (xyz = self .ts_1_xyz , weights = True )
767+ xyzs = nmd .get_displaced_xyzs (xyz = self .ts_1_xyz , amplitude = 0.25 ,
768+ normal_mode_disp = normal_mode_disp [0 ], weights = weights )
769+ formed_bonds , broken_bonds = self .rxn_1 .get_formed_and_broken_bonds ()
770+ # Valid TS: formed and broken bonds move in opposite directions
771+ self .assertTrue (nmd .check_bond_directionality (
772+ formed_bonds = formed_bonds , broken_bonds = broken_bonds , xyzs = xyzs , weights = weights ))
773+ # Swapping xyzs reverses all signs but keeps them opposite -> still valid
774+ self .assertTrue (nmd .check_bond_directionality (
775+ formed_bonds = formed_bonds , broken_bonds = broken_bonds , xyzs = (xyzs [1 ], xyzs [0 ]), weights = weights ))
776+
777+ def test_check_bond_directionality_invalid_same_sign (self ):
778+ """Test check_bond_directionality() fails when formed and broken bonds move in the same direction."""
779+ freq_log = os .path .join (ARC_TESTING_PATH , 'freq' , 'TS_CH4_OH.log' )
780+ freqs , normal_mode_disp = parse_normal_mode_displacement (log_file_path = freq_log )
781+ weights = nmd .get_weights_from_xyz (xyz = self .ts_1_xyz , weights = True )
782+ xyzs = nmd .get_displaced_xyzs (xyz = self .ts_1_xyz , amplitude = 0.25 ,
783+ normal_mode_disp = normal_mode_disp [0 ], weights = weights )
784+ formed_bonds , broken_bonds = self .rxn_1 .get_formed_and_broken_bonds ()
785+ # Trick: swap formed and broken labels so they now have the same sign
786+ # (what was a "formed" bond with positive delta is now labeled "broken" -> both positive)
787+ self .assertFalse (nmd .check_bond_directionality (
788+ formed_bonds = formed_bonds , broken_bonds = formed_bonds , xyzs = xyzs , weights = weights ))
789+
790+ def test_check_bond_directionality_edge_cases (self ):
791+ """Test check_bond_directionality() edge cases."""
792+ freq_log = os .path .join (ARC_TESTING_PATH , 'freq' , 'TS_CH4_OH.log' )
793+ freqs , normal_mode_disp = parse_normal_mode_displacement (log_file_path = freq_log )
794+ weights = nmd .get_weights_from_xyz (xyz = self .ts_1_xyz , weights = True )
795+ xyzs = nmd .get_displaced_xyzs (xyz = self .ts_1_xyz , amplitude = 0.25 ,
796+ normal_mode_disp = normal_mode_disp [0 ], weights = weights )
797+ formed_bonds , broken_bonds = self .rxn_1 .get_formed_and_broken_bonds ()
798+ # Empty bonds -> True (no check needed)
799+ self .assertTrue (nmd .check_bond_directionality (
800+ formed_bonds = [], broken_bonds = [], xyzs = xyzs , weights = weights ))
801+ # Only formed bonds -> True (internal consistency only)
802+ self .assertTrue (nmd .check_bond_directionality (
803+ formed_bonds = formed_bonds , broken_bonds = [], xyzs = xyzs , weights = weights ))
804+ # Only broken bonds -> True (internal consistency only)
805+ self .assertTrue (nmd .check_bond_directionality (
806+ formed_bonds = [], broken_bonds = broken_bonds , xyzs = xyzs , weights = weights ))
807+ # Large min_delta that excludes all bonds -> True (nothing significant to check)
808+ self .assertTrue (nmd .check_bond_directionality (
809+ formed_bonds = formed_bonds , broken_bonds = broken_bonds , xyzs = xyzs , weights = weights ,
810+ min_delta = 1e6 ))
811+
812+ def test_check_bond_directionality_h_migration (self ):
813+ """Test check_bond_directionality() with a real intra-H-migration TS (iC3H7 -> nC3H7)."""
814+ xyz_path = os .path .join (ARC_TESTING_PATH , 'composite' , 'C3H7' , 'TS3.log' )
815+ standard_ts_orientation_xyz = check_xyz_dict (xyz_path )
816+ freqs , nmd_array = parse_normal_mode_displacement (xyz_path )
817+ weights = nmd .get_weights_from_xyz (standard_ts_orientation_xyz )
818+ xyzs = nmd .get_displaced_xyzs (xyz = standard_ts_orientation_xyz , amplitude = 0.25 ,
819+ normal_mode_disp = nmd_array [0 ], weights = weights )
820+ rxn = ARCReaction (r_species = [ARCSpecies (label = 'iC3H7' , smiles = 'C[CH]C' ,
821+ xyz = os .path .join (ARC_TESTING_PATH , 'composite' , 'C3H7' , 'iC3H7.gjf' ))],
822+ p_species = [ARCSpecies (label = 'nC3H7' , smiles = '[CH2]CC' ,
823+ xyz = os .path .join (ARC_TESTING_PATH , 'composite' , 'C3H7' , 'nC3H7.gjf' ))])
824+ rxn .ts_species = ARCSpecies (label = 'TS' , is_ts = True , xyz = xyz_path )
825+ formed_bonds , broken_bonds = rxn .get_formed_and_broken_bonds ()
826+ self .assertTrue (nmd .check_bond_directionality (
827+ formed_bonds = formed_bonds , broken_bonds = broken_bonds , xyzs = xyzs , weights = weights ))
828+
829+ def test_baseline_and_std_uses_median_and_mad (self ):
830+ """Test that get_bond_length_changes_baseline_and_std() uses median + MAD*1.4826."""
831+ # Construct synthetic displaced xyzs with known bond length differences
832+ # Use the existing HO2+N2H4 data and verify the computation is median-based
833+ weights = nmd .get_weights_from_xyz (xyz = self .ts_2_xyz , weights = False )
834+ freqs , normal_mode_disp = parse_normal_mode_displacement (log_file_path = self .freq_log_path_2 )
835+ xyzs = nmd .get_displaced_xyzs (xyz = self .ts_2_xyz , amplitude = 0.25 ,
836+ normal_mode_disp = normal_mode_disp [0 ], weights = weights )
837+ # Get the raw diffs for the non-reactive bonds
838+ non_reactive_bonds = [(0 , 1 ), (1 , 2 ), (4 , 7 ), (4 , 8 ), (3 , 6 ), (3 , 4 )]
839+ diffs , _ = nmd .get_bond_length_changes (bonds = non_reactive_bonds , xyzs = xyzs , weights = None )
840+ # Manually compute expected median and MAD
841+ expected_baseline = float (np .median (diffs ))
842+ expected_mad = float (np .median (np .abs (diffs - expected_baseline )))
843+ expected_std = expected_mad * 1.4826
844+ baseline , std = nmd .get_bond_length_changes_baseline_and_std (
845+ non_reactive_bonds = non_reactive_bonds , xyzs = xyzs )
846+ self .assertAlmostEqual (baseline , expected_baseline , 10 )
847+ self .assertAlmostEqual (std , expected_std , 10 )
848+
849+ def test_baseline_and_std_empty_bonds (self ):
850+ """Test that get_bond_length_changes_baseline_and_std() returns None for empty bonds."""
851+ weights = nmd .get_weights_from_xyz (xyz = self .ts_2_xyz , weights = False )
852+ freqs , normal_mode_disp = parse_normal_mode_displacement (log_file_path = self .freq_log_path_2 )
853+ xyzs = nmd .get_displaced_xyzs (xyz = self .ts_2_xyz , amplitude = 0.25 ,
854+ normal_mode_disp = normal_mode_disp [0 ], weights = weights )
855+ baseline , std = nmd .get_bond_length_changes_baseline_and_std (
856+ non_reactive_bonds = [], xyzs = xyzs )
857+ self .assertIsNone (baseline )
858+ self .assertIsNone (std )
859+
860+ def test_module_constants (self ):
861+ """Test that the NMD module-level constants are properly defined."""
862+ self .assertEqual (nmd .SIGMA_THRESHOLD , 3.0 )
863+ self .assertEqual (nmd .STD_FLOOR , 1e-4 )
864+ self .assertEqual (nmd .DIRECTIONALITY_MIN_DELTA , 0.005 )
865+
866+ def test_sigma_threshold_separates_correct_from_incorrect_ts (self ):
867+ """Test that the 3.0 sigma threshold correctly separates valid and invalid TSs for iC3H7 <=> nC3H7."""
868+ base_path = os .path .join (ARC_TESTING_PATH , 'composite' , 'C3H7' )
869+ rxn = ARCReaction (r_species = [ARCSpecies (label = 'iC3H7' , smiles = 'C[CH]C' ,
870+ xyz = os .path .join (base_path , 'iC3H7.gjf' ))],
871+ p_species = [ARCSpecies (label = 'nC3H7' , smiles = '[CH2]CC' ,
872+ xyz = os .path .join (base_path , 'nC3H7.gjf' ))])
873+ # Correct TS (TS3): sigma should be well above SIGMA_THRESHOLD
874+ self .generic_job .local_path_to_output_file = os .path .join (base_path , 'TS3.log' )
875+ rxn .ts_species = ARCSpecies (label = 'TS' , is_ts = True , xyz = os .path .join (base_path , 'TS3.log' ))
876+ self .assertTrue (nmd .analyze_ts_normal_mode_displacement (reaction = rxn , job = self .generic_job , amplitude = 0.25 ))
877+ # Wrong TS (TS1, iC3H7-like saddle point): should fail
878+ self .generic_job .local_path_to_output_file = os .path .join (base_path , 'TS1.log' )
879+ rxn .ts_species = ARCSpecies (label = 'TS' , is_ts = True , xyz = os .path .join (base_path , 'TS1.log' ))
880+ self .assertFalse (nmd .analyze_ts_normal_mode_displacement (reaction = rxn , job = self .generic_job , amplitude = 0.25 ))
881+
882+ def test_isomerization_uses_changed_bonds_as_primary (self ):
883+ """Test that the NMD check handles isomerization (only changed-order bonds) via the C2H5NO2 <=> C2H5ONO case."""
884+ # This reaction has formed and broken bonds too, but tests the full pipeline including changed bonds
885+ self .generic_job .local_path_to_output_file = os .path .join (ARC_TESTING_PATH , 'composite' , 'C2H5NO2__C2H5ONO.out' )
886+ valid = nmd .analyze_ts_normal_mode_displacement (reaction = self .rxn_3 ,
887+ job = self .generic_job ,
888+ amplitude = 0.25 ,
889+ weights = True )
890+ self .assertTrue (valid )
891+
762892 @classmethod
763893 def tearDownClass (cls ):
764894 """
0 commit comments