Skip to content

Commit 8320cb3

Browse files
committed
NMD tst
1 parent 1aeea47 commit 8320cb3

1 file changed

Lines changed: 136 additions & 6 deletions

File tree

arc/checks/nmd_test.py

Lines changed: 136 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)