@@ -560,6 +560,163 @@ def test_ejm31_to_jmc28():
560560 assert dihedrals1 .num_functions () - 5 == new_dihedrals1 .num_functions ()
561561
562562
563+ def test_pfkfb3_48_to_47 ():
564+ """
565+ Test ghost atom modifications for the PFKFB3 ligands 48 to 47 (methoxy to
566+ ethyl). This has a dual junction with two ghost branches at lambda = 0:
567+ ghost hydrogens 47 and 48 on the ether oxygen (atom 27), whose physical
568+ neighbours are atoms 13 and 28.
569+
570+ Stiffening the physical-bridge-ghost angles to 90 degrees confines each
571+ ghost to the normal of the physical plane. The intraghost angle must be
572+ stiffened to 180 degrees so that the two ghosts sit on opposite sides of
573+ that plane, otherwise the same-side arrangement is degenerate at lambda = 0
574+ and cannot escape once the intraghost angle grows in. The input geometry
575+ has both ghosts on the same side of the plane.
576+ """
577+
578+ import numpy as np
579+ from sire .legacy .Mol import AtomIdx
580+
581+ mols = sr .load_test_files ("pfkfb3_48_47.s3" )
582+
583+ angles0 = mols [0 ].property ("angle0" )
584+
585+ k_hard = 100
586+
587+ new_mols , modifications = modify (mols , k_hard = k_hard )
588+
589+ new_angles0 = new_mols [0 ].property ("angle0" )
590+
591+ info = mols [0 ].info ()
592+
593+ # No angles should be removed.
594+ assert angles0 .num_functions () == new_angles0 .num_functions ()
595+ assert modifications ["lambda_0" ]["removed_angles" ] == []
596+
597+ # The four physical-bridge-ghost angles are stiffened to 90 degrees and the
598+ # intraghost angle to 180 degrees with a quarter of the force constant.
599+ k_intraghost = 0.25 * k_hard
600+
601+ expected = {
602+ (AtomIdx (13 ), AtomIdx (27 ), AtomIdx (47 )): f"{ k_hard } [theta - 1.5708]^2" ,
603+ (AtomIdx (13 ), AtomIdx (27 ), AtomIdx (48 )): f"{ k_hard } [theta - 1.5708]^2" ,
604+ (AtomIdx (28 ), AtomIdx (27 ), AtomIdx (47 )): f"{ k_hard } [theta - 1.5708]^2" ,
605+ (AtomIdx (28 ), AtomIdx (27 ), AtomIdx (48 )): f"{ k_hard } [theta - 1.5708]^2" ,
606+ (
607+ AtomIdx (47 ),
608+ AtomIdx (27 ),
609+ AtomIdx (48 ),
610+ ): f"{ k_intraghost :g} [theta - 3.14159]^2" ,
611+ }
612+
613+ found = {}
614+ for p in new_angles0 .potentials ():
615+ idx0 = info .atom_idx (p .atom0 ())
616+ idx1 = info .atom_idx (p .atom1 ())
617+ idx2 = info .atom_idx (p .atom2 ())
618+ if (idx0 , idx1 , idx2 ) in expected :
619+ found [(idx0 , idx1 , idx2 )] = str (p .function ())
620+ elif (idx2 , idx1 , idx0 ) in expected :
621+ found [(idx2 , idx1 , idx0 )] = str (p .function ())
622+
623+ assert found == expected
624+
625+ assert modifications ["lambda_0" ]["stiffened_angles" ]["47,27,48" ] == {
626+ "k" : k_intraghost ,
627+ "theta0" : 180.0 ,
628+ }
629+
630+ # Minimising at lambda = 0 must move the ghosts to opposite sides of the
631+ # physical plane.
632+ def ghost_sides (mol ):
633+ coords = mol .property ("coordinates" )
634+
635+ def pos (i ):
636+ c = coords [i ]
637+ return np .array ([c .x ().value (), c .y ().value (), c .z ().value ()])
638+
639+ bridge = pos (27 )
640+ normal = np .cross (pos (13 ) - bridge , pos (28 ) - bridge )
641+ return (
642+ np .sign ((pos (47 ) - bridge ) @ normal ),
643+ np .sign ((pos (48 ) - bridge ) @ normal ),
644+ )
645+
646+ new_mols = sr .morph .link_to_reference (new_mols )
647+
648+ s0 , s1 = ghost_sides (new_mols [0 ])
649+ assert s0 == s1
650+
651+ minimised = (
652+ new_mols .minimisation (lambda_value = 0.0 , platform = "CPU" , cutoff_type = "rf" )
653+ .run ()
654+ .commit ()
655+ )
656+
657+ s0 , s1 = ghost_sides (minimised [0 ])
658+ assert s0 == - s1
659+
660+
661+ def test_pfkfb3_48_to_47_skipped_neighbour ():
662+ """
663+ Test that the intraghost angle is preserved, not stiffened to 180
664+ degrees, when stiffening is skipped through a poorly-scoring physical
665+ neighbour of the bridge. Atom 28 is made transmuting so that it scores
666+ worse than atom 13. The ghosts then no longer sit on the normal of the
667+ physical plane, so a 180 degree intraghost angle would be frustrated.
668+ """
669+
670+ from sire .legacy .Mol import AtomIdx , Element
671+
672+ mols = sr .load_test_files ("pfkfb3_48_47.s3" )
673+
674+ mol = mols [0 ].edit ().atom (28 ).set_property ("element1" , Element ("N" )).molecule ()
675+ mols .update (mol .commit ())
676+
677+ angles0 = mols [0 ].property ("angle0" )
678+
679+ k_hard = 100
680+
681+ new_mols , modifications = modify (mols , k_hard = k_hard )
682+
683+ new_angles0 = new_mols [0 ].property ("angle0" )
684+
685+ info = mols [0 ].info ()
686+
687+ assert angles0 .num_functions () == new_angles0 .num_functions ()
688+ assert modifications ["lambda_0" ]["removed_angles" ] == []
689+ assert set (modifications ["lambda_0" ]["stiffened_angles" ]) == {
690+ "13,27,47" ,
691+ "13,27,48" ,
692+ }
693+
694+ # Angles through atom 28 and the intraghost angle keep their original form.
695+ original = {}
696+ for p in angles0 .potentials ():
697+ idx = (
698+ info .atom_idx (p .atom0 ()),
699+ info .atom_idx (p .atom1 ()),
700+ info .atom_idx (p .atom2 ()),
701+ )
702+ original [idx ] = str (p .function ())
703+
704+ preserved = [
705+ (AtomIdx (28 ), AtomIdx (27 ), AtomIdx (47 )),
706+ (AtomIdx (28 ), AtomIdx (27 ), AtomIdx (48 )),
707+ (AtomIdx (47 ), AtomIdx (27 ), AtomIdx (48 )),
708+ ]
709+
710+ for p in new_angles0 .potentials ():
711+ idx = (
712+ info .atom_idx (p .atom0 ()),
713+ info .atom_idx (p .atom1 ()),
714+ info .atom_idx (p .atom2 ()),
715+ )
716+ if idx in preserved or idx [::- 1 ] in preserved :
717+ assert str (p .function ()) == original [idx ]
718+
719+
563720def check_angle (info , potentials , idx0 , idx1 , idx2 ):
564721 """
565722 Check if an angle potential is in a list of potentials.
0 commit comments