Task: Fix formal charge reading from pdb files; store formal charges in topology (openmm/openmm#4630)

org: openmm
repo: openmm
pr_number: 4630
issue_title: Fix formal charge reading from pdb files; store formal charges in topology
issue_body: This pull request fixes some bugs with formal charge handling in pdb files, and also stores the charges in the resulting topologies.

How charges are stored, from the PDB format spec https://www.wwpdb.org/documentation/file-format-content/format33/sect9.html#ATOM

> Columns 79 - 80 indicate any charge on the atom, e.g., 2+, 1-. In most cases, these are blank

The charges are not stored as +1 or -1, rather the sign follows the number.  I have confirmed this using some example files saved from popular software.

The existing code before this patch tries to parse this with `try: self.formal_charge = int(pdb_line[78:80])` which always fails, both for missing charges and for present, correctly formatted charges, because `int('1+')` fails.  The correspnding catch statement sets the charge to None.

The current fix reverses the string before trying to parse it as int.

The second part of this pull request makes it possible to store formal charges in the topology, as follows:
- add an optional formal_charge argument to the Atom constructor
- add an optional formal_charge argument to topology.newAtom()
- pass charges from the PdbStructure to the Topology

Test case, using the attached file:

```
pdb_file = 'formal_charge.pdb'
# check that PdbStructure reads the charges correctly - this always prints None before this patch
from openmm.app.internal.pdbstructure import PdbStructure
fh = open(pdb_file, 'r')
pdb = PdbStructure(fh, load_all_models=True, extraParticleIdentifier='EP')
for chain in pdb.iter_chains():
    for residue in chain.iter_residues():
        for atom in residue.atoms_by_name.values():
            print(atom.get_name(), atom.formal_charge)

# check that Topology gets the charges - this would raise an exception before this patch, because a.formal_charge doesn't exist
import openmm as mm
import openmm.app as app
pdb = app.PDBFile(pdb_file)
for a in topology.atoms():
    print(a, a.formal_charge)
```
resolved_issues: title: Fix formal charge reading from pdb files; store formal charges in topology
body: ### Issue Description

**Problem:**
Formal charges on atoms in PDB files are not being correctly parsed and stored in the topology. According to the PDB format specification, formal charges are represented in columns 79-80 as a number followed by a sign (e.g., "2+", "1-"). The current implementation fails to parse these charges correctly, resulting in formal charges being set to `None` even when they are present and correctly formatted. Additionally, the topology does not store formal charges, which prevents users from accessing this information when working with molecular structures.

**Expected Behavior:**
1. Formal charges in PDB files should be correctly parsed, regardless of their format (e.g., "2+", "1-", "+2", "-1").
2. Parsed formal charges should be stored in the topology, allowing users to access and utilize this information when working with atoms in molecular simulations.
3. The software should handle missing charges gracefully, setting them to `None` without causing errors.

## 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/internal/pdbstructure.py b/wrappers/python/openmm/app/internal/pdbstructure.py
index 3711ee2bfb..aa81838fa4 100644
--- a/wrappers/python/openmm/app/internal/pdbstructure.py
+++ b/wrappers/python/openmm/app/internal/pdbstructure.py
@@ -785,8 +785,15 @@ def __init__(self, pdb_line, pdbstructure=None, extraParticleIdentifier='EP'):
         # segment id, element_symbol, and formal_charge are not always present
         self.segment_id = pdb_line[72:76].strip()
         self.element_symbol = pdb_line[76:78].strip()
-        try: self.formal_charge = int(pdb_line[78:80])
-        except ValueError: self.formal_charge = None
+        try:
+            # formal_charge should always be a one digit followed by a sign, eg "2-", "3+"
+            # this is a bit more permissive, it will also read "-2", "+3", or "3 "
+            formal_charge = pdb_line[78:80]
+            if formal_charge.endswith('+') or formal_charge.endswith('-'):
+                formal_charge = formal_charge[::-1]
+            self.formal_charge = int(formal_charge)
+        except ValueError:
+            self.formal_charge = None
         # figure out atom element
         if self.element_symbol == extraParticleIdentifier:
             self.element = 'EP'
@@ -904,7 +911,7 @@ def _pdb_string(self, serial_number=None, alternate_location_indicator=None):
         end =  "%-4s%2s" % (\
             self.segment_id, self.element_symbol)
         formal_charge = "  "
-        if (self.formal_charge != None): formal_charge = "%+2d" % self.formal_charge
+        if (self.formal_charge is not None): formal_charge = ("%+2d" % self.formal_charge)[::-1]
         return names+numbers+end+formal_charge
 
     def __str__(self):
diff --git a/wrappers/python/openmm/app/pdbfile.py b/wrappers/python/openmm/app/pdbfile.py
index 626f0ac8dc..b909f52790 100644
--- a/wrappers/python/openmm/app/pdbfile.py
+++ b/wrappers/python/openmm/app/pdbfile.py
@@ -152,7 +152,7 @@ def __init__(self, file, extraParticleIdentifier='EP'):
                                 element = elem.get_by_symbol(upper[0])
                             except KeyError:
                                 pass
-                    newAtom = top.addAtom(atomName, element, r, str(atom.serial_number))
+                    newAtom = top.addAtom(atomName, element, r, str(atom.serial_number), formalCharge=atom.formal_charge)
                     atomByNumber[atom.serial_number] = newAtom
         self._positions = []
         for model in pdb.iter_models(True):
@@ -389,9 +389,13 @@ def writeModel(topology, positions, file=sys.stdout, modelIndex=None, keepIds=Fa
                     else:
                         atomName = atom.name
                     coords = positions[posIndex]
-                    line = "%s%5s %-4s %3s %s%4s%1s   %s%s%s  1.00  0.00          %2s  " % (
+                    if atom.formalCharge is not None:
+                        formalCharge = ("%+2d" % atom.formalCharge)[::-1]
+                    else:
+                        formalCharge = '  '
+                    line = "%s%5s %-4s %3s %s%4s%1s   %s%s%s  1.00  0.00          %2s%2s" % (
                         recordName, _formatIndex(atomIndex, 5), atomName, resName, chainName, resId, resIC, _format_83(coords[0]),
-                        _format_83(coords[1]), _format_83(coords[2]), symbol)
+                        _format_83(coords[1]), _format_83(coords[2]), symbol, formalCharge)
                     if len(line) != 80:
                         raise ValueError('Fixed width overflow detected')
                     print(line, file=file)
diff --git a/wrappers/python/openmm/app/topology.py b/wrappers/python/openmm/app/topology.py
index 46ff9f997f..63d8a2ecde 100644
--- a/wrappers/python/openmm/app/topology.py
+++ b/wrappers/python/openmm/app/topology.py
@@ -166,7 +166,7 @@ def addResidue(self, name, chain, id=None, insertionCode=''):
         chain._residues.append(residue)
         return residue
 
-    def addAtom(self, name, element, residue, id=None):
+    def addAtom(self, name, element, residue, id=None, formalCharge=None):
         """Create a new Atom and add it to the Topology.
 
         Parameters
@@ -180,7 +180,8 @@ def addAtom(self, name, element, residue, id=None):
         id : string=None
             An optional identifier for the atom.  If this is omitted, an id is
             generated based on the atom index.
-
+        formalCharge : int=None
+            An optional formal charge for the atom.
         Returns
         -------
         Atom
@@ -190,7 +191,7 @@ def addAtom(self, name, element, residue, id=None):
             raise ValueError('All atoms within a residue must be contiguous')
         if id is None:
             id = str(self._numAtoms+1)
-        atom = Atom(name, element, self._numAtoms, residue, id)
+        atom = Atom(name, element, self._numAtoms, residue, id, formalCharge=formalCharge)
         self._numAtoms += 1
         residue._atoms.append(atom)
         return atom
@@ -452,7 +453,7 @@ def __repr__(self):
 class Atom(object):
     """An Atom object represents an atom within a Topology."""
 
-    def __init__(self, name, element, index, residue, id):
+    def __init__(self, name, element, index, residue, id, formalCharge=None):
         """Construct a new Atom.  You should call addAtom() on the Topology instead of calling this directly."""
         ## The name of the Atom
         self.name = name
@@ -464,6 +465,8 @@ def __init__(self, name, element, index, residue, id):
         self.residue = residue
         ## A user defined identifier for this Atom
         self.id = id
+        ## An optional formal charge for this Atom
+        self.formalCharge = formalCharge
 
     def __repr__(self):
         return "<Atom %d (%s) of chain %d residue %d (%s)>" % (self.index, self.name, self.residue.chain.index, self.residue.index, self.residue.name)