Task: Check all wildcard combinations for dihedrals and impropers (openmm/openmm#4830)

org: openmm
repo: openmm
pr_number: 4830
issue_title: Check all wildcard combinations for dihedrals and impropers
issue_body: Addresses #4828.  Allows up to 3 wildcards in any positions for dihedrals and impropers.  4 out of 4 wildcards in a parameter file shouldn't happen so that isn't checked (it won't match and an error will be raised unless there were other matching entries).  The maximum number of wildcards is just the `max_wc_count` argument to `_match_with_wildcards` though so it can be changed if we want.

ParmEd seems to go further in its handling of these wildcards for impropers and considers other permutations by sorting the atom types, but that doesn't seem consistent with what CHARMM actually does as far as I can tell.

The test generates some PSF files, and PRM files with wildcards, and makes sure that the dihedrals or impropers match (or don't).
resolved_issues: title: Check all wildcard combinations for dihedrals and impropers
body: ### Issue Description

**Problem:**  
The current implementation of dihedral and improper parameter matching does not fully support the use of wildcard atom types in all possible positions. This limitation can lead to cases where valid dihedral or improper parameters are not matched, resulting in errors when generating molecular systems. Additionally, the handling of wildcards for impropers is inconsistent with the expected behavior based on CHARMM conventions.

**Expected Behavior:**  
The parameter matching process should allow up to three wildcard atom types in any position for dihedrals and impropers. This ensures that valid parameters are matched correctly, reducing errors during system generation. For impropers, the handling of wildcards should align with CHARMM conventions, ensuring consistency in parameter matching.

## IMPORTANT INSTRUCTIONS

1. If you edit a python file, you MUST run 'make PythonInstall' inside the build directory
2. If you edit a c++ file, you MUST run 'make -j4 && make install" inside the build directory

Reference solution:
fix_patch: diff --git a/wrappers/python/openmm/app/charmmpsffile.py b/wrappers/python/openmm/app/charmmpsffile.py
index ab57331c97..46f9a176ac 100644
--- a/wrappers/python/openmm/app/charmmpsffile.py
+++ b/wrappers/python/openmm/app/charmmpsffile.py
@@ -34,6 +34,7 @@
 from __future__ import division, absolute_import, print_function
 
 from functools import wraps
+from itertools import combinations
 from math import pi, cos, sin, sqrt
 import os
 import re
@@ -603,9 +604,9 @@ def loadParameters(self, parmset):
         - If any parameters that are necessary cannot be found, a
           MissingParameter exception is raised.
         - If any dihedral or improper parameters cannot be found, I will try
-          inserting wildcards (at either end for dihedrals and as the two
-          central atoms in impropers) and see if that matches.  Wild-cards
-          will apply ONLY if specific parameters cannot be found.
+          inserting wildcards (in up to three positions) and see if that
+          matches.  Wild-cards will apply ONLY if specific parameters cannot be
+          found.
         - This method will expand the dihedral_parameter_list attribute by
           adding a separate Dihedral object for each term for types that
           have a multi-term expansion
@@ -655,16 +656,11 @@ def loadParameters(self, parmset):
         for dih in self.dihedral_list:
             # Store the atoms
             a1, a2, a3, a4 = dih.atom1, dih.atom2, dih.atom3, dih.atom4
-            at1, at2, at3, at4 = a1.attype, a2.attype, a3.attype, a4.attype
-            # First see if the exact dihedral is specified
-            key = min((at1,at2,at3,at4), (at4,at3,at2,at1))
-            if not key in parmset.dihedral_types:
-                # Check for wild-cards
-                key = min(('X',at2,at3,'X'), ('X',at3,at2,'X'))
-                if not key in parmset.dihedral_types:
-                    raise MissingParameter('No dihedral parameters found for '
-                                           '%r' % dih)
-            dtlist = parmset.dihedral_types[key]
+            try:
+                at_list = [a1.attype, a2.attype, a3.attype, a4.attype]
+                dtlist = _match_with_wildcards(at_list, parmset.dihedral_types, 3)
+            except KeyError:
+                raise MissingParameter('No dihedral parameters found for %r' % dih)
             for i, dt in enumerate(dtlist):
                 self.dihedral_parameter_list.append(Dihedral(a1,a2,a3,a4,dt))
                 # See if we include the end-group interactions for this
@@ -678,18 +674,11 @@ def loadParameters(self, parmset):
         for imp in self.improper_list:
             # Store the atoms
             a1, a2, a3, a4 = imp.atom1, imp.atom2, imp.atom3, imp.atom4
-            at1, at2, at3, at4 = a1.attype, a2.attype, a3.attype, a4.attype
-            key = min((at1,at2,at3,at4), (at4,at3,at2,at1))
-            if not key in parmset.improper_types:
-                key = min((at1,'X', 'X',at4),(at4,'X','X',at1))
-                if not key in parmset.improper_types:
-                    raise MissingParameter('No improper dihedral parameters found for '
-                                           '%r' % imp)
             try:
-                imp.improper_type = parmset.improper_types[key]
+                at_list = [a1.attype, a2.attype, a3.attype, a4.attype]
+                imp.improper_type = _match_with_wildcards(at_list, parmset.improper_types, 3)
             except KeyError:
-                raise MissingParameter('No improper parameters found for %r' %
-                                       imp)
+                raise MissingParameter('No improper parameters found for %r' % imp)
         # Now do the cmaps. These will not have wild-cards
         for cmap in self.cmap_list:
             # Store the atoms for easy reference
@@ -1854,6 +1843,26 @@ def _mbondi3_radii(atom_list):
 
 # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 
+def _match_with_wildcards(at_list, type_dict, max_wc_count):
+    """
+    Tries to find a match for at_list in type_dict.  If one can't be found,
+    tries to insert up to max_wc_count wildcards in different combinations.
+    Assumes that for a given key, min(key, key[::-1]) is the one in type_dict.
+    """
+
+    for wc_count in range(max_wc_count + 1):
+        for wc_indices in combinations(range(len(at_list)), wc_count):
+            key = list(at_list)
+            for wc_index in wc_indices:
+                key[wc_index] = 'X'
+            key = tuple(key)
+            key = min(key, key[::-1])
+            if key in type_dict:
+                return type_dict[key]
+    raise KeyError(key)
+
+# ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
+
 if __name__ == '__main__':
     import doctest
     doctest.testmod()