Parses the PDB file into a indexed representation (a tagged list of lines, see constructor docstring).
           A set of Atoms is created, including x, y, z coordinates when applicable. These atoms are grouped into Residues.
           Finally, the limits of the 3D space are calculated to be used for Atom binning.

           ATOM serial numbers appear to be sequential within a model regardless of alternate locations (altLoc, see 1ABE). This
           code assumes that this holds as this serial number is used as an index.
           If an ATOM has a corresponding ANISOU record, the latter record uses the same serial number.

def parse(self, lines = None):
        '''Parses the PDB file into a indexed representation (a tagged list of lines, see constructor docstring).
           A set of Atoms is created, including x, y, z coordinates when applicable. These atoms are grouped into Residues.
           Finally, the limits of the 3D space are calculated to be used for Atom binning.

           ATOM serial numbers appear to be sequential within a model regardless of alternate locations (altLoc, see 1ABE). This
           code assumes that this holds as this serial number is used as an index.
           If an ATOM has a corresponding ANISOU record, the latter record uses the same serial number.
        '''
        indexed_lines = []
        MODEL_count = 0
        records_types_with_atom_serial_numbers = set(['ATOM', 'HETATM', 'TER', 'ANISOU'])
        removable_records_types_with_atom_serial_numbers = set(['ATOM', 'HETATM', 'ANISOU'])
        removable_xyz_records_types_with_atom_serial_numbers = set(['ATOM', 'HETATM'])
        xs, ys, zs = [], [], []

        # atoms maps ATOM/HETATM serial numbers to Atom objects. Atom objects know which Residue object they belong to and Residue objects maintain a list of their Atoms.
        atoms = {}

        # atoms maps chain -> residue IDs to Residue objects. Residue objects remember which ATOM/HETATM/ANISOU records (stored as Atom objects) belong to them
        residues = {}

        atom_name_to_group = {}
        for line in self.lines:
            record_type = line[:6].strip()
            if record_type in removable_records_types_with_atom_serial_numbers:

                #altLoc = line[16]
                atom_name = line[12:16].strip()
                chain = line[21]
                resid = line[22:27] # residue ID + insertion code
                serial_number = int(line[6:11])
                element_name = None
                if record_type == 'ATOM':
                    element_name = line[12:14].strip() # see the ATOM section of PDB format documentation. The element name is stored in these positions, right-justified.
                    element_name = ''.join([w for w in element_name if w.isalpha()]) # e.g. 1
                    if atom_name not in atom_name_to_group:
                        atom_name_to_group[atom_name] = element_name
                    else:
                        assert(atom_name_to_group[atom_name] == element_name)

                residues[chain] = residues.get(chain, {})
                residues[chain][resid] = residues[chain].get(resid, Residue(chain, resid, line[17:20]))
                new_atom = Atom(residues[chain][resid], atom_name, element_name, serial_number, line[16])
                residues[chain][resid].add(record_type.strip(), new_atom)

                if record_type in removable_xyz_records_types_with_atom_serial_numbers:
                    x, y, z = float(line[30:38]), float(line[38:46]), float(line[46:54])
                    xs.append(x)
                    ys.append(y)
                    zs.append(z)
                    assert(serial_number not in atoms) # the logic of this class relies on this assertion - that placed records have a unique identifier
                    atoms[serial_number] = new_atom
                    atoms[serial_number].place(x, y, z, record_type)
                    indexed_lines.append((record_type, serial_number, line, new_atom))
                else:
                    indexed_lines.append((record_type, serial_number, line))
            else:
                if record_type == 'MODEL ':
                    MODEL_count += 1
                    if MODEL_count > 1:
                        raise Exception('This code needs to be updated to properly handle NMR structures.')
                indexed_lines.append((None, line))

        if not xs:
            raise Exception('No coordinates found.')

        # Calculate the side size needed for a cube to contain all of the points, with buffers to account for edge-cases
        min_x, min_y, min_z, max_x, max_y, max_z = min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)
        self.min_x, self.min_y, self.min_z, self.max_x, self.max_y, self.max_z = min(xs)-self.buffer, min(ys)-self.buffer, min(zs)-self.buffer, max(xs)+self.buffer, max(ys)+self.buffer, max(zs)+self.buffer
        self.max_dimension = (self.buffer * 4) + max(self.max_x - self.min_x, self.max_y - self.min_y, self.max_z - self.min_z)
        self.residues = residues
        self.atoms = atoms
        self.indexed_lines = indexed_lines
        self.atom_name_to_group = atom_name_to_group