Dihedral/out-of-plane angle among four atoms.

        Returns the out-of-plane angle among four atoms from geometry
        `g_num`, in degrees.  The reference plane
        is spanned by `at_1`, `at_2` and `at_3`. The out-of-plane angle is
        defined such that a positive angle represents a counter-clockwise
        rotation of the projected `at_3`\\ :math:`\\rightarrow`\\ `at_4`
        vector with respect to the
        reference plane when looking from `at_3` toward `at_2`.
        Zero rotation corresponds to occlusion of `at_1` and `at_4`;
        that is, the case where
        the respective rejections of `at_1`
        :math:`\\rightarrow`\\ `at_2` and
        `at_3`\\ :math:`\\rightarrow`\\ `at_4` onto
        `at_2`\\ :math:`\\rightarrow`\\ `at_3`
        are ANTI-PARALLEL.

        .. todo:: Pull the above to User Guide eventually, with figures.

        All four atom indices must be distinct. Both of the atom trios 1-2-3
        and 2-3-4 must be sufficiently nonlinear, as diagnosed by a bend
        angle different from 0 or 180 degrees by at least
        :data:`PRM.NON_PARALLEL_TOL <opan.const.PRM.NON_PARALLEL_TOL>`.

        Parameters
        ----------
        g_num
            |int| -- Index of the desired geometry

        at_1
            |int| -- Index of the first atom

        at_2
            |int| -- Index of the second atom

        at_3
            |int| -- Index of the third atom

        at_4
            |int| -- Index of the fourth atom

        Returns
        -------
        dihed
            |npfloat_| --
            Out-of-plane/dihedral angle in degrees for the indicated `at_#`,
            drawn from geometry `g_num`

        Raises
        ------
        ~exceptions.IndexError
            If an invalid (out-of-range) `g_num` or `at_#` is provided

        ~exceptions.ValueError
            If any indices `at_#` are equal

        ~opan.error.XYZError
            (typecode :data:`~opan.error.XYZError.DIHED`) If either
            of the atom trios (1-2-3 or 2-3-4) is too close to linearity

def dihed_single(self, g_num, at_1, at_2, at_3, at_4):
        """ Dihedral/out-of-plane angle among four atoms.

        Returns the out-of-plane angle among four atoms from geometry
        `g_num`, in degrees.  The reference plane
        is spanned by `at_1`, `at_2` and `at_3`. The out-of-plane angle is
        defined such that a positive angle represents a counter-clockwise
        rotation of the projected `at_3`\\ :math:`\\rightarrow`\\ `at_4`
        vector with respect to the
        reference plane when looking from `at_3` toward `at_2`.
        Zero rotation corresponds to occlusion of `at_1` and `at_4`;
        that is, the case where
        the respective rejections of `at_1`
        :math:`\\rightarrow`\\ `at_2` and
        `at_3`\\ :math:`\\rightarrow`\\ `at_4` onto
        `at_2`\\ :math:`\\rightarrow`\\ `at_3`
        are ANTI-PARALLEL.

        .. todo:: Pull the above to User Guide eventually, with figures.

        All four atom indices must be distinct. Both of the atom trios 1-2-3
        and 2-3-4 must be sufficiently nonlinear, as diagnosed by a bend
        angle different from 0 or 180 degrees by at least
        :data:`PRM.NON_PARALLEL_TOL <opan.const.PRM.NON_PARALLEL_TOL>`.

        Parameters
        ----------
        g_num
            |int| -- Index of the desired geometry

        at_1
            |int| -- Index of the first atom

        at_2
            |int| -- Index of the second atom

        at_3
            |int| -- Index of the third atom

        at_4
            |int| -- Index of the fourth atom

        Returns
        -------
        dihed
            |npfloat_| --
            Out-of-plane/dihedral angle in degrees for the indicated `at_#`,
            drawn from geometry `g_num`

        Raises
        ------
        ~exceptions.IndexError
            If an invalid (out-of-range) `g_num` or `at_#` is provided

        ~exceptions.ValueError
            If any indices `at_#` are equal

        ~opan.error.XYZError
            (typecode :data:`~opan.error.XYZError.DIHED`) If either
            of the atom trios (1-2-3 or 2-3-4) is too close to linearity

        """
        # library imports
        import numpy as np
        from scipy import linalg as spla
        from .utils.vector import ortho_basis, rej, vec_angle
        from .utils import safe_cast as scast
        from .error import XYZError
        from .const import PRM

        # The below errors are explicitly checked and raised since the indices
        #  are multiplied by three when they are used as an index
        #  and thus give non-intuitive errors in later code.
        # Complain if at_1 is invalid
        if not(-self.num_atoms <= at_1 < self.num_atoms):
            raise IndexError("Invalid index for 'at_1' ({0})".format(at_1))

        # Complain if at_2 is invalid
        if not(-self.num_atoms <= at_2 < self.num_atoms):
            raise IndexError("Invalid index for 'at_2' ({0})".format(at_2))

        # Complain if at_3 is invalid
        if not(-self.num_atoms <= at_3 < self.num_atoms):
            raise IndexError("Invalid index for 'at_3' ({0})".format(at_3))

        # Complain if at_4 is invalid
        if not(-self.num_atoms <= at_4 < self.num_atoms):
            raise IndexError("Invalid index for 'at_4' ({0})".format(at_4))

        # Should never be necessary (save for badly erroneous calling code),
        #  but coerce the at_x to their floor() values.  This is again
        #  needed since they are multiplied by three in the index expresssions
        #  below, and can cause funny behavior when truncated by the indexing
        at_1 = scast(np.floor(at_1), np.int_)
        at_2 = scast(np.floor(at_2), np.int_)
        at_3 = scast(np.floor(at_3), np.int_)
        at_4 = scast(np.floor(at_4), np.int_)

        # Proofread the atom numbers. Performed by double-iterative scan of
        #  the atom numbers, converting the index equality test results to
        #  ints and summing the results.  Since each ats_n is not compared to
        #  itself, a sum of zero should diagnose the required mutually
        #  nonidentical indices.
        #
        # Pile the atom indices into a vector
        ats = [at_1, at_2, at_3, at_4]

        # Scan over the vector of indices pairwise without repetition, and
        #  without examining for at_i == at_i (which is trivially and always
        #  True).  Store the outcomes as integers (True == 1; False == 0)
        ats_test = [int(ats[x] == ats[y]) for x in range(4) \
                                        for y in range(x+1,4)]

        # For a proper set of indices, the sum over ats_test will be zero.
        if sum(ats_test) > 0:
            # Improper set of indices; at least one pair is duplicated.
            #  Collate the duplicative pairings and raise ValueError.
            #  np.triu_indices generates index pairs in the same sequence as
            #  the above double iteration over ats, but as a list of two
            #  np.arrays.  column_stack puts them together as column vectors,
            #  allowing the conditional iteration over x to select only those
            #  index pairs that correspond to duplicated indices.  The
            #  resulting filtered pairs are converted to tuples for concise
            #  formatting in the output.
            ats_pairs = [tuple(np.column_stack(np.triu_indices(4,1))[x])
                                        for x in range(6) if ats_test[x] == 1]
            raise ValueError("Duplicate atom indices: {0}".format(ats_pairs))
        ## end if

        # Check to ensure non-collinearity of the 1-2-3 and 2-3-4 atom trios
        for idx in range(2):
            # Store the relevant angle
            ang = self.angle_single(g_num, [at_2, at_3][idx],
                                         [at_1, at_2][idx],
                                         [at_3, at_4][idx])

            # Check for whether angle is too close to zero or 180 degrees
            if np.min([ang, 180.0 - ang]) < PRM.NON_PARALLEL_TOL:
                # Too close; raise error
                raise XYZError(XYZError.DIHED,
                        "Angle {0} is insufficiently nonlinear"
                            .format([(at_2, at_1, at_3),
                            (at_3, at_2, at_4)][idx]),
                        "XYZ file: {0}".format(self.XYZ_path))
            ## end if
        ## next idx

        # Store normalized atomic displacement vector at_2 --> at_3 as that
        #  defining the projection plane
        plane_norm = self.displ_single(g_num, at_2, at_3)
        plane_norm /= spla.norm(plane_norm)

        # Retrieve the orthonormal basis in the projection plane, with the
        #  first vector being the normalized projection of the at_1 --> at_2
        #  displacement onto that plane
        on1, on2 = ortho_basis(plane_norm, \
                            self.displ_single(g_num, at_1, at_2))

        # Project the at_3 --> at_4 displacement onto the plane
        #
        # Retrieve the "back-side" displacement vector
        back_vec = self.displ_single(g_num, at_3, at_4)

        # Project onto the plane by subtracting out the plane_norm projection
        #  and re-normalize
        back_vec = rej(back_vec, plane_norm)
        back_vec /= spla.norm(back_vec)

        # Calculate the absolute value of the departure of the dihedral/
        #  out-of-plane angle from 180 degrees as derived from the dot-product
        #  of on1 and back_vec. Both should be normalized at this point, so
        #  the calculation is straightforward
        dihed = vec_angle(back_vec, on1)

        # Given the handedness of the spanning vectors provided by ortho_basis,
        #  the sign of the dihed departure is that of the dot product
        #  of back_vec and on2.
        dihed *= np.sign(np.dot(back_vec, on2))

        # Conversion to the stated typical definition of a dihedral now
        #  requires addition of 180 degrees.
        dihed += 180.0

        # Should be set to return the value
        return dihed