@@ -680,11 +680,88 @@ def _triple(
680680 except :
681681 connectivity = mol .property ("connectivity" )
682682
683- # Store the element of the bridge atom.
684- element = mol .atom (bridge ).property ("element" + suffix )
683+ # Link the molecule to the desired end state.
684+ if is_lambda1 :
685+ end_state_mol = _morph .link_to_perturbed (mol )
686+ else :
687+ end_state_mol = _morph .link_to_reference (mol )
688+
689+ from rdkit .Chem import HybridizationType
690+ from sire .convert import to_rdkit
691+
692+ # Convert the molecule to RDKit to determine the hybridisation of the bridge atom.
693+ try :
694+ rdmol = to_rdkit (end_state_mol )
695+ except Exception as e :
696+ msg = "Failed to convert molecule to RDKit for hybridisation check: " + str (e )
697+ _logger .error (msg )
698+ raise RuntimeError (msg )
699+
700+ # Get the hybridisation of the bridge atom.
701+ hybridisation = rdmol .GetAtomWithIdx (bridge .value ()).GetHybridization ()
702+
703+ # Non-planar junction.
704+ if (
705+ hybridisation == HybridizationType .SP3
706+ or hybridisation == HybridizationType .SP3D
707+ ):
708+ _logger .debug (" Non-planar junction." )
709+
710+ # First, modify the force constants of the angle terms between the ghost
711+ # atoms and the physical system to be very low.
712+
713+ # Get the end state angle functions.
714+ angles = mol .property ("angle" + suffix )
685715
686- # Planar junction.
687- if element == _SireMol .Element ("C" ):
716+ # Initialise a container to store the updated angle functions.
717+ new_angles = _SireMM .ThreeAtomFunctions (mol .info ())
718+
719+ # Indices for the softened angle terms.
720+ angle_idxs = []
721+
722+ for p in angles .potentials ():
723+ idx0 = info .atom_idx (p .atom0 ())
724+ idx1 = info .atom_idx (p .atom1 ())
725+ idx2 = info .atom_idx (p .atom2 ())
726+
727+ if (
728+ idx0 in ghosts
729+ and idx2 in physical
730+ or idx2 in ghosts
731+ and idx0 in physical
732+ ):
733+ from sire .legacy .CAS import Symbol
734+
735+ theta = Symbol ("theta" )
736+
737+ # Cast the angle to an Amber angle to get the expression.
738+ amber_angle = _SireMM .AmberAngle (p .function (), theta )
739+
740+ # Create a new Amber angle with a modified force constant.
741+
742+ # We'll optimise the equilibrium angle for the softened angle term.
743+ if optimise_angles :
744+ amber_angle = _SireMM .AmberAngle (0.05 , amber_angle .theta0 ())
745+ angle_idxs .append ((idx0 , idx1 , idx2 ))
746+ # Use the existing equilibrium angle.
747+ else :
748+ amber_angle = _SireMM .AmberAngle (k_soft , amber_angle .theta0 ())
749+
750+ # Generate the new angle expression.
751+ expression = amber_angle .to_expression (theta )
752+
753+ # Set the force constant to a very low value.
754+ new_angles .set (idx0 , idx1 , idx2 , expression )
755+
756+ _logger .debug (
757+ f" Softening angle: [{ idx0 .value ()} -{ idx1 .value ()} -{ idx2 .value ()} ], "
758+ f"{ p .function ()} --> { expression } "
759+ )
760+
761+ else :
762+ new_angles .set (idx0 , idx1 , idx2 , p .function ())
763+
764+ else :
688765 _logger .debug (" Planar junction." )
689766
690767 # First remove all bonded terms between one of the physical atoms
@@ -782,194 +859,6 @@ def _triple(
782859 .commit ()
783860 )
784861
785- # Non-planar junction.
786- elif element == _SireMol .Element ("N" ):
787- _logger .debug (" Non-planar junction." )
788-
789- # First, modify the force constants of the angle terms between the ghost
790- # atoms and the physical system to be very low.
791-
792- # Get the end state angle functions.
793- angles = mol .property ("angle" + suffix )
794-
795- # Initialise a container to store the updated angle functions.
796- new_angles = _SireMM .ThreeAtomFunctions (mol .info ())
797-
798- # Indices for the softened angle terms.
799- angle_idxs = []
800-
801- for p in angles .potentials ():
802- idx0 = info .atom_idx (p .atom0 ())
803- idx1 = info .atom_idx (p .atom1 ())
804- idx2 = info .atom_idx (p .atom2 ())
805-
806- if (
807- idx0 in ghosts
808- and idx2 in physical
809- or idx2 in ghosts
810- and idx0 in physical
811- ):
812- from sire .legacy .CAS import Symbol
813-
814- theta = Symbol ("theta" )
815-
816- # Cast the angle to an Amber angle to get the expression.
817- amber_angle = _SireMM .AmberAngle (p .function (), theta )
818-
819- # Create a new Amber angle with a modified force constant.
820-
821- # We'll optimise the equilibrium angle for the softened angle term.
822- if optimise_angles :
823- amber_angle = _SireMM .AmberAngle (0.05 , amber_angle .theta0 ())
824- angle_idxs .append ((idx0 , idx1 , idx2 ))
825- # Use the existing equilibrium angle.
826- else :
827- amber_angle = _SireMM .AmberAngle (k_soft , amber_angle .theta0 ())
828-
829- # Generate the new angle expression.
830- expression = amber_angle .to_expression (theta )
831-
832- # Set the force constant to a very low value.
833- new_angles .set (idx0 , idx1 , idx2 , expression )
834-
835- _logger .debug (
836- f" Softening angle: [{ idx0 .value ()} -{ idx1 .value ()} -{ idx2 .value ()} ], "
837- f"{ p .function ()} --> { expression } "
838- )
839-
840- else :
841- new_angles .set (idx0 , idx1 , idx2 , p .function ())
842-
843- # Next, remove all dihedral starting from the ghost atoms and ending in
844- # the physical system. Also, only preserve dihedrals terminating at one
845- # of the physical atoms.
846-
847- # Get the end state dihedral functions.
848- dihedrals = mol .property ("dihedral" + suffix )
849-
850- # Initialise containers to store the updated dihedral functions.
851- new_dihedrals = _SireMM .FourAtomFunctions (mol .info ())
852-
853- for p in dihedrals .potentials ():
854- idx0 = info .atom_idx (p .atom0 ())
855- idx1 = info .atom_idx (p .atom1 ())
856- idx2 = info .atom_idx (p .atom2 ())
857- idx3 = info .atom_idx (p .atom3 ())
858- idxs = [idx0 , idx1 , idx2 , idx3 ]
859-
860- # If there is one ghost atom, then this dihedral must begin or terminate
861- # at the ghost atom.
862- num_ghosts = len ([x for x in idxs if x in ghosts ])
863- if num_ghosts == 1 :
864- _logger .debug (
865- f" Removing dihedral: [{ idx0 .value ()} -{ idx1 .value ()} -{ idx2 .value ()} -{ idx3 .value ()} ], { p .function ()} "
866- )
867- # Remove the dihedral if includes a ghost and doesn't terminate at the first
868- # physical atom.
869- elif (_is_ghost (mol , [idx0 ], is_lambda1 )[0 ] and idx3 in physical [1 :]) or (
870- _is_ghost (mol , [idx3 ], is_lambda1 )[0 ] and idx0 in physical [1 :]
871- ):
872- _logger .debug (
873- f" Removing dihedral: [{ idx0 .value ()} -{ idx1 .value ()} -{ idx2 .value ()} -{ idx3 .value ()} ], { p .function ()} "
874- )
875- else :
876- new_dihedrals .set (idx0 , idx1 , idx2 , idx3 , p .function ())
877-
878- # Update the molecule.
879- mol = mol .edit ().set_property ("angle" + suffix , new_angles ).molecule ().commit ()
880- mol = (
881- mol .edit ()
882- .set_property ("dihedral" + suffix , new_dihedrals )
883- .molecule ()
884- .commit ()
885- )
886-
887- # Optimise the equilibrium value of theta0 for the softened angle terms.
888- if optimise_angles :
889- _logger .debug (" Optimising equilibrium values for softened angles." )
890-
891- import sire .morph as _morph
892- from sire .units import radian as _radian
893-
894- # Initialise the equilibrium angle values.
895- theta0s = {}
896- for idx in angle_idxs :
897- theta0s [idx ] = []
898-
899- # Perform multiple minimisations to get an average for the theta0 values.
900- for _ in range (num_optimise ):
901- # Minimise the molecule.
902- min_mol = _morph .link_to_reference (mol )
903- minimiser = min_mol .minimisation (
904- lambda_value = 1.0 if is_lambda1 else 0.0 ,
905- constraint = "none" ,
906- platform = "cpu" ,
907- )
908- minimiser .run ()
909-
910- # Commit the changes.
911- min_mol = minimiser .commit ()
912-
913- # Get the equilibrium angle values.
914- for idx in angle_idxs :
915- try :
916- theta0s [idx ].append (min_mol .angles (* idx ).sizes ()[0 ].to (_radian ))
917- except :
918- raise ValueError (f"Could not find optimised angle term: { idx } " )
919-
920- # Compute the mean and standard error.
921- import numpy as _np
922-
923- theta0_means = {}
924- theta0_stds = {}
925- for idx in theta0s :
926- theta0_means [idx ] = _np .mean (theta0s [idx ])
927- theta0_stds [idx ] = _np .std (theta0s [idx ]) / _np .sqrt (len (theta0s [idx ]))
928-
929- # Get the existing angles.
930- angles = mol .property ("angle" + suffix )
931-
932- # Initialise a container to store the updated angle functions.
933- new_angles = _SireMM .ThreeAtomFunctions (mol .info ())
934-
935- # Update the angle potentials.
936- for p in angles .potentials ():
937- idx0 = info .atom_idx (p .atom0 ())
938- idx1 = info .atom_idx (p .atom1 ())
939- idx2 = info .atom_idx (p .atom2 ())
940- idx = (idx0 , idx1 , idx2 )
941-
942- # This is the softened angle term.
943- if idx in angle_idxs :
944- # Get the optimised equilibrium angle.
945- theta0 = theta0_means [idx ]
946- std = theta0_stds [idx ]
947-
948- # Create the new angle function.
949- amber_angle = _SireMM .AmberAngle (k_soft , theta0 )
950-
951- # Generate the new angle expression.
952- expression = amber_angle .to_expression (Symbol ("theta" ))
953-
954- # Set the equilibrium angle to 90 degrees.
955- new_angles .set (idx0 , idx1 , idx2 , expression )
956-
957- _logger .debug (
958- f" Optimising angle: [{ idx0 .value ()} -{ idx1 .value ()} -{ idx2 .value ()} ], "
959- f"{ p .function ()} --> { expression } (std err: { std :.3f} radian)"
960- )
961-
962- else :
963- new_angles .set (idx0 , idx1 , idx2 , p .function ())
964-
965- # Update the molecule.
966- mol = (
967- mol .edit ()
968- .set_property ("angle" + suffix , new_angles )
969- .molecule ()
970- .commit ()
971- )
972-
973862 # Return the updated molecule.
974863 return mol
975864
0 commit comments