ls in unitcell.py

the entries are organized as follows:

key : point group symmetry

entry 0 in list: number of SST triangles needed to specify the 
fundamental region. this could have values 0, 1 or 2. If it is 0,
then the whole sphere is valid (either upper or ower or both depending
on symmetry). If this is 1, then only 1 triangle is needed. If two, then
two triangles are needed i.e. 4 coordinates

entry 1 in list: specify coordinates of the triangle(s). this will be 
empty array if size is 0 in previous entry. If size in previous entry is 1,
then this will 3x3. Finally if size in previous entry is 2, this will be 3x4.

entry 2 in list is the connectivity of the triangles. This is only useful for
the cases of T, Th, O and symmetry groups.

entry 3 in list: if both upper anf lower hemispheres are to be considered. For
the case of x-rays only upper hemisphere is considered because of Friedel's law,
which imposes and artificial inversion symmetry such that hkl and -hkl can't be
distinguished. However, for the formulation is general and can handle all symmetry
groups. This will say 'upper' or 'both' depending on what hemisphere is considered.

there are no triangles for the triclininc cases and needs to be handles differently

'''
pg2vertex = {
    'c1': [3, np.array([[0., 0., 1.],
                        [1., 0., 0.],
                        [-0.5, np.sqrt(3.)/2., 0.],
                        [-0.5, -np.sqrt(3.)/2., 0.]]).T,
          np.array([[0, 1, 2], [0, 2, 3], [0, 3, 1]]).T, 'both'],

    # supergroup 00 in our convention
    'ci': [3, np.array([[0., 0., 1.],
                        [1.0, 0., 0.],
                        [-0.5, np.sqrt(3.)/2., 0.],
                        [-0.5, -np.sqrt(3.)/2., 0.]]).T,
          np.array([[0, 1, 2], [0, 2, 3], [0, 3, 1]]).T, 'upper'],

    'c2': [2, np.array([[0., 0., 1.],
                        [1., 0., 0.],
                        [0., 1., 0.],
                        [-1., 0., 0.]]).T,
           np.array([[0, 1, 2], [0, 2, 3]]).T,
           'both'],

    # supergroup 1 in our convention
    'cs': [3, np.array([[0., 0., 1.],
                        [1., 0., 0.],
                        [-0.5, np.sqrt(3.)/2., 0.],
                        [-0.5, -np.sqrt(3.)/2., 0.]]).T,
          np.array([[0, 1, 2], [0, 2, 3], [0, 3, 1]]).T,
           'upper'],

    'c2h': [2, np.array([[0., 0., 1.],
                         [1., 0., 0.],
                         [0., 1., 0.],
                         [-1., 0., 0.]]).T,
            np.array([[0, 1, 2], [0, 2, 3]]).T,
            'upper'],

    'd2': [2, np.array([[0., 0., 1.],
                        [1., 0., 0.],
                        [0., 1., 0.],
                        [-1., 0., 0.]]).T,
           np.array([[0, 1, 2], [0, 2, 3]]).T,
           'upper'],

    # supergroup 2 in our convention
    'c2v': [2, np.array([[0., 0., 1.],
                         [1., 0., 0.],
                         [0., 1., 0.],
                         [0., 0., -1.]]).T,
            np.array([[0, 1, 2], [3, 1, 2]]).T,
            'both'],

    # supergroup 3 in our convention
    'd2h': [1, np.array([[0., 0., 1.],
                         [1., 0., 0.],
                         [0., 1., 0.]]).T,
            np.atleast_2d(np.array([0, 1, 2])).T,
            'upper'],

    'c4': [1, np.array([[0., 0., 1.],
                        [1., 0., 0.],
                        [0., 1., 0.]]).T,
           np.atleast_2d(np.array([0, 1, 2])).T,
           'both'],

    # supergroup 01 in our convention
    's4': [2, np.array([[0., 0., 1.],
                        [1., 0., 0.],
                        [0., 1., 0.],
                        [-1., 0., 0.]]).T,
           np.array([[0, 1, 2], [0, 2, 3]]).T,
           'upper'],

    'c4h': [1, np.array([[0., 0., 1.],
                         [1., 0., 0.],
                         [0., 1., 0.]]),
            np.atleast_2d(np.array([0, 1, 2])).T,
            'upper'],

    'd4': [1, np.array([[0., 0., 1.],
                        [1., 0., 0.],
                        [0., 1., 0.0]]),
           np.atleast_2d(np.array([0, 1, 2])).T,
           'upper'],

    # supergroup 4 in our convention
    'c4v': [1, np.array([[0., 0., 1.],
                         [1., 0., 0.],
                         [1./np.sqrt(2.), 1./np.sqrt(2.), 0.]]).T,
            np.atleast_2d(np.array([0, 1, 2])).T,
            'both'],

    'd2d': [1, np.array([[0., 0., 1.],
                         [1., 0., 0.],
                         [0., 1., 0.]]),
            np.atleast_2d(np.array([0, 1, 2])).T,
            'upper'],

    # supergroup 5 in our convention
    'd4h': [1, np.array([[0., 0., 1.],
                         [1., 0., 0.],
                         [1./np.sqrt(2.), 1./np.sqrt(2.), 0.]]).T,
            np.atleast_2d(np.array([0, 1, 2])).T,
            'upper'],

    'c3': [1, np.array([[0., 0., 1.],
                        [np.sqrt(3.)/2., -0.5, 0.],
                        [0., 1., 0.]]).T,
           np.atleast_2d(np.array([0, 1, 2])).T,
           'both'],

    # supergroup 02 in our convention
    's6': [1, np.array([[0., 0., 1.],
                        [1., 0., 0.],
                        [-0.5, np.sqrt(3.)/2., 0.]]).T,
           np.atleast_2d(np.array([0, 1, 2])).T,
           'upper'],

    'd3': [1, np.array([[0., 0., 1.],
                        [1., 0., 0.],
                        [-0.5, np.sqrt(3.)/2., 0.]]).T,
           np.atleast_2d(np.array([0, 1, 2])).T,
           'upper'],

    # supergroup 6 in our convention
    'c3v': [1, np.array([[0., 0., 1.],
                         [np.sqrt(3.)/2., -0.5, 0.],
                         [np.sqrt(3.)/2., 0.5, 0.]]).T,
            np.atleast_2d(np.array([0, 1, 2])).T,
            'both'],

    'd3d': [1, np.array([[0., 0., 1.],
                         [np.sqrt(3.)/2., -0.5, 0.],
                         [np.sqrt(3.)/2., 0.5, 0.]]).T,
            np.atleast_2d(np.array([0, 1, 2])).T,
            'upper'],

    'c6': [1, np.array([[0., 0., 1.],
                        [1., 0., 0.],
                        [0.5, np.sqrt(3.)/2., 0.]]).T,
           np.atleast_2d(np.array([0, 1, 2])).T,
           'both'],

    'c3h': [1, np.array([[0., 0., 1.],
                         [1., 0., 0.],
                         [-0.5, np.sqrt(3.)/2., 0.]]).T,
            np.atleast_2d(np.array([0, 1, 2])).T,
            'upper'],

    'c6h': [1, np.array([[0., 0., 1.],
                         [1., 0., 0.],
                         [0.5, np.sqrt(3.)/2., 0.]]).T,
            np.atleast_2d(np.array([0, 1, 2])).T,
            'upper'],

    'd6': [1, np.array([[0., 0., 1.],
                        [1., 0., 0.],
                        [0.5, np.sqrt(3.)/2., 0.]]).T,
           np.atleast_2d(np.array([0, 1, 2])).T,
           'both'],

    # supergroup 7 in our convention
    'c6v': [1, np.array([[0., 0., 1.],
                         [1., 0., 0.],
                         [np.sqrt(3.)/2., 0.5, 0.]]).T,
            np.atleast_2d(np.array([0, 1, 2])).T,
            'both'],

    # supergroup 8 in our convention
    'd3h': [1, np.array([[0., 0., 1.],
                         [1., 0., 0.],
                         [np.sqrt(3.)/2., 0.5, 0.]]).T,
            np.atleast_2d(np.array([0, 1, 2])).T,
            'upper'],

    # supergroup 9 in our convention
    'd6h': [1, np.array([[0., 0., 1.],
                         [1., 0., 0.],
                         [np.sqrt(3.)/2., 0.5, 0.]]).T,
            np.atleast_2d(np.array([0, 1, 2])).T,
            'upper'],

    # Special case with 2 triangles
    't': [2, np.array([[0., 0., 1.],
                       [1./np.sqrt(3.), -1./np.sqrt(3.), 1./np.sqrt(3.)],
                       [1./np.sqrt(3.), 1./np.sqrt(3.), 1./np.sqrt(3.)],
                       [1., 0., 0.]]).T,
          np.array([[0, 1, 2], [1, 3, 2]]).T,
          'upper'],

    # Special case with two triangles
    'th': [2, np.array([[0., 0., 1.],
                        [1./np.sqrt(2.), 0., 1./np.sqrt(2.)],
                        [1./np.sqrt(3.), 1./np.sqrt(3.), 1./np.sqrt(3.)],
                        [0., 1./np.sqrt(2.), 1./np.sqrt(2.)]]).T,
           np.array([[0, 1, 2], [0, 2, 3]]).T,
           'upper'],

    # Special case with two triangles, same as Th
    'o': [2, np.array([[0., 0., 1.],
                       [1./np.sqrt(2.), 0., 1./np.sqrt(2.)],
                       [1./np.sqrt(3.), 1./np.sqrt(3.), 1./np.sqrt(3.)],
                       [0., 1./np.sqrt(2.), 1./np.sqrt(2.)]]).T,
          np.array([[0, 1, 2], [0, 2, 3]]).T,
          'upper'],

    # supergroup 10 in our convention
    'td': [1, np.array([[0., 0., 1.],
                        [1./np.sqrt(3.), -1./np.sqrt(3.), 1./np.sqrt(3.)],
                        [1./np.sqrt(3.), 1./np.sqrt(3.), 1./np.sqrt(3.)]]).T,
           np.atleast_2d(np.array([0, 1, 2])).T,
           'upper'],

    # supergroup 11 in our convention
    'oh': [1, np.array([[0., 0., 1.],
                        [1./np.sqrt(2.), 0., 1./np.sqrt(2.)],
                        [1./np.sqrt(3.), 1./np.sqrt(3.), 1./np.sqrt(3.)]]).T,
           np.atleast_2d(np.array([0, 1, 2])).T,
           'upper']
}


class sector:
    '''
    @AUTHOR  Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
    @DATE    10/28/2020 SS 1.0 original
    @DETAIL  this class is used to store spherical patch for a given point group.
             the class also has methods to compute the color of a direction by
             computing the hue, saturation and lightness values in [0,1]. these
             values can be converted to rgb for display with the well known 
             conversion formula.


    All the methodology and equations have been taken from the paper:
    Orientations – perfectly colored, G. Nolze and R. Hielscher, J. Appl. Cryst. (2016). 49, 1786–1802

    '''

    def __init__(self, pgsym, lauesym, supergroupsym, supergrouplauesym):
        '''
        AUTHOR: Saransh Singh, Lawrence Livermore national Lab, saransh1@llnl.gov
        DATE:   11/11/2020 SS 1.0 original
                11/12/2020 SS 1.1 added lauesym as additional input parameter
                11/23/2020 SS 1.2 added supergroupsym as additional parameter

        @detail: this routine initializes the data needed for reducing a 
        direction to the stereographic fundamental zone (standard
        stereographic triangle) for the pointgroup/lauegroup symmetry
        of the crystal.
        '''
        self.vertices = {}
        self.ntriangle = {}
        self.barycenter = {}
        self.connectivity = {}
        self.hemisphere = {}

        data = pg2vertex[pgsym]
        self.ntriangle['pg'] = data[0]
        self.vertices['pg'] = data[1]
        self.connectivity['pg'] = data[2]
        self.hemisphere['pg'] = data[3]

        data = pg2vertex[lauesym]
        self.ntriangle['laue'] = data[0]
        self.vertices['laue'] = data[1]
        self.connectivity['laue'] = data[2]
        self.hemisphere['laue'] = data[3]

        data = pg2vertex[supergroupsym]
        self.ntriangle['super'] = data[0]
        self.vertices['super'] = data[1]
        self.connectivity['super'] = data[2]
        self.hemisphere['super'] = data[3]

        data = pg2vertex[supergrouplauesym]
        self.ntriangle['superlaue'] = data[0]
        self.vertices['superlaue'] = data[1]
        self.connectivity['superlaue'] = data[2]
        self.hemisphere['superlaue'] = data[3]

        if(self.ntriangle['pg'] != 0):
            # compute the barycenter or the centroid of point group
            b = np.mean(self.vertices['pg'], axis=1)
            b = b/np.linalg.norm(b)
            self.barycenter['pg'] = b
        else:
            self.barycenter['pg'] = np.array([0., 0., 1.])

        if(self.ntriangle['laue'] != 0):
            # compute the barycenter or the centroid of the laue group triangle
            b = np.mean(self.vertices['laue'], axis=1)
            b = b/np.linalg.norm(b)
            self.barycenter['laue'] = b
        else:
            self.barycenter['laue'] = np.array([0., 0., 1.])

        if(self.ntriangle['super'] != 0):
            # compute the barycenter or the centroid of the supergroup group triangle
            b = np.mean(self.vertices['super'], axis=1)
            b = b/np.linalg.norm(b)
            self.barycenter['super'] = b
        else:
            self.barycenter['super'] = np.array([0., 0., 1.])

        if(self.ntriangle['superlaue'] != 0):
            # compute the barycenter or the centroid of the supergroup group triangle
            b = np.mean(self.vertices['superlaue'], axis=1)
            b = b/np.linalg.norm(b)
            self.barycenter['superlaue'] = b
        else:
            self.barycenter['superlaue'] = np.array([0., 0., 1.])

    def check_norm(self, dir3):
        '''
        @AUTHOR  Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
        @DATE    10/29/2020 SS 1.0 original
        @PARAM   dir3 direction in fundamental sector. size is nx3
        @DETAIL  this function is used to make sure the directions are all unit norm

        '''
        n = np.linalg.norm(dir3, axis=1)
        mask = n > eps
        n = n[mask]
        dir3[mask, :] = dir3[mask, :]/np.tile(n, [3, 1]).T

    def check_hemisphere(self):

        zcoord = np.array([self.vx[2], self.vy[2], self.vz[2]])
        if(np.logical_or(np.all(zcoord >= 0.),  np.all(zcoord <= 0.))):
            pass
        else:
            raise RuntimeError("sphere_sector: the vertices of the stereographic \
                triangle are not in the same hemisphere")

    def inside_sphericalpatch(self, vertex, dir3):
        '''
            @AUTHOR  Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
            @DATE    12/09/2020 SS 1.0 original
            @PARAM   vertex vertices of the spherical triangle
                     dir3 normalized direction vectors
                     switch which group to check. acceptable arguments are 'pg', 'laue', 'supergroup'
                     and 'supergroup_laue'
            @DETAIL  check if direction is inside a spherical patch
                     the logic used as follows:
                     if determinant of [x A B], [x B C] and [x C A] are 
                     all same sign, then the sphere is inside the traingle
                     formed by A, B and C
                     returns a mask with inside as True and outside as False
        '''
        nn = vertex.shape[1]

        mask = []
        d = np.zeros([nn, ])

        for x in dir3:
            x2 = np.atleast_2d(x).T

            for ii in range(nn):
                A = np.atleast_2d(vertex[:, np.mod(ii, nn)]).T
                B = np.atleast_2d(vertex[:, np.mod(ii+1, nn)]).T
                d[ii] = np.linalg.det(np.hstack((x2, A, B)))

                '''
                catching cases very close to FZ boundary when the
                determinant can be very small positive or negative
                number
                '''
                if(np.abs(d[ii]) < eps):
                    d[ii] = 0.

            ss = np.unique(np.sign(d))
            if(np.all(ss >= 0.)):
                mask.append(True)
            else:
                mask.append(False)

        mask = np.array(mask)
        return mask

    def fillet_region(self, dir3, switch):
        '''
        @AUTHOR  Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
        @DATE    12/09/2020 SS 1.0 original
        @PARAM   vertex vertices of the spherical triangle
                 dir3 normalized direction vectors
                 switch which group to check. acceptable arguments are 'super', 'superlaue'
                'super' ----> point group coloring
                'superlaue' ----> laue group coloring

        this function will check which fillet the point lies in
        returns 0 if its barycenter, vertex 0 and vertex 1
        returns 1 if its barycenter, vertex 1 and vertex 2
        returns 2 if its barycenter, vertex 2 and vertex 3

        it is implicitly assumed that the point lies inside the 
        spherical triangle. behavior is unknown if it is not the
        case

        first make the vertices of the three fillets
        '''

        vertex = np.copy(self.vertices[switch])
        fregion = -np.ones([dir3.shape[0], ]).astype(np.int32)

        bar_cen = self.barycenter[switch]

        # if barycenter matches one of the vertices, then remove that vertex
        mask = np.all(bar_cen == vertex.T,axis=1)
        vertex = vertex[:,~mask]

        nn = vertex.shape[1]
        f = np.zeros([nn, 3, 3])

        for i in range(nn):
            idx1 = np.mod(i, nn)
            idx2 = np.mod(i+1, nn)
            A = np.atleast_2d(vertex[:, idx1]).T
            B = np.atleast_2d(vertex[:, idx2]).T
            f[i, :, :] = np.hstack((np.atleast_2d(bar_cen).T, A, B))

        for i in range(nn):
            inside = np.logical_and(self.inside_sphericalpatch(
                                    np.squeeze(f[i, :, :]), dir3),
                                    fregion == -1)
            fregion[inside] = i

        return fregion

    def point_on_boundary(self, dir3, switch):
        '''
            @AUTHOR  Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
            @DATE    12/09/2020 SS 1.0 original
            @PARAM   dir3 direction in fundamental sector. size is nx3
                     switch color using pg or laue group
            @DETAIL  this function figures out the equivalent point on the boundary 
            given that the point is inside the spherical triangle
        '''
        vertex = self.vertices[switch]
        fregion = self.fillet_region(dir3, switch)
        dir3_b = np.zeros(dir3.shape)
        nn = vertex.shape[1]

        for i in range(fregion.shape[0]):

            f = fregion[i]
            d = dir3[i, :]

            A = vertex[:, np.mod(f, nn)]
            B = vertex[:, np.mod(f+1, nn)]

            nhat = np.cross(B, A)
            nhat = nhat/np.linalg.norm(nhat)

            lam = np.dot(nhat, d)
            deldir = lam*nhat

            dp = d - deldir
            ndp = np.linalg.norm(dp)
            if(ndp > 0.):
                dp = dp/ndp
            else:
                dp = d

            dir3_b[i, :] = dp

        return dir3_b, fregion

    def calculate_rho(self, dir3, switch):
        '''
        @AUTHOR  Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
        @DATE    12/09/2020 SS 1.0 original
        @PARAM   dir3 direction in fundamental sector. size is nx3
                 switch color using pg or laue group
        @DETAIL  this function is used to calculate the azimuthal angle
                 of a bunch of directions. it is assumed all directions 
                 are indide the SST
        '''
        vertex = self.vertices[switch]
        bar_cen = self.barycenter[switch]
        rho = np.zeros([dir3.shape[0], ])

        # handle triclinic and monoclinic cases a little differently
        if(np.all(bar_cen == np.array([0., 0., 1.]))):
            rho = np.arctan2(dir3[:,1], dir3[:,0]) + np.pi

        else:
            dir3_b, fregion = self.point_on_boundary(dir3, switch)
            nn = vertex.shape[1]

            for i in range(fregion.shape[0]):
                f = fregion[i]
                d = dir3_b[i, :]

                A = vertex[:, np.mod(f, nn)]
                B = vertex[:, np.mod(f+1, nn)]

                # angle between A and B
                omega = np.dot(A, B)
                if(np.abs(omega) > 1.):
                    omega = np.sign(omega)

                # angle between point and A
                omegap = np.dot(A, d)
                if(np.abs(omegap) > 1.):
                    omegap = np.sign(omega)

                omega = np.arccos(omega)
                omegap = np.arccos(omegap)

                if(omegap != 0.):
                    rho[i] = 2*np.pi*omegap/omega/nn + f*2.*np.pi/nn
                else:
                    rho[i] = f*2.*np.pi/nn

        return rho

    def calculate_theta(self, dir3, switch):
        '''
        @AUTHOR  Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
        @DATE    12/09/2020 SS 1.0 original
        @PARAM   dir3 direction in fundamental sector. size is nx3
                 switch color using pg or laue group
        @DETAIL  this function is used to calculate the polar angle 
        of direction vectors. it is assumed that the direction vector
        lies inside the SST
        '''
        vertex = self.vertices[switch]
        dir3_b, fregion = self.point_on_boundary(dir3, switch)
        theta = np.zeros([dir3.shape[0], ])

        bar_cen = self.barycenter[switch]

        # handle triclinic and monoclinic cases a little differently
        if(np.all(bar_cen == np.array([0., 0., 1.]))):
            dp = np.dot(np.array([0., 0., 1.]), dir3.T)
            # catch some cases where dot product is 1+/-epsilon
            mask = np.abs(dp) > 1.
            dp[mask] = np.sign(dp[mask])
            theta = np.arccos(dp)

        else:
        # first calculate the angle the point makes with the barycenter
            omega = np.dot(bar_cen, dir3.T)
            mask = np.abs(omega) > 1.0
            omega[mask] = np.sign(omega[mask])

            # calculate the angle the boundary point makes with the barycenter
            omegap = np.dot(bar_cen, dir3_b.T)
            mask = np.abs(omegap) > 1.0
            omegap[mask] = np.sign(omegap[mask])

            omega = np.arccos(omega)
            omegap = np.arccos(omegap)

            zmask = omegap == 0.

            theta[~zmask] = np.pi*omega[~zmask]/omegap[~zmask]/2.0
            theta[zmask] = 0.0
        return theta

    def hue_speed(self, rho):
        '''
            @AUTHOR  Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
            @DATE    12/09/2020 SS 1.0 original
            @PARAM   rho azimuthal angle
            @DETAIL  calculate the hue speed for a vector of azimuthal angles
                    this is utilized in increasing the area of the red, blue and 
                    green regions
        '''
        rho = rho - np.pi
        v = 0.5 + np.exp(-(4./7.)*rho**2) + \
            np.exp(-(4./7.)*(rho - 2.*np.pi/3.)**2) + \
            np.exp(-(4./7.)*(rho + 2.*np.pi/3.)**2)

        return v

    def hue_speed_normalization_factor(self):
        pass

    def calc_hue(self, dir3, switch):
        '''
        @AUTHOR  Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
        @DATE    10/28/2020 SS 1.0 original
                 11/23/2020 SS 1.2 added mask argument which tell the directions
                 for which the supergroup reductions dont match the point or laue
                 group reductions. mask has size dir3.shape[0]
                 12/09/2020 SS 2.0 completely rewrite the way in which computation
                 is performed. all the routines have been rewritten

        @PARAM   dir3 direction in fundamental sector. behavior is undefined if
                 direction is outside the fundamental sector
                 switch color by laue group or point group? acceptable values are 'super', 'superlaue'
                'super' ----> point group coloring
                'superlaue' ----> laue group coloring
        @DETAIL  calculate hue. this is aggigned based on the azimuthal angle in the
        stereographic triangle. the laueswitch controls which fundamental sector to use.

        '''
        rho = self.calculate_rho(dir3, switch)
        r = np.linspace(0., 2*np.pi, 1000)
        v = self.hue_speed(r)
        cons = np.trapz(v, r)

        h = np.zeros(rho.shape)

        for i in range(rho.shape[0]):
            r = np.linspace(0., rho[i], 1000)
            v = self.hue_speed(r)
            h[i] = np.trapz(v, r)/cons

        return h

    def calc_saturation(self, l):
        '''
        @AUTHOR  Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
        @DATE    10/28/2020 SS 1.0 original
                 11/12/2020 SS 1.1 added laueswitch as argument
                 11/16/2020 SS 2.0 more balanced saturation from JAC papaer
                 the factor lambda_s is hard coded to 1/4. Since lighness is needed,
                 the function now uses L as an input instead of dir3 to avoid recomputing L
                 11/23/2020 SS 1.2 added mask argument which tell the directions
                 for which the supergroup reductions dont match the point or laue
                 group reductions. mask has size dir3.shape[0]

        @PARAM   L lightness values
        @DETAIL  calculate saturation. this is always set to 1.

        '''
        s = 1. - 2.*0.25*np.abs(l - 0.5)
        return s

    def calc_lightness(self, dir3, mask, switch):
        '''
        @AUTHOR  Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
        @DATE    10/28/2020 SS 1.0 original
                 11/12/2020 SS 1.1 added laueswitch as argument
                 11/16/2020 SS 1.2 more balanced lightness key from the JAC paper
                 the factor lambda is hard coded to 1/4
                 11/23/2020 SS 1.2 added mask argument which tell the directions
                 for which the supergroup reductions dont match the point or laue
                 group reductions. mask has size dir3.shape[0]
                 12/09/2020 SS 2.0 completely rewrite the way in which computation
                 is performed. all the routines have been rewritten

        @PARAM   dir3 direction in fundamental sector. behavior is undefined if
                 laueswitch get colors in laue group or pg
                 mask boolean mask for points where the symmetry reductions donot match the
                 supergroup symmetry reductions
        @DETAIL  this function is used to calculate the hsl color for direction vectors
                in dir3. if laueswitch is True, then color is assigned based on laue group

        '''
        theta = np.pi - self.calculate_theta(dir3, switch)
        f1 = theta/np.pi
        f2 = np.sin(theta/2.)**2
        l = 0.35*f1 + 0.65*f2
        l[~mask] = 1. - l[~mask]

        return l

    def get_color(self, dir3, mask, switch):
        '''
        @AUTHOR  Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
        @DATE    10/28/2020 SS 1.0 original
                 11/12/2020 SS 1.1 added laueswitch as argument
                 11/23/2020 SS 1.2 added mask argument which tell the directions
                 for which the supergroup reductions dont match the point or laue
                 group reductions. mask has size dir3.shape[0]
                
        @PARAM   dir3 direction in fundamental sector. behavior is undefined if
                 mask True if symmetry reduction of dir3 using point group does not
                 match the super group and False otherwise
                 switch get colors in laue group or pg
        @DETAIL  this function is used to calculate the hsl color for direction vectors
                in dir3. if laueswitch is True, then color is assigned based on laue group

        '''
        hsl = np.zeros(dir3.shape)

        hsl[:, 0] = self.calc_hue(dir3, switch)
        hsl[:, 2] = self.calc_lightness(dir3, mask, switch)
        hsl[:, 1] = self.calc_saturation(hsl[:, 2])

        return hsl


# hexrd/nf_config/__init__.py

import yaml

from . import nf_root
from . import utils


"""
Note that we need to use the open() builtin in what was formerly the "open()"
function. So we define the _open(), and then redefine open() to the new
function.
"""
open_file = open


def open(file_name=None):
    """
    Reads configuration settings from a yaml file.

    Returns a list of configuration objects, one for each document section in
    the file.
    """
    if file_name is None:
        print('no filename')
        return [nf_root.NFRootConfig({})]

    with open_file(file_name) as f:
        print(f)
        res = []
        for cfg in yaml.load_all(f, Loader=yaml.SafeLoader):
            try:
                # take the previous config section and update with values
                # from the current one
                res.append(utils.merge_dicts(res[0], cfg))
            except IndexError:
                # this is the first config section
                res.append(cfg)
        return [nf_root.NFRootConfig(i) for i in res]


def save(config_list, file_name):
    res = [cfg._cfg for cfg in config_list]

    with file(file_name, 'w') as f:
        if len(res) > 1:
            yaml.dump_all(res, f)
        else:
            yaml.dump(res, f)


# hexrd/nf_config/config.py

"""Base Config class"""

import logging
import os

from .utils import null

logger = logging.getLogger('hexrd.config')

class Config(object):

    _dirty = False

    def __init__(self, cfg):
        self._cfg = cfg

    @property
    def dirty(self):
        return self._dirty

    def get(self, key, default=null):
        args = key.split(':')
        args, item = args[:-1], args[-1]
        temp = self._cfg
        for arg in args:
            temp = temp.get(arg, {})
            # intermediate block may be None:
            temp = {} if temp is None else temp
        try:
            res = temp[item]
        except KeyError:
            if default is not null:
                logger.info(
                    '%s not specified, defaulting to %s', key, default
                    )
                res = temp.get(item, default)
            else:
                raise RuntimeError(
                    '%s must be specified in configuration file' % key
                    )
        return res

    def set(self, key, val):
        args = key.split(':')
        args, item = args[:-1], args[-1]
        temp = self._cfg
        for arg in args:
            temp = temp.get(arg, {})
            # intermediate block may be None:
            temp = {} if temp is None else temp
        if temp.get(item, null) != val:
            temp[item] = val
            self._dirty = True

    def dump(self, filename):
        import yaml

        with open(filename, 'w') as f:
            yaml.dump(self._cfg, f)
        self._dirty = False

    @staticmethod
    def check_filename(fname, wdir):
        """Check whether filename is valid relative to working directory

        fname - the name of the file

        Returns the absolute path of the filename if the file exists

        If fname is an absolute path, use that; otherwise take it as a path
        relative to the working directory.
"""
        temp = fname
        if not os.path.isabs(fname):
            temp = os.path.join(wdir, temp)
        if os.path.exists(temp):
            return temp
        raise IOError(
            'file: "%s" not found' % temp
            )


# hexrd/nf_config/experiment.py

import logging
import os
import numpy as np

from .config import Config


logger = logging.getLogger('hexrd.config')


class ExperimentConfig(Config):

    @property
    def beam_energy(self):
        return self._cfg.get('experiment:beam_energy')

    @property
    def mat_name(self):
        return self._cfg.get('experiment:mat_name')

    @property
    def ome_range(self):
        ome_start = self._cfg.get('experiment:ome_start', 0)
        ome_end = self._cfg.get('experiment:ome_end', 359.75)
        ome_range = [(ome_start, ome_end)]
        return ome_range

    @property
    def max_tth(self):
        return self._cfg.get('experiment:max_tth', None)

    @property
    def comp_thresh(self):
        key = 'experiment:comp_thresh'
        temp = self._cfg.get(key, None)
        if temp is None:
            return temp
        elif np.logical_and(temp <= 1.0, temp > 0.0):
            return temp
        else:
            raise RuntimeError('comp_thresh must be None or a number between 0 and 1')
            
    @property
    def chi2_thresh(self):
        key = 'experiment:chi2_thresh'
        temp = self._cfg.get(key, None)
        if temp is None:
            return temp
        elif np.logical_and(temp <= 1.0, temp > 0.0):
            return temp
        else:
            raise RuntimeError('chi2_thresh must be None or a number between 0 and 1')


    @property
    def misorientation(self):
        key = self._cfg.get('experiment:misorientation:use_misorientation')
        if key is True:
            parms = dict(misorientation_bnd='experiment:bnd',
            misorientation_spacing='experiment:spacing')
            return parms
        else:
            parms = dict(misorientation_bnd=None,
            misorientation_spacing=None) #won't look at spacing if bnd is 0
            return parms


# hexrd/nf_config/images.py

import logging
import os

from .config import Config


logger = logging.getLogger('hexrd.config')

processing_methods = {
    'gaussian': dict(sigma=2.0, size=3.0),
    'dilations_only': dict(num_erosions=2, num_dilations=3)}

class ImagesConfig(Config):

    @property
    def data_folder(self):
        return self._cfg.get('images:data_folder')

    @property
    def stem(self):
        return self._cfg.get('images:stem')

    @property
    def num_digits(self):
        return self._cfg.get('images:num_digits')

    @property
    def img_start(self):
        return self._cfg.get('images:img_start')

    @property
    def nframes(self):
        return self._cfg.get('images:nframes', 1440)

    @property
    def processing(self):
        return ProcessingConfig(self._cfg)


class ProcessingConfig(Config):

    @property
    def num_for_dark(self):
        return self._cfg.get('images:processing:num_for_dark', 200)

    @property
    def img_threshold(self):
        return self._cfg.get('images:processing:img_threshold', 0)

    @property
    def ome_dilation_iter(self):
        return self._cfg.get('images:processing:ome_dilation_iter', 1)

    @property
    def method(self):
        key = 'images:processing:method'
        try:
            temp = self._cfg.get(key)
            assert len(temp) == 1., \
                "method must have exactly one key"
            if isinstance(temp, dict):
                method_spec = next(iter(list(temp.keys())))
                if method_spec.lower() not in processing_methods:
                    raise RuntimeError(
                        'invalid image processing method "%s"'
                        % method_spec
                    )
                else:
                    return temp
        except:
            raise RuntimeError(
                "Undefined image processing method"
            )


# hexrd/nf_config/input_files.py

import logging
import os

from .config import Config


logger = logging.getLogger('hexrd.config')


class InputConfig(Config):

    @property
    def det_file(self):
        temp = self._cfg.get('input_files:det_file')
        if isinstance(temp, (int, float)):
            temp = [temp, temp]
        return temp

    @property
    def mat_file(self):
        temp = self._cfg.get('input_files:mat_file')
        if isinstance(temp, (int, float)):
            temp = [temp, temp]
        return temp

    @property
    def grain_out_file(self):
        temp = self._cfg.get('input_files:grain_out_file')
        if isinstance(temp, (int, float)):
            temp = [temp, temp]
        return temp


# hexrd/nf_config/multiprocessing.py

import logging
import os
import psutil
import multiprocessing as mp

from .config import Config


logger = logging.getLogger('hexrd.config')


class MultiprocessingConfig(Config):

    @property
    def num_cpus(self):
        # determine number of processes to run in parallel
        multiproc = self.get('multiprocessing:num_cpus', default=-1)
        ncpus = mp.cpu_count()
        if multiproc == 'all':
            res = ncpus
        elif multiproc == 'half':
            temp = ncpus // 2
            res = temp if temp else 1
        elif isinstance(multiproc, int):
            if multiproc >= 0:
                if multiproc > ncpus:
                    logger.warning(
                        'Resuested %s processes, %d available',
                        multiproc, ncpus
                        )
                    res = ncpus
                else:
                    res = multiproc if multiproc else 1
            else:
                temp = ncpus + multiproc
                if temp < 1:
                    logger.warning(
                        'Cannot use less than 1 process, requested %d of %d',
                        temp, ncpus
                        )
                    res = 1
                else:
                    res = temp
        else:
            temp = ncpus - 1
            logger.warning(
                "Invalid value %s for multiprocessing",
                multiproc
                )
            res = temp
        return res

    @num_cpus.setter
    def num_cpus(self, val):
        if val in ('half', 'all', -1):
            self.set('multiprocessing:num_cpus', val)
        elif (val >= 0 and val <= mp.cpu_count):
            self.set('multiprocessing', int(val))
        else:
            raise RuntimeError(
                '"num_cpus": must be 1:%d, got %s'
                % (mp.cpu_count(), val)
                )

    @property
    def check(self):
        return self._cfg.get('multiprocessing:check', None)

    @property
    def limit(self):
        return self._cfg.get('multiprocessing:limit', None)

    @property
    def chunk_size(self):
        return self._cfg.get('multiprocessing:chunk_size', 500)

    @property
    def max_RAM(self):
        key = self._cfg.get('multiprocessing:RAM_set')
        if key is True:
            return self._cfg.get('multiprocessing:max_RAM')
        else:
            return psutil.virtual_memory().available


# hexrd/nf_config/nf_root.py

import os
import logging
import multiprocessing as mp

from hexrd.constants import shared_ims_key
from hexrd import imageseries

from .config import Config
from .multiprocessing import MultiprocessingConfig
from .reconstruction import ReconstructionConfig
from .images import ImagesConfig
from .experiment import ExperimentConfig
from .input_files import InputConfig
from .tomography import TomographyConfig

logger = logging.getLogger('hexrd.config')


class NFRootConfig(Config):

    @property
    def main_dir(self):
        return self._cfg.get('main_dir')

    @property
    def output_dir(self):
        return self._cfg.get('output_dir', self.main_dir)

    @property
    def analysis_name(self):
        return self._cfg.get('analysis_name', 'NF_')

    @property
    def output_plot_check(self):
        return self._cfg.get('output_plot_check')

    @property
    def multiprocessing(self):
        return MultiprocessingConfig(self)

    @property
    def tomography(self):
        return TomographyConfig(self)

    @property
    def reconstruction(self):
        return ReconstructionConfig(self)

    @property
    def images(self):
        return ImagesConfig(self)

    @property
    def experiment(self):
        return ExperimentConfig(self)

    @property
    def input_files(self):
        return InputConfig(self)


# hexrd/nf_config/reconstruction.py

import logging
import os

from .config import Config


logger = logging.getLogger('hexrd.config')


class ReconstructionConfig(Config):

    @property
    def tomography(self):
        key = self._cfg.get('reconstruction:tomography:use_mask')
        if key is True:
            parms = dict(use_mask = True,
            mask_data_file='reconstruction:tomography:mask_data_file',
            mask_vert_offset='reconstruction:tomography:mask_vert_offset')
            return parms
        else:
            parms = dict(use_mask = False,
            mask_data_file=None,
            mask_vert_offset=None)
            return

    @property
    def cross_sectional_dim(self):
        return self._cfg.get('reconstruction:cross_sectional_dim')

    @property
    def voxel_spacing(self):
        return self._cfg.get('reconstruction:voxel_spacing')

    @property
    def v_bnds(self):
        return self._cfg.get('reconstruction:v_bnds',[0.0,0.0])

    @property
    def beam_stop_y_cen(self):
        return self._cfg.get('reconstruction:beam_stop_y_cen')

    @property
    def beam_stop_width(self):
        return self._cfg.get('reconstruction:beam_stop_parms')


# hexrd/nf_config/utils.py

import copy


def merge_dicts(a, b):
    """Return a merged dict, updating values from `a` with values from `b`."""
    # need to pass a deep copy of a at the top level only:
    return _merge_dicts(copy.deepcopy(a), b)


def _merge_dicts(a, b):
    for k, v in b.items():
        if isinstance(v, dict):
            if a.get(k) is None:
                # happens in cases where all but section head is commented
                a[k] = {}
            _merge_dicts(a[k], v)
        else:
            if v is None and a.get(k) is not None:
                # entire section commented out. Inherit, don't overwrite
                pass
            else:
                a[k] = v
    return a


class Null():
    pass


null = Null()


# hexrd/sampleOrientations/__init__.py

from hexrd.sampleOrientations.sampleRFZ import sampleRFZ

# hexrd/sampleOrientations/conversions.py

import numpy as np
from hexrd import constants
from hexrd.utils.decorators import numba_njit_if_available

if constants.USE_NUMBA:
    from numba import prange
else:
    prange = range

ap_2 = constants.cuA_2
sc = constants.sc

@numba_njit_if_available(cache=True, nogil=True)
def getPyramid(xyz):
    x = xyz[0]
    y = xyz[1]
    z = xyz[2]
    if (np.abs(x) <= z) and (np.abs(y) <= z):
        return 1

    elif (np.abs(x) <= -z) and (np.abs(y) <= -z):
        return 2

    elif (np.abs(z) <= x) and (np.abs(y) <= x):
        return 3

    elif (np.abs(z) <= -x) and (np.abs(y) <= -x):
        return 4

    elif (np.abs(x) <= y) and (np.abs(z) <= y):
        return 5

    elif (np.abs(x) <= -y) and (np.abs(z) <= -y):
        return 6


@numba_njit_if_available(cache=True, nogil=True)
def cu2ro(cu):
    ho = cu2ho(cu)
    return ho2ro(ho)

@numba_njit_if_available(cache=True, nogil=True)
def cu2ho(cu):
    ma = np.max(np.abs(cu))
    assert ma <= ap_2, "point outside cubochoric grid"
    pyd = getPyramid(cu)

    if pyd == 1 or pyd == 2:
        sXYZ = cu
    elif pyd == 3 or pyd == 4:
        sXYZ = np.array([cu[1], cu[2], cu[0]])
    elif pyd == 5 or pyd == 6:
        sXYZ = np.array([cu[2], cu[0], cu[1]])

    xyz = sXYZ * sc
    ma = np.max(np.abs(xyz))
    if ma < 1E-8:
        return np.array([0.0, 0.0, 0.0])

    ma2 = np.max(np.abs(xyz[0:2]))
    if ma2 < 1E-8:
        LamXYZ = np.array([0.0, 0.0, constants.pref * xyz[2]])

    else:
        if np.abs(xyz[1]) <= np.abs(xyz[0]):
            q = (np.pi/12.0) * xyz[1]/xyz[0]
            c = np.cos(q)
            s = np.sin(q)
            q = constants.prek * xyz[0] / np.sqrt(np.sqrt(2.0)-c)
            T1 = (np.sqrt(2.0) * c - 1.0) * q
            T2 = np.sqrt(2.0) * s * q
        else:
            q = (np.pi/12.0) * xyz[0]/xyz[1]
            c = np.cos(q)
            s = np.sin(q)
            q = constants.prek * xyz[1] / np.sqrt(np.sqrt(2.0)-c)
            T1 = np.sqrt(2.0) * s * q
            T2 = (np.sqrt(2.0) * c - 1.0) * q

        c = T1**2 + T2**2
        s = np.pi * c / (24.0 * xyz[2]**2)
        c = np.sqrt(np.pi) * c / np.sqrt(24.0) / xyz[2]
        q = np.sqrt( 1.0 - s )
        LamXYZ = np.array([T1 * q, T2 * q, constants.pref * xyz[2] - c])

    if pyd == 1 or pyd == 2:
        return LamXYZ
    elif pyd == 3 or pyd == 4:
        return np.array([LamXYZ[2], LamXYZ[0], LamXYZ[1]])
    elif pyd == 5 or pyd == 6:
        return np.array([LamXYZ[1], LamXYZ[2], LamXYZ[0]])

@numba_njit_if_available(cache=True, nogil=True)
def ho2ro(ho):
    ax = ho2ax(ho)
    return ax2ro(ax)

@numba_njit_if_available(cache=True, nogil=True)
def ho2ax(ho):
    hmag = np.linalg.norm(ho[:])**2
    if hmag < 1E-8:
        return np.array([0.0, 0.0, 1.0, 0.0])
    hm = hmag
    hn = ho/np.sqrt(hmag)
    s = constants.tfit[0] + constants.tfit[1] * hmag
    for ii in range(2, 21):
        hm = hm*hmag
        s = s + constants.tfit[ii] * hm
    s = 2.0 * np.arccos(s)
    diff = np.abs(s - np.pi)
    if diff < 1E-8:
        return np.array([hn[0], hn[1], hn[2], np.pi])
    else:
        return np.array([hn[0], hn[1], hn[2], s])

@numba_njit_if_available(cache=True, nogil=True)
def ax2ro(ax):
    if np.abs(ax[3]) < 1E-8:
        return np.array([0.0, 0.0, 1.0, 0.0])

    elif np.abs(ax[3] - np.pi) < 1E-8:
        return np.array([ax[0], ax[1], ax[2], np.inf])

    else:
        return np.array([ax[0], ax[1], ax[2], np.tan(ax[3]*0.5)])

@numba_njit_if_available(cache=True, nogil=True)
def ro2qu(ro):
    ax = ro2ax(ro)
    return ax2qu(ax)

@numba_njit_if_available(cache=True, nogil=True)
def ro2ax(ro):
    if np.abs(ro[3]) < 1E-8:
        return np.array([0.0, 0.0, 1.0, 0.0])
    elif ro[3] == np.inf:
        return np.array([ro[0], ro[1], ro[2], np.pi])
    else:
        ang = 2.0*np.arctan(ro[3])
        mag = 1.0/np.linalg.norm(ro[0:3])
        return np.array([ro[0]*mag, ro[1]*mag, ro[2]*mag, ang])


@numba_njit_if_available(cache=True, nogil=True)
def ax2qu(ro):
    if np.abs(ro[3]) < 1E-8:
        return np.array([1.0, 0.0, 0.0, 0.0])
    else:
        c = np.cos(ro[3]*0.5)
        s = np.sin(ro[3]*0.5)
        return np.array([c, ro[0]*s, ro[1]*s, ro[2]*s])


# hexrd/sampleOrientations/rfz.py

import numpy as np
from hexrd.constants import FZtypeArray, FZorderArray
from hexrd.utils.decorators import numba_njit_if_available
from hexrd import constants

if constants.USE_NUMBA:
    from numba import prange
else:
    prange = range

@numba_njit_if_available(cache=True, nogil=True)
def getFZtypeandOrder(pgnum):
    FZtype = FZtypeArray[pgnum-1]
    FZorder = FZorderArray[pgnum-1]
    return np.array([FZtype, FZorder])

@numba_njit_if_available(cache=True, nogil=True)
def insideCyclicFZ(ro, FZorder):
    res = False
    if ro[3] == np.inf:
        if FZorder == 2:
            if ro[1] == 0.0:
                res = True
        else:
            if ro[2] == 0.0:
                res = True
    else:
        if FZorder == 2:
            res = np.abs(ro[1]*ro[3]) <= constants.BP[FZorder-1]
        else:
            res = np.abs(ro[2]*ro[3]) <= constants.BP[FZorder-1]

    return res

@numba_njit_if_available(cache=True, nogil=True)
def insideDihedralFZ(ro, FZorder):
    if np.abs(ro[3]) >= np.sqrt(3.0):
        return False
    else:
        rod = ro[0:3] * ro[3]

    c1 = (np.abs(rod[2]) <= constants.BP[FZorder-1])

    if c1:
        if   FZorder == 2:
            c2 = np.logical_and(np.abs(rod[0]) <= 1.0, 
                                np.abs(rod[1]) <= 1.0)

        elif FZorder == 3:
            srt = np.sqrt(3.0)/2.0
            c2 = np.abs(srt*rod[0] + 0.5*rod[1]) <= 1.0
            c3 = np.abs(srt*rod[0] - 0.5*rod[1]) <= 1.0
            c4 = np.abs(rod[1]) <= 1.0
            return np.logical_and(c2, 
                   np.logical_and(c3, c4))

        elif FZorder == 4:
            r22 = 1.0/np.sqrt(2.0)
            c2 = np.logical_and(np.abs(rod[0]) <= 1.0,
                                np.abs(rod[1]) <= 1.0)
            c3 = np.logical_and(r22*np.abs(rod[0]+rod[1]) <= 1.0,
                                r22*np.abs(rod[0]-rod[1]) <= 1.0)
            return np.logical_and(c2, c3)

        elif FZorder == 6:
            srt = np.sqrt(3.0)/2.0
            c2 = np.abs(0.5*rod[0] + srt*rod[1]) < 1.0
            c2 = np.logical_and(c2,
                 np.abs(srt*rod[0] + 0.5*rod[1]) < 1.0)
            c2 = np.logical_and(c2,
                 np.abs(0.5*rod[0] - srt*rod[1]) < 1.0)
            c2 = np.logical_and(c2,
                 np.abs(srt*rod[0] - 0.5*rod[1]) < 1.0)
            c2 = np.logical_and(c2, 
                 np.logical_and(np.abs(rod[0]) <= 1.0,
                                np.abs(rod[1]) <= 1.0))
            return c2
    else:
        return False

@numba_njit_if_available(cache=True, nogil=True)
def insideCubicFZ(ro, kwrd):
    rod = np.abs(ro[0:3] * ro[3])

    if kwrd == 'oct':
        c1 = (np.max(rod) - constants.BP[3]) <= 1E-8
    else:
        c1 = True

    c2 = (rod[0]+rod[1]+rod[2] - 1.0) <= 1E-8
    res = np.logical_and(c1, c2)
    return res

@numba_njit_if_available(cache=True, nogil=True)
def insideFZ(ro, pgnum):
    res = getFZtypeandOrder(pgnum)
    FZtype = res[0] 
    FZorder = res[1]

    if FZtype == 0:
        return True
    elif FZtype == 1:
        return insideCyclicFZ(ro, FZorder)
    elif FZtype == 2:
        if ro[3] == np.inf:
            return False
        else:
            return insideDihedralFZ(ro, FZorder)
    elif FZtype == 3:
        if ro[3] == np.inf:
            return False
        else:
            return insideCubicFZ(ro, 'tet')
    elif FZtype == 4:
        if ro[3] == np.inf:
            return False
        else:
            return insideCubicFZ(ro, 'oct')



# hexrd/sampleOrientations/sampleRFZ.py

import numpy as np
from hexrd.utils.decorators import numba_njit_if_available
from hexrd.sampleOrientations.conversions import cu2ro, ro2qu
from hexrd.sampleOrientations.rfz import insideFZ
from hexrd import constants

if constants.USE_NUMBA:
    from numba import prange
else:
    prange = range

@numba_njit_if_available(cache=True, nogil=True, parallel=True)
def _sample(pgnum,
            N,
            delta,
            shift,
            ap_2):

    N3 = (2*N+1)**3
    res = np.full((N3, 4), np.nan, dtype=np.float64)

    for ii in prange(-N, N+1):
        xx = (ii + shift) * delta
        for jj in prange(-N, N+1):
            yy = (jj + shift) * delta
            for kk in prange(-N, N+1):
                zz = (kk + shift) * delta
                cu = np.array([xx, yy, zz])
                ma = np.max(np.abs(cu))

                if ma <= ap_2:
                    ro = cu2ro(cu)
                    if insideFZ(ro, pgnum):
                        idx = (ii+N)*(2*N+1)**2 + (jj+N)*(2*N+1) + (kk+N)
                        res[idx,:] = ro2qu(ro)

    return res

class sampleRFZ:

    """This class samples the rodrigues fundamental zone
    of a point group uniformly in the density sense and
    returns a list of orientations which are spaced,
    on an average, to the user specified angular spacing
    @author Saransh Singh, LLNL
    @date   11/28/2022 1.0 original

     Note
    ----
    Details can be found in:
    S. Singh and M. De Graef, "Orientation sampling for 
    dictionary-based diffraction pattern indexing methods". 
    MSMSE 24, 085013 (2016)

    Attributes
    ----------
    pgnum: int
        point group number of crystal
    average_angular_spacing" float
        average angular spacing of sampling (in degrees)

    """

    def __init__(self,
                 pgnum,
                 sampling_type='default',
                 average_angular_spacing=3.0):
        """__init__ method of the sampleRFZ class.


        Parameters
        ----------
        pgnum : int
            point group number
        sampling_type : str
            default sampling with origin
            special which is shifted to mid-points
            of each cubochoric cell
        average_angular_spacing : float
            average angular spacing in degrees

        """

        self.ap_2 = constants.cuA_2
        self.pgnum = pgnum
        self.sampling_type = sampling_type
        self.avg_ang_spacing = average_angular_spacing

    def sampling_N(self):
        """Get the number of sampling steps in the cubochoric
        cube based on the average angular spacing requested.
        Uses eqns. 9 and 10 of S. Singh and M. De Graef MSMSE 24,
        085013 (2016)

        """
        if self.sampling_type.lower() == 'default':
            return np.rint(131.97049 / (self.avg_ang_spacing - 0.03732)).astype(np.int32)
        elif self.sampling_type.lower() == 'special':
            return np.rint(125.70471 / (self.avg_ang_spacing - 0.07127)).astype(np.int32)

    def sample(self):
        res = _sample(self.pgnum,
                                    self.cubN,
                                    self.delta,
                                    self.shift,
                                    self.ap_2)
        mask = ~np.isnan(res[:,0])
        res = res[mask,:]
        self.orientations = res
    def sample_if_possible(self):
        required_attributes = ('pgnum', 'avg_ang_spacing', 'sampling_type')
        if not all(hasattr(self, x) for x in required_attributes):
            return

        self.sample()

    @property
    def pgnum(self):
        return self._pgnum

    @pgnum.setter
    def pgnum(self, pgn):
        self._pgnum = pgn
        self.sample_if_possible()

    @property
    def sampling_type(self):
        return self._sampling_type

    @sampling_type.setter
    def sampling_type(self, stype):
        self._sampling_type = stype
        self.sample_if_possible()

    @property
    def avg_ang_spacing(self):
        return self._avg_ang_spacing


    @avg_ang_spacing.setter
    def avg_ang_spacing(self, ang):
        self._avg_ang_spacing = ang
        self.sample_if_possible()

    @property
    def cubN(self):
        return self.sampling_N()

    @property
    def shift(self):
        if self.sampling_type == 'default':
            return 0.0
        elif self.sampling_type == 'special':
            return 0.5

    @property
    def delta(self):
        return self.ap_2 / self.cubN
    




# hexrd/transforms/__init__.py

# ============================================================
# Copyright (c) 2012, Lawrence Livermore National Security, LLC. 
# Produced at the Lawrence Livermore National Laboratory. 
# Written by Joel Bernier <bernier2@llnl.gov> and others. 
# LLNL-CODE-529294. 
# All rights reserved.
# 
# This file is part of HEXRD. For details on dowloading the source,
# see the file COPYING.
# 
# Please also see the file LICENSE.
# 
# This program is free software; you can redistribute it and/or modify it under the
# terms of the GNU Lesser General Public License (as published by the Free Software
# Foundation) version 2.1 dated February 1999.
# 
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY OF MERCHANTABILITY 
# or FITNESS FOR A PARTICULAR PURPOSE. See the terms and conditions of the 
# GNU General Public License for more details.
# 
# You should have received a copy of the GNU Lesser General Public
# License along with this program (see file LICENSE); if not, write to
# the Free Software Foundation, Inc., 59 Temple Place, Suite 330,
# Boston, MA 02111-1307 USA or visit <http://www.gnu.org/licenses/>.
# ============================================================
"""Tools or X-ray diffraction analysis
"""
from . import xf
from . import xfcapi








# hexrd/transforms/xf.py

#! /usr/bin/env python
# ============================================================
# Copyright (c) 2012, Lawrence Livermore National Security, LLC.
# Produced at the Lawrence Livermore National Laboratory.
# Written by Joel Bernier <bernier2@llnl.gov> and others.
# LLNL-CODE-529294.
# All rights reserved.
#
# This file is part of HEXRD. For details on dowloading the source,
# see the file COPYING.
#
# Please also see the file LICENSE.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License (as published by the Free
# Software Foundation) version 2.1 dated February 1999.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY OF MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the terms and conditions of the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this program (see file LICENSE); if not, write to
# the Free Software Foundation, Inc., 59 Temple Place, Suite 330,
# Boston, MA 02111-1307 USA or visit <http://www.gnu.org/licenses/>.
# ============================================================

import sys
import numpy as np
# np.seterr(invalid='ignore')

import scipy.sparse as sparse

from hexrd import matrixutil as mutil

from hexrd.constants import USE_NUMBA
if USE_NUMBA:
    import numba

# =============================================================================
# Module Data
# =============================================================================

epsf = np.finfo(float).eps      # ~2.2e-16
ten_epsf = 10 * epsf                # ~2.2e-15
sqrt_epsf = np.sqrt(epsf)            # ~1.5e-8

periodDict = {'degrees': 360.0, 'radians': 2*np.pi}
angularUnits = 'radians'        # module-level angle units
d2r = np.pi/180.0

# basis vectors
I3 = np.eye(3)                                 # (3, 3) identity
Xl = np.array([[1., 0., 0.]], order='C').T     # X in the lab frame
Yl = np.array([[0., 1., 0.]], order='C').T     # Y in the lab frame
Zl = np.array([[0., 0., 1.]], order='C').T     # Z in the lab frame

zeroVec = np.zeros(3, order='C')

# reference beam direction and eta=0 ref in LAB FRAME for standard geometry
bVec_ref = -Zl
eta_ref = Xl

# reference stretch
vInv_ref = np.array([[1., 1., 1., 0., 0., 0.]], order='C').T


# =============================================================================
# Functions
# =============================================================================


def makeGVector(hkl, bMat):
    """
    take a CRYSTAL RELATIVE B matrix onto a list of hkls to output unit
    reciprocal lattice vectors (a.k.a. lattice plane normals)

    Required Arguments:
    hkls -- (3, n) ndarray of n hstacked reciprocal lattice vector component
            triplets
    bMat -- (3, 3) ndarray representing the matirix taking reciprocal lattice
            vectors to the crystal reference frame

    Output:
    gVecs -- (3, n) ndarray of n unit reciprocal lattice vectors
             (a.k.a. lattice plane normals)

    To Do:
    * might benefit from some assert statements to catch improperly shaped
      input.
    """
    assert hkl.shape[0] == 3, 'hkl input must be (3, n)'
    return unitVector(np.dot(bMat, hkl))


if USE_NUMBA:
    @numba.njit(nogil=True, cache=True)
    def _anglesToGVecHelper(angs, out):
        # gVec_e = np.vstack([[np.cos(0.5*angs[:, 0]) * np.cos(angs[:, 1])],
        #                     [np.cos(0.5*angs[:, 0]) * np.sin(angs[:, 1])],
        #                     [np.sin(0.5*angs[:, 0])]])
        n = angs.shape[0]
        for i in range(n):
            ca0 = np.cos(0.5*angs[i, 0])
            sa0 = np.sin(0.5*angs[i, 0])
            ca1 = np.cos(angs[i, 1])
            sa1 = np.sin(angs[i, 1])
            out[i, 0] = ca0 * ca1
            out[i, 1] = ca0 * sa1
            out[i, 2] = sa0

    def anglesToGVec(angs, bHat_l, eHat_l, rMat_s=I3, rMat_c=I3):
        """
        from 'eta' frame out to lab
        (with handy kwargs to go to crystal or sample)
        """
        rMat_e = makeEtaFrameRotMat(bHat_l, eHat_l)
        gVec_e = np.empty((angs.shape[0], 3))
        _anglesToGVecHelper(angs, gVec_e)
        mat = np.dot(rMat_c.T, np.dot(rMat_s.T, rMat_e))
        return np.dot(mat, gVec_e.T)
else:
    def anglesToGVec(angs, bHat_l, eHat_l, rMat_s=I3, rMat_c=I3):
        """
        from 'eta' frame out to lab
        (with handy kwargs to go to crystal or sample)
        """
        rMat_e = makeEtaFrameRotMat(bHat_l, eHat_l)
        gVec_e = np.vstack([[np.cos(0.5*angs[:, 0]) * np.cos(angs[:, 1])],
                            [np.cos(0.5*angs[:, 0]) * np.sin(angs[:, 1])],
                            [np.sin(0.5*angs[:, 0])]])
        mat = np.dot(rMat_c.T, np.dot(rMat_s.T, rMat_e))
        return np.dot(mat, gVec_e)


def gvecToDetectorXY(gVec_c,
                     rMat_d, rMat_s, rMat_c,
                     tVec_d, tVec_s, tVec_c,
                     beamVec=bVec_ref):
    """
    Takes a list of unit reciprocal lattice vectors in crystal frame to the
    specified detector-relative frame.

    The following conditions must be satisfied:

    1) the reciprocal lattice vector must be able to satisfy a bragg condition
    2) the associated diffracted beam must intersect the detector plane

    Parameters
    ----------
    gVec_c : numpy.ndarray
        (3, n) array of n reciprocal lattice vectors in the CRYSTAL FRAME.
    rMat_d : numpy.ndarray
        (3, 3) array, the COB taking DETECTOR FRAME components to LAB FRAME.
    rMat_s : numpy.ndarray
        (3, 3) array, the COB taking SAMPLE FRAME components to LAB FRAME.
    rMat_c : numpy.ndarray
        (3, 3) array, the COB taking CRYSTAL FRAME components to SAMPLE FRAME.
    tVec_d : numpy.ndarray
        (3, 1) array, the translation vector connecting LAB to DETECTOR.
    tVec_s : numpy.ndarray
        (3, 1) array, the translation vector connecting LAB to SAMPLE.
    tVec_c : numpy.ndarray
        (3, 1) array, the translation vector connecting SAMPLE to CRYSTAL.
    beamVec : numpy.ndarray, optional
        (3, 1) array, the vector pointing to the X-ray source in the LAB FRAME.
        The default is bVec_ref.

    Returns
    -------
    numpy.ndarray
        (3, m)darray containing the intersections of m <= n diffracted beams
        associated with gVecs.

    """
    ztol = epsf

    nVec_l = np.dot(rMat_d, Zl)                 # detector plane normal
    bHat_l = unitVector(beamVec.reshape(3, 1))  # make sure beam vector is unit
    P0_l = tVec_s + np.dot(rMat_s, tVec_c)      # origin of CRYSTAL FRAME
    P3_l = tVec_d                               # origin of DETECTOR FRAME

    # form unit reciprocal lattice vectors in lab frame (w/o translation)
    gVec_l = np.dot(rMat_s, np.dot(rMat_c, unitVector(gVec_c)))

    # dot with beam vector (upstream direction)
    bDot = np.dot(-bHat_l.T, gVec_l).squeeze()

    # see who can diffract; initialize output array with NaNs
    canDiffract = np.atleast_1d(
        np.logical_and(bDot >= ztol, bDot <= 1. - ztol)
    )
    npts = sum(canDiffract)
    retval = np.nan * np.ones_like(gVec_l)
    if np.any(canDiffract):
        # subset of admissable reciprocal lattice vectors
        adm_gVec_l = gVec_l[:, canDiffract].reshape(3, npts)

        # initialize diffracted beam vector array
        dVec_l = np.empty((3, npts))
        for ipt in range(npts):
            dVec_l[:, ipt] = np.dot(
                makeBinaryRotMat(adm_gVec_l[:, ipt]), -bHat_l
            ).squeeze()
            pass

        # ###############################################################
        # displacement vector calculation

        # first check for non-instersections
        denom = np.dot(nVec_l.T, dVec_l).flatten()
        dzero = abs(denom) < ztol
        denom[dzero] = 1.           # mitigate divide-by-zero
        cantIntersect = denom > 0.  # index to dVec_l that can't hit det

        # displacement scaling (along dVec_l)
        u = np.dot(nVec_l.T, P3_l - P0_l).flatten() / denom

        # filter out non-intersections, fill with NaNs
        u[np.logical_or(dzero, cantIntersect)] = np.nan

        # diffracted beam points IN DETECTOR FRAME
        P2_l = P0_l + np.tile(u, (3, 1)) * dVec_l
        P2_d = np.dot(rMat_d.T, P2_l - tVec_d)

        # put feasible transformed gVecs into return array
        retval[:, canDiffract] = P2_d
        pass
    return retval[:2, :].T


def detectorXYToGvec(xy_det,
                     rMat_d, rMat_s,
                     tVec_d, tVec_s, tVec_c,
                     distortion=None,
                     beamVec=bVec_ref, etaVec=eta_ref,
                     output_ref=False):
    """
    Takes a list cartesian (x, y) pairs in the detector coordinates and
    calculates the associated reciprocal lattice (G) vectors and
    (bragg angle, azimuth) pairs with respect to the specified beam and
    azimth (eta) reference directions.

    Parameters
    ----------
    xy_det : numpy.ndarray
        DESCRIPTION.
    rMat_d : numpy.ndarray
        (3, 3) array, the COB taking DETECTOR FRAME components to LAB FRAME.
    rMat_s : numpy.ndarray
        (3, 3) array, the COB taking SAMPLE FRAME components to LAB FRAME.
    tVec_d : numpy.ndarray
        (3, 1) array, the translation vector connecting LAB to DETECTOR.
    tVec_s : numpy.ndarray
        (3, 1) array, the translation vector connecting LAB to SAMPLE.
    tVec_c : numpy.ndarray
        (3, 1) array, the translation vector connecting SAMPLE to CRYSTAL.
    distortion : class, optional
        the distortion operator, if applicable. The default is None.
    beamVec : numpy.ndarray, optional
        (3, 1) array, the vector pointing to the X-ray source in the LAB FRAME.
        The default is bVec_ref.
    etaVec : numpy.ndarray, optional
        (3, 1) array, the vector defining eta=0 in the LAB FRAME.
        The default is eta_ref.
    output_ref : bool, optional
        if true, will output calculated angles in the LAB FRAME.
        The default is False.

    Returns
    -------
    tth_eta_ref : tuple, optional
        if output_ref is True, (n, 2) ndarray containing the (tTh, eta) pairs
        in the LAB REFERENCE FRAME associated with each (x, y)
    tth_eta : tuple
        (n, 2) ndarray containing the (tTh, eta) pairs associated with
        each (x, y).
    gVec_l : numpy.ndarray
        (3, n) ndarray containing the associated G vector directions in the
        LAB FRAME associated with gVecs
    """

    npts = len(xy_det)  # number of input (x, y) pairs

    # force unit vectors for beam vector and ref eta
    bHat_l = unitVector(beamVec.reshape(3, 1))
    eHat_l = unitVector(etaVec.reshape(3, 1))

    if distortion is not None:
        xy_det = distortion.apply(xy_det)

    # form in-plane vectors for detector points list in DETECTOR FRAME
    P2_d = np.hstack([np.atleast_2d(xy_det), np.zeros((npts, 1))]).T

    # in LAB FRAME
    P2_l = np.dot(rMat_d, P2_d) + tVec_d
    P0_l = tVec_s + np.dot(rMat_s, tVec_c)   # origin of CRYSTAL FRAME

    # diffraction unit vector components in LAB FRAME
    dHat_l = unitVector(P2_l - P0_l)

    # ###############################################################
    # generate output

    # DEBUGGING
    assert abs(np.dot(bHat_l.T, eHat_l)) < 1. - sqrt_epsf, \
        "eta ref and beam cannot be parallel!"

    rMat_e = makeEtaFrameRotMat(bHat_l, eHat_l)
    dHat_e = np.dot(rMat_e.T, dHat_l)

    tTh = np.arccos(np.dot(bHat_l.T, dHat_l)).flatten()
    eta = np.arctan2(dHat_e[1, :], dHat_e[0, :]).flatten()

    # angles for reference frame
    dHat_ref_l = unitVector(P2_l)
    dHat_ref_e = np.dot(rMat_e.T, dHat_ref_l)
    tTh_ref = np.arccos(np.dot(bHat_l.T, unitVector(P2_l))).flatten()
    eta_ref = np.arctan2(dHat_ref_e[1, :], dHat_ref_e[0, :]).flatten()

    # get G-vectors by rotating d by 90-theta about b x d
    # (numpy 'cross' works on row vectors)
    n_g = unitVector(np.cross(bHat_l.T, dHat_l.T).T)

    gVec_l = rotate_vecs_about_axis(0.5 * (np.pi - tTh), n_g, dHat_l)

    if output_ref:
        return (tTh_ref, eta_ref), (tTh, eta), gVec_l
    return (tTh, eta), gVec_l


def oscillAnglesOfHKLs(hkls, chi, rMat_c, bMat, wavelength,
                       vInv=vInv_ref, beamVec=bVec_ref, etaVec=eta_ref):
    """


    Parameters
    ----------
    hkls : numpy.ndarray
        (3, n_) array of reciprocal lattice vector components.
    chi : scalar
        the inclination angle of the SAMPLE FRAME about the LAB X.
    rMat_c : numpy.ndarray
        (3, 3) array, the COB taking CRYSTAL FRAME components to SAMPLE FRAME.
    bMat : numpy.ndarray
        (3, 3) array, the COB matrix taking RECIPROCAL FRAME components to the
        CRYSTAL FRAME.
    wavelength : scalar
        the X-ray wavelength in Ångstroms.
    vInv : numpy.ndarray, optional
        (6, 1) array representing the components of the inverse stretch tensor
        in the SAMPLE FRAME. The default is vInv_ref.
    beamVec : numpy.ndarray, optional
        (3, 1) array, the vector pointing to the X-ray source in the LAB FRAME.
        The default is bVec_ref.
    etaVec : numpy.ndarray, optional
        (3, 1) array, the vector defining eta=0 in the LAB FRAME.
        The default is eta_ref.

    Returns
    -------
    retval : tuple
        Two (3, n) numpy.ndarrays representing the pair of feasible
        (tTh, eta, ome) solutions for the n input hkls.

    Notes
    -----
    The reciprocal lattice vector, G, will satisfy the the Bragg condition
    when:

        b.T * G / ||G|| = -sin(theta)

    where b is the incident beam direction (k_i) and theta is the Bragg
    angle consistent with G and the specified wavelength. The components of
    G in the lab frame in this case are obtained using the crystal
    orientation, Rc, and the single-parameter oscillation matrix, Rs(ome):

        Rs(ome) * Rc * G / ||G||

    The equation above can be rearranged to yeild an expression of the form:

        a*sin(ome) + b*cos(ome) = c

    which is solved using the relation:

        a*sin(x) + b*cos(x) = sqrt(a**2 + b**2) * sin(x + alpha)

        --> sin(x + alpha) = c / sqrt(a**2 + b**2)

    where:

        alpha = arctan2(b, a)

     The solutions are:

                /
                |       arcsin(c / sqrt(a**2 + b**2)) - alpha
            x = <
                |  pi - arcsin(c / sqrt(a**2 + b**2)) - alpha
                \\

    There is a double root in the case the reflection is tangent to the
    Debye-Scherrer cone (c**2 = a**2 + b**2), and no solution if the
    Laue condition cannot be satisfied (filled with NaNs in the results
    array here)
    """
    # reciprocal lattice vectors in CRYSTAL frame
    gVec_c = np.dot(bMat, hkls)
    # stretch tensor in SAMPLE frame
    vMat_s = mutil.vecMVToSymm(vInv)
    # reciprocal lattice vectors in SAMPLE frame
    gVec_s = np.dot(vMat_s, np.dot(rMat_c, gVec_c))
    # unit reciprocal lattice vectors in SAMPLE frame
    gHat_s = unitVector(gVec_s)
    # unit reciprocal lattice vectors in CRYSTAL frame
    gHat_c = np.dot(rMat_c.T, gHat_s)
    # make sure beam direction is a unit vector
    bHat_l = unitVector(beamVec.reshape(3, 1))
    # make sure eta = 0 direction is a unit vector
    eHat_l = unitVector(etaVec.reshape(3, 1))

    # sin of the Bragg angle assoc. with wavelength
    sintht = 0.5 * wavelength * columnNorm(gVec_s)

    # sin and cos of the oscillation axis tilt
    cchi = np.cos(chi)
    schi = np.sin(chi)

    # coefficients for harmonic equation
    a = gHat_s[2, :]*bHat_l[0] \
        + schi*gHat_s[0, :]*bHat_l[1] - cchi*gHat_s[0, :]*bHat_l[2]
    b = gHat_s[0, :]*bHat_l[0] \
        - schi*gHat_s[2, :]*bHat_l[1] + cchi*gHat_s[2, :]*bHat_l[2]
    c = -sintht - cchi*gHat_s[1, :]*bHat_l[1] - schi*gHat_s[1, :]*bHat_l[2]

    # should all be 1-d: a = a.flatten(); b = b.flatten(); c = c.flatten()

    # form solution
    abMag = np.sqrt(a*a + b*b)
    assert np.all(abMag > 0), "Beam vector specification is infealible!"
    phaseAng = np.arctan2(b, a)
    rhs = c / abMag
    rhs[abs(rhs) > 1.] = np.nan
    rhsAng = np.arcsin(rhs)  # will give NaN for abs(rhs) >  1. + 0.5*epsf

    # write ome angle output arrays (NaNs persist here)
    ome0 = rhsAng - phaseAng
    ome1 = np.pi - rhsAng - phaseAng

    goodOnes_s = -np.isnan(ome0)

    # DEBUGGING
    assert np.all(np.isnan(ome0) == np.isnan(ome1)), \
        "infeasible hkls do not match for ome0, ome1!"

    # do etas -- ONLY COMPUTE IN CASE CONSISTENT REFERENCE COORDINATES
    if abs(np.dot(bHat_l.T, eHat_l)) < 1. - sqrt_epsf and np.any(goodOnes_s):
        eta0 = np.nan * np.ones_like(ome0)
        eta1 = np.nan * np.ones_like(ome1)

        # make eta basis COB with beam antiparallel with Z
        rMat_e = makeEtaFrameRotMat(bHat_l, eHat_l)

        goodOnes = np.tile(goodOnes_s, (1, 2)).flatten()

        numGood_s = sum(goodOnes_s)
        numGood = 2 * numGood_s
        tmp_eta = np.empty(numGood)
        tmp_gvec = np.tile(gHat_c, (1, 2))[:, goodOnes]
        allome = np.hstack([ome0, ome1])

        for i in range(numGood):
            rMat_s = makeOscillRotMat([chi, allome[goodOnes][i]])
            gVec_e = np.dot(
                rMat_e.T, np.dot(
                    rMat_s, np.dot(
                        rMat_c, tmp_gvec[:, i].reshape(3, 1)
                    )
                )
            )
            tmp_eta[i] = np.arctan2(gVec_e[1], gVec_e[0])
            pass
        eta0[goodOnes_s] = tmp_eta[:numGood_s]
        eta1[goodOnes_s] = tmp_eta[numGood_s:]

        # make assoc tTh array
        tTh = 2.*np.arcsin(sintht).flatten()
        tTh0 = tTh
        tTh0[-goodOnes_s] = np.nan
        retval = (np.vstack([tTh0.flatten(), eta0.flatten(), ome0.flatten()]),
                  np.vstack([tTh0.flatten(), eta1.flatten(), ome1.flatten()]),)
    else:
        retval = (ome0.flatten(), ome1.flatten())
        pass
    return retval


def polarRebin(thisFrame,
               npdiv=2, mmPerPixel=(0.2, 0.2), convertToTTh=False,
               rMat_d=I3, tVec_d=np.r_[0., 0., -1000.],
               beamVec=bVec_ref, etaVec=eta_ref,
               rhoRange=np.r_[20, 200], numRho=1000,
               etaRange=(d2r*np.r_[-5, 355]),
               numEta=36, verbose=True, log=None):
    """
    Performs polar rebinning of an input image.

    Parameters
    ----------
    thisFrame : array_like
        An (n, m) array representing an image.
    npdiv : int, optional
        The number of pixel subdivision. The default is 2.
    mmPerPixel : array_like, optional
        A 2-element sequence with the pixel pitch in mm for the row and column
        dimensions, respectively. The default is (0.2, 0.2).
    convertToTTh : bool, optional
        If true, output abcissa is in angular (two-theta) coordinates.
        The default is False.
    rMat_d : numpy.ndarray, optional
        (3, 3) array, the COB taking DETECTOR FRAME components to LAB FRAME.
        The default is I3.
    tVec_d : numpy.ndarray, optional
        (3, 1) array, the translation vector connecting LAB to DETECTOR.
        The default is np.r_[0., 0., -1000.].
    beamVec : TYPE, optional
        DESCRIPTION. The default is bVec_ref.
    etaVec : TYPE, optional
        DESCRIPTION. The default is eta_ref.
    rhoRange : array_like, optional
        the min/max limits of the rebinning region in pixels.
        The default is np.r_[20, 200].
    numRho : int, optional
        the number of bins to use in the radial dimension. The default is 1000.
    etaRange : array_like, optional
        A 2-element sequence describing the min/max azimuthal angles in
        radians.  The default is (-0.08726646259971647, 6.19591884457987).
    numEta : int, optional
        The number of azimuthal bins. The default is 36.
    verbose : bool, optional
        DESCRIPTION. The default is True.
    log : TYPE, optional
        DESCRIPTION. The default is None.

    Raises
    ------
    RuntimeError
        DESCRIPTION.

    Returns
    -------
    polImg : TYPE
        DESCRIPTION.

    """
    """
    Caking algorithm

    INPUTS

    thisFrame
    npdiv=2, pixel subdivision (n x n) to determine bin membership
    rhoRange=[100, 1000] - radial range in pixels
    numRho=1200 - number of radial bins
    etaRange=np.pi*np.r_[-5, 355]/180. -- range of eta
    numEta=36 - number of eta subdivisions
    ROI=None - region of interest (four vector)
    corrected=False - uses 2-theta instead of rho
    verbose=True,

    """

    startEta = etaRange[0]
    stopEta = etaRange[1]

    startRho = rhoRange[0]
    stopRho = rhoRange[1]

    subPixArea = 1/float(npdiv)**2  # areal rescaling for subpixel intensities

    # MASTER COORDINATES
    #  - in pixel indices, UPPER LEFT PIXEL is [0, 0] --> (row, col)
    #  - in fractional pixels, UPPER LEFT CORNER is [-0.5, -0.5] --> (row, col)
    #  - in cartesian frame, the LOWER LEFT CORNER is [0, 0] --> (col, row)
    x = thisFrame[0, :, :].flatten()
    y = thisFrame[1, :, :].flatten()
    roiData = thisFrame[2, :, :].flatten()

    # need rhos (or tThs) and etas)
    if convertToTTh:
        dAngs = detectorXYToGvec(np.vstack([x, y]).T,
                                 rMat_d, I3,
                                 tVec_d, zeroVec, zeroVec,
                                 beamVec=beamVec, etaVec=etaVec)
        rho = dAngs[0][0]       # this is tTh now
        eta = dAngs[0][1]
    else:
        # in here, we are vanilla cartesian
        rho = np.sqrt(x*x + y*y)
        eta = np.arctan2(y, x)
    eta = mapAngle(eta, [startEta, 2*np.pi + startEta], units='radians')

    # MAKE POLAR BIN CENTER ARRAY
    deltaEta = (stopEta - startEta) / float(numEta)
    deltaRho = (stopRho - startRho) / float(numRho)

    rowEta = startEta + deltaEta * (np.arange(numEta) + 0.5)
    colRho = startRho + deltaRho * (np.arange(numRho) + 0.5)

    # initialize output dictionary
    polImg = {}
    polImg['radius'] = colRho
    polImg['azimuth'] = rowEta
    polImg['intensity'] = np.zeros((numEta, numRho))
    polImg['deltaRho'] = deltaRho

    if verbose:
        msg = "INFO: Masking pixels\n"
        if log:
            log.write(msg)
        else:
            print(msg)
            pass

    rhoI = startRho - 10*deltaRho
    rhoF = stopRho + 10*deltaRho
    inAnnulus = np.where((rho >= rhoI) & (rho <= rhoF))[0]
    for i in range(numEta):
        if verbose:
            msg = "INFO: Processing sector %d of %d\n" % (i+1, numEta)
            if log:
                log.write(msg)
            else:
                print(msg)
                pass

        # import pdb;pdb.set_trace()
        etaI1 = rowEta[i] - 10.5*deltaEta
        etaF1 = rowEta[i] + 10.5*deltaEta

        tmpEta = eta[inAnnulus]
        inSector = np.where((tmpEta >= etaI1) & (tmpEta <= etaF1))[0]

        nptsIn = len(inSector)

        tmpX = x[inAnnulus[inSector]]
        tmpY = y[inAnnulus[inSector]]
        tmpI = roiData[inAnnulus[inSector]]

        # subdivide pixels
        #   - note that these are in fractional pixel coordinates (centered)
        #   - must convert to working units (see 'self.pixelPitchUnits')
        subCrds = (np.arange(npdiv) + 0.5) / npdiv

        intX, intY = np.meshgrid(subCrds, subCrds)

        intX = np.tile(intX.flatten(), (nptsIn, 1)).T.flatten()
        intY = np.tile(intY.flatten(), (nptsIn, 1)).T.flatten()

        # expand coords using pixel subdivision
        tmpX = np.tile(tmpX, (npdiv**2, 1)).flatten() \
            + (intX - 0.5)*mmPerPixel[0]
        tmpY = np.tile(tmpY, (npdiv**2, 1)).flatten() \
            + (intY - 0.5)*mmPerPixel[1]
        tmpI = np.tile(tmpI, (npdiv**2, 1)).flatten() / subPixArea

        if convertToTTh:
            dAngs = detectorXYToGvec(np.vstack([tmpX, tmpY]).T,
                                     rMat_d, I3,
                                     tVec_d, zeroVec, zeroVec,
                                     beamVec=beamVec, etaVec=etaVec)
            tmpRho = dAngs[0][0]       # this is tTh now
            tmpEta = dAngs[0][1]
        else:
            tmpRho = np.sqrt(tmpX*tmpX + tmpY*tmpY)
            tmpEta = np.arctan2(tmpY, tmpX)
        tmpEta = mapAngle(
            tmpEta, [startEta, 2*np.pi + startEta],
            units='radians'
        )

        etaI2 = rowEta[i] - 0.5*deltaEta
        etaF2 = rowEta[i] + 0.5*deltaEta

        inSector2 = ((tmpRho >= startRho) & (tmpRho <= stopRho)) \
            & ((tmpEta >= etaI2) & (tmpEta <= etaF2))

        tmpRho = tmpRho[inSector2]
        tmpI = tmpI[inSector2]

        binId = np.floor((tmpRho - startRho) / deltaRho)
        nSubpixelsIn = len(binId)

        if (nSubpixelsIn > 0):
            tmpI = sparse.csc_matrix(
                (tmpI, (binId, np.arange(nSubpixelsIn))),
                shape=(numRho, nSubpixelsIn)
            )
            binId = sparse.csc_matrix(
                (np.ones(nSubpixelsIn), (binId, np.arange(nSubpixelsIn))),
                shape=(numRho, nSubpixelsIn)
            )

            # Normalized contribution to the ith sector's radial bins
            binIdSum = np.asarray(binId.sum(1)).flatten()
            whereNZ = np.asarray(
                np.not_equal(polImg['intensity'][i, :], binIdSum)
            )
            polImg['intensity'][i, whereNZ] = \
                np.asarray(tmpI.sum(1))[whereNZ].flatten() / binIdSum[whereNZ]
    return polImg


# =============================================================================
# Utility Functions
# =============================================================================


def _z_project(x, y):
    return np.cos(x) * np.sin(y) - np.sin(x) * np.cos(y)


def arccosSafe(temp):
    """
    Protect against numbers slightly larger than 1 in magnitude
    due to round-off
    """
    temp = np.atleast_1d(temp)
    if np.any(abs(temp) > 1.00001):
        print("attempt to take arccos of %s" % temp, file=sys.stderr)
        raise RuntimeError("unrecoverable error")

    gte1 = temp >= 1.
    lte1 = temp <= -1.

    temp[gte1] = 1
    temp[lte1] = -1

    ang = np.arccos(temp)

    return ang


def angularDifference(angList0, angList1, units=angularUnits):
    """
    Calculate the proper (acute) angular difference in the context
    of a branch cut.

    Parameters
    ----------
    angList0 : array_like
        An (n, ) array on input angles in radians.
    angList1 : array_like
        An (n, ) array on input angles in radians.
    units : str, optional
        Either 'degrees' or 'radians'. The default is 'radians'.

    Returns
    -------
    numpy.ndarray
        The (n, ) array of acute angular differences bwetween the inputs
        on the circle [-pi, pi].

    """
    period = periodDict[units]  # module-level
    # take difference as arrays
    diffAngles = np.atleast_1d(angList0) - np.atleast_1d(angList1)
    return abs(np.remainder(diffAngles + 0.5*period, period) - 0.5*period)


def mapAngle(ang, *args, **kwargs):
    """Utility routine to map an angle into a specified period

    actual function is mapAngle(ang[, range], units=angularUnits).  range is
    optional and defaults to the appropriate angle for the unit centered on 0.
    """
    units = angularUnits
    period = periodDict[units]

    kwargKeys = list(kwargs.keys())
    for iArg in range(len(kwargKeys)):
        if kwargKeys[iArg] == 'units':
            units = kwargs[kwargKeys[iArg]]
        else:
            raise RuntimeError("Unknown keyword argument: "
                               + str(kwargKeys[iArg]))

    try:
        period = periodDict[units.lower()]
    except(KeyError):
        raise RuntimeError("unknown angular units: "
                           + str(kwargs[kwargKeys[iArg]]))

    ang = np.asarray(ang, dtype=float)

    # if we have a specified angular range, use that
    if len(args) > 0:
        angRange = np.asarray(args[0], dtype=float)

        # divide of multiples of period
        ang = ang - np.int(ang / period) * period

        lb = angRange.min()
        ub = angRange.max()

        if abs(ub - lb) != period:
            raise RuntimeError('range is incomplete!')

        lbi = ang < lb
        while lbi.sum() > 0:
            ang[lbi] = ang[lbi] + period
            lbi = ang < lb
            pass
        ubi = ang > ub
        while ubi.sum() > 0:
            ang[ubi] = ang[ubi] - period
            ubi = ang > ub
            pass
        retval = ang
    else:
        retval = np.mod(ang + 0.5*period, period) - 0.5*period
    return retval


def reg_grid_indices(edges, points_1d):
    """
    get indices in a 1-d regular grid.

    edges are just that:

    point:            x (2.5)
                      |
    edges:   |1    |2    |3    |4    |5
             -------------------------
    indices: |  0  |  1  |  2  |  3  |
             -------------------------

    above the deltas are + and the index for the point is 1

    point:                  x (2.5)
                            |
    edges:   |5    |4    |3    |2    |1
             -------------------------
    indices: |  0  |  1  |  2  |  3  |
             -------------------------

    here the deltas are - and the index for the point is 2

    * can handle grids with +/- deltas
    * be careful when using with a cyclical angular array!  edges and points
      must be mapped to the same branch cut, and
      abs(edges[0] - edges[-1]) = 2*pi
    """
    ztol = 1e-12

    assert len(edges) >= 2, "must have at least 2 edges"

    points_1d = np.r_[points_1d].flatten()
    delta = float(edges[1] - edges[0])

    on_last_rhs = points_1d >= edges[-1] - ztol
    points_1d[on_last_rhs] = points_1d[on_last_rhs] - ztol

    if delta > 0:
        on_last_rhs = points_1d >= edges[-1] - ztol
        points_1d[on_last_rhs] = points_1d[on_last_rhs] - ztol
        idx = np.floor((points_1d - edges[0]) / delta)
    elif delta < 0:
        on_last_rhs = points_1d <= edges[-1] + ztol
        points_1d[on_last_rhs] = points_1d[on_last_rhs] + ztol
        idx = np.ceil((points_1d - edges[0]) / delta) - 1
    else:
        raise RuntimeError("edges array gives delta of 0")
    return np.array(idx, dtype=int)


def columnNorm(a):
    """
    normalize array of column vectors (hstacked, axis = 0)
    """
    if len(a.shape) > 2:
        raise RuntimeError("incorrect shape: arg must be 1-d or 2-d, "
                           + "yours is %d" % (len(a.shape)))
    cnrma = np.sqrt(sum(np.asarray(a)**2, 0))
    return cnrma


def rowNorm(a):
    """
    normalize array of row vectors (vstacked, axis = 1)
    """
    if len(a.shape) > 2:
        raise RuntimeError("incorrect shape: arg must be 1-d or 2-d, "
                           + "yours is %d" % (len(a.shape)))
    cnrma = np.sqrt(sum(np.asarray(a)**2, 1))
    return cnrma


if USE_NUMBA:
    @numba.njit(nogil=True, cache=True)
    def _unitVectorSingle(a, b):
        n = a.shape[0]
        nrm = 0.0
        for i in range(n):
            nrm += a[i]*a[i]
        nrm = np.sqrt(nrm)
        # prevent divide by zero
        if nrm > epsf:
            for i in range(n):
                b[i] = a[i] / nrm
        else:
            for i in range(n):
                b[i] = a[i]

    @numba.njit(nogil=True, cache=True)
    def _unitVectorMulti(a, b):
        n = a.shape[0]
        m = a.shape[1]
        for j in range(m):
            nrm = 0.0
            for i in range(n):
                nrm += a[i, j]*a[i, j]
            nrm = np.sqrt(nrm)
            # prevent divide by zero
            if nrm > epsf:
                for i in range(n):
                    b[i, j] = a[i, j] / nrm
            else:
                for i in range(n):
                    b[i, j] = a[i, j]

    def unitVector(a):
        """
        normalize array of column vectors (hstacked, axis = 0)
        """
        result = np.empty_like(a)
        if a.ndim == 1:
            _unitVectorSingle(a, result)
        elif a.ndim == 2:
            _unitVectorMulti(a, result)
        else:
            raise ValueError("incorrect arg shape; must be 1-d or 2-d, "
                             + "yours is %d-d" % (a.ndim))
        return result

else:  # not USE_NUMBA
    def unitVector(a):
        """
        normalize array of column vectors (hstacked, axis = 0)
        """
        assert a.ndim in [1, 2], \
            "incorrect arg shape; must be 1-d or 2-d, yours is %d-d" \
            % (a.ndim)

        m = a.shape[0]
        n = 1

        nrm = np.tile(np.sqrt(sum(np.asarray(a)**2, 0)), (m, n))

        # prevent divide by zero
        zchk = nrm <= epsf
        nrm[zchk] = 1.

        nrma = a/nrm

        return nrma


def makeDetectorRotMat(tiltAngles):
    """
    Form the (3, 3) tilt rotations from the tilt angle list:

    tiltAngles = [gamma_Xl, gamma_Yl, gamma_Zl] in radians
    """
    # form rMat_d from parameter list
    cos_gX = np.cos(tiltAngles[0])
    sin_gX = np.sin(tiltAngles[0])

    cos_gY = np.cos(tiltAngles[1])
    sin_gY = np.sin(tiltAngles[1])

    cos_gZ = np.cos(tiltAngles[2])
    sin_gZ = np.sin(tiltAngles[2])

    rotXl = np.array(
        [[1., 0., 0.],
         [0., cos_gX, -sin_gX],
         [0., sin_gX, cos_gX]]
    )
    rotYl = np.array(
        [[cos_gY, 0., sin_gY],
         [0., 1., 0.],
         [-sin_gY, 0., cos_gY]]
    )
    rotZl = np.array(
        [[cos_gZ, -sin_gZ, 0.],
         [sin_gZ, cos_gZ, 0.],
         [0., 0., 1.]]
    )
    return np.dot(rotZl, np.dot(rotYl, rotXl))


def makeOscillRotMat(oscillAngles):
    """
    oscillAngles = [chi, ome]
    """
    cchi = np.cos(oscillAngles[0])
    schi = np.sin(oscillAngles[0])

    come = np.cos(oscillAngles[1])
    some = np.sin(oscillAngles[1])

    rchi = np.array(
        [[1., 0., 0.],
         [0., cchi, -schi],
         [0., schi, cchi]]
    )

    rome = np.array(
        [[come, 0., some],
         [0., 1., 0.],
         [-some, 0., come]]
    )

    return np.dot(rchi, rome)


def makeRotMatOfExpMap(expMap):
    """
    Calculate the rotation matrix of an exponential map.

    Parameters
    ----------
    expMap : array_like
        A 3-element sequence representing the exponential map n*phi.

    Returns
    -------
    rMat : np.ndarray
        The (3, 3) array representing the rotation matrix of the input
        exponential map parameters.
    """
    expMap = np.asarray(expMap).flatten()
    phi = np.norm(expMap)
    if phi > epsf:
        wMat = np.array(
            [[0., -expMap[2], expMap[1]],
             [expMap[2], 0., -expMap[0]],
             [-expMap[1], expMap[0], 0.]])

        rMat = I3 \
            + (np.sin(phi) / phi) * wMat \
            + ((1. - np.cos(phi)) / (phi*phi)) * np.dot(wMat, wMat)
    else:
        rMat = I3

    return rMat


def makeBinaryRotMat(axis):
    """
    """
    n = np.asarray(axis).flatten()
    assert len(n) == 3, 'Axis input does not have 3 components'
    return 2*np.dot(n.reshape(3, 1), n.reshape(1, 3)) - I3


if USE_NUMBA:
    @numba.njit(nogil=True, cache=True)
    def _makeEtaFrameRotMat(bHat_l, eHat_l, out):
        # bHat_l and eHat_l CANNOT have 0 magnitude!
        # must catch this case as well as colinear bHat_l/eHat_l elsewhere...
        bHat_mag = np.sqrt(bHat_l[0]**2 + bHat_l[1]**2 + bHat_l[2]**2)

        # assign Ze as -bHat_l
        for i in range(3):
            out[i, 2] = -bHat_l[i] / bHat_mag

        # find Ye as Ze ^ eHat_l
        Ye0 = out[1, 2]*eHat_l[2] - eHat_l[1]*out[2, 2]
        Ye1 = out[2, 2]*eHat_l[0] - eHat_l[2]*out[0, 2]
        Ye2 = out[0, 2]*eHat_l[1] - eHat_l[0]*out[1, 2]

        Ye_mag = np.sqrt(Ye0**2 + Ye1**2 + Ye2**2)

        out[0, 1] = Ye0 / Ye_mag
        out[1, 1] = Ye1 / Ye_mag
        out[2, 1] = Ye2 / Ye_mag

        # find Xe as Ye ^ Ze
        out[0, 0] = out[1, 1]*out[2, 2] - out[1, 2]*out[2, 1]
        out[1, 0] = out[2, 1]*out[0, 2] - out[2, 2]*out[0, 1]
        out[2, 0] = out[0, 1]*out[1, 2] - out[0, 2]*out[1, 1]

    def makeEtaFrameRotMat(bHat_l, eHat_l):
        """
        make eta basis COB matrix with beam antiparallel with Z

        takes components from ETA frame to LAB

        **NO EXCEPTION HANDLING FOR COLINEAR ARGS IN NUMBA VERSION!

        ...put checks for non-zero magnitudes and non-colinearity in wrapper?
        """
        result = np.empty((3, 3))
        _makeEtaFrameRotMat(bHat_l.reshape(3), eHat_l.reshape(3), result)
        return result

else:  # not USE_NUMBA
    def makeEtaFrameRotMat(bHat_l, eHat_l):
        """
        make eta basis COB matrix with beam antiparallel with Z

        takes components from ETA frame to LAB
        """
        # normalize input
        bHat_l = unitVector(bHat_l.reshape(3, 1))
        eHat_l = unitVector(eHat_l.reshape(3, 1))

        # find Ye as cross(eHat_l, bHat_l), normalize if kosher
        Ye = np.cross(eHat_l.flatten(), bHat_l.flatten())
        if np.sqrt(np.sum(Ye*Ye)) < 1e-8:
            raise RuntimeError("bHat_l and eHat_l must NOT be colinear!")
        Ye = unitVector(Ye.reshape(3, 1))

        # find Xe as cross(bHat_l, Ye)
        Xe = np.cross(bHat_l.flatten(), Ye.flatten()).reshape(3, 1)
        return np.hstack([Xe, Ye, -bHat_l])


def angles_in_range(angles, starts, stops, degrees=True):
    """Determine whether angles lie in or out of specified ranges

    *angles* - a list/array of angles
    *starts* - a list of range starts
    *stops* - a list of range stops

    OPTIONAL ARGS:
    *degrees* - [True] angles & ranges in degrees (or radians)
"""
    TAU = 360.0 if degrees else 2*np.pi
    nw = len(starts)
    na = len(angles)
    in_range = np.zeros((na), dtype=bool)
    for i in range(nw):
        amin = starts[i]
        amax = stops[i]
        for j in range(na):
            a = angles[j]
            acheck = amin + np.mod(a - amin, TAU)
            if acheck <= amax:
                in_range[j] = True

    return in_range


def validateAngleRanges(angList, startAngs, stopAngs, ccw=True):
    """
    A better way to go.  find out if an angle is in the range
    CCW or CW from start to stop

    There is, of course, an ambigutiy if the start and stop angle are
    the same; we treat them as implying 2*pi having been mapped
    """
    # Prefer ravel over flatten because flatten never skips the copy
    angList = np.asarray(angList).ravel()      # needs to have len
    startAngs = np.asarray(startAngs).ravel()  # needs to have len
    stopAngs = np.asarray(stopAngs).ravel()    # needs to have len

    n_ranges = len(startAngs)
    assert len(stopAngs) == n_ranges, \
        "length of min and max angular limits must match!"

    # to avoid warnings in >=, <= later down, mark nans;
    # need these to trick output to False in the case of nan input
    nan_mask = np.isnan(angList)

    reflInRange = np.zeros(angList.shape, dtype=bool)

    # bin length for chunking
    binLen = np.pi / 2.

    # in plane vectors defining wedges
    x0 = np.vstack([np.cos(startAngs), np.sin(startAngs)])
    x1 = np.vstack([np.cos(stopAngs), np.sin(stopAngs)])

    # dot products
    dp = np.sum(x0 * x1, axis=0)
    if np.any(dp >= 1. - sqrt_epsf) and n_ranges > 1:
        # ambiguous case
        raise RuntimeError(
            "At least one of your ranges is alread 360 degrees!"
        )
    elif dp[0] >= 1. - sqrt_epsf and n_ranges == 1:
        # trivial case!
        reflInRange = np.ones(angList.shape, dtype=bool)
        reflInRange[nan_mask] = False
    else:
        # solve for arc lengths
        # ...note: no zeros should have made it here
        a = x0[0, :]*x1[1, :] - x0[1, :]*x1[0, :]
        b = x0[0, :]*x1[0, :] + x0[1, :]*x1[1, :]
        phi = np.arctan2(b, a)

        arclen = 0.5*np.pi - phi          # these are clockwise
        cw_phis = arclen < 0
        arclen[cw_phis] = 2*np.pi + arclen[cw_phis]   # all positive (CW) now
        if not ccw:
            arclen = 2*np.pi - arclen

        if sum(arclen) > 2*np.pi:
            raise RuntimeWarning(
                "Specified angle ranges sum to > 360 degrees"
            )

        # check that there are no more thandp = np.zeros(n_ranges)
        for i in range(n_ranges):
            # number or subranges using 'binLen'
            numSubranges = int(np.ceil(arclen[i]/binLen))

            # check remaider
            binrem = np.remainder(arclen[i], binLen)
            if binrem == 0:
                finalBinLen = binLen
            else:
                finalBinLen = binrem

            # if clockwise, negate bin length
            if not ccw:
                binLen = -binLen
                finalBinLen = -finalBinLen

            # Create sub ranges on the fly to avoid ambiguity in dot product
            # for wedges >= 180 degrees
            subRanges = np.array(
                [startAngs[i] + binLen*j for j in range(numSubranges)]
                + [startAngs[i] + binLen*(numSubranges - 1) + finalBinLen]
            )

            for k in range(numSubranges):
                zStart = _z_project(angList, subRanges[k])
                zStop = _z_project(angList, subRanges[k + 1])
                if ccw:
                    zStart[nan_mask] = 999.
                    zStop[nan_mask] = -999.
                    reflInRange = reflInRange | \
                        np.logical_and(zStart <= 0, zStop >= 0)
                else:
                    zStart[nan_mask] = -999.
                    zStop[nan_mask] = 999.
                    reflInRange = reflInRange | \
                        np.logical_and(zStart >= 0, zStop <= 0)
    return reflInRange


def rotate_vecs_about_axis(angle, axis, vecs):
    """
    Rotate vectors about an axis

    INPUTS
    *angle* - array of angles (len == 1 or n)
    *axis*  - array of unit vectors (shape == (3, 1) or (3, n))
    *vec*   - array of vectors to be rotated (shape = (3, n))

    Quaternion formula:
    if we split v into parallel and perpedicular components w.r.t. the
    axis of quaternion q,

        v = a + n

    then the action of rotating the vector dot(R(q), v) becomes

        v_rot = (q0**2 - |q|**2)(a + n) + 2*dot(q, a)*q + 2*q0*cross(q, n)

    """
    angle = np.atleast_1d(angle)

    # quaternion components
    q0 = np.cos(0.5*angle)
    q1 = np.sin(0.5*angle)
    qv = np.tile(q1, (3, 1)) * axis

    # component perpendicular to axes (inherits shape of vecs)
    vp0 = vecs[0, :] - axis[0, :]*axis[0, :]*vecs[0, :] \
        - axis[0, :]*axis[1, :]*vecs[1, :] - axis[0, :]*axis[2, :]*vecs[2, :]
    vp1 = vecs[1, :] - axis[1, :]*axis[1, :]*vecs[1, :] \
        - axis[1, :]*axis[0, :]*vecs[0, :] - axis[1, :]*axis[2, :]*vecs[2, :]
    vp2 = vecs[2, :] - axis[2, :]*axis[2, :]*vecs[2, :] \
        - axis[2, :]*axis[0, :]*vecs[0, :] - axis[2, :]*axis[1, :]*vecs[1, :]

    # dot product with components along; cross product with components normal
    qdota = (axis[0, :] * vecs[0, :] +
             axis[1, :] * vecs[1, :] +
             axis[2, :] * vecs[2, :])\
        * (axis[0, :] * qv[0, :] +
           axis[1, :] * qv[1, :] +
           axis[2, :] * qv[2, :])
    qcrossn = np.vstack([qv[1, :]*vp2 - qv[2, :]*vp1,
                         qv[2, :]*vp0 - qv[0, :]*vp2,
                         qv[0, :]*vp1 - qv[1, :]*vp0])

    # quaternion formula
    v_rot = np.tile(q0*q0 - q1*q1, (3, 1)) * vecs \
        + 2. * np.tile(qdota, (3, 1)) * qv \
        + 2. * np.tile(q0, (3, 1)) * qcrossn
    return v_rot


def quat_product_matrix(q, mult='right'):
    """
    Form 4 x 4 array to perform the quaternion product

    USAGE
        qmat = quatProductMatrix(q, mult='right')

    INPUTS
        1) quats is (4,), an iterable representing a unit quaternion
           horizontally concatenated
        2) mult is a keyword arg, either 'left' or 'right', denoting
           the sense of the multiplication:

                       / quatProductMatrix(h, mult='right') * q
           q * h  --> <
                       \\ quatProductMatrix(q, mult='left') * h

    OUTPUTS
        1) qmat is (4, 4), the left or right quaternion product
           operator

    NOTES
       *) This function is intended to replace a cross-product based
          routine for products of quaternions with large arrays of
          quaternions (e.g. applying symmetries to a large set of
          orientations).
    """
    if mult == 'right':
        qmat = np.array(
            [[q[0], -q[1], -q[2], -q[3]],
             [q[1], q[0], q[3], -q[2]],
             [q[2], -q[3], q[0], q[1]],
             [q[3], q[2], -q[1], q[0]]]
        )
    elif mult == 'left':
        qmat = np.array(
            [[q[0], -q[1], -q[2], -q[3]],
             [q[1], q[0], -q[3], q[2]],
             [q[2], q[3], q[0], -q[1]],
             [q[3], -q[2], q[1], q[0]]]
        )
    return qmat


def quat_distance(q1, q2, qsym):
    """
    """
    # qsym from PlaneData objects are (4, nsym)
    # convert symmetries to (4, 4) qprod matrices
    nsym = qsym.shape[1]
    rsym = np.zeros((nsym, 4, 4))
    for i in range(nsym):
        rsym[i, :, :] = quat_product_matrix(qsym[:, i], mult='right')

    # inverse of q1 in matrix form
    q1i = quat_product_matrix(
        np.r_[1, -1, -1, -1] * np.atleast_1d(q1).flatten(),
        mult='right'
    )

    # Do R * Gc, store as vstacked equivalent quaternions (nsym, 4)
    q2s = np.dot(rsym, q2)

    # Calculate the class of misorientations for full symmetrically equivalent
    # q1 and q2: (4, ) * (4, nsym)
    eqv_mis = np.dot(q1i, q2s.T)

    # find the largest scalar component
    q0_max = np.argmax(abs(eqv_mis[0, :]))

    # compute the distance
    qmin = eqv_mis[:, q0_max]

    return 2 * arccosSafe(qmin[0] * np.sign(qmin[0]))


# hexrd/transforms/xfcapi.py

#! /usr/bin/env python
# ============================================================
# Copyright (c) 2012, Lawrence Livermore National Security, LLC.
# Produced at the Lawrence Livermore National Laboratory.
# Written by Joel Bernier <bernier2@llnl.gov> and others.
# LLNL-CODE-529294.
# All rights reserved.
#
# This file is part of HEXRD. For details on dowloading the source,
# see the file COPYING.
#
# Please also see the file LICENSE.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License (as published by the Free
# Software Foundation) version 2.1 dated February 1999.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY OF MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the terms and conditions of the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this program (see file LICENSE); if not, write to
# the Free Software Foundation, Inc., 59 Temple Place, Suite 330,
# Boston, MA 02111-1307 USA or visit <http://www.gnu.org/licenses/>.
# ============================================================

import numpy as np
import sys

from hexrd.extensions import _transforms_CAPI

from numpy import float_ as nFloat
from numpy import int_ as nInt

# ######################################################################
# Module Data
epsf = np.finfo(float).eps      # ~2.2e-16
ten_epsf = 10 * epsf                # ~2.2e-15
sqrt_epsf = np.sqrt(epsf)            # ~1.5e-8

periodDict = {'degrees': 360.0, 'radians': 2*np.pi}
angularUnits = 'radians'        # module-level angle units

# basis vectors
I3 = np.eye(3)                                        # (3, 3) identity
Xl = np.ascontiguousarray(I3[:, 0].reshape(3, 1))     # X in the lab frame
Yl = np.ascontiguousarray(I3[:, 1].reshape(3, 1))     # Y in the lab frame
Zl = np.ascontiguousarray(I3[:, 2].reshape(3, 1))     # Z in the lab frame

# reference stretch
vInv_ref = np.array([[1., 1., 1., 0., 0., 0.]], order='C').T

# reference beam direction and eta=0 ref in LAB FRAME for standard geometry
bVec_ref = -Zl
eta_ref = Xl

# ######################################################################
# Funtions


def anglesToGVec(angs, bHat_l=bVec_ref, eHat_l=eta_ref, chi=0., rMat_c=I3):
    """
    from 'eta' frame out to lab (with handy kwargs to go to crystal or sample)

    * setting omega to zero in ang imput and omitting rMat_c leaves
      in the lab frame in accordance with beam frame specs.
    """
    angs = np.ascontiguousarray(np.atleast_2d(angs))
    bHat_l = np.ascontiguousarray(bHat_l.flatten())
    eHat_l = np.ascontiguousarray(eHat_l.flatten())
    rMat_c = np.ascontiguousarray(rMat_c)
    chi = float(chi)
    return _transforms_CAPI.anglesToGVec(angs,
                                         bHat_l, eHat_l,
                                         chi, rMat_c)


def anglesToDVec(angs, bHat_l=bVec_ref, eHat_l=eta_ref, chi=0., rMat_c=I3):
    """
    from 'eta' frame out to lab (with handy kwargs to go to crystal or sample)

    * setting omega to zero in ang imput and omitting rMat_c leaves
      in the lab frame in accordance with beam frame specs.
    """
    angs = np.ascontiguousarray(np.atleast_2d(angs))
    bHat_l = np.ascontiguousarray(bHat_l.flatten())
    eHat_l = np.ascontiguousarray(eHat_l.flatten())
    rMat_c = np.ascontiguousarray(rMat_c)
    chi = float(chi)
    return _transforms_CAPI.anglesToDVec(angs,
                                         bHat_l, eHat_l,
                                         chi, rMat_c)


def makeGVector(hkl, bMat):
    """
    take a CRYSTAL RELATIVE B matrix onto a list of hkls to output unit
    reciprocal lattice vectors (a.k.a. lattice plane normals)

    Required Arguments:
    hkls -- (3, n) ndarray of n hstacked reciprocal lattice vector component
            triplets
    bMat -- (3, 3) ndarray representing the matirix taking reciprocal lattice
            vectors to the crystal reference frame

    Output:
    gVecs -- (3, n) ndarray of n unit reciprocal lattice vectors
             (a.k.a. lattice plane normals)

    To Do:
    * might benefit from some assert statements to catch improperly shaped
      input.
    """
    assert hkl.shape[0] == 3, 'hkl input must be (3, n)'
    return unitRowVector(np.dot(bMat, hkl))


def gvecToDetectorXY(gVec_c,
                     rMat_d, rMat_s, rMat_c,
                     tVec_d, tVec_s, tVec_c,
                     beamVec=bVec_ref):
    """
    Takes a list of unit reciprocal lattice vectors in crystal frame to the
    specified detector-relative frame, subject to the conditions:

    1) the reciprocal lattice vector must be able to satisfy a bragg condition
    2) the associated diffracted beam must intersect the detector plane

    Required Arguments:
    gVec_c -- (n, 3) ndarray of n reciprocal lattice vector components
              in the CRYSTAL FRAME
    rMat_d -- (3, 3) ndarray, the COB taking DETECTOR FRAME components
              to LAB FRAME
    rMat_s -- (3, 3) ndarray, the COB taking SAMPLE FRAME components
              to LAB FRAME
    rMat_c -- (3, 3) ndarray, the COB taking CRYSTAL FRAME components
              to SAMPLE FRAME
    tVec_d -- (3, 1) ndarray, the translation vector connecting
              LAB to DETECTOR
    tVec_s -- (3, 1) ndarray, the translation vector connecting
              LAB to SAMPLE
    tVec_c -- (3, 1) ndarray, the translation vector connecting
              SAMPLE to CRYSTAL

    Outputs:
    (m, 2) ndarray containing the intersections of m <= n diffracted beams
    associated with gVecs
    """
    rMat_d = np.ascontiguousarray(rMat_d)
    rMat_s = np.ascontiguousarray(rMat_s)
    rMat_c = np.ascontiguousarray(rMat_c)
    gVec_c = np.ascontiguousarray(np.atleast_2d(gVec_c))
    tVec_d = np.ascontiguousarray(tVec_d.flatten())
    tVec_s = np.ascontiguousarray(tVec_s.flatten())
    tVec_c = np.ascontiguousarray(tVec_c.flatten())
    beamVec = np.ascontiguousarray(beamVec.flatten())
    return _transforms_CAPI.gvecToDetectorXY(gVec_c,
                                             rMat_d, rMat_s, rMat_c,
                                             tVec_d, tVec_s, tVec_c,
                                             beamVec)


def gvecToDetectorXYArray(gVec_c,
                          rMat_d, rMat_s, rMat_c,
                          tVec_d, tVec_s, tVec_c,
                          beamVec=bVec_ref):
    """
    Takes a list of unit reciprocal lattice vectors in crystal frame to the
    specified detector-relative frame, subject to the conditions:

    1) the reciprocal lattice vector must be able to satisfy a bragg condition
    2) the associated diffracted beam must intersect the detector plane

    Required Arguments:
    gVec_c -- (n, 3) ndarray of n reciprocal lattice vector components
              in the CRYSTAL FRAME
    rMat_d -- (3, 3) ndarray, the COB taking DETECTOR FRAME components
              to LAB FRAME
    rMat_s -- (n, 3, 3) ndarray of n COB taking SAMPLE FRAME components
              to LAB FRAME
    rMat_c -- (3, 3) ndarray, the COB taking CRYSTAL FRAME components
              to SAMPLE FRAME
    tVec_d -- (3, 1) ndarray, the translation vector connecting
              LAB to DETECTOR in LAB
    tVec_s -- (3, 1) ndarray, the translation vector connecting
              LAB to SAMPLE in LAB
    tVec_c -- (3, 1) ndarray, the translation vector connecting
              SAMPLE to CRYSTAL in SAMPLE

    Outputs:
    (m, 2) ndarray containing the intersections of m <= n diffracted beams
    associated with gVecs
    """
    gVec_c = np.ascontiguousarray(gVec_c)
    rMat_d = np.ascontiguousarray(rMat_d)
    rMat_s = np.ascontiguousarray(rMat_s)
    rMat_c = np.ascontiguousarray(rMat_c)
    tVec_d = np.ascontiguousarray(tVec_d.flatten())
    tVec_s = np.ascontiguousarray(tVec_s.flatten())
    tVec_c = np.ascontiguousarray(tVec_c.flatten())
    beamVec = np.ascontiguousarray(beamVec.flatten())
    return _transforms_CAPI.gvecToDetectorXYArray(gVec_c,
                                                  rMat_d, rMat_s, rMat_c,
                                                  tVec_d, tVec_s, tVec_c,
                                                  beamVec)


def detectorXYToGvec(xy_det,
                     rMat_d, rMat_s,
                     tVec_d, tVec_s, tVec_c,
                     beamVec=bVec_ref, etaVec=eta_ref):
    """
    Takes a list cartesian (x, y) pairs in the detector coordinates and
    calculates the associated reciprocal lattice (G) vectors and
    (bragg angle, azimuth) pairs with respect to the specified beam and
    azimth (eta) reference directions

    Required Arguments:
    xy_det -- (n, 2) ndarray or list-like input of n detector (x, y) points
    rMat_d -- (3, 3) ndarray, the COB taking DETECTOR FRAME components
              to LAB FRAME
    rMat_s -- (3, 3) ndarray, the COB taking SAMPLE FRAME components
              to LAB FRAME
    tVec_d -- (3, 1) ndarray, the translation vector connecting
              LAB to DETECTOR in LAB
    tVec_s -- (3, 1) ndarray, the translation vector connecting
              LAB to SAMPLE in LAB
    tVec_c -- (3, 1) ndarray, the translation vector connecting
              SAMPLE to CRYSTAL in SAMPLE

    Optional Keyword Arguments:
    beamVec -- (3, 1) mdarray containing the incident beam direction
               components in the LAB FRAME
    etaVec  -- (3, 1) mdarray containing the reference azimuth direction
               components in the LAB FRAME

    Outputs:
    (n, 2) ndarray containing the (tTh, eta) pairs associated with each (x, y)
    (n, 3) ndarray containing the associated G vector directions
    in the LAB FRAME associated with gVecs
    """
    xy_det = np.ascontiguousarray(np.atleast_2d(xy_det))
    rMat_d = np.ascontiguousarray(rMat_d)
    rMat_s = np.ascontiguousarray(rMat_s)
    tVec_d = np.ascontiguousarray(tVec_d.flatten())
    tVec_s = np.ascontiguousarray(tVec_s.flatten())
    tVec_c = np.ascontiguousarray(tVec_c.flatten())
    beamVec = np.ascontiguousarray(beamVec.flatten())
    etaVec = np.ascontiguousarray(etaVec.flatten())
    return _transforms_CAPI.detectorXYToGvec(xy_det,
                                             rMat_d, rMat_s,
                                             tVec_d, tVec_s, tVec_c,
                                             beamVec, etaVec)


def detectorXYToGvecArray(xy_det,
                          rMat_d, rMat_s,
                          tVec_d, tVec_s, tVec_c,
                          beamVec=bVec_ref, etaVec=eta_ref):
    """
    Takes a list cartesian (x, y) pairs in the detector coordinates and
    calculates the associated reciprocal lattice (G) vectors and
    (bragg angle, azimuth) pairs with respect to the specified beam and azimth
    (eta) reference directions

    Required Arguments:
    xy_det -- (n, 2) ndarray or list-like input of n detector (x, y) points
    rMat_d -- (3, 3) ndarray, the COB taking DETECTOR FRAME components
              to LAB FRAME
    rMat_s -- (n, 3, 3) ndarray, the COB taking SAMPLE FRAME components
              to LAB FRAME
    tVec_d -- (3, 1) ndarray, the translation vector connecting
              LAB to DETECTOR in LAB
    tVec_s -- (3, 1) ndarray, the translation vector connecting
              LAB to SAMPLE in LAB
    tVec_c -- (3, 1) ndarray, the translation vector connecting
              SAMPLE to CRYSTAL in SAMPLE

    Optional Keyword Arguments:
    beamVec -- (3, 1) mdarray containing the incident beam direction
               components in the LAB FRAME
    etaVec  -- (3, 1) mdarray containing the reference azimuth direction
               components in the LAB FRAME

    Outputs:
    (n, 2) ndarray containing the (tTh, eta) pairs associated with each (x, y)
    (n, 3) ndarray containing the associated G vector directions
    in the LAB FRAME associated with gVecs
    """
    xy_det = np.ascontiguousarray(np.atleast_2d(xy_det))
    rMat_d = np.ascontiguousarray(rMat_d)
    rMat_s = np.ascontiguousarray(rMat_s)
    tVec_d = np.ascontiguousarray(tVec_d.flatten())
    tVec_s = np.ascontiguousarray(tVec_s.flatten())
    tVec_c = np.ascontiguousarray(tVec_c.flatten())
    beamVec = np.ascontiguousarray(beamVec.flatten())
    etaVec = np.ascontiguousarray(etaVec.flatten())
    return _transforms_CAPI.detectorXYToGvec(xy_det,
                                             rMat_d, rMat_s,
                                             tVec_d, tVec_s, tVec_c,
                                             beamVec, etaVec)


def oscillAnglesOfHKLs(hkls, chi, rMat_c, bMat, wavelength,
                       vInv=None, beamVec=bVec_ref, etaVec=eta_ref):
    """
    Takes a list of unit reciprocal lattice vectors in crystal frame to the
    specified detector-relative frame, subject to the conditions:

    1) the reciprocal lattice vector must be able to satisfy a bragg condition
    2) the associated diffracted beam must intersect the detector plane

    Required Arguments:
    hkls       -- (n, 3) ndarray of n reciprocal lattice vectors
                  in the CRYSTAL FRAME
    chi        -- float representing the inclination angle of the
                  oscillation axis (std coords)
    rMat_c     -- (3, 3) ndarray, the COB taking CRYSTAL FRAME components
                  to SAMPLE FRAME
    bMat       -- (3, 3) ndarray, the COB taking RECIPROCAL LATTICE components
                  to CRYSTAL FRAME
    wavelength -- float representing the x-ray wavelength in Angstroms

    Optional Keyword Arguments:
    beamVec -- (3, 1) mdarray containing the incident beam direction
               components in the LAB FRAME
    etaVec  -- (3, 1) mdarray containing the reference azimuth direction
               components in the LAB FRAME

    Outputs:
    ome0 -- (n, 3) ndarray containing the feasible (tTh, eta, ome) triplets
            for each input hkl (first solution)
    ome1 -- (n, 3) ndarray containing the feasible (tTh, eta, ome) triplets
            for each input hkl (second solution)

    Notes:
    ------------------------------------------------------------------------
    The reciprocal lattice vector, G, will satisfy the the Bragg condition
    when:

        b.T * G / ||G|| = -sin(theta)

    where b is the incident beam direction (k_i) and theta is the Bragg
    angle consistent with G and the specified wavelength. The components of
    G in the lab frame in this case are obtained using the crystal
    orientation, Rc, and the single-parameter oscillation matrix, Rs(ome):

        Rs(ome) * Rc * G / ||G||

    The equation above can be rearranged to yield an expression of the form:

        a*sin(ome) + b*cos(ome) = c

    which is solved using the relation:

        a*sin(x) + b*cos(x) = sqrt(a**2 + b**2) * sin(x + alpha)

        --> sin(x + alpha) = c / sqrt(a**2 + b**2)

    where:

        alpha = atan2(b, a)

     The solutions are:

                /
                |       arcsin(c / sqrt(a**2 + b**2)) - alpha
            x = <
                |  pi - arcsin(c / sqrt(a**2 + b**2)) - alpha
                \

    There is a double root in the case the reflection is tangent to the
    Debye-Scherrer cone (c**2 = a**2 + b**2), and no solution if the
    Laue condition cannot be satisfied (filled with NaNs in the results
    array here)
    """
    hkls = np.array(hkls, dtype=float, order='C')
    if vInv is None:
        vInv = np.ascontiguousarray(vInv_ref.flatten())
    else:
        vInv = np.ascontiguousarray(vInv.flatten())
    beamVec = np.ascontiguousarray(beamVec.flatten())
    etaVec = np.ascontiguousarray(etaVec.flatten())
    bMat = np.ascontiguousarray(bMat)
    return _transforms_CAPI.oscillAnglesOfHKLs(
        hkls, chi, rMat_c, bMat, wavelength, vInv, beamVec, etaVec
    )


"""
#######################################################################
######                  Utility Functions                        ######
#######################################################################

"""


def arccosSafe(temp):
    """
    Protect against numbers slightly larger than 1 due to round-off
    """

    # Oh, the tricks we must play to make this overloaded and robust...
    if type(temp) is list:
        temp = np.asarray(temp)
    elif type(temp) is np.ndarray:
        if len(temp.shape) == 0:
            temp = temp.reshape(1)

    if (temp > 1.00001).any():
        print("attempt to take arccos of %s" % temp, file=sys.stderr)
        raise RuntimeError("unrecoverable error")
    elif (temp < -1.00001).any():
        print("attempt to take arccos of %s" % temp, file=sys.stderr)
        raise RuntimeError("unrecoverable error")

    gte1 = temp >= 1.
    lte1 = temp <= -1.

    temp[gte1] = 1
    temp[lte1] = -1

    ang = np.arccos(temp)

    return ang


def angularDifference(angList0, angList1, units=angularUnits):
    """
    Do the proper (acute) angular difference in the context of a branch cut.

    *) Default angular range is [-pi, pi]
    """
    period = periodDict[units]
    # take difference as arrays
    diffAngles = np.atleast_1d(angList0) - np.atleast_1d(angList1)

    return abs(np.remainder(diffAngles + 0.5*period, period) - 0.5*period)


def mapAngle(ang, *args, **kwargs):
    """
    Utility routine to map an angle into a specified period
    """
    units = angularUnits
    period = periodDict[units]

    kwargKeys = list(kwargs.keys())
    for iArg in range(len(kwargKeys)):
        if kwargKeys[iArg] == 'units':
            units = kwargs[kwargKeys[iArg]]
        else:
            raise RuntimeError(
                "Unknown keyword argument: " + str(kwargKeys[iArg]))

    try:
        period = periodDict[units.lower()]
    except(KeyError):
        raise RuntimeError("unknown angular units: " +
                           str(kwargs[kwargKeys[iArg]]))

    ang = np.atleast_1d(nFloat(ang))

    # if we have a specified angular range, use that
    if len(args) > 0:
        angRange = np.atleast_1d(nFloat(args[0]))

        # divide of multiples of period
        ang = ang - nInt(ang / period) * period

        lb = angRange.min()
        ub = angRange.max()

        if abs(abs(ub - lb) - period) > sqrt_epsf:
            raise RuntimeError('range is incomplete!')

        lbi = ang < lb
        while lbi.sum() > 0:
            ang[lbi] = ang[lbi] + period
            lbi = ang < lb
            pass
        ubi = ang > ub
        while ubi.sum() > 0:
            ang[ubi] = ang[ubi] - period
            ubi = ang > ub
            pass
        retval = ang
    else:
        retval = np.mod(ang + 0.5*period, period) - 0.5*period
    return retval


def columnNorm(a):
    """
    normalize array of column vectors (hstacked, axis = 0)
    """
    if len(a.shape) > 2:
        raise RuntimeError(
            "incorrect shape: arg must be 1-d or 2-d, yours is %d"
            % (len(a.shape))
        )

    cnrma = np.sqrt(np.sum(np.asarray(a)**2, 0))

    return cnrma


def rowNorm(a):
    """
    normalize array of row vectors (vstacked, axis = 1)
    """
    if len(a.shape) > 2:
        raise RuntimeError(
            "incorrect shape: arg must be 1-d or 2-d, yours is %d"
            % (len(a.shape))
        )

    cnrma = np.sqrt(np.sum(np.asarray(a)**2, 1))

    return cnrma


def unitRowVector(vecIn):
    vecIn = np.ascontiguousarray(vecIn)
    if vecIn.ndim == 1:
        return _transforms_CAPI.unitRowVector(vecIn)
    elif vecIn.ndim == 2:
        return _transforms_CAPI.unitRowVectors(vecIn)
    else:
        assert vecIn.ndim in [1, 2], \
            "arg shape must be 1-d or 2-d, yours is %d-d" % (vecIn.ndim)


def makeDetectorRotMat(tiltAngles):
    """
    Form the (3, 3) tilt rotations from the tilt angle list:

    tiltAngles = [gamma_Xl, gamma_Yl, gamma_Zl] in radians
    """
    arg = np.ascontiguousarray(np.r_[tiltAngles].flatten())
    return _transforms_CAPI.makeDetectorRotMat(arg)


def makeOscillRotMat(oscillAngles):
    """
    oscillAngles = [chi, ome]
    """
    arg = np.ascontiguousarray(np.r_[oscillAngles].flatten())
    return _transforms_CAPI.makeOscillRotMat(arg)


def makeOscillRotMatArray(chi, omeArray):
    """
    Applies makeOscillAngles multiple times, for one
    chi value and an array of omega values.
    """
    arg = np.ascontiguousarray(omeArray)
    return _transforms_CAPI.makeOscillRotMatArray(chi, arg)


def makeRotMatOfExpMap(expMap):
    """
    make a rotation matrix from an exponential map
    """
    arg = np.ascontiguousarray(expMap.flatten())
    return _transforms_CAPI.makeRotMatOfExpMap(arg)


def makeRotMatOfQuat(quats):
    """
    make rotation matrix from vstacked unit quaternions

    """
    arg = np.ascontiguousarray(quats)
    return _transforms_CAPI.makeRotMatOfQuat(arg)


def makeBinaryRotMat(axis):
    arg = np.ascontiguousarray(axis.flatten())
    return _transforms_CAPI.makeBinaryRotMat(arg)


def makeEtaFrameRotMat(bHat_l, eHat_l):
    arg1 = np.ascontiguousarray(bHat_l.flatten())
    arg2 = np.ascontiguousarray(eHat_l.flatten())
    return _transforms_CAPI.makeEtaFrameRotMat(arg1, arg2)


def validateAngleRanges(angList, angMin, angMax, ccw=True):
    # FIXME: broken
    angList = np.asarray(angList, dtype=np.double, order="C")
    angMin = np.asarray(angMin, dtype=np.double, order="C")
    angMax = np.asarray(angMax, dtype=np.double, order="C")
    return _transforms_CAPI.validateAngleRanges(angList, angMin, angMax, ccw)


def rotate_vecs_about_axis(angle, axis, vecs):
    return _transforms_CAPI.rotate_vecs_about_axis(angle, axis, vecs)


def quat_distance(q1, q2, qsym):
    """
    qsym coming from hexrd.crystallogray.PlaneData.getQSym() is C-contiguous
    """
    q1 = np.ascontiguousarray(q1.flatten())
    q2 = np.ascontiguousarray(q2.flatten())
    return _transforms_CAPI.quat_distance(q1, q2, qsym)


def homochoricOfQuat(quats):
    """
    Compute homochoric parameters of unit quaternions

    quats is (4, n)
    """
    q = np.ascontiguousarray(quats.T)
    return _transforms_CAPI.homochoricOfQuat(q)

# def rotateVecsAboutAxis(angle, axis, vecs):
#     return _transforms_CAPI.rotateVecsAboutAxis(angle, axis, vecs)


# hexrd/utils/__init__.py

# utils package`


# hexrd/utils/compatibility.py

from importlib.metadata import version

import h5py


def h5py_read_string(dataset):
    if version('h5py') >= '3':
        # In h5py >= 3.0.0, h5py no longer converts the data type to a
        # string automatically, and we have to do it manually...
        string_dtype = h5py.check_string_dtype(dataset.dtype)
        if string_dtype is not None and string_dtype.encoding == 'utf-8':
            dataset = dataset.asstr()

    return dataset[()]


# hexrd/utils/concurrent.py

import os


def distribute_tasks(num_tasks, max_workers=os.cpu_count()):
    # Distribute tasks as evenly as possible to each worker.
    # Returns a list of tuples, where each tuple in the list
    # represents the [start, stop) task range for a worker.
    result = []
    num_assigned = 0

    tasks_per_worker = num_tasks // max_workers
    remainder = num_tasks % max_workers
    for i in range(max_workers):
        if num_assigned == num_tasks:
            break

        tasks = tasks_per_worker
        if remainder != 0:
            tasks += 1
            remainder -= 1

        if i == 0:
            previous = 0
        else:
            previous = result[-1][1]

        result.append((previous, previous + tasks))
        num_assigned += tasks

    return result


# hexrd/utils/decorators.py

# encoding: utf-8
"""Decorators that don't go anywhere else.

This module contains decorators that don't really go with another module
in :mod:`hexrd.utils`. Before putting something here please see if it should
go into another topical module in :mod:`hexrd.utils`.
"""

from collections import OrderedDict
from functools import wraps

import numpy as np
import xxhash

from hexrd.constants import USE_NUMBA


def undoc(func):
    """Mark a function or class as undocumented.
    This is found by inspecting the AST, so for now it must be used directly
    as @undoc, not as e.g. @decorators.undoc
    """
    return func


def memoize(func=None, maxsize=2):
    """Decorator. Caches a function's return value each time it is called.
    If called later with the same arguments, the cached value is returned
    (not reevaluated).

    This uses an LRU cache, where, before the maxsize is exceeded, the
    least recently used item will be removed.

    Numpy array arguments will be hashed for use in the cache.

    We are not using `functools.lru_cache()` only because it requires
    hashed arguments. Here, we can create hashes for the arguments on
    our own, and still pass the unhashed arguments to the function.
    """

    def decorator(func):

        cache = OrderedDict()
        hits = 0
        misses = 0

        def cache_info():
            return {
                'hits': hits,
                'misses': misses,
                'maxsize': maxsize,
                'currsize': len(cache),
            }

        def set_cache_maxsize(x):
            nonlocal maxsize
            maxsize = x

            while len(cache) > maxsize:
                # Remove the left item (least recently used)
                cache.popitem(last=False)

        setattr(func, 'cache_info', cache_info)
        setattr(func, 'set_cache_maxsize', set_cache_maxsize)

        @wraps(func)
        def wrapped(*args, **kwargs):
            nonlocal misses
            nonlocal hits

            all_args = list(args) + sorted(kwargs.items())
            key = _make_hashable(all_args)
            if key not in cache:
                # Make sure the current size is less than the max size
                while len(cache) >= maxsize:
                    # Remove the left item (least recently used)
                    cache.popitem(last=False)

                # This inserts the item on the right (most recently used)
                cache[key] = func(*args, **kwargs)
                misses += 1
            else:
                # Move the item to the right (most recently used)
                cache.move_to_end(key)
                hits += 1

            return cache[key]

        return wrapped

    if func is None:
        return decorator
    else:
        return decorator(func)


def _make_hashable(items):
    """Convert a list of items into hashable forms

    Note: they may not be able to be converted back.
    """

    def convert(x):
        # Perform any conversions here to make a variable hashable
        if isinstance(x, np.ndarray):
            # Create an sha1 of the data, and throw in a string
            # and the shape.
            return ('__type_np.ndarray', x.shape,
                    xxhash.xxh3_128_hexdigest(x))
        elif isinstance(x, (list, tuple)):
            return _make_hashable(x)
        elif isinstance(x, dict):
            return _make_hashable(sorted(x.items()))
        return x

    return tuple(map(convert, items))


def numba_njit_if_available(func=None, *args, **kwargs):
    # Forwards decorator to numba.njit if numba is available
    # Otherwise, does nothing.

    def decorator(func):
        if USE_NUMBA:
            import numba
            return numba.njit(*args, **kwargs)(func)
        else:
            # Do nothing...
            return func

    if func is None:
        return decorator
    else:
        return decorator(func)


# Also expose prange depending on whether we have numba or not
if USE_NUMBA:
    from numba import prange
else:
    prange = range


# hexrd/utils/multiprocess_generic.py

from multiprocessing import Queue, Process, cpu_count


"""
this class was put in utils as there isn't any other obvious place where it
belongs. this routine is a generic multiprocessing routine for taking a function
which has to be run over multiple inputs and parallelizes it ove all available cpus.

This is an example script from the Github repo https://github.com/brmather/pycurious

"""


class GenericMultiprocessing:

    def __init__(self):
        pass

    def _func_queue(self, func, q_in, q_out, *args, **kwargs):
        """ 
        Retrive processes from the queue 
        """
        while True:
            pos, var = q_in.get()
            if pos is None:
                break

            res = func(var, *args, **kwargs)
            q_out.put((pos, res))
            print("finished azimuthal position #",
                  pos, "with rwp = ", res[2]*100., "%")
        return

    def parallelise_function(self, var, func, *args, **kwargs):
        """ 
        Split evaluations of func across processors 
        """
        n = len(var)

        processes = []
        q_in = Queue(1)
        q_out = Queue()

        # get the maximum number of processes that will be started
        nprocs = cpu_count()
        print("# of cpu = ", nprocs, "running on all of them.")

        for i in range(nprocs):
            pass_args = [func, q_in, q_out]

            p = Process(target=self._func_queue,
                        args=tuple(pass_args),
                        kwargs=kwargs)

            processes.append(p)

        for p in processes:
            p.daemon = True
            p.start()

        # put items in the queue
        sent = [q_in.put((i, var[i])) for i in range(n)]
        [q_in.put((None, None)) for _ in range(nprocs)]

        # get the results
        results = [[] for i in range(n)]
        for i in range(len(sent)):
            index, res = q_out.get()
            results[index] = res

        # wait until each processor has finished
        [p.join() for p in processes]
        p.terminate()
        # reorder results
        return results


# hexrd/utils/profiler.py

"""Functions helping instrumented profiling of scripts

This functions provide functionality to easily add instrumented
profiling of certain functions. The functions to instrument will be
provided by a yaml file containing the name of the function with its
full dot path.
"""

from __future__ import print_function, absolute_import

import warnings

try:
    import importlib
except ImportError:
    pass

try:
    import nvtxpy as nvtx
except ImportError:
    pass



def instrument_function(fn_desc):
    """Interpret a record for an instrumented function, and instrument
    accordingly. The record, fn_desc, contains:

    'fn' is the full path to the function
    'color' is the color to use when instrumenting (nvtx feature)

    """

    # we must split 'fn' into the module and the function itself. Note
    # this is not trivial as we may find several levels of objects
    # inside the containing module. Try sequentially...
    full_name = fn_desc['fn']
    color = fn_desc.get('color', 'black')
    color = getattr(nvtx.colors, color, nvtx.colors.black)
    parts = full_name.split('.')

    # last item will always be the function name
    fn_name = parts[-1]

    # number of parts in the path
    path_parts = len(parts) - 1

    # consume as many as possible with import (ignore last part that is
    # the function name)
    pos = 0
    for i in range(1, path_parts+1):
        try:
            m = importlib.import_module('.'.join(parts[0:i]))
            pos = i
        except ImportError as e:
            break

    # at this point, i points at the starting of the dotted path to the
    # function to instrument... follow the parts till we get to the
    # actual function
    try:
        o = m
        for i in range(pos, path_parts):
            o = getattr(o, parts[i])

        # instrument...
        original = getattr(o, fn_name)
        override = nvtx.profiled(full_name, color=color)(original)
        setattr(o, fn_name, override)
    except AttributeError:
        warnings.warn('Could not instrument "{0}"'.format(full_name))



def parse_file(filename):
    """Parse a file and instrument the associated functions"""
    try:
        import yaml
        with open(filename, 'r') as f:
            cfg = yaml.load(f)

        if 'profile' not in cfg:
            msg = 'profile file "{0}" missing a profile section'
            warnings.warn(msg.format(filename))
            return

        profile_cfg = cfg['profile']
        if 'instrument' in profile_cfg:
            # instrument all
            [instrument_function(fn_desc) for fn_desc in
             profile_cfg['instrument']]
    except Exception as e:
        msg = 'Failed to include profile file: {0}'
        warnings.warn(msg.format(filename))
        warnings.warn(str(e))


def instrument_all(filenames):
    """Instrument functions based on a list of profiler configuration files.

    """
    [parse_file(filename) for filename in filenames]

    
def dump_results(args):
    print(" STATS ".center(72, '='))
    fmt = "{2:>14}, {1:>8}, {0:<40}"
    print(fmt.format("FUNCTION", "CALLS", "TIME"))
    fmt = "{2:>14F}, {1:>8}, {0:<40}"
    sorted_by_time = sorted(nvtx.getstats().iteritems(), key=lambda tup: tup[1][1])
    for key, val in sorted_by_time:
        print(fmt.format(key, *val))



# hexrd/utils/progressbar.py

from __future__ import absolute_import

try:
    from progressbar import ProgressBar as _ProgressBar
    from progressbar import Bar, ETA, Percentage, ReverseBar, signal

    class ProgressBar(_ProgressBar):
        "overriding the default to delete the progress bar when finished"
        def finish(self):
            'Puts the ProgressBar bar in the finished state.'
            self.finished = True
            self.update(self.maxval)
            # clear the progress bar:
            self.fd.write('\r'+' '*self.term_width+'\r')
            if self.signal_set:
                signal.signal(signal.SIGWINCH, signal.SIG_DFL)

except:
    # Dummy no-op progress bar to simplify code using ProgressBar
    class ProgressBar(object):
        def __init__(*args, **kwargs):
            pass

        def start(self):
            return self

        def finish(self):
            pass

        def update(self, x):
            pass

    Bar = ETA = Percentage = ReverseBar = ProgressBar


# hexrd/wppf/LeBailCalibration.py

import importlib.resources
import numpy as np
from numpy.polynomial.chebyshev import Chebyshev
import lmfit
import warnings
from hexrd.wppf.peakfunctions import \
calc_rwp, computespectrum_pvfcj, \
computespectrum_pvtch,\
computespectrum_pvpink,\
calc_Iobs_pvfcj,\
calc_Iobs_pvtch,\
calc_Iobs_pvpink
from hexrd.wppf.spectrum import Spectrum
from hexrd.wppf import wppfsupport, LeBail
from hexrd.wppf.parameters import Parameters
from lmfit import Parameters as Parameters_lmfit
from hexrd.wppf.phase import Phases_LeBail, Material_LeBail
from hexrd.imageutil import snip1d, snip1d_quad
from hexrd.material import Material
from hexrd.valunits import valWUnit
from hexrd.constants import keVToAngstrom

from hexrd import instrument
from hexrd import imageseries
from hexrd.imageseries import omega
from hexrd.xrdutil import PolarView
import time

class LeBailCalibrator:
    """
    ======================================================================
    ======================================================================

    >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab,
                    saransh1@llnl.gov
    >> @DATE:       08/24/2021 SS 1.0 original

    >> @DETAILS:    new lebail class which is specifically designed for
    instrument calibration using the LeBail method. in this partcular
    implementation, instead of using the iterative method for computing the
    peak intensities, we will use them as parameters in the optimization
    problem. the inputs include the instrument, the imageseries, material,
    peakshapes etc.

    >> @PARAMETERS  instrument the instrument class in hexrd
                    img_dict dictionary of images with same keys as
                    detectors in the instrument class

    ======================================================================
    ======================================================================
    """
    def __init__(self,
                 instrument,
                 img_dict,
                 extent=(0.,90.,0.,360.),
                 pixel_size=(0.1, 1.0),
                 params=None,
                 phases=None,
                 azimuthal_step=5.0,
                 bkgmethod={'chebyshev': 3},
                 peakshape="pvtch",
                 intensity_init=None,
                 apply_solid_angle_correction=False,
                 apply_lp_correction=False,
                 polarization=None):

        self.bkgmethod = bkgmethod
        self.peakshape = peakshape
        self.extent = extent
        self.pixel_size = pixel_size
        self.azimuthal_step = azimuthal_step

        self.img_dict = img_dict

        self._tstart = time.time()

        self.sacorrection = apply_solid_angle_correction
        self.lpcorrection = apply_lp_correction
        self.polarization = polarization

        self.instrument = instrument

        self.phases = phases
        self.params = params

        self.intensity_init = intensity_init

        self.initialize_Icalc()

        self.calc_simulated()

        self._tstop = time.time()

        self.nfev = 0
        self.Rwplist = np.empty([0])
        self.gofFlist = np.empty([0])

    def __str__(self):
        resstr = '<LeBail Fit class>\nParameters of \
        the model are as follows:\n'
        resstr += self.params.__str__()
        return resstr

    def calctth(self):
        self.tth = {}
        self.hkls = {}
        self.dsp = {}
        for p in self.phases:
            self.tth[p] = {}
            self.hkls[p] = {}
            self.dsp[p] = {}
            for k, l in self.phases.wavelength.items():
                t = self.phases[p].getTTh(l[0].value)
                allowed = self.phases[p].wavelength_allowed_hkls
                t = t[allowed]
                hkl = self.phases[p].hkls[allowed, :]
                dsp = self.phases[p].dsp[allowed]
                tth_min = self.tth_min
                tth_max = self.tth_max
                limit = np.logical_and(t >= tth_min,
                                       t <= tth_max)
                self.tth[p][k] = t[limit]
                self.hkls[p][k] = hkl[limit, :]
                self.dsp[p][k] = dsp[limit]

    def initialize_Icalc(self):
        """
        @DATE 01/22/2021 SS modified the function so Icalc can be initialized with
        a dictionary of structure factors
        """

        self.Icalc = {}

        for ii in range(len(self.lineouts)):

            Icalc = {}
            g = {}
            prefix = f"azpos{ii}"
            lo = self.lineouts[prefix].data[:,1]
            if(self.intensity_init is None):
                if np.nanmax(lo) > 0:
                    n10 = np.floor(np.log10(np.nanmax(lo))) - 2
                else:
                    n10 = 0

            for p in self.phases:
                Icalc[p] = {}
                for k, l in self.phases.wavelength.items():
                    Icalc[p][k] = (10**n10)*np.ones(self.tth[p][k].shape)

            self.Icalc[prefix] = Icalc

        self.refine_background = False
        self.refine_instrument = False

    def prepare_polarview(self):
        self.masked = self.pv.warp_image(self.img_dict, \
                                        pad_with_nans=True, \
                                        do_interpolation=True)
        lo = self.masked.sum(axis=0) / np.sum(~self.masked.mask, axis=0)
        self.fulllineout = np.vstack((self.tth_list,lo)).T
        self.prepare_lineouts()

    def prepare_lineouts(self):
        self.lineouts = {}
        if hasattr(self, 'masked'):
           azch = self.azimuthal_chunks
           tth = self.tth_list
           for ii in range(azch.shape[0]-1):
                istr = azch[ii]
                istp = azch[ii+1]
                lo = self.masked[istr:istp,:].sum(axis=0) / \
                np.sum(~self.masked[istr:istp,:].mask, axis=0)
                data = np.ma.vstack((tth,lo)).T
                key = f"azpos{ii}"
                self.lineouts[key] = data


    def computespectrum(self,
                        instr_updated,
                        lp_updated):
        """
        this function calls the computespectrum function in the
        lebaillight class for all the azimuthal positions and
        accumulates the error vector from each of those lineouts.
        this is more or less a book keeping function rather
        """
        errvec = np.empty([0,])
        rwp = []

        for k,v in self.lineouts_sim.items():
            v.params = self.params
            if instr_updated:
                v.lineout = self.lineouts[k]
            if lp_updated:
                v.tth = self.tth
                v.hkls = self.hkls
                v.dsp = self.dsp
            v.shkl = self.shkl
            v.CalcIobs()
            v.computespectrum()

            ww = v.weights
            evec = ww*(v.spectrum_expt._y -
                   v.spectrum_sim._y)**2
            evec = np.sqrt(evec)
            evec = np.nan_to_num(evec)
            errvec = np.concatenate((errvec,evec))

            weighted_expt = np.nan_to_num(ww*v.spectrum_expt._y**2)

            wss = np.trapz(evec, v.tth_list)
            den = np.trapz(weighted_expt, v.tth_list)
            r = np.sqrt(wss/den)*100.
            if ~np.isnan(r):
                rwp.append(r)

        return errvec, rwp

    def calcrwp(self, params):
        """
        this is one of the main functions which differs fundamentally
        to the regular Lebail class. this function computes the residual
        as a colletion of residuals at different eta, omega values.
        for the most traditional HED case, we will have only a single value
        of omega. However, there will be support for the more complicated
        HEDM case in the future.
        """

        lp_updated = self.update_param_vals(params)
        self.update_shkl(params)
        instr_updated = self.update_instrument(params)
        errvec, rwp = self.computespectrum(instr_updated,
                                      lp_updated)
        self.Rwp = np.mean(rwp)
        self.nfev += 1
        self.Rwplist = np.append(self.Rwplist, self.Rwp)

        if np.mod(self.nfev, 10) == 0:
            msg = (f"refinement ongoing... \n weighted residual at "
            f"iteration # {self.nfev} = {self.Rwp}\n")
            print(msg)

        return errvec

    def initialize_lmfit_parameters(self):

        params = self.params
        params = lmfit.Parameters()

        for p in self.params:
            par = self.params[p]
            if(par.vary):
                params.add(p, value=par.value, min=par.min, max=par.max, vary=True)
        return params

    def Refine(self):
        """
        >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
        >> @DATE:       05/19/2020 SS 1.0 original
        >> @DETAILS:    this routine performs the least squares refinement for all variables
                        which are allowed to be varied.
        """

        params = self.initialize_lmfit_parameters()

        # fdict = {'ftol': 1e-6, 'xtol': 1e-6, 'gtol': 1e-6, 'factor':1000}
        fitter = lmfit.Minimizer(self.calcrwp, params)

        fdict = {'ftol': 1e-6, 'xtol': 1e-6, 'gtol': 1e-6}
        res = fitter.least_squares(**fdict)
        # res = fitter.leastsq(**fdict)
        self.res = res

        if self.res.success:
            msg = (f"\n \n optimization successful: {self.res.message}. \n"
                f"weighted residual error = {self.Rwp}")
        else:
            msg = (f"\n \n optimization unsuccessful: {self.res.message}. \n"
                f"weighted residual error = {self.Rwp}")

        print(msg)

    def update_param_vals(self,
                          params):
        """
        @date 03/12/2021 SS 1.0 original
        take values in parameters and set the
        corresponding class values with the same
        name
        """
        for p in params:
            self._params[p].value = params[p].value
            self._params[p].min = params[p].min
            self._params[p].max = params[p].max

        updated_lp = False

        for p in self.phases:
            mat = self.phases[p]
            """
            PART 1: update the lattice parameters
            """
            lp = []
            lpvary = False
            pre = p + '_'

            name = [f"{pre}{x}" for x in wppfsupport._lpname]

            for nn in name:
                if nn in params:
                    if params[nn].vary:
                        lpvary = True
                    lp.append(params[nn].value)
                elif nn in self.params:
                    lp.append(self.params[nn].value)

            if(not lpvary):
                pass
            else:
                lp = self.phases[p].Required_lp(lp)
                mat.lparms = np.array(lp)
                mat._calcrmt()
                updated_lp = True

        if updated_lp:
            self.calctth()

        return updated_lp

    def update_shkl(self, params):
        """
        if certain shkls are refined, then update
        them using the params arg. else use values from
        the parameter class
        """
        updated_shkl = False
        shkl_dict = {}
        for p in self.phases:
            shkl_name = self.phases[p].valid_shkl
            eq_const = self.phases[p].eq_constraints
            mname = self.phases[p].name
            key = [f"{mname}_{s}" for s in shkl_name]
            for s,k in zip(shkl_name,key):
                if k in params:
                    shkl_dict[s] = params[k].value
                else:
                    shkl_dict[s] = self.params[k].value

            self.phases[p].shkl = wppfsupport._fill_shkl(\
                shkl_dict, eq_const)

    def update_instrument(self, params):
        instr_updated = False
        for key,det in self._instrument.detectors.items():
            for ii in range(3):
                pname = f"{key}_tvec{ii}"
                if pname in params:
                    if params[pname].vary:
                        det.tvec[ii] = params[pname].value
                        instr_updated = True
                pname = f"{key}_tilt{ii}"
                if pname in params:
                    if params[pname].vary:
                        det.tilt[ii] = params[pname].value
                        instr_updated = True

        if instr_updated:
            self.prepare_polarview()
        return instr_updated

    @property
    def bkgdegree(self):
        if "chebyshev" in self.bkgmethod.keys():
            return self.bkgmethod["chebyshev"]


    @property
    def instrument(self):
        return self._instrument

    @instrument.setter
    def instrument(self, ins):
        if isinstance(ins, instrument.HEDMInstrument):
            self._instrument = ins
            self.pv = PolarView(self.extent[0:2],
                                ins,
                                eta_min=self.extent[2],
                                eta_max=self.extent[3],
                                pixel_size=self.pixel_size)

            self.prepare_polarview()
        else:
            msg = "input is not an instrument class."
            raise RuntimeError(msg)

    # @property
    # def omegaimageseries(self):
    #     return self._omegaims

    # @omegaimageseries.setter
    # def omegaimageseries(self, oims):
    #     if isinstance(oims, omega.OmegaImageSeries):
    #         self._omegaims
    #     else:
    #         msg = "input is not omega image series."
    #         raise RuntimeError(msg)

    @property
    def init_time(self):
        return self._tstop - self._tstart

    @property
    def extent(self):
        return self._extent

    @extent.setter
    def extent(self, ext):
        self._extent = ext

        if hasattr(self, "instrument"):
            if hasattr(self, "pixel_size"):
                self.pv = PolarView(ext[0:2],
                                self.instrument,
                                eta_min=ext[2],
                                eta_max=ext[3],
                                pixel_size=self.pixel_size)
                self.prepare_polarview()

    """
    this property returns a azimuthal range over which
    the summation is performed to get the lineouts
    """
    @property
    def azimuthal_chunks(self):
        extent = self.extent
        step = self.azimuthal_step
        azlim = extent[2:]
        pxsz = self.pixel_size[1]
        shp = self.masked.shape[0]
        npix = int(np.round(step/pxsz))
        return np.r_[np.arange(0,shp,npix),shp]

    @property
    def tth_list(self):
        return np.squeeze(np.degrees(self.pv.angular_grid[1][0,:]))

    @property
    def wavelength(self):
        lam = keVToAngstrom(self.instrument.beam_energy)
        return {"lam1":
                [valWUnit('lp', 'length', lam, 'angstrom'),1.0]}

    def striphkl(self, g):
        return str(g)[1:-1].replace(" ","")

    @property
    def refine_background(self):
        return self._refine_background

    @refine_background.setter
    def refine_background(self, val):
        if "chebyshev" in self.bkgmethod.keys():
            if isinstance(val, bool):
                self._refine_background = val
                prefix = "azpos"
                for ii in range(len(self.lineouts)):
                    pname = [f"{prefix}{ii}_bkg_C{jj}" for jj in range(self.bkgdegree)]
                    for p in pname:
                        self.params[p].vary = val
            else:
                msg = "only boolean values accepted"
                raise ValueError(msg)
        else:
            msg = f"background method doesn't support refinement."
            print(msg)

    @property
    def refine_instrument(self):
        return self._refine_instrument

    @refine_instrument.setter
    def refine_instrument(self, val):
        if isinstance(val, bool):
            self._refine_instrument = val
            for key in self.instrument.detectors:
                pnametvec = [f"{key}_tvec{i}" for i in range(3)]
                pnametilt = [f"{key}_tilt{i}" for i in range(3)]
                for ptv,pti in zip(pnametvec,pnametilt):
                    self.params[ptv].vary = val
                    self.params[pti].vary = val
        else:
            msg = "only boolean values accepted"
            raise ValueError(msg)

    @property
    def refine_translation(self):
        return self._refine_tilt

    @refine_translation.setter
    def refine_translation(self, val):
        if isinstance(val, bool):
            self._refine_translation = val
            for key in self.instrument.detectors:
                pnametvec = [f"{key}_tvec{i}" for i in range(3)]
                for ptv in pnametvec:
                    self.params[ptv].vary = val
        else:
            msg = "only boolean values accepted"
            raise ValueError(msg)

    @property
    def refine_tilt(self):
        return self._refine_tilt

    @refine_tilt.setter
    def refine_tilt(self, val):
        if isinstance(val, bool):
            self._refine_tilt = val
            for key in self.instrument.detectors:
                pnametilt = [f"{key}_tilt{i}" for i in range(3)]
                for pti in pnametilt:
                    self.params[pti].vary = val
        else:
            msg = "only boolean values accepted"
            raise ValueError(msg)

    @property
    def pixel_size(self):
        return self._pixel_size

    @pixel_size.setter
    def pixel_size(self, px_sz):
        self._pixel_size = px_sz

        if hasattr(self, "instrument"):
            if hasattr(self, "extent"):
                self.pv = PolarView(self.extent[0:2],
                            ins,
                            eta_min=self.extent[2],
                            eta_max=self.extent[3],
                            pixel_size=px_sz)
                self.prepare_polarview()

    @property
    def sacorrection(self):
        return self._sacorrection

    @sacorrection.setter
    def sacorrection(self, val):
        if isinstance(val, bool):
            self._sacorrection = val
            if hasattr(self, 'instrument'):
                self.prepare_polarview()
        else:
            msg = "only boolean values accepted"
            raise ValueError(msg)

    @property
    def lpcorrection(self):
        return self._lpcorrection

    @lpcorrection.setter
    def lpcorrection(self, val):
        if isinstance(val, bool):
            self._lpcorrection = val
            if hasattr(self, 'instrument'):
                self.prepare_polarview()
        else:
            msg = "only boolean values accepted"
            raise ValueError(msg)


    @property
    def img_dict(self):

        imd = self._img_dict.copy()

        if self.sacorrection:
            for dname, det in self.instrument.detectors.items():
                solid_angs = det.pixel_solid_angles
                imd[dname] = imd[dname] / solid_angs

        if self.lpcorrection:
            hpol, vpol = self.polarization
            for dname, det in self.instrument.detectors.items():
                lp = det.polarization_factor(hpol, vpol) *\
                det.lorentz_factor()
                imd[dname] = imd[dname] / lp

        return imd

    @img_dict.setter
    def img_dict(self, imd):
        self._img_dict = imd
        if hasattr(self, 'instrument'):
            self.prepare_polarview()

    @property
    def polarization(self):
        return self._polarization

    @polarization.setter
    def polarization(self, val):
        if val is None:
            self._polarization = (0.5, 0.5)
        else:
            self._polarization = val

    @property
    def azimuthal_step(self):
        return self._azimuthal_step

    @azimuthal_step.setter
    def azimuthal_step(self, val):
        self._azimuthal_step = val
        self.prepare_lineouts()

    @property
    def tth_min(self):
        return self.extent[0]+self.pixel_size[0]*0.5

    @property
    def tth_max(self):
        return self.extent[1]-+self.pixel_size[0]*0.5

    @property
    def peakshape(self):
        return self._peakshape

    @peakshape.setter
    def peakshape(self, val):
        """
        @TODO make sure the parameter list
        is updated when the peakshape changes
        """
        if isinstance(val, str):
            if val == "pvfcj":
                self._peakshape = 0
            elif val == "pvtch":
                self._peakshape = 1
            elif val == "pvpink":
                self._peakshape = 2
            else:
                msg = (f"invalid peak shape string. "
                    f"must be: \n"
                    f"1. pvfcj: pseudo voight (Finger, Cox, Jephcoat)\n"
                    f"2. pvtch: pseudo voight (Thompson, Cox, Hastings)\n"
                    f"3. pvpink: Pink beam (Von Dreele)")
                raise ValueError(msg)
        elif isinstance(val, int):
            if val >=0 and val <=2:
                self._peakshape = val
            else:
                msg = (f"invalid peak shape int. "
                    f"must be: \n"
                    f"1. 0: pseudo voight (Finger, Cox, Jephcoat)\n"
                    f"2. 1: pseudo voight (Thompson, Cox, Hastings)\n"
                    f"3. 2: Pink beam (Von Dreele)")
                raise ValueError(msg)

        """
        update parameters
        """
        if hasattr(self, 'params'):
            params = wppfsupport._generate_default_parameters_Rietveld(
                    self.phases, self.peakshape)
            for p in params:
                if p in self.params:
                    params[p] = self.params[p]
            self._params = params


    @property
    def phases(self):
        return self._phases

    @phases.setter
    def phases(self, phase_info):
        """
        >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
        >> @DATE:       06/08/2020 SS 1.0 original
                        09/11/2020 SS 1.1 multiple different ways to initialize phases
                        09/14/2020 SS 1.2 added phase initialization from material.Material class
                        03/05/2021 SS 2.0 moved everything to property setter
        >> @DETAILS:    load the phases for the LeBail fits
        """

        if(phase_info is not None):
            if(isinstance(phase_info, Phases_LeBail)):
                """
                directly passing the phase class
                """
                self._phases = phase_info

            else:

                if(hasattr(self, 'wavelength')):
                    if(self.wavelength is not None):
                        p = Phases_LeBail(wavelength=self.wavelength)
                else:
                    p = Phases_LeBail()

                if(isinstance(phase_info, dict)):
                    """
                    initialize class using a dictionary with key as
                    material file and values as the name of each phase
                    """
                    for material_file in phase_info:
                        material_names = phase_info[material_file]
                        if(not isinstance(material_names, list)):
                            material_names = [material_names]
                        p.add_many(material_file, material_names)

                elif(isinstance(phase_info, str)):
                    """
                    load from a yaml file
                    """
                    if(path.exists(phase_info)):
                        p.load(phase_info)
                    else:
                        raise FileError('phase file doesn\'t exist.')

                elif(isinstance(phase_info, Material)):
                    p[phase_info.name] = Material_LeBail(
                        fhdf=None,
                        xtal=None,
                        dmin=None,
                        material_obj=phase_info)

                elif(isinstance(phase_info, list)):
                    for mat in phase_info:
                        p[mat.name] = Material_LeBail(
                            fhdf=None,
                            xtal=None,
                            dmin=None,
                            material_obj=mat)

                        p.num_phases += 1

                    for mat in p:
                        p[mat].pf = 1.0/p.num_phases

                self._phases = p

        self.calctth()

        for p in self.phases:
            self.phases[p].valid_shkl, \
            self.phases[p].eq_constraints, \
            self.phases[p].rqd_index, \
            self.phases[p].trig_ptype = \
            wppfsupport._required_shkl_names(self.phases[p])

    @property
    def params(self):
        return self._params

    @params.setter
    def params(self, param_info):
        """
        >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
        >> @DATE:       05/19/2020 SS 1.0 original
                        09/11/2020 SS 1.1 modified to accept multiple input types
                        03/05/2021 SS 2.0 moved everything to the property setter
        >> @DETAILS:    initialize parameter list from file. if no file given, then initialize
                        to some default values (lattice constants are for CeO2)
        """
        from scipy.special import roots_legendre
        xn, wn = roots_legendre(16)
        self.xn = xn[8:]
        self.wn = wn[8:]

        if(param_info is not None):
            pl = wppfsupport._generate_default_parameters_LeBail(
                self.phases, self.peakshape, ptype="lmfit")
            self.lebail_param_list = [p for p in pl]
            if(isinstance(param_info, Parameters_lmfit)):
                """
                directly passing the parameter class
                """
                self._params = param_info
                params = param_info
            else:
                params = Parameters_lmfit()

                if(isinstance(param_info, dict)):
                    """
                    initialize class using dictionary read from the yaml file
                    """
                    for k in param_info:
                        v = param_info[k]
                        params.add(k, value=np.float(v[0]),
                                   min=np.float(v[1]), max=np.float(v[2]),
                                   vary=np.bool(v[3]))

                elif(isinstance(param_info, str)):
                    """
                    load from a yaml file
                    """
                    if(path.exists(param_info)):
                        params.load(param_info)
                    else:
                        raise FileError('input spectrum file doesn\'t exist.')

                """
                this part initializes the lattice parameters in the
                """
                for p in self.phases:
                    wppfsupport._add_lp_to_params(
                        params, self.phases[p])

                self._params = params
        else:

            params = wppfsupport._generate_default_parameters_LeBail(
                self.phases, self.peakshape, ptype="lmfit")
            self.lebail_param_list = [p for p in params]
            wppfsupport._add_detector_geometry(params, self.instrument)
            if "chebyshev" in self.bkgmethod.keys():
                wppfsupport._add_background(params, self.lineouts, self.bkgdegree)
            self._params = params

    @property
    def shkl(self):
        shkl = {}
        for p in self.phases:
            shkl[p] = self.phases[p].shkl
        return shkl


    def calc_simulated(self):
        self.lineouts_sim = {}
        for key, lo in self.lineouts.items():
            self.lineouts_sim[key] = LeBaillight(key,
                                                 lo,
                                                 self.Icalc[key],
                                                 self.tth,
                                                 self.hkls,
                                                 self.dsp,
                                                 self.shkl,
                                                 self.lebail_param_list,
                                                 self.params,
                                                 self.peakshape,
                                                 self.bkgmethod)

class LeBaillight:
    """
    just a lightweight LeBail class which does only the
    simple computation of diffraction spectrum given the
    parameters and intensity values
    """
    def __init__(self,
                name,
                lineout,
                Icalc,
                tth,
                hkls,
                dsp,
                shkl,
                lebail_param_list,
                params,
                peakshape,
                bkgmethod):

        self.name = name
        self.lebail_param_list = lebail_param_list
        self.peakshape = peakshape
        self.params = params
        self.lineout = lineout
        self.Icalc = Icalc
        self.shkl = shkl
        self.tth = tth
        self.hkls = hkls
        self.dsp = dsp
        self.bkgmethod = bkgmethod
        self.computespectrum()
        # self.CalcIobs()
        # self.computespectrum()

    def computespectrum(self):

        x = self.tth_list
        y = np.zeros(x.shape)
        tth_list = np.ascontiguousarray(self.tth_list)

        for p in self.tth:
            for k in self.tth[p]:

                Ic = self.Icalc[p][k]

                shft_c = np.cos(0.5*np.radians(self.tth[p][k]))*self.params["shft"].value
                trns_c = np.sin(np.radians(self.tth[p][k]))*self.params["trns"].value
                tth = self.tth[p][k] + \
                      self.params["zero_error"].value + \
                      shft_c + \
                      trns_c

                dsp = self.dsp[p][k]
                hkls = self.hkls[p][k]
                n = np.min((tth.shape[0], Ic.shape[0]))
                shkl = self.shkl[p]
                name = p
                eta_n = f"{name}_eta_fwhm"
                eta_fwhm = self.params[eta_n].value
                strain_direction_dot_product = 0.
                is_in_sublattice = False

                cag = np.array([self.params["U"].value,
                                self.params["V"].value,
                                self.params["W"].value])
                gaussschrerr = self.params["P"].value
                lorbroad = np.array([self.params["X"].value,
                                     self.params["Y"].value])
                anisbroad = np.array([self.params["Xe"].value,
                                      self.params["Ye"].value,
                                      self.params["Xs"].value])
                if self.peakshape == 0:
                    HL = self.params["HL"].value
                    SL = self.params["SL"].value
                    args = (cag,
                            gaussschrerr,
                            lorbroad,
                            anisbroad,
                            shkl,
                            eta_fwhm,
                            HL,
                            SL,
                            tth,
                            dsp,
                            hkls,
                            strain_direction_dot_product,
                            is_in_sublattice,
                            tth_list,
                            Ic,
                            self.xn,
                            self.wn)

                elif self.peakshape == 1:
                    args = (cag,
                            gaussschrerr,
                            lorbroad,
                            anisbroad,
                            shkl,
                            eta_fwhm,
                            tth,
                            dsp,
                            hkls,
                            strain_direction_dot_product,
                            is_in_sublattice,
                            tth_list,
                            Ic)

                elif self.peakshape == 2:
                    alpha = np.array([self.params["alpha0"].value,
                                      self.params["alpha1"].value])
                    beta = np.array([self.params["beta0"].value,
                                     self.params["beta1"].value])
                    args = (alpha,
                            beta,
                            cag,
                            gaussschrerr,
                            lorbroad,
                            anisbroad,
                            shkl,
                            eta_fwhm,
                            tth,
                            dsp,
                            hkls,
                            strain_direction_dot_product,
                            is_in_sublattice,
                            tth_list,
                            Ic)

                y += self.computespectrum_fcn(*args)

        self._spectrum_sim = Spectrum(x=x, y=y)

    def CalcIobs(self):
        """
        >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
        >> @DATE:       06/08/2020 SS 1.0 original
        >> @DETAILS:    this is one of the main functions to partition the expt intensities
                        to overlapping peaks in the calculated pattern
        """

        self.Iobs = {}
        spec_expt = self.spectrum_expt.data_array
        spec_sim  = self.spectrum_sim.data_array
        tth_list = np.ascontiguousarray(self.tth_list)
        for p in self.tth:

            self.Iobs[p] = {}
            for k in self.tth[p]:

                Ic = self.Icalc[p][k]

                shft_c = np.cos(0.5*np.radians(self.tth[p][k]))*self.params["shft"].value
                trns_c = np.sin(np.radians(self.tth[p][k]))*self.params["trns"].value
                tth = self.tth[p][k] + \
                      self.params["zero_error"].value + \
                      shft_c + \
                      trns_c

                dsp = self.dsp[p][k]
                hkls = self.hkls[p][k]
                n = np.min((tth.shape[0], Ic.shape[0]))
                shkl = self.shkl[p]
                name = p
                eta_n = f"{name}_eta_fwhm"
                eta_fwhm = self.params[eta_n].value
                strain_direction_dot_product = 0.
                is_in_sublattice = False

                cag = np.array([self.params["U"].value,
                                self.params["V"].value,
                                self.params["W"].value])
                gaussschrerr = self.params["P"].value
                lorbroad = np.array([self.params["X"].value,
                                     self.params["Y"].value])
                anisbroad = np.array([self.params["Xe"].value,
                                      self.params["Ye"].value,
                                      self.params["Xs"].value])

                if self.peakshape == 0:
                    HL = self.params["HL"].value
                    SL = self.params["SL"].value
                    args = (cag,
                            gaussschrerr,
                            lorbroad,
                            anisbroad,
                            shkl,
                            eta_fwhm,
                            HL,
                            SL,
                            self.xn,
                            self.wn,
                            tth,
                            dsp,
                            hkls,
                            strain_direction_dot_product,
                            is_in_sublattice,
                            tth_list,
                            Ic,
                            spec_expt,
                            spec_sim)

                elif self.peakshape == 1:
                    args = (cag,
                            gaussschrerr,
                            lorbroad,
                            anisbroad,
                            shkl,
                            eta_fwhm,
                            tth,
                            dsp,
                            hkls,
                            strain_direction_dot_product,
                            is_in_sublattice,
                            tth_list,
                            Ic,
                            spec_expt,
                            spec_sim)

                elif self.peakshape == 2:
                    alpha = np.array([self.params["alpha0"].value,
                                      self.params["alpha1"].value])
                    beta = np.array([self.params["beta0"].value,
                                     self.params["beta1"].value])
                    args = (alpha,
                            beta,
                            cag,
                            gaussschrerr,
                            lorbroad,
                            anisbroad,
                            shkl,
                            eta_fwhm,
                            tth,
                            dsp,
                            hkls,
                            strain_direction_dot_product,
                            is_in_sublattice,
                            tth_list,
                            Ic,
                            spec_expt,
                            spec_sim)

                self.Iobs[p][k] = self.calc_Iobs_fcn(*args)
        self.Icalc = self.Iobs

    @property
    def weights(self):
        lo = self.lineout
        weights = np.divide(1., np.sqrt(lo.data[:,1]))
        weights[np.isinf(weights)] = 0.0

        return weights


    @property
    def bkgdegree(self):
        if "chebyshev" in self.bkgmethod.keys():
            return self.bkgmethod["chebyshev"]

    @property
    def tth_step(self):
        return (self.lineout.data[1,0]-self.lineout.data[0,0])

    @property
    def background(self):
        tth, I = self.spectrum_expt.data
        mask = self.mask[:,1]

        if "chebyshev" in self.bkgmethod.keys():
            pname = [f"{self.name}_bkg_C{ii}"
            for ii in range(self.bkgdegree)]

            coef = [self.params[p].value for p in pname]
            c = Chebyshev(coef,domain=[tth[0],tth[-1]])
            bkg = c(tth)
            bkg[mask] = np.nan

        elif 'snip1d' in self.bkgmethod.keys():
            ww = np.rint(self.bkgmethod["snip1d"][0] /
            self.tth_step).astype(np.int32)

            numiter = self.bkgmethod["snip1d"][1]

            bkg = np.squeeze(snip1d_quad(np.atleast_2d(I),
                                        w=ww, numiter=numiter))
            bkg[mask] = np.nan
        return bkg

    @property
    def spectrum_sim(self):
        tth, I = self._spectrum_sim.data
        mask = self.mask[:,1]
        # I[mask] = np.nan
        I += self.background

        return Spectrum(x=tth, y=I)

    @property
    def spectrum_expt(self):
        d = self.lineout.data
        mask = self.mask[:,1]
        # d[mask,1] = np.nan
        return Spectrum(x=d[:,0], y=d[:,1])

    @property
    def params(self):
        return self._params

    @params.setter
    def params(self, params):
        """
        set the local value of the parameters to the
        global values from the calibrator class
        """
        if isinstance(params, Parameters_lmfit):
            if hasattr(self, '_params'):
                for p in params:
                    if (p in self.lebail_param_list) or (self.name in p):
                        self._params[p].value = params[p].value
                        self._params[p].max = params[p].max
                        self._params[p].min = params[p].min
                        self._params[p].vary = params[p].vary
            else:
                from scipy.special import roots_legendre
                xn, wn = roots_legendre(16)
                self.xn = xn[8:]
                self.wn = wn[8:]
                self._params = Parameters_lmfit()
                for p in params:
                    if (p in self.lebail_param_list) or (self.name in p):
                        self._params.add(name=p,
                                         value=params[p].value,
                                         max=params[p].max,
                                         min=params[p].min,
                                         vary=params[p].vary)

            # if hasattr(self, "tth") and \
            # hasattr(self, "dsp") and \
            # hasattr(self, "lineout"):
            #     self.computespectrum()
        else:
            msg = "only lmfit Parameters class permitted"
            raise ValueError(msg)

    @property
    def lineout(self):
        return self._lineout

    @lineout.setter
    def lineout(self,lo):
        if isinstance(lo,np.ma.MaskedArray):
            self._lineout = lo
        else:
            msg = f"only masked arrays input is allowed."
            raise ValueError(msg)

        # if hasattr(self, "tth"):
        #     self.computespectrum()

    @property
    def mask(self):
        return self.lineout.mask

    @property
    def tth_list(self):
        return self.lineout[:,0].data


    @property
    def tth(self):
        return self._tth


    @tth.setter
    def tth(self, val):
        if isinstance(val, dict):
            self._tth = val
            # if hasattr(self,"dsp"):
            #     self.computespectrum()
        else:
            msg = (f"two theta vallues need "
                   f"to be in a dictionary")
            raise ValueError(msg)

    @property
    def hkls(self):
        return self._hkls
    @hkls.setter
    def hkls(self, val):
        if isinstance(val, dict):
            self._hkls = val
            # if hasattr(self,"tth") and\
            # hasattr(self,"dsp") and\
            # hasattr(self,"lineout"):
            #     self.computespectrum()
        else:
            msg = (f"two theta vallues need "
                   f"to be in a dictionary")
            raise ValueError(msg)

    @property
    def dsp(self):
        return self._dsp

    @dsp.setter
    def dsp(self, val):
        if isinstance(val, dict):
            self._dsp = val
            # self.computespectrum()
        else:
            msg = (f"two theta vallues need "
                   f"to be in a dictionary")
            raise ValueError(msg)

    @property
    def mask(self):
        return self.lineout.mask

    @property
    def tth_list(self):
        return self.lineout[:,0].data
    

    @property
    def Icalc(self):
        return self._Icalc

    @Icalc.setter
    def Icalc(self, I):
        self._Icalc = I

    @property
    def computespectrum_fcn(self):
        if self.peakshape == 0:
            return computespectrum_pvfcj
        elif self.peakshape == 1:
            return computespectrum_pvtch
        elif self.peakshape == 2:
            return computespectrum_pvpink

    @property
    def calc_Iobs_fcn(self):
        if self.peakshape == 0:
            return calc_Iobs_pvfcj
        elif self.peakshape == 1:
            return calc_Iobs_pvtch
        elif self.peakshape == 2:
            return calc_Iobs_pvpink


# hexrd/wppf/RietveldHEDM.py

import importlib.resources
import numpy as np
import warnings
from hexrd.wppf.peakfunctions import \
calc_rwp, computespectrum_pvfcj, \
computespectrum_pvtch,\
computespectrum_pvpink,\
calc_Iobs_pvfcj,\
calc_Iobs_pvtch,\
calc_Iobs_pvpink
from hexrd.wppf import wppfsupport
from hexrd.imageutil import snip1d, snip1d_quad
from hexrd.material import Material
from hexrd.valunits import valWUnit
from hexrd.wppf.texture import harmonic_model,\
pole_figures, inverse_pole_figures
from hexrd import instrument
from hexrd import imageseries
from hexrd.imageseries import omega

class RietveldHEDM:
    """
    ======================================================================
    ======================================================================

    >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab,
                    saransh1@llnl.gov
    >> @DATE:       08/24/2021 SS 1.0 original

    >> @DETAILS:    new rietveld class which is specifically designed to
    handle the HEDM style datasets. the input to this class will be
    the imageseries and the instrument class, along with the usual material,
    peakshape, background method etc. this class will also provide the
    flexibility to add the instrument parameters as refinable parameters
    and allow full 2-d fitting using the rietveld forward model. however,
    the most important test case is going to the texture inversion using the
    generalized axis distribution function for powder like HEDM datasets.
    ======================================================================
    ======================================================================
    """
    def __init__(self,
                 instrument,
                 omegaimageseries,
                 params=None,
                 phases=None,
                 bkgmethod={'chebyshev': 3},
                 peakshape='pvfcj',
                 shape_factor=1.,
                 particle_size=1.,
                 phi=0.,
                 texture_model=None):

        self.bkgmethod = bkgmethod
        self.shape_factor = shape_factor
        self.particle_size = particle_size
        self.phi = phi
        self.peakshape = peakshape
        self.texture_model = texture_model

        self._tstart = time.time()

        self.instrument = instrument
        self.omegaimageseries = omegaimageseries

        self.phases = phases
        self.params = params

        # self.PolarizationFactor()
        # self.computespectrum()

        self._tstop = time.time()

        self.niter = 0
        self.Rwplist = np.empty([0])
        self.gofFlist = np.empty([0])

    def remove_texture_parameters(self):
        if hasattr(self.texture_model):
            if self.texture_model_name == "harmonic":
                names = list(self.texture_model.coeff_loc.keys())
                if "phon" in self.params:
                    del self.params["phon"]
                for n in names:
                    if n in self.params:
                        del self.params[n]
        else:
            pass

    def add_texture_parameters(self):
        if self.texture_model_name == "harmonic":
            names = list(self.texture_model.coeff_loc.keys())
            if not "phon" in self.params:
                self.params.add("phon", value=0.0, min=0.0)
            else:
                self.params[n].value = 0.0
                self.params[n].vary = True
                self.params[n].min = 0.0
                self.params[n].max = np.inf
            for n in names:
                if not n in self.params:
                    self.params.add(n, value=0.0)
                else:
                    self.params[n].value = 0.0
                    self.params[n].vary = True
                    self.params[n].min = -np.inf
                    self.params[n].max = np.inf

    def init_texturemodel(self,
                     eta_data=(-180.,180.,10.),
                     omega_data=(-180.,180.,10.),
                     sample_symmetry="cylindrical",
                     max_degree=10
                     ):
    """
    this function initializes the texture model for the Rietveld
    class. the user needs to specify the start and stop ranges
    as well as the step sizes for eta and omega angles for the
    HEDM datasets. The range for tth is already a part of the class.
    this is likely going to be a fairly complicated function with
    a lot of moving parts. one texture model will be initialized for
    each member of the phase class. the coefficients of the texture
    model will not be a part of the main parameter class, since there
    are a lot of these coefficients. instead we will use the parameter
    class initialized inside the texture model.
    """

    pass
    # self.texturemodel = {}
    # eta = np.arange(eta_data[0],
    #                 eta_data[1]+1.,
    #                 eta_data[2])
    # omega = np.arange(omega_data[0],
    #                 omega_data[1]+1.,
    #                 omega_data[2])

    # eta = np.radians(eta)
    # omega = np.radians(omega)
    # ones = np.ones(eta.shape)

    # for p in self.phases:
    #     mat = self.phases[p]
    #     for k in self.phases.wavelength.keys()[0]:
    #         tth = self.tth[p][k]
    #         hkls = self.hkls[p][k]
    #         pfdata = {}
    #         for t,g in zip(tth,hkls):
    #             key = str(g)[1:-1].replace(" ","")
    #             tthtile = np.tile(t,[eta.shape[0],])
    #             data = None
    #             for o in omega:
    #                 omgtile = np.tile(o,[eta.shape[0],])
    #                 tmp = np.vstack((tthtile,eta,omgtile,ones)).T
    #                 if data is None:
    #                     data = tmp
    #                 else:
    #                     data = np.vstack((data,tmp))
    #             pfdata[key] = data

    #         pf = pole_figures(mat,
    #                           hkls,
    #                           pfdata)
    #         self.texturemodel[p] = harmonic_model(pf,
    #                                 sample_symmetry,
    #                                 max_degree)

    @property
    def instrument(self):
        return self._instrument

    @instrument.setter
    def instrument(self, ins):
        if isinstance(ins, instrument):
            self._instrument = ins
            self.PolarizationFactor()
            self.computespectrum()
        else:
            msg = "input is not an instrument class."
            raise RuntimeError(msg)

    @property
    def omegaimageseries(self):
        return self._omegaims

    @omegaimageseries.setter
    def omegaimageseries(self, oims):
        if isinstance(oims, omega.OmegaImageSeries):
            self._omegaims
        else:
            msg = "input is not omega image series."
            raise RuntimeError(msg)

    @property
    def init_time(self):
        return self._tstop - self._tstart

    @property
    def pixel_size(self):
        return self._pixel_size

    @pixel_size.setter
    def pixel_size(self, px_sz):
        self._pixel_size = px_sz

        if hasattr(self, "instrument"):
            if hasattr(self, "extent"):
                self.pv = PolarView(self.extent[0:2],
                            ins,
                            eta_min=self.extent[2],
                            eta_max=self.extent[3],
                            pixel_size=px_sz)
                self.prepare_polarview()

    @property
    def extent(self):
        return self._extent

    @extent.setter
    def extent(self, ext):
        self._extent = ext

        if hasattr(self, "instrument"):
            if hasattr(self, "pixel_size"):
                self.pv = PolarView(ext[0:2],
                                self.instrument,
                                eta_min=ext[2],
                                eta_max=ext[3],
                                pixel_size=self.pixel_size)
                self.prepare_polarview()

    """
    this property returns a azimuthal range over which
    the summation is performed to get the lineouts
    """
    @property
    def azimuthal_chunks(self):
        extent = self.extent
        step = self.azimuthal_step
        azlim = extent[2:]
        pxsz = self.pixel_size[1]
        shp = self.masked.shape[0]
        npix = int(np.round(step/pxsz))
        return np.r_[np.arange(0,shp,npix),shp]

    @property
    def azimuthal_step(self):
        return self._azimuthal_step

    @azimuthal_step.setter
    def azimuthal_step(self, val):
        self._azimuthal_step = val
        self.prepare_lineouts()

    @property
    def tth_min(self):
        return self.extent[0]

    @property
    def tth_max(self):
        return self.extent[1]

    @property
    def peakshape(self):
        return self._peakshape

    @peakshape.setter
    def peakshape(self, val):
        """
        @TODO make sure the parameter list
        is updated when the peakshape changes
        """
        if isinstance(val, str):
            if val == "pvfcj":
                self._peakshape = 0
            elif val == "pvtch":
                self._peakshape = 1
            elif val == "pvpink":
                self._peakshape = 2
            else:
                msg = (f"invalid peak shape string. "
                    f"must be: \n"
                    f"1. pvfcj: pseudo voight (Finger, Cox, Jephcoat)\n"
                    f"2. pvtch: pseudo voight (Thompson, Cox, Hastings)\n"
                    f"3. pvpink: Pink beam (Von Dreele)")
                raise ValueError(msg)
        elif isinstance(val, int):
            if val >=0 and val <=2:
                self._peakshape = val
            else:
                msg = (f"invalid peak shape int. "
                    f"must be: \n"
                    f"1. 0: pseudo voight (Finger, Cox, Jephcoat)\n"
                    f"2. 1: pseudo voight (Thompson, Cox, Hastings)\n"
                    f"3. 2: Pink beam (Von Dreele)")
                raise ValueError(msg)

        """
        update parameters
        """
        if hasattr(self, 'params'):
            params = wppfsupport._generate_default_parameters_Rietveld(
                    self.phases, self.peakshape)
            for p in params:
                if p in self.params:
                    params[p] = self.params[p]
            self._params = params


    @property
    def phases(self):
        return self._phases

    @phases.setter
    def phases(self, phase_info):
        """
        >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
        >> @DATE:       06/08/2020 SS 1.0 original
                        09/11/2020 SS 1.1 multiple different ways to initialize phases
                        09/14/2020 SS 1.2 added phase initialization from material.Material class
                        03/05/2021 SS 2.0 moved everything to property setter
        >> @DETAILS:    load the phases for the Rietveld fits
        """

        if(phase_info is not None):
            if(isinstance(phase_info, Phases_Rietveld)):
                """
                directly passing the phase class
                """
                self._phases = phase_info

            else:

                if(hasattr(self, 'wavelength')):
                    if(self.wavelength is not None):
                        p = Phases_Rietveld(wavelength=self.wavelength)
                else:
                    p = Phases_Rietveld()

                if(isinstance(phase_info, dict)):
                    """
                    initialize class using a dictionary with key as
                    material file and values as the name of each phase
                    """
                    for material_file in phase_info:
                        material_names = phase_info[material_file]
                        if(not isinstance(material_names, list)):
                            material_names = [material_names]
                        p.add_many(material_file, material_names)

                elif(isinstance(phase_info, str)):
                    """
                    load from a yaml file
                    """
                    if(path.exists(phase_info)):
                        p.load(phase_info)
                    else:
                        raise FileError('phase file doesn\'t exist.')

                elif(isinstance(phase_info, Material)):
                    p[phase_info.name] = Material_Rietveld(
                        fhdf=None,
                        xtal=None,
                        dmin=None,
                        material_obj=phase_info)

                elif(isinstance(phase_info, list)):
                    for mat in phase_info:
                        p[mat.name] = Material_Rietveld(
                            fhdf=None,
                            xtal=None,
                            dmin=None,
                            material_obj=mat)

                        p.num_phases += 1

                    for mat in p:
                        p[mat].pf = 1.0/p.num_phases

                self._phases = p

        self.calctth()

        for p in self.phases:
            self.phases[p].valid_shkl, \
            self.phases[p].eq_constraints, \
            self.phases[p].rqd_index, \
            self.phases[p].trig_ptype = \
            wppfsupport._required_shkl_names(self.phases[p])

    @property
    def params(self):
        return self._params

    @params.setter
    def params(self, param_info):
        """
        >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
        >> @DATE:       05/19/2020 SS 1.0 original
                        09/11/2020 SS 1.1 modified to accept multiple input types
                        03/05/2021 SS 2.0 moved everything to the property setter
        >> @DETAILS:    initialize parameter list from file. if no file given, then initialize
                        to some default values (lattice constants are for CeO2)
        """
        from scipy.special import roots_legendre
        xn, wn = roots_legendre(16)
        self.xn = xn[8:]
        self.wn = wn[8:]

        if(param_info is not None):
            pl = wppfsupport._generate_default_parameters_Rietveld(
                self.phases, self.peakshape, ptype="lmfit")
            self.rietveld_param_list = [p for p in pl]
            if(isinstance(param_info, Parameters_lmfit)):
                """
                directly passing the parameter class
                """
                self._params = param_info
                params = param_info
            else:
                params = Parameters_lmfit()

                if(isinstance(param_info, dict)):
                    """
                    initialize class using dictionary read from the yaml file
                    """
                    for k in param_info:
                        v = param_info[k]
                        params.add(k, value=np.float(v[0]),
                                   min=np.float(v[1]), max=np.float(v[2]),
                                   vary=np.bool(v[3]))

                elif(isinstance(param_info, str)):
                    """
                    load from a yaml file
                    """
                    if(path.exists(param_info)):
                        params.load(param_info)
                    else:
                        raise FileError('input spectrum file doesn\'t exist.')

                """
                this part initializes the lattice parameters in the
                """
                for p in self.phases:
                    wppfsupport._add_lp_to_params(
                        params, self.phases[p])

                self._params = params
        else:

            params = wppfsupport._generate_default_parameters_Rietveld(
                self.phases, self.peakshape, ptype="lmfit")
            self.rietveld_param_list = [p for p in params]
            wppfsupport._add_detector_geometry(params, self.instrument)
            wppfsupport._add_background(params, self.lineouts, self.bkgdegree)
            self._params = params

    @property
    def wavelength(self):
        lam = keVToAngstrom(self.instrument.beam_energy)
        return {"lam1":
                [valWUnit('lp', 'length', lam, 'angstrom'),1.0]}

    @property
    def tth_list(self):
        extent = self.extent
        shp = self.masked.shape[1]
        tthlim = extent[0:2]
        return np.linspace(self.tth_min,
                           self.tth_max,
                           shp)

    @property
    def shkl(self):
        shkl = {}
        for p in self.phases:
            shkl[p] = self.phases[p].shkl
        return shkl

    @property
    def texture_model(self):
        return self._texture_model

    @texture_model.setter
    def texture_model(self, model):
        if isinstance(model, str):

            if model == "harmonic":
                self.init_texture_model_harmonic()
            else:
                msg = f"unknown texture model"
        elif model is None:
            """
            set model to None and remove extra
            parameters from the Rietveld parameter
            list
            """
            self._texture_model = None
            self.remove_texture_parameters()

    @property
    def bkgdegree(self):
        if "chebyshev" in self.bkgmethod.keys():
            return self.bkgmethod["chebyshev"]

    @property
    def refine_background(self):
        return self._refine_background

    @refine_background.setter
    def refine_background(self, val):
        if isinstance(val, bool):
            self._refine_background = val
            prefix = "azpos"
            for ii in range(len(self.lineouts)):
                pname = [f"{prefix}{ii}_bkg_C{jj}" for jj in range(self.bkgdegree)]
                for p in pname:
                    self.params[p].vary = val
        else:
            msg = "only boolean values accepted"
            raise ValueError(msg)

# hexrd/wppf/WPPF.py

# standard imports
# ---------
from os import path
import time
import warnings


# 3rd party imports
# -----------------
import h5py

import lmfit

import numpy as np

from scipy.interpolate import CubicSpline

# hexrd imports
# -------------
from hexrd import constants
from hexrd.imageutil import snip1d_quad
from hexrd.material import Material
from hexrd.utils.multiprocess_generic import GenericMultiprocessing
from hexrd.valunits import valWUnit
from hexrd.wppf.peakfunctions import (
    calc_rwp,
    computespectrum_pvfcj,
    computespectrum_pvtch,
    computespectrum_pvpink,
    calc_Iobs_pvfcj,
    calc_Iobs_pvtch,
    calc_Iobs_pvpink,
)
from hexrd.wppf import wppfsupport
from hexrd.wppf.spectrum import Spectrum
from hexrd.wppf.parameters import Parameters
from hexrd.wppf.phase import (
    Phases_LeBail,
    Phases_Rietveld,
    Material_LeBail,
    Material_Rietveld,
)


class LeBail:
    """
    ===========================================================================

    >> @AUTHOR: Saransh Singh, Lawrence Livermore National Lab,
                saransh1@llnl.gov
    >> @DATE: 05/19/2020 SS 1.0 original
              09/11/2020 SS 1.1 expt_spectrum, params and phases now have
              multiple input option for easy integration with hexrdgui
              09/14/2020 SS 1.2 bkgmethod is now a dictionary. if method is
              'chebyshev', the the value specifies the degree of the polynomial
              to use for background estimation
              01/22/2021 SS 1.3 added intensity_init option to initialize
              intensity with structure factors if the user so chooses
              01/22/2021 SS 1.4 added option to specify background via a
              filename or numpy array
              03/12/2021 SS 1.5 added _generate_default_parameter function

    >> @DETAILS: this is the main LeBail class and contains all the refinable
                 parameters for the analysis. Since the LeBail method has no
                 structural information during refinement, the refinable
                 parameters for this model will be:

                 1. a, b, c, alpha, beta, gamma : unit cell parameters
                 2. U, V, W : cagliotti paramaters
                 3. 2theta_0 : Instrumental zero shift error
                 4. eta1, eta2, eta3 : weight factor for gaussian vs lorentzian

                 @NOTE: All angles are always going to be in degrees

    >> @PARAMETERS:
        expt_spectrum: name of file or numpy array or Spectrum
                       class of experimental intensity
        params: yaml file or dictionary or Parameter class
        phases: yaml file or dictionary or Phases_Lebail class
        wavelength: dictionary of wavelengths
        bkgmethod: method to estimate background. either spline or chebyshev
                   fit or filename or numpy array
                   (last two options added 01/22/2021 SS)
        Intensity_init: if set to none, then some power of 10 is used.
                        User has option to pass in dictionary of structure
                        factors. must ensure that the size of structure factor
                        matches the possible reflections (added 01/22/2021 SS)
    ============================================================================
    """

    def _nm(x):
        return valWUnit("lp", "length", x, "nm")

    def __init__(
        self,
        expt_spectrum=None,
        params=None,
        phases=None,
        wavelength={
            "kalpha1": [_nm(0.15406), 1.0],
            "kalpha2": [_nm(0.154443), 1.0]
        },
        bkgmethod={"spline": None},
        intensity_init=None,
        peakshape="pvfcj",
    ):

        self.peakshape = peakshape
        self.bkgmethod = bkgmethod
        self.intensity_init = intensity_init

        # self.initialize_expt_spectrum(expt_spectrum)
        self.spectrum_expt = expt_spectrum

        if wavelength is not None:
            self.wavelength = wavelength

        self._tstart = time.time()

        self.phases = phases

        self.params = params

        self.initialize_Icalc()

        self.computespectrum()

        self._tstop = time.time()
        self.tinit = self._tstop - self._tstart
        self.niter = 0
        self.Rwplist = np.empty([0])
        self.gofFlist = np.empty([0])

    def __str__(self):
        resstr = "<LeBail Fit class>\nParameters of \
        the model are as follows:\n"
        resstr += self.params.__str__()
        return resstr

    def checkangle(ang, name):

        if np.abs(ang) > 180.0:
            warnings.warn(
                name
                + " : the absolute value of angles \
                                seems to be large > 180 degrees"
            )

    def params_vary_off(self):
        """
        no params are varied
        """
        for p in self.params:
            self.params[p].vary = False

    def params_vary_on(self):
        """
        all params are varied
        """
        for p in self.params:
            self.params[p].vary = True

    def dump_hdf5(self, file):
        """
        >> @AUTHOR: Saransh Singh, Lawrence Livermore National Lab,
                    saransh1@llnl.gov
        >> @DATE:   01/19/2021 SS 1.0 original
        >> @DETAILS: write out the hdf5 file with all the spectrum, parameters
                     and phases pecified by filename or h5py.File object
        """
        if isinstance(file, str):
            fexist = path.isfile(file)
            if fexist:
                fid = h5py.File(file, "r+")
            else:
                fid = h5py.File(file, "x")

        elif isinstance(file, h5py.File):
            fid = file

        else:
            raise RuntimeError(
                "Parameters: dump_hdf5 Pass in a filename \
                string or h5py.File object"
            )

        self.phases.dump_hdf5(fid)
        self.params.dump_hdf5(fid)
        self.spectrum_expt.dump_hdf5(fid, "experimental")
        self.spectrum_sim.dump_hdf5(fid, "simulated")
        self.background.dump_hdf5(fid, "background")

    def initialize_bkg(self):
        """
        the cubic spline seems to be the ideal route in terms
        of determining the background intensity. this involves
        selecting a small (~5) number of points from the spectrum,
        usually called the anchor points. a cubic spline interpolation
        is performed on this subset to estimate the overall background.
        scipy provides some useful routines for this

        the other option implemented is the chebyshev polynomials. this
        basically automates the background determination and removes the
        user from the loop which is required for the spline type background.
        """
        if self.bkgmethod is None:
            self._background = []
            for tth in self.tth_list:
                self._background.append(Spectrum(x=tth, y=np.zeros(tth.shape)))

        elif "spline" in self.bkgmethod.keys():
            self._background = []
            self.selectpoints()
            for i, pts in enumerate(self.points):
                tth = self._spectrum_expt[i]._x
                x = pts[:, 0]
                y = pts[:, 1]
                self._background.append(self.splinefit(x, y, tth))

        elif "chebyshev" in self.bkgmethod.keys():
            self.chebyshevfit()

        elif "file" in self.bkgmethod.keys():
            if len(self._spectrum_expt) > 1:
                raise RuntimeError(
                    "initialize_bkg: \
                    file input not allowed for \
                    masked spectra."
                )
            else:
                bkg = Spectrum.from_file(self.bkgmethod["file"])
                x = bkg.x
                y = bkg.y
                cs = CubicSpline(x, y)

                yy = cs(self.tth_list)

                self._background = [Spectrum(x=self.tth_list[0], y=yy)]

        elif "array" in self.bkgmethod.keys():
            if len(self._spectrum_expt) > 1:
                raise RuntimeError(
                    "initialize_bkg: \
                    file input not allowed for \
                    masked spectra."
                )
            else:
                x = self.bkgmethod["array"][:, 0]
                y = self.bkgmethod["array"][:, 1]
                cs = CubicSpline(x, y)

                yy = cs(self._tth_list)

                self._background = [Spectrum(x=self.tth_list, y=yy)]

        elif "snip1d" in self.bkgmethod.keys():
            self._background = []
            for i, s in enumerate(self._spectrum_expt):
                if not self.tth_step:
                    ww = 3
                else:
                    if self.tth_step[i] > 0.0:
                        ww = np.rint(
                            self.bkgmethod["snip1d"][0] / self.tth_step[i]
                        ).astype(np.int32)
                    else:
                        ww = 3

                numiter = self.bkgmethod["snip1d"][1]

                yy = np.squeeze(
                    snip1d_quad(np.atleast_2d(s.y), w=ww, numiter=numiter)
                )
                self._background.append(Spectrum(x=self._tth_list[i], y=yy))

    def chebyshevfit(self):
        """
        03/08/2021 SS spectrum_expt is a list now. accounting
        for that change
        """
        self._background = []
        degree = self.bkgmethod["chebyshev"]

        for i, s in enumerate(self._spectrum_expt):
            tth = self._tth_list[i]
            if s.y.shape[0] <= degree:
                self._background.append(Spectrum(x=tth, y=np.zeros(tth.shape)))
            else:
                p = np.polynomial.Chebyshev.fit(
                    tth, s.y, degree, w=self._weights[i] ** 4
                )
                self._background.append(Spectrum(x=tth, y=p(tth)))

    def selectpoints(self):
        """
        03/08/2021 SS spectrum_expt is a list now. accounting
        for that change
        """
        # Keep matplotlib as an optional dependency
        # FIXME: move this function to where it is needed
        from pylab import plot, ginput, close, title, xlabel, ylabel

        self.points = []
        for i, s in enumerate(self._spectrum_expt):
            txt = (
                f"Select points for background estimation;\n"
                f"click middle mouse button when done. segment # {i}"
            )
            title(txt)

            plot(s.x, s.y, "-k")
            xlabel("2$\theta$")
            ylabel("intensity (a.u.)")

            self.points.append(np.asarray(ginput(0, timeout=-1)))

            close()

    # cubic spline fit of background using custom points chosen from plot
    def splinefit(self, x, y, tth):
        """
        03/08/2021 SS adding tth as input. this is the
        list of points for which background is estimated
        """
        cs = CubicSpline(x, y)
        bkg = cs(tth)
        return Spectrum(x=tth, y=bkg)

    def calctth(self):
        self.tth = {}
        self.hkls = {}
        self.dsp = {}
        for p in self.phases:
            self.tth[p] = {}
            self.hkls[p] = {}
            self.dsp[p] = {}
            for k, l in self.phases.wavelength.items():
                t = self.phases[p].getTTh(l[0].getVal('nm'))
                allowed = self.phases[p].wavelength_allowed_hkls
                t = t[allowed]
                hkl = self.phases[p].hkls[allowed, :]
                dsp = self.phases[p].dsp[allowed]
                tth_min = min(self.tth_min)
                tth_max = max(self.tth_max)
                limit = np.logical_and(t >= tth_min, t <= tth_max)
                self.tth[p][k] = t[limit]
                self.hkls[p][k] = hkl[limit, :]
                self.dsp[p][k] = dsp[limit]

    def initialize_Icalc(self):
        """
        @DATE 01/22/2021 SS modified the function so Icalc can be initialized
        with a dictionary of structure factors
        """

        self.Icalc = {}

        if self.intensity_init is None:
            if self.spectrum_expt._y.max() > 0:
                n10 = np.floor(np.log10(self.spectrum_expt._y.max())) - 2
            else:
                n10 = 0

            for p in self.phases:
                self.Icalc[p] = {}
                for k in self.phases.wavelength.keys():
                    self.Icalc[p][k] = \
                        (10 ** n10) * np.ones(self.tth[p][k].shape)

        elif isinstance(self.intensity_init, dict):
            """
            first check if intensities for all phases are present in the
            passed dictionary
            """
            for p in self.phases:
                if p not in self.intensity_init:
                    raise RuntimeError(
                        "LeBail: Intensity was initialized\
                     using custom values. However, initial values for one \
                     or more phases seem to be missing from the dictionary."
                    )
                self.Icalc[p] = {}

                """
                now check that the size of the initial intensities provided is
                consistent with the number of reflections
                    (size of initial intensity > size of hkl is allowed.
                     the unused values are ignored.)
                for this we need to step through the different wavelengths in
                the spectrum and check each of them
                """
                for k in self.phases.wavelength:
                    if k not in self.intensity_init[p]:
                        raise RuntimeError(
                            "LeBail: Intensity was initialized\
                         using custom values. However, initial values for one \
                         or more wavelengths in spectrum seem to be missing \
                         from the dictionary."
                        )

                    if (self.tth[p][k].shape[0]
                            <= self.intensity_init[p][k].shape[0]):
                        self.Icalc[p][k] = self.intensity_init[p][k][
                            0:self.tth[p][k].shape[0]
                        ]
        else:
            raise RuntimeError(
                "LeBail: Intensity_init must be either\
                 None or a dictionary"
            )

    def computespectrum(self):
        """
        >> @AUTHOR: Saransh Singh, Lawrence Livermore National Lab,
                    saransh1@llnl.gov
        >> @DATE:   06/08/2020 SS 1.0 original
        >> @DETAILS: compute the simulated spectrum
        """
        x = self.tth_list
        y = np.zeros(x.shape)
        tth_list = np.ascontiguousarray(self.tth_list)

        for iph, p in enumerate(self.phases):

            for k, l in self.phases.wavelength.items():

                Ic = self.Icalc[p][k]

                shft_c = np.cos(0.5 * np.radians(self.tth[p][k])) * self.shft
                trns_c = np.sin(np.radians(self.tth[p][k])) * self.trns
                tth = self.tth[p][k] + self.zero_error + shft_c + trns_c

                dsp = self.dsp[p][k]
                hkls = self.hkls[p][k]
                # n = np.min((tth.shape[0], Ic.shape[0]))
                shkl = self.phases[p].shkl
                name = self.phases[p].name
                eta_n = f"self.{name}_eta_fwhm"
                eta_fwhm = eval(eta_n)
                strain_direction_dot_product = 0.0
                is_in_sublattice = False

                if self.peakshape == 0:
                    args = (
                        np.array([self.U, self.V, self.W]),
                        self.P,
                        np.array([self.X, self.Y]),
                        np.array([self.Xe, self.Ye, self.Xs]),
                        shkl,
                        eta_fwhm,
                        self.HL,
                        self.SL,
                        tth,
                        dsp,
                        hkls,
                        strain_direction_dot_product,
                        is_in_sublattice,
                        tth_list,
                        Ic,
                        self.xn,
                        self.wn,
                    )

                elif self.peakshape == 1:
                    args = (
                        np.array([self.U, self.V, self.W]),
                        self.P,
                        np.array([self.X, self.Y]),
                        np.array([self.Xe, self.Ye, self.Xs]),
                        shkl,
                        eta_fwhm,
                        tth,
                        dsp,
                        hkls,
                        strain_direction_dot_product,
                        is_in_sublattice,
                        tth_list,
                        Ic,
                    )

                elif self.peakshape == 2:
                    args = (
                        np.array([self.alpha0, self.alpha1]),
                        np.array([self.beta0, self.beta1]),
                        np.array([self.U, self.V, self.W]),
                        self.P,
                        np.array([self.X, self.Y]),
                        np.array([self.Xe, self.Ye, self.Xs]),
                        shkl,
                        eta_fwhm,
                        tth,
                        dsp,
                        hkls,
                        strain_direction_dot_product,
                        is_in_sublattice,
                        tth_list,
                        Ic,
                    )

                y += self.computespectrum_fcn(*args)

        self._spectrum_sim = Spectrum(x=x, y=y)

        P = calc_num_variables(self.params)

        errvec, self.Rwp, self.gofF = calc_rwp(
            self.spectrum_sim.data_array,
            self.spectrum_expt.data_array,
            self.weights.data_array,
            P,
        )
        return errvec

    def CalcIobs(self):
        """
        >> @AUTHOR:  Saransh Singh, Lawrence Livermore National Lab,
                     saransh1@llnl.gov
        >> @DATE:    06/08/2020 SS 1.0 original
        >> @DETAILS: this is one of the main functions to partition the expt
                     intensities to overlapping peaks in the calculated pattern
        """

        self.Iobs = {}
        spec_expt = self.spectrum_expt.data_array
        spec_sim = self.spectrum_sim.data_array
        tth_list = np.ascontiguousarray(self.tth_list)
        for iph, p in enumerate(self.phases):

            self.Iobs[p] = {}
            for k, l in self.phases.wavelength.items():

                Ic = self.Icalc[p][k]

                shft_c = np.cos(0.5 * np.radians(self.tth[p][k])) * self.shft
                trns_c = np.sin(np.radians(self.tth[p][k])) * self.trns
                tth = self.tth[p][k] + self.zero_error + shft_c + trns_c

                dsp = self.dsp[p][k]
                hkls = self.hkls[p][k]
                # n = np.min((tth.shape[0], Ic.shape[0]))  # !!! not used
                shkl = self.phases[p].shkl
                name = self.phases[p].name
                eta_n = f"self.{name}_eta_fwhm"
                eta_fwhm = eval(eta_n)
                strain_direction_dot_product = 0.0
                is_in_sublattice = False

                if self.peakshape == 0:
                    args = (
                        np.array([self.U, self.V, self.W]),
                        self.P,
                        np.array([self.X, self.Y]),
                        np.array([self.Xe, self.Ye, self.Xs]),
                        shkl,
                        eta_fwhm,
                        self.HL,
                        self.SL,
                        self.xn,
                        self.wn,
                        tth,
                        dsp,
                        hkls,
                        strain_direction_dot_product,
                        is_in_sublattice,
                        tth_list,
                        Ic,
                        spec_expt,
                        spec_sim,
                    )

                elif self.peakshape == 1:
                    args = (
                        np.array([self.U, self.V, self.W]),
                        self.P,
                        np.array([self.X, self.Y]),
                        np.array([self.Xe, self.Ye, self.Xs]),
                        shkl,
                        eta_fwhm,
                        tth,
                        dsp,
                        hkls,
                        strain_direction_dot_product,
                        is_in_sublattice,
                        tth_list,
                        Ic,
                        spec_expt,
                        spec_sim,
                    )

                elif self.peakshape == 2:
                    args = (
                        np.array([self.alpha0, self.alpha1]),
                        np.array([self.beta0, self.beta1]),
                        np.array([self.U, self.V, self.W]),
                        self.P,
                        np.array([self.X, self.Y]),
                        np.array([self.Xe, self.Ye, self.Xs]),
                        shkl,
                        eta_fwhm,
                        tth,
                        dsp,
                        hkls,
                        strain_direction_dot_product,
                        is_in_sublattice,
                        tth_list,
                        Ic,
                        spec_expt,
                        spec_sim,
                    )

                self.Iobs[p][k] = self.calc_Iobs_fcn(*args)

    def calcRwp(self, params):
        """
        >> @AUTHOR:  Saransh Singh, Lawrence Livermore National Lab,
                     saransh1@llnl.gov
        >> @DATE:    05/19/2020 SS 1.0 original
        >> @DETAILS: this routine computes the rwp for a set of parameters.
                     the parameters are used to set the values in the LeBail
                     class too
        """

        self._set_params_vals_to_class(params, init=False, skip_phases=False)
        self._update_shkl(params)

        errvec = self.computespectrum()

        return errvec

    def initialize_lmfit_parameters(self):

        params = lmfit.Parameters()

        for p in self.params:
            par = self.params[p]
            if par.vary:
                params.add(p, value=par.value, min=par.lb, max=par.ub)

        return params

    def update_parameters(self):

        for p in self.res.params:
            par = self.res.params[p]
            self.params[p].value = par.value

    def RefineCycle(self, print_to_screen=True):
        """
        >> @AUTHOR:  Saransh Singh, Lawrence Livermore National Lab,
                     saransh1@llnl.gov
        >> @DATE:    06/08/2020 SS 1.0 original
                     01/28/2021 SS 1.1 added optional print_to_screen argument
        >> @DETAILS: this is one refinement cycle for the least squares,
                     typically few  10s to 100s of cycles may be required for
                     convergence
        """
        self.CalcIobs()
        self.Icalc = self.Iobs

        self.res = self.Refine()
        if self.res is not None:
            self.update_parameters()
        self.niter += 1
        self.Rwplist = np.append(self.Rwplist, self.Rwp)
        self.gofFlist = np.append(self.gofFlist, self.gofF)

        if print_to_screen:
            print(
                "Finished iteration. Rwp: \
                {:.3f} % goodness of fit: {:.3f}".format(
                    self.Rwp * 100.0, self.gofF
                )
            )

    def Refine(self):
        """
        >> @AUTHOR:  Saransh Singh, Lawrence Livermore National Lab,
                     saransh1@llnl.gov
        >> @DATE:    05/19/2020 SS 1.0 original
        >> @DETAILS: this routine performs the least squares refinement for all
                     variables which are allowed to be varied.
        """

        params = self.initialize_lmfit_parameters()

        if len(params) > 0:
            fdict = {
                "ftol": 1e-6,
                "xtol": 1e-6,
                "gtol": 1e-6,
                "verbose": 0,
                "max_nfev": 1000,
                "method": "trf",
                "jac": "2-point",
            }
            fitter = lmfit.Minimizer(self.calcRwp, params)

            res = fitter.least_squares(**fdict)
            return res
        else:
            msg = "nothing to refine. updating intensities"
            print(msg)
            errvec = self.computespectrum()
            return getattr(self, 'res', None)

    def updatespectrum(self):
        """
        >> @AUTHOR:  Saransh Singh, Lawrence Livermore National Lab,
                     saransh1@llnl.gov
        >> @DATE:    11/23/2020 SS 1.0 original
                     03/05/2021 SS 1.1 added computation and update of Rwp
        >> @DETAILS: this routine computes the spectrum for an updated list of
                     parameters intended to be used for sensitivity and
                     identifiability analysis
        """

        """
        the err variable is the difference between
        simulated and experimental spectra
        """
        # ???: is this supposed to return something, or is it incomplete?
        params = self.initialize_lmfit_parameters()
        errvec = self.calcRwp(params)

    def _update_shkl(self, params):
        """
        if certain shkls are refined, then update
        them using the params arg. else use values from
        the parameter class
        """
        shkl_dict = {}
        for p in self.phases:
            shkl_name = self.phases[p].valid_shkl
            eq_const = self.phases[p].eq_constraints
            mname = self.phases[p].name
            key = [f"{mname}_{s}" for s in shkl_name]
            for s, k in zip(shkl_name, key):
                if k in params:
                    shkl_dict[s] = params[k].value
                else:
                    shkl_dict[s] = self.params[k].value

            self.phases[p].shkl = wppfsupport._fill_shkl(shkl_dict, eq_const)

    @property
    def U(self):
        return self._U

    @U.setter
    def U(self, Uinp):
        self._U = Uinp
        return

    @property
    def V(self):
        return self._V

    @V.setter
    def V(self, Vinp):
        self._V = Vinp
        return

    @property
    def W(self):
        return self._W

    @W.setter
    def W(self, Winp):
        self._W = Winp
        return

    @property
    def X(self):
        return self._X

    @X.setter
    def X(self, Xinp):
        self._X = Xinp
        return

    @property
    def Y(self):
        return self._Y

    @Y.setter
    def Y(self, Yinp):
        self._Y = Yinp
        return

    @property
    def gamma(self):
        return self._gamma

    @gamma.setter
    def gamma(self, val):
        self._gamma = val

    @property
    def Hcag(self):
        return self._Hcag

    @Hcag.setter
    def Hcag(self, val):
        self._Hcag = val

    @property
    def HL(self):
        return self._HL

    @HL.setter
    def HL(self, val):
        self._HL = val

    @property
    def SL(self):
        return self._SL

    @SL.setter
    def SL(self, val):
        self._SL = val

    @property
    def alpha0(self):
        return self._alpha0

    @alpha0.setter
    def alpha0(self, val):
        self._alpha0 = val

    @property
    def alpha1(self):
        return self._alpha1

    @alpha1.setter
    def alpha1(self, val):
        self._alpha1 = val

    @property
    def beta0(self):
        return self._beta0

    @beta0.setter
    def beta0(self, val):
        self._beta0 = val

    @property
    def beta1(self):
        return self._beta1

    @beta1.setter
    def beta1(self, val):
        self._beta1 = val

    @property
    def tth_list(self):
        if isinstance(self.spectrum_expt._x, np.ma.MaskedArray):
            return self.spectrum_expt._x.filled()
        else:
            return self.spectrum_expt._x

    @property
    def zero_error(self):
        return self._zero_error

    @zero_error.setter
    def zero_error(self, value):
        self._zero_error = value
        return

    @property
    def eta_fwhm(self):
        return self._eta_fwhm

    @eta_fwhm.setter
    def eta_fwhm(self, val):
        self._eta_fwhm = val

    @property
    def shft(self):
        return self._shft

    @shft.setter
    def shft(self, val):
        self._shft = val

    @property
    def trns(self):
        return self._trns

    @trns.setter
    def trns(self, val):
        self._trns = val

    @property
    def peakshape(self):
        return self._peakshape

    @peakshape.setter
    def peakshape(self, val):
        """
        @TODO make sure the parameter list
        is updated when the peakshape changes
        """
        if isinstance(val, str):
            if val == "pvfcj":
                self._peakshape = 0
            elif val == "pvtch":
                self._peakshape = 1
            elif val == "pvpink":
                self._peakshape = 2
            else:
                msg = (
                    "invalid peak shape string. "
                    "must be: \n"
                    "1. pvfcj: pseudo voight (Finger, Cox, Jephcoat)\n"
                    "2. pvtch: pseudo voight (Thompson, Cox, Hastings)\n"
                    "3. pvpink: Pink beam (Von Dreele)"
                )
                raise ValueError(msg)
        elif isinstance(val, int):
            if val >= 0 and val <= 2:
                self._peakshape = val
            else:
                msg = (
                    "invalid peak shape int. "
                    "must be: \n"
                    "1. 0: pseudo voight (Finger, Cox, Jephcoat)\n"
                    "2. 1: pseudo voight (Thompson, Cox, Hastings)\n"
                    "3. 2: Pink beam (Von Dreele)"
                )
                raise ValueError(msg)

        """
        update parameters
        """
        if hasattr(self, "params"):
            params = wppfsupport._generate_default_parameters_LeBail(
                self.phases, self.peakshape
            )
            for p in params:
                if p in self.params:
                    params[p] = self.params[p]
            self._params = params
            self._set_params_vals_to_class(params, init=True, skip_phases=True)
            self.computespectrum()

    @property
    def computespectrum_fcn(self):
        if self.peakshape == 0:
            return computespectrum_pvfcj
        elif self.peakshape == 1:
            return computespectrum_pvtch
        elif self.peakshape == 2:
            return computespectrum_pvpink

    @property
    def calc_Iobs_fcn(self):
        if self.peakshape == 0:
            return calc_Iobs_pvfcj
        elif self.peakshape == 1:
            return calc_Iobs_pvtch
        elif self.peakshape == 2:
            return calc_Iobs_pvpink

    @property
    def spectrum_expt(self):
        vector_list = [s.y for s in self._spectrum_expt]

        spec_masked = join_regions(
            vector_list, self.global_index, self.global_shape
        )
        return Spectrum(x=self._tth_list_global, y=spec_masked)

    @spectrum_expt.setter
    def spectrum_expt(self, expt_spectrum):
        """
        >> @AUTHOR:  Saransh Singh, Lawrence Livermore National Lab,
                     saransh1@llnl.gov
        >> @DATE:    05/19/2020 SS 1.0 original
                     09/11/2020 SS 1.1 multiple data types accepted as input
                     09/14/2020 SS 1.2 background method chebyshev now has user
                       specified polynomial degree
                     03/03/2021 SS 1.3 moved the initialization to the property
                       definition of self.spectrum_expt
                     05/03/2021 SS 2.0 moved weight calculation and background
                       initialization to the property definition
                     03/05/2021 SS 2.1 adding support for masked array
                       np.ma.MaskedArray
                     03/08/2021 SS 3.0 spectrum_expt is now a list to deal with
                        the masked arrays
        >> @DETAILS: load the experimental spectum of 2theta-intensity
        """
        if expt_spectrum is not None:
            if isinstance(expt_spectrum, Spectrum):
                """
                directly passing the spectrum class
                """
                self._spectrum_expt = [expt_spectrum]
                # self._spectrum_expt.nan_to_zero()
                self.global_index = [(0, expt_spectrum.shape[0])]
                self.global_mask = np.zeros(
                    [
                        expt_spectrum.shape[0],
                    ],
                    dtype=np.bool,
                )
                self._tth_list = [s._x for s in self._spectrum_expt]
                self._tth_list_global = expt_spectrum._x
                self.offset = False

            elif isinstance(expt_spectrum, np.ndarray):
                """
                initialize class using a nx2 array
                """
                if np.ma.is_masked(expt_spectrum):
                    """
                    @date 03/05/2021 SS 1.0 original
                    this is an instance of masked array where there are
                    nans in the spectrum. this will have to be handled with
                    a lot of care. steps are as follows:
                    1. if array is masked array, then check if any values are
                       masked or not.
                    2. if they are then the spectrum_expt is a list of
                       individual islands of the spectrum, each with its own
                       background
                    3. Every place where spectrum_expt is used, we will do a
                       type test to figure out the logic of the operations
                    """
                    expt_spec_list, gidx = separate_regions(expt_spectrum)
                    self.global_index = gidx
                    self.global_shape = expt_spectrum.shape[0]
                    self.global_mask = expt_spectrum.mask[:, 1]
                    self._spectrum_expt = []
                    for s in expt_spec_list:
                        self._spectrum_expt.append(
                            Spectrum(
                                x=s[:, 0],
                                y=s[:, 1],
                                name="expt_spectrum"
                            )
                        )

                else:
                    max_ang = expt_spectrum[-1, 0]
                    if max_ang < np.pi:
                        warnings.warn(
                            "angles are small and appear to \
                            be in radians. please check"
                        )

                    self._spectrum_expt = [
                        Spectrum(
                            x=expt_spectrum[:, 0],
                            y=expt_spectrum[:, 1],
                            name="expt_spectrum",
                        )
                    ]

                    self.global_index = [
                        (0, self._spectrum_expt[0].x.shape[0])
                    ]
                    self.global_shape = expt_spectrum.shape[0]
                    self.global_mask = np.zeros(
                        [
                            expt_spectrum.shape[0],
                        ],
                        dtype=np.bool,
                    )

                self._tth_list = [s._x for s in self._spectrum_expt]
                self._tth_list_global = expt_spectrum[:, 0]
                self.offset = False

            elif isinstance(expt_spectrum, str):
                """
                load from a text file
                undefined behavior if text file has nans
                """
                if path.exists(expt_spectrum):
                    self._spectrum_expt = [
                        Spectrum.from_file(expt_spectrum, skip_rows=0)
                    ]
                    # self._spectrum_expt.nan_to_zero()
                    self.global_index = [
                        (0, self._spectrum_expt[0].x.shape[0])
                    ]
                    self.global_shape = self._spectrum_expt[0].x.shape[0]
                    self.global_mask = np.zeros(
                        [
                            self.global_shape,
                        ],
                        dtype=np.bool,
                    )
                else:
                    raise FileNotFoundError(
                        "input spectrum file doesn't exist."
                    )

                self._tth_list = [self._spectrum_expt[0]._x]
                self._tth_list_global = self._spectrum_expt[0]._x
                self.offset = False

            """
            03/08/2021 SS tth_min and max are now lists
            """
            self.tth_max = []
            self.tth_min = []
            self.ntth = []
            for s in self._spectrum_expt:
                self.tth_max.append(s.x.max())
                self.tth_min.append(s.x.min())
                self.ntth.append(s.x.shape[0])

            """
            03/02/2021 SS added tth_step for some computations
            related to snip background estimation
            @TODO this will not work for masked spectrum
            03/08/2021 tth_step is a list now
            """
            self.tth_step = []
            for tmi, tma, nth in zip(self.tth_min, self.tth_max, self.ntth):
                if nth > 1:
                    self.tth_step.append((tma - tmi) / nth)
                else:
                    self.tth_step.append(0.0)

            """
            @date 03/03/2021 SS
            there are cases when the intensity in the spectrum is
            negative. our approach will be to offset the spectrum to make all
            the values positive for the computation and then finally offset it
            when the computation has finished.
            03/08/2021 all quantities are lists now
            """
            for s in self._spectrum_expt:
                self.offset = []
                self.offset_val = []
                if np.any(s.y < 0.0):
                    self.offset.append(True)
                    self.offset_val.append(s.y.min())
                    s.y = s.y - s.y.min()

            """
            @date 09/24/2020 SS
            catching the cases when intensity is zero.
            for these points the weights will become
            infinite. therefore, these points will be
            masked out and assigned a weight of zero.
            In addition, if any points have negative
            intensity, they will also be assigned a zero
            weight
            03/08/2021 SS everything is a list now
            """
            self._weights = []
            for s in self._spectrum_expt:
                mask = s.y <= 0.0
                ww = np.zeros(s.y.shape)
                """also initialize statistical weights
                for the error calculation"""
                ww[~mask] = 1.0 / np.sqrt(s.y[~mask])
                self._weights.append(ww)

            self.initialize_bkg()
        else:
            raise RuntimeError("expt_spectrum setter: spectrum is None")

    @property
    def spectrum_sim(self):
        tth, inten = self._spectrum_sim.data
        inten[self.global_mask] = np.nan
        inten += self.background.y

        return Spectrum(x=tth, y=inten)

    @property
    def background(self):
        vector_list = [s.y for s in self._background]

        bkg_masked = join_regions(
            vector_list, self.global_index, self.global_shape
        )
        return Spectrum(x=self.tth_list, y=bkg_masked)

    @property
    def weights(self):
        weights_masked = join_regions(
            self._weights, self.global_index, self.global_shape
        )
        return Spectrum(x=self.tth_list, y=weights_masked)

    @property
    def params(self):
        return self._params

    @params.setter
    def params(self, param_info):
        """
        >> @AUTHOR:  Saransh Singh, Lawrence Livermore National Lab,
                     saransh1@llnl.gov
        >> @DATE:    05/19/2020 SS 1.0 original
                     09/11/2020 SS 1.1 modified to accept multiple input types
                     03/05/2021 SS 2.0 moved everything to the property setter
        >> @DETAILS: initialize parameter list from file. if no file given,
                     then initialize to some default values
                     (lattice constants are for CeO2)
        """
        from scipy.special import roots_legendre

        xn, wn = roots_legendre(16)
        self.xn = xn[8:]
        self.wn = wn[8:]

        if param_info is not None:
            if isinstance(param_info, Parameters):
                """
                directly passing the parameter class
                """
                self._params = param_info
                params = param_info

            else:
                params = Parameters()

                if isinstance(param_info, dict):
                    """
                    initialize class using dictionary read from the yaml file
                    """
                    for k, v in param_info.items():
                        params.add(
                            k,
                            value=np.float(v[0]),
                            lb=np.float(v[1]),
                            ub=np.float(v[2]),
                            vary=np.bool(v[3]),
                        )

                elif isinstance(param_info, str):
                    """
                    load from a yaml file
                    """
                    if path.exists(param_info):
                        params.load(param_info)
                    else:
                        raise FileNotFoundError(
                            "input spectrum file doesn't exist."
                        )

                """
                this part initializes the lattice parameters in the
                """
                for p in self.phases:
                    wppfsupport._add_lp_to_params(params, self.phases[p])

                self._params = params
        else:
            """
            first three are cagliotti parameters
            next two are the lorentz paramters
            final is the zero instrumental peak position error
            mixing factor calculated by Thomax, Cox, Hastings formula
            """
            params = wppfsupport._generate_default_parameters_LeBail(
                self.phases, self.peakshape
            )
            self._params = params

        self._set_params_vals_to_class(params, init=True, skip_phases=True)

    @property
    def phases(self):
        return self._phases

    @phases.setter
    def phases(self, phase_info):
        """
        >> @AUTHOR:  Saransh Singh, Lawrence Livermore National Lab,
                     saransh1@llnl.gov
        >> @DATE:    06/08/2020 SS 1.0 original
                     09/11/2020 SS 1.1 multiple different ways to initialize
                       phases
                     09/14/2020 SS 1.2 added phase initialization from
                       material.Material class
                     03/05/2021 SS 2.0 moved everything to property setter
        >> @DETAILS: load the phases for the LeBail fits
        """

        if phase_info is not None:
            if isinstance(phase_info, Phases_LeBail):
                """
                directly passing the phase class
                """
                self._phases = phase_info

            else:

                if hasattr(self, "wavelength"):
                    if self.wavelength is not None:
                        p = Phases_LeBail(wavelength=self.wavelength)
                else:
                    p = Phases_LeBail()

                if isinstance(phase_info, dict):
                    """
                    initialize class using a dictionary with key as
                    material file and values as the name of each phase
                    """
                    for material_file in phase_info:
                        material_names = phase_info[material_file]
                        if not isinstance(material_names, list):
                            material_names = [material_names]
                        p.add_many(material_file, material_names)

                elif isinstance(phase_info, str):
                    """
                    load from a yaml file
                    """
                    if path.exists(phase_info):
                        p.load(phase_info)
                    else:
                        raise FileNotFoundError("phase file doesn't exist.")

                elif isinstance(phase_info, Material):
                    p[phase_info.name] = Material_LeBail(
                        fhdf=None, xtal=None,
                        dmin=None, material_obj=phase_info
                    )

                elif isinstance(phase_info, list):
                    for mat in phase_info:
                        p[mat.name] = Material_LeBail(
                            fhdf=None, xtal=None,
                            dmin=None, material_obj=mat
                        )

                        p.num_phases += 1

                    for mat in p:
                        p[mat].pf = 1.0 / p.num_phases

                self._phases = p

        self.calctth()

        for p in self.phases:
            (
                self.phases[p].valid_shkl,
                self.phases[p].eq_constraints,
                self.phases[p].rqd_index,
                self.phases[p].trig_ptype,
            ) = wppfsupport._required_shkl_names(self.phases[p])

    def _set_params_vals_to_class(self, params, init=False, skip_phases=False):
        """
        @date 03/12/2021 SS 1.0 original
        take values in parameters and set the
        corresponding class values with the same
        name
        """
        for p in params:
            if init:
                setattr(self, p, params[p].value)
            else:
                if hasattr(self, p):
                    setattr(self, p, params[p].value)

        if not skip_phases:

            updated_lp = False

            for p in self.phases:
                mat = self.phases[p]
                """
                PART 1: update the lattice parameters
                """
                lp = []
                lpvary = False
                pre = p + "_"

                name = [f"{pre}{x}" for x in wppfsupport._lpname]

                for nn in name:
                    if nn in params:
                        if params[nn].vary:
                            lpvary = True
                        lp.append(params[nn].value)
                    elif nn in self.params:
                        lp.append(self.params[nn].value)

                if not lpvary:
                    pass
                else:
                    lp = self.phases[p].Required_lp(lp)
                    mat.lparms = np.array(lp)
                    mat._calcrmt()
                    updated_lp = True

            if updated_lp:
                self.calctth()


def _nm(x):
    return valWUnit("lp", "length", x, "nm")


def extract_intensities(
    polar_view,
    tth_array,
    params=None,
    phases=None,
    wavelength={"kalpha1": _nm(0.15406), "kalpha2": _nm(0.154443)},
    bkgmethod={"chebyshev": 10},
    intensity_init=None,
    termination_condition={"rwp_perct_change": 0.05, "max_iter": 100},
    peakshape="pvtch",
):
    """
    ===========================================================================

    >> @AUTHOR:  Saransh Singh, Lanwrence Livermore National Lab,
                 saransh1@llnl.gov
    >> @DATE:    01/28/2021 SS 1.0 original
                 03/03/2021 SS 1.1 removed detector_mask since polar_view is
                 now a masked array
    >> @DETAILS: this function is used for extracting the experimental pole
                 figure intensities from the polar 2theta-eta map. The workflow
                 is to simply run the LeBail class, in parallel, over the
                 different azimuthal profiles and return the Icalc values for
                 the different wavelengths in the calculation. For now, the
                 multiprocessing is done using the multiprocessing module which
                 comes natively with python. Extension to MPI will be done
                 later if necessary.
    >> @PARAMS   polar_view: mxn array with the polar view. the parallelization
                             is done !!! this is now a masked numpy array !!!
                             over "m" i.e. the eta dimension
                    tth_array: nx1 array with two theta values at each sampling
                               point
                    params: parameter values for the LeBail class. Could be in
                            the form of yaml file, dictionary or Parameter
                            class
                    phases: materials to use in intensity extraction. could be
                            a list of material objects, or file or dictionary
                    wavelength: dictionary of wavelengths to be used in the
                                computation
                    bkgmethod: "spline" or "chebyshev" or "snip"
                                default is chebyshev
                    intensity_init: initial intensities for each reflection.
                                    If none, then it is specified to some power
                                    of 10 depending on maximum intensity in
                                    spectrum (only used for powder simulator)
    ============================================================================
    """

    # prepare the data file to distribute suing multiprocessing
    data_inp_list = []

    # check if the dimensions all match
    if polar_view.shape[1] != tth_array.shape[0]:
        raise RuntimeError(
            "WPPF : extract_intensities : \
                            inconsistent dimensions \
                            of polar_view and tth_array variables."
        )

    non_zeros_index = []
    for i in range(polar_view.shape[0]):
        d = polar_view[i, :]
        # make sure that there is atleast one nonzero pixel

        if np.sum(~d.mask) > 1:
            data = np.ma.stack((tth_array, d)).T
            data_inp_list.append(data)
            non_zeros_index.append(i)

    kwargs = {
        "params": params,
        "phases": phases,
        "wavelength": wavelength,
        "bkgmethod": bkgmethod,
        "termination_condition": termination_condition,
        "peakshape": peakshape,
    }

    P = GenericMultiprocessing()
    results = P.parallelise_function(
        data_inp_list, single_azimuthal_extraction, **kwargs
    )

    """
    process the outputs from the multiprocessing to make the
    simulated polar views, tables of hkl <--> intensity etc. at
    each azimuthal location
    in this section, all the rows which had no pixels
    falling on detector will be handles
    separately
    """
    pv_simulated = np.zeros(polar_view.shape)
    extracted_intensities = []
    hkls = []
    tths = []
    for i in range(len(non_zeros_index)):
        idx = non_zeros_index[i]
        xp, yp, rwp, Icalc, hkl, tth = results[i]

        intp_int = np.interp(tth_array, xp, yp, left=0.0, right=0.0)

        pv_simulated[idx, :] = intp_int

        extracted_intensities.append(Icalc)
        hkls.append(hkl)
        tths.append(tth)

    """
    make the values outside detector NaNs and convert to masked array
    """
    pv_simulated[polar_view.mask] = np.nan
    pv_simulated = np.ma.masked_array(
        pv_simulated, mask=np.isnan(pv_simulated)
    )

    return extracted_intensities, hkls, tths, non_zeros_index, pv_simulated


def single_azimuthal_extraction(
    expt_spectrum,
    params=None,
    phases=None,
    wavelength={"kalpha1": _nm(0.15406), "kalpha2": _nm(0.154443)},
    bkgmethod={"chebyshev": 10},
    intensity_init=None,
    termination_condition=None,
    peakshape="pvtch",
):

    kwargs = {
        "expt_spectrum": expt_spectrum,
        "params": params,
        "phases": phases,
        "wavelength": wavelength,
        "bkgmethod": bkgmethod,
        "peakshape": peakshape,
    }

    # get termination conditions for the LeBail refinement
    del_rwp = termination_condition["rwp_perct_change"]
    max_iter = termination_condition["max_iter"]

    L = LeBail(**kwargs)

    rel_error = 1.0
    init_error = 1.0
    niter = 0

    # when change in Rwp < 0.05% or reached maximum iteration
    while rel_error > del_rwp and niter < max_iter:
        L.RefineCycle(print_to_screen=False)
        rel_error = 100.0 * np.abs((L.Rwp - init_error))
        init_error = L.Rwp
        niter += 1

    res = (L.spectrum_sim._x, L.spectrum_sim._y, L.Rwp, L.Iobs, L.hkls, L.tth)
    return res


class Rietveld:
    """
    ===========================================================================

    >> @AUTHOR:  Saransh Singh, Lawrence Livermore National Lab,
                 saransh1@llnl.gov
    >> @DATE:    01/08/2020 SS 1.0 original
                 07/13/2020 SS 2.0 complete rewrite to include new
                   parameter/material/pattern class
                 02/01/2021 SS 2.1 peak shapes from Thompson,Cox and Hastings
                   formula using X anf Y parameters for the lorentzian peak
                   widths
    >> @DETAILS: this is the main rietveld class and contains all the refinable
                 parameters for the analysis. the member classes are as follows
                 (in order of initialization):
                    1. Spectrum     contains the experimental spectrum
                    2. Background   contains the background extracted from
                                    spectrum
                    3. Refine       contains all the machinery for refinement
    ============================================================================
    """

    def __init__(
        self,
        expt_spectrum=None,
        params=None,
        phases=None,
        wavelength={"kalpha1": [_nm(0.15406), 1.0],
                    "kalpha2": [_nm(0.154443), 0.52]},
        bkgmethod={"spline": None},
        peakshape="pvfcj",
        shape_factor=1.0,
        particle_size=1.0,
        phi=0.0,
    ):

        self.bkgmethod = bkgmethod
        self.shape_factor = shape_factor
        self.particle_size = particle_size
        self.phi = phi
        self.peakshape = peakshape
        self.spectrum_expt = expt_spectrum

        self._tstart = time.time()

        if wavelength is not None:
            self.wavelength = wavelength
            for k, v in self.wavelength.items():
                v[0] = valWUnit("lp", "length", v[0].getVal("nm"), "nm")

        self.phases = phases

        self.params = params

        self.PolarizationFactor()
        self.computespectrum()

        self._tstop = time.time()
        self.tinit = self._tstop - self._tstart

        self.niter = 0
        self.Rwplist = np.empty([0])
        self.gofFlist = np.empty([0])

    def __str__(self):
        resstr = "<Rietveld Fit class>\nParameters of \
        the model are as follows:\n"
        resstr += self.params.__str__()
        return resstr

    def initialize_bkg(self):
        """
        the cubic spline seems to be the ideal route in terms
        of determining the background intensity. this involves
        selecting a small (~5) number of points from the spectrum,
        usually called the anchor points. a cubic spline interpolation
        is performed on this subset to estimate the overall background.
        scipy provides some useful routines for this

        the other option implemented is the chebyshev polynomials. this
        basically automates the background determination and removes the
        user from the loop which is required for the spline type background.
        """
        if self.bkgmethod is None:
            self._background = []
            for tth in self.tth_list:
                self._background.append(Spectrum(x=tth, y=np.zeros(tth.shape)))

        elif "spline" in self.bkgmethod.keys():
            self._background = []
            self.selectpoints()
            for i, pts in enumerate(self.points):
                tth = self._spectrum_expt[i]._x
                x = pts[:, 0]
                y = pts[:, 1]
                self._background.append(self.splinefit(x, y, tth))

        elif "chebyshev" in self.bkgmethod.keys():
            self.chebyshevfit()

        elif "file" in self.bkgmethod.keys():
            if len(self._spectrum_expt) > 1:
                raise RuntimeError(
                    "initialize_bkg: \
                    file input not allowed for \
                    masked spectra."
                )
            else:
                bkg = Spectrum.from_file(self.bkgmethod["file"])
                x = bkg.x
                y = bkg.y
                cs = CubicSpline(x, y)

                yy = cs(self.tth_list)

                self._background = [Spectrum(x=self.tth_list[0], y=yy)]

        elif "array" in self.bkgmethod.keys():
            if len(self._spectrum_expt) > 1:
                raise RuntimeError(
                    "initialize_bkg: \
                    file input not allowed for \
                    masked spectra."
                )
            else:
                x = self.bkgmethod["array"][:, 0]
                y = self.bkgmethod["array"][:, 1]
                cs = CubicSpline(x, y)

                yy = cs(self._tth_list)

                self._background = [Spectrum(x=self.tth_list, y=yy)]

        elif "snip1d" in self.bkgmethod.keys():
            self._background = []
            for i, s in enumerate(self._spectrum_expt):
                if not self.tth_step:
                    ww = 3
                else:
                    if self.tth_step[i] > 0.0:
                        ww = np.rint(
                            self.bkgmethod["snip1d"][0] / self.tth_step[i]
                        ).astype(np.int32)
                    else:
                        ww = 3

                numiter = self.bkgmethod["snip1d"][1]

                yy = np.squeeze(
                    snip1d_quad(np.atleast_2d(s.y), w=ww, numiter=numiter)
                )
                self._background.append(Spectrum(x=self._tth_list[i], y=yy))

    def chebyshevfit(self):
        """
        03/08/2021 SS spectrum_expt is a list now. accounting
        for that change
        """
        self._background = []
        degree = self.bkgmethod["chebyshev"]
        for i, s in enumerate(self._spectrum_expt):
            tth = self._tth_list[i]
            p = np.polynomial.Chebyshev.fit(
                tth, s.y, degree, w=self._weights[i] ** 2
            )
            self._background.append(Spectrum(x=tth, y=p(tth)))

    def selectpoints(self):
        """
        03/08/2021 SS spectrum_expt is a list now. accounting
        for that change
        """

        # Keep matplotlib as an optional dependency
        from pylab import plot, ginput, close, title, xlabel, ylabel

        self.points = []
        for i, s in enumerate(self._spectrum_expt):
            txt = (
                f"Select points for background estimation;\n"
                f"click middle mouse button when done. segment # {i}"
            )
            title(txt)

            plot(s.x, s.y, "-k")
            xlabel("2$\theta$")
            ylabel("intensity (a.u.)")

            self.points.append(np.asarray(ginput(0, timeout=-1)))

            close()

    # cubic spline fit of background using custom points chosen from plot
    def splinefit(self, x, y, tth):
        """
        03/08/2021 SS adding tth as input. this is the
        list of points for which background is estimated
        """
        cs = CubicSpline(x, y)
        bkg = cs(tth)
        return Spectrum(x=tth, y=bkg)

    def calctth(self):
        self.tth = {}
        self.hkls = {}
        self.dsp = {}
        self.limit = {}
        for p in self.phases:
            self.tth[p] = {}
            self.hkls[p] = {}
            self.dsp[p] = {}
            self.limit[p] = {}

            for k, l in self.phases.wavelength.items():
                t = self.phases[p][k].getTTh(l[0].getVal('nm'))
                allowed = self.phases[p][k].wavelength_allowed_hkls
                t = t[allowed]
                hkl = self.phases[p][k].hkls[allowed, :]
                dsp = self.phases[p][k].dsp[allowed]
                tth_min = min(self.tth_min)
                tth_max = max(self.tth_max)
                limit = np.logical_and(t >= tth_min, t <= tth_max)
                self.limit[p][k] = limit
                self.tth[p][k] = t[limit]
                self.hkls[p][k] = hkl[limit, :]
                self.dsp[p][k] = dsp[limit]

    def calcsf(self):
        self.sf = {}
        self.sf_raw = {}
        self.extinction = {}
        self.absorption = {}
        for p in self.phases:
            self.sf[p] = {}
            self.sf_raw[p] = {}
            self.extinction[p] = {}
            self.absorption[p] = {}
            for k, l in self.phases.wavelength.items():
                w = l[0].getVal("nm")
                w_int = l[1]
                tth = self.tth[p][k]
                # allowed = self.phases[p][k].wavelength_allowed_hkls
                # limit = self.limit[p][k]
                self.sf[p][k], self.sf_raw[p][k] = \
                    self.phases[p][k].CalcXRSF(w, w_int)

                self.extinction[p][k] = self.phases[p][k].calc_extinction(
                    10.0 * w,
                    tth,
                    self.sf_raw[p][k],
                    self.shape_factor,
                    self.particle_size,
                )
                self.absorption[p][k] = self.phases[p][k].calc_absorption(
                    tth, self.phi, 10.0 * w
                )

    def PolarizationFactor(self):

        # tth = self.tth
        self.LP = {}
        Ph = self.Ph
        for p in self.phases:
            self.LP[p] = {}
            for k, l in self.phases.wavelength.items():
                t = np.radians(self.tth[p][k])
                self.LP[p][k] = (
                    (1 + Ph * np.cos(t) ** 2)
                    / np.cos(0.5 * t)
                    / np.sin(0.5 * t) ** 2
                    #/ (2.0 * (1 + Ph))
                )

    def computespectrum(self):
        """
        >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab
        >> @EMAIL:      saransh1@llnl.gov
        >> @DATE:       06/08/2020 SS 1.0 original
        >> @DETAILS:    compute the simulated spectrum
        """
        x = self.tth_list
        y = np.zeros(x.shape)
        tth_list = np.ascontiguousarray(self.tth_list)

        for iph, p in enumerate(self.phases):

            for k, l in self.phases.wavelength.items():

                shft_c = np.cos(0.5 * np.radians(self.tth[p][k])) * self.shft
                trns_c = np.sin(np.radians(self.tth[p][k])) * self.trns
                tth = self.tth[p][k] + self.zero_error + shft_c + trns_c

                pf = self.phases[p][k].pf / self.phases[p][k].vol ** 2
                sf = self.sf[p][k]
                lp = self.LP[p][k]
                extinction = self.extinction[p][k]
                absorption = self.absorption[p][k]

                n = np.min((tth.shape[0], sf.shape[0], lp.shape[0]))

                tth = tth[:n]
                sf = sf[:n]
                lp = lp[:n]
                extinction = extinction[:n]
                absorption = absorption[:n]

                Ic = self.scale * pf * sf * lp  # *extinction*absorption

                dsp = self.dsp[p][k]
                hkls = self.hkls[p][k]

                shkl = self.phases[p][k].shkl
                name = self.phases[p][k].name
                eta_n = f"self.{name}_eta_fwhm"
                eta_fwhm = eval(eta_n)
                strain_direction_dot_product = 0.0
                is_in_sublattice = False

                if self.peakshape == 0:
                    args = (
                        np.array([self.U, self.V, self.W]),
                        self.P,
                        np.array([self.X, self.Y]),
                        np.array([self.Xe, self.Ye, self.Xs]),
                        shkl,
                        eta_fwhm,
                        self.HL,
                        self.SL,
                        tth,
                        dsp,
                        hkls,
                        strain_direction_dot_product,
                        is_in_sublattice,
                        tth_list,
                        Ic,
                        self.xn,
                        self.wn,
                    )

                elif self.peakshape == 1:
                    args = (
                        np.array([self.U, self.V, self.W]),
                        self.P,
                        np.array([self.X, self.Y]),
                        np.array([self.Xe, self.Ye, self.Xs]),
                        shkl,
                        eta_fwhm,
                        tth,
                        dsp,
                        hkls,
                        strain_direction_dot_product,
                        is_in_sublattice,
                        tth_list,
                        Ic,
                    )

                elif self.peakshape == 2:
                    args = (
                        np.array([self.alpha0, self.alpha1]),
                        np.array([self.beta0, self.beta1]),
                        np.array([self.U, self.V, self.W]),
                        self.P,
                        np.array([self.X, self.Y]),
                        np.array([self.Xe, self.Ye, self.Xs]),
                        shkl,
                        eta_fwhm,
                        tth,
                        dsp,
                        hkls,
                        strain_direction_dot_product,
                        is_in_sublattice,
                        tth_list,
                        Ic,
                    )

                y += self.computespectrum_fcn(*args)

        self._spectrum_sim = Spectrum(x=x, y=y)

        P = calc_num_variables(self.params)

        errvec, self.Rwp, self.gofF = calc_rwp(
            self.spectrum_sim.data_array,
            self.spectrum_expt.data_array,
            self.weights.data_array,
            P,
        )
        return errvec

    def calcRwp(self, params):
        """
        >> @AUTHOR:  Saransh Singh, Lawrence Livermore National Lab,
                     saransh1@llnl.gov
        >> @DATE:    05/19/2020 SS 1.0 original
        >> @DETAILS: this routine computes the weighted error between
                     calculated and experimental spectra. goodness of fit is
                     also calculated. the weights are the inverse squareroot of
                     the experimental intensities
        """

        """
        the err variable is the difference between
        simulated and experimental spectra
        """
        self._set_params_vals_to_class(params, init=False, skip_phases=False)
        self._update_shkl(params)
        errvec = self.computespectrum()

        return errvec

    def initialize_lmfit_parameters(self):

        params = lmfit.Parameters()

        for p in self.params:
            par = self.params[p]
            if par.vary:
                params.add(p, value=par.value, min=par.lb, max=par.ub)

        return params

    def update_parameters(self):

        for p in self.res.params:
            par = self.res.params[p]
            self.params[p].value = par.value

    def Refine(self):
        """
        >> @AUTHOR:  Saransh Singh, Lawrence Livermore National Lab,
                     saransh1@llnl.gov
        >> @DATE:    05/19/2020 SS 1.0 original
        >> @DETAILS: this routine performs the least squares refinement for all
                     variables that are allowed to be varied.
        """

        params = self.initialize_lmfit_parameters()

        if len(params) > 0:
            fdict = {
                "ftol": 1e-6,
                "xtol": 1e-6,
                "gtol": 1e-6,
                "verbose": 0,
                "max_nfev": 1000,
                "method": "trf",
                "jac": "2-point",
            }

            fitter = lmfit.Minimizer(self.calcRwp, params)

            self.res = fitter.least_squares(**fdict)

            self.update_parameters()

            self.niter += 1
            self.Rwplist = np.append(self.Rwplist, self.Rwp)
            self.gofFlist = np.append(self.gofFlist, self.gofF)

            print(
                "Finished iteration. Rwp: {:.3f} % goodness of \
                  fit: {:.3f}".format(
                    self.Rwp * 100.0, self.gofF
                )
            )
        else:
            print("Nothing to refine...")

    def _set_params_vals_to_class(self, params, init=False, skip_phases=False):
        """
        @date: 03/12/2021 SS 1.0 original
        @details: set the values from parameters to the Rietveld class
        """
        for p in params:
            if init:
                setattr(self, p, params[p].value)
            else:
                if hasattr(self, p):
                    setattr(self, p, params[p].value)
        if not skip_phases:
            updated_lp = False
            updated_atominfo = False
            pf = np.zeros([self.phases.num_phases,])
            pf_cur = self.phases.phase_fraction.copy()
            for ii, p in enumerate(self.phases):
                name = f"{p}_phase_fraction"
                if name in params:
                    pf[ii] = params[name].value
                else:
                    pf[ii] = pf_cur[ii]

                for lpi in self.phases[p]:
                    mat = self.phases[p][lpi]

                    """
                    PART 1: update the lattice parameters
                    """
                    lp = []
                    lpvary = False
                    pre = f"{p}_"
                    name = [f"{pre}{x}" for x in wppfsupport._lpname]
                    for nn in name:
                        if nn in params:
                            if params[nn].vary:
                                lpvary = True
                            lp.append(params[nn].value)
                        elif nn in self.params:
                            lp.append(self.params[nn].value)

                    if not lpvary:
                        pass
                    else:
                        lp = self.phases[p][lpi].Required_lp(lp)
                        self.phases[p][lpi].lparms = np.array(lp)
                        updated_lp = True
                    """
                    PART 2: update the atom info
                    """
                    atom_type = mat.atom_type
                    atom_label = wppfsupport._getnumber(atom_type)

                    for i in range(atom_type.shape[0]):
                        Z = atom_type[i]
                        elem = constants.ptableinverse[Z]
                        nx = f"{p}_{elem}{atom_label[i]}_x"
                        ny = f"{p}_{elem}{atom_label[i]}_y"
                        nz = f"{p}_{elem}{atom_label[i]}_z"
                        oc = f"{p}_{elem}{atom_label[i]}_occ"

                        if mat.aniU:
                            Un = []
                            for j in range(6):
                                Un.append(
                                    (
                                        f"{p}_{elem}"
                                        f"{atom_label[i]}"
                                        f"_{wppfsupport._nameU[j]}"
                                    )
                                )
                        else:
                            dw = f"{p}_{elem}{atom_label[i]}_dw"

                        if nx in params:
                            x = params[nx].value
                            updated_atominfo = True
                        else:
                            x = self.params[nx].value

                        if ny in params:
                            y = params[ny].value
                            updated_atominfo = True
                        else:
                            y = self.params[ny].value

                        if nz in params:
                            z = params[nz].value
                            updated_atominfo = True
                        else:
                            z = self.params[nz].value

                        if oc in params:
                            oc = params[oc].value
                            updated_atominfo = True
                        else:
                            oc = self.params[oc].value

                        if mat.aniU:
                            U = []
                            for j in range(6):
                                if Un[j] in params:
                                    updated_atominfo = True
                                    U.append(params[Un[j]].value)
                                else:
                                    U.append(self.params[Un[j]].value)
                            U = np.array(U)
                            mat.U[i, :] = U
                        else:
                            if dw in params:
                                dw = params[dw].value
                                updated_atominfo = True
                            else:
                                dw = self.params[dw].value
                            mat.U[i] = dw

                        mat.atom_pos[i, :] = np.array([x, y, z, oc])

                    if mat.aniU:
                        mat.calcBetaij()
                    if updated_lp:
                        mat._calcrmt()

                if updated_lp:
                    self.calctth()

                if updated_lp or updated_atominfo:
                    self.calcsf()

            self.phases.phase_fraction = pf

    def _update_shkl(self, params):
        """
        if certain shkls are refined, then update
        them using the params arg. else use values from
        the parameter class
        """
        shkl_dict = {}
        for p in self.phases:
            for k in self.phases[p]:
                shkl_name = self.phases[p][k].valid_shkl
                eq_const = self.phases[p][k].eq_constraints
                mname = self.phases[p][k].name
                key = [f"{mname}_{s}" for s in shkl_name]
                for s, kk in zip(shkl_name, key):
                    if kk in params:
                        shkl_dict[s] = params[kk].value
                    else:
                        shkl_dict[s] = self.params[kk].value

                self.phases[p][k].shkl = wppfsupport._fill_shkl(
                    shkl_dict, eq_const
                )

    @property
    def params(self):
        return self._params

    @params.setter
    def params(self, param_info):
        """
        >> @AUTHOR:  Saransh Singh, Lawrence Livermore National Lab,
                    bsaransh1@llnl.gov
        >> @DATE:    05/19/2020 SS 1.0 original
        >>           07/15/2020 SS 1.1 modified to add lattice parameters,
                       atom positions and isotropic DW factors
                     02/01/2021 SS 2.0 modified to follow same input style as
                       LeBail class with inputs of Parameter class, dict or
                       filename valid
        >> @DETAILS: initialize parameter list from file. if no file given,
                     then initialize to some default values
                     (lattice constants are for CeO2)
        """
        from scipy.special import roots_legendre

        xn, wn = roots_legendre(16)
        self.xn = xn[8:]
        self.wn = wn[8:]
        if param_info is not None:
            if isinstance(param_info, Parameters):
                """
                directly passing the parameter class
                """
                self._params = param_info
                params = param_info
            else:
                params = Parameters()

                if isinstance(param_info, dict):
                    """
                    initialize class using dictionary read from the yaml file
                    """
                    for k, v in param_info.items():
                        params.add(
                            k,
                            value=np.float(v[0]),
                            lb=np.float(v[1]),
                            ub=np.float(v[2]),
                            vary=np.bool(v[3]),
                        )

                elif isinstance(param_info, str):
                    """
                    load from a yaml file
                    """
                    if path.exists(param_info):
                        params.load(param_info)
                    else:
                        raise FileNotFoundError(
                            "input spectrum file doesn't exist."
                        )

                    """
                    this part initializes the lattice parameters, atom
                    positions in asymmetric unit, occupation and the isotropic
                    debye waller factor. the anisotropic DW factors will be
                    added in the future
                    """
                    for p in self.phases:
                        for lpi in self.phases[p]:
                            wppfsupport._add_atominfo_to_params(
                                params, self.phases[p][lpi]
                            )

                self._params = params

        else:
            """
            first three are cagliotti parameters
            next are the lorentz paramters
            a scale parameter and
            final is the zero instrumental peak position error
            """
            params = wppfsupport._generate_default_parameters_Rietveld(
                self.phases, self.peakshape
            )
            self._params = params

        self._set_params_vals_to_class(params, init=True, skip_phases=True)

    @property
    def spectrum_expt(self):
        vector_list = [s.y for s in self._spectrum_expt]

        spec_masked = join_regions(
            vector_list, self.global_index, self.global_shape
        )
        return Spectrum(x=self._tth_list_global, y=spec_masked)

    @spectrum_expt.setter
    def spectrum_expt(self, expt_spectrum):
        """
        >> @AUTHOR:  Saransh Singh, Lawrence Livermore National Lab,
                     saransh1@llnl.gov
        >> @DATE:    05/19/2020 SS 1.0 original
                     09/11/2020 SS 1.1 multiple data types accepted as input
                     09/14/2020 SS 1.2 background method chebyshev now has user
                       specified polynomial degree
                     03/03/2021 SS 1.3 moved the initialization to the property
                       definition of self.spectrum_expt
                     05/03/2021 SS 2.0 moved weight calculation and background
                       initialization to the property definition
                     03/05/2021 SS 2.1 adding support for masked array
                       np.ma.MaskedArray
                     03/08/2021 SS 3.0 spectrum_expt is now a list to deal with
                       the masked arrays
        >> @DETAILS: load the experimental spectum of 2theta-intensity
        """
        if expt_spectrum is not None:
            if isinstance(expt_spectrum, Spectrum):
                """
                directly passing the spectrum class
                """
                self._spectrum_expt = [expt_spectrum]
                # self._spectrum_expt.nan_to_zero()
                self.global_index = [(0, expt_spectrum.shape[0])]
                self.global_mask = np.zeros(
                    [
                        expt_spectrum.shape[0],
                    ],
                    dtype=np.bool,
                )
                self._tth_list = [s._x for s in self._spectrum_expt]
                self._tth_list_global = expt_spectrum._x
                self.offset = False

            elif isinstance(expt_spectrum, np.ndarray):
                """
                initialize class using a nx2 array
                """
                if np.ma.is_masked(expt_spectrum):
                    """
                    @date 03/05/2021 SS 1.0 original
                    this is an instance of masked array where there are
                    nans in the spectrum. this will have to be handled with
                    a lot of care. steps are as follows:
                    1. if array is masked array, then check if any values are
                    masked or not.
                    2. if they are then the spectrum_expt is a list of
                    individual islands of the spectrum, each with its own
                    background
                    3. Every place where spectrum_expt is used, we will do a
                    type test to figure out the logic of the operations
                    """
                    expt_spec_list, gidx = separate_regions(expt_spectrum)
                    self.global_index = gidx
                    self.global_shape = expt_spectrum.shape[0]
                    self.global_mask = expt_spectrum.mask[:, 1]
                    self._spectrum_expt = []
                    for s in expt_spec_list:
                        self._spectrum_expt.append(
                            Spectrum(x=s[:, 0],
                                     y=s[:, 1],
                                     name="expt_spectrum")
                        )

                else:
                    max_ang = expt_spectrum[-1, 0]
                    if max_ang < np.pi:
                        warnings.warn(
                            "angles are small and appear to \
                            be in radians. please check"
                        )

                    self._spectrum_expt = [
                        Spectrum(
                            x=expt_spectrum[:, 0],
                            y=expt_spectrum[:, 1],
                            name="expt_spectrum",
                        )
                    ]

                    self.global_index = [
                        (0, self._spectrum_expt[0].x.shape[0])
                    ]
                    self.global_shape = expt_spectrum.shape[0]
                    self.global_mask = np.zeros(
                        [
                            expt_spectrum.shape[0],
                        ],
                        dtype=np.bool,
                    )

                self._tth_list = [s._x for s in self._spectrum_expt]
                self._tth_list_global = expt_spectrum[:, 0]
                self.offset = False

            elif isinstance(expt_spectrum, str):
                """
                load from a text file
                undefined behavior if text file has nans
                """
                if path.exists(expt_spectrum):
                    self._spectrum_expt = [
                        Spectrum.from_file(expt_spectrum, skip_rows=0)
                    ]
                    # self._spectrum_expt.nan_to_zero()
                    self.global_index = [
                        (0, self._spectrum_expt[0].x.shape[0])
                    ]
                    self.global_shape = self._spectrum_expt[0].x.shape[0]
                    self.global_mask = np.zeros(
                        [
                            self.global_shape,
                        ],
                        dtype=np.bool,
                    )
                else:
                    raise FileNotFoundError(
                        "input spectrum file doesn't exist."
                    )

                self._tth_list = [self._spectrum_expt[0]._x]
                self._tth_list_global = self._spectrum_expt[0]._x
                self.offset = False

            """
            03/08/2021 SS tth_min and max are now lists
            """
            self.tth_max = []
            self.tth_min = []
            self.ntth = []
            for s in self._spectrum_expt:
                self.tth_max.append(s.x.max())
                self.tth_min.append(s.x.min())
                self.ntth.append(s.x.shape[0])

            """
            03/02/2021 SS added tth_step for some computations
            related to snip background estimation
            @TODO this will not work for masked spectrum
            03/08/2021 tth_step is a list now
            """
            self.tth_step = []
            for tmi, tma, nth in zip(self.tth_min, self.tth_max, self.ntth):
                if nth > 1:
                    self.tth_step.append((tma - tmi) / nth)
                else:
                    self.tth_step.append(0.0)

            """
            @date 03/03/2021 SS
            there are cases when the intensity in the spectrum is
            negative. our approach will be to offset the spectrum to make all
            the values positive for the computation and then finally offset it
            when the computation has finished.
            03/08/2021 all quantities are lists now
            """
            for s in self._spectrum_expt:
                self.offset = []
                self.offset_val = []
                if np.any(s.y < 0.0):
                    self.offset.append(True)
                    self.offset_val.append(s.y.min())
                    s.y = s.y - s.y.min()

            """
            @date 09/24/2020 SS
            catching the cases when intensity is zero.
            for these points the weights will become
            infinite. therefore, these points will be
            masked out and assigned a weight of zero.
            In addition, if any points have negative
            intensity, they will also be assigned a zero
            weight
            03/08/2021 SS everything is a list now
            """
            self._weights = []
            for s in self._spectrum_expt:
                mask = s.y <= 0.0
                ww = np.zeros(s.y.shape)
                """also initialize statistical weights
                for the error calculation"""
                ww[~mask] = 1.0 / np.sqrt(s.y[~mask])
                self._weights.append(ww)

            self.initialize_bkg()
        else:
            raise RuntimeError("expt_spectrum setter: spectrum is None")

    @property
    def spectrum_sim(self):
        tth, inten = self._spectrum_sim.data
        inten[self.global_mask] = np.nan
        inten += self.background.y

        return Spectrum(x=tth, y=inten)

    @property
    def background(self):
        vector_list = [s.y for s in self._background]

        bkg_masked = join_regions(
            vector_list, self.global_index, self.global_shape
        )
        return Spectrum(x=self.tth_list, y=bkg_masked)

    @property
    def weights(self):
        weights_masked = join_regions(
            self._weights, self.global_index, self.global_shape
        )
        return Spectrum(x=self.tth_list, y=weights_masked)

    @property
    def phases(self):
        return self._phases

    @phases.setter
    def phases(self, phase_info):
        """
        >> @AUTHOR:  Saransh Singh, Lawrence Livermore National Lab,
                     saransh1@llnl.gov
        >> @DATE:    06/08/2020 SS 1.0 original
                     02/01/2021 SS 2.0 modified to follow same input style as
                       LeBail class with inputs of Material class, dict, list
                       or filename valid
        >> @DETAILS: load the phases for the LeBail fits
        """

        if phase_info is not None:
            if isinstance(phase_info, Phases_Rietveld):
                """
                directly passing the phase class
                """
                self._phases = phase_info
            else:

                if hasattr(self, "wavelength"):
                    if self.wavelength is not None:
                        p = Phases_Rietveld(wavelength=self.wavelength)
                else:
                    p = Phases_Rietveld()

                if isinstance(phase_info, dict):
                    """
                    initialize class using a dictionary with key as
                    material file and values as the name of each phase
                    """
                    for material_file in phase_info:
                        material_names = phase_info[material_file]
                        if not isinstance(material_names, list):
                            material_names = [material_names]
                        p.add_many(material_file, material_names)

                elif isinstance(phase_info, str):
                    """
                    load from a yaml file
                    """
                    if path.exists(phase_info):
                        p.load(phase_info)
                    else:
                        raise FileNotFoundError("phase file doesn't exist.")

                elif isinstance(phase_info, Material):
                    if not p.phase_dict:
                        p[phase_info.name] = {}

                    for k, v in self.wavelength.items():
                        E = (
                            1.0e6
                            * constants.cPlanck
                            * constants.cLight
                            / constants.cCharge
                            / v[0].getVal('nm')
                        )
                        phase_info.beamEnergy = valWUnit(
                            "kev", "ENERGY", E, "keV"
                        )
                        p[phase_info.name][k] = Material_Rietveld(
                            fhdf=None, xtal=None,
                            dmin=None, material_obj=phase_info
                        )
                        p[phase_info.name][k].pf = 1.0
                    p.num_phases = 1

                elif isinstance(phase_info, list):
                    for mat in phase_info:
                        p[mat.name] = {}
                        for k, v in self.wavelength.items():
                            E = (
                                1.0e6
                                * constants.cPlanck
                                * constants.cLight
                                / constants.cCharge
                                / v[0].getVal('nm')
                            )
                            mat.beamEnergy = valWUnit(
                                "kev", "ENERGY", E, "keV"
                            )
                            p[mat.name][k] = Material_Rietveld(
                                fhdf=None, xtal=None,
                                dmin=None, material_obj=mat
                            )
                        p.num_phases += 1

                    for mat in p:
                        for k, v in self.wavelength.items():
                            p[mat][k].pf = 1.0 / p.num_phases
                self._phases = p

        self.calctth()
        self.calcsf()

        for p in self.phases:
            for k in self.phases[p]:
                (
                    self._phases[p][k].valid_shkl,
                    self._phases[p][k].eq_constraints,
                    self._phases[p][k].rqd_index,
                    self._phases[p][k].trig_ptype,
                ) = wppfsupport._required_shkl_names(self._phases[p][k])

    @property
    def peakshape(self):
        return self._peakshape

    @peakshape.setter
    def peakshape(self, val):
        """
        @TODO make sure the parameter list
        is updated when the peakshape changes
        """
        if isinstance(val, str):
            if val == "pvfcj":
                self._peakshape = 0
            elif val == "pvtch":
                self._peakshape = 1
            elif val == "pvpink":
                self._peakshape = 2
            else:
                msg = (
                    "invalid peak shape string. "
                    "must be: \n"
                    "1. pvfcj: pseudo voight (Finger, Cox, Jephcoat)\n"
                    "2. pvtch: pseudo voight (Thompson, Cox, Hastings)\n"
                    "3. pvpink: Pink beam (Von Dreele)"
                )
                raise ValueError(msg)
        elif isinstance(val, int):
            if val >= 0 and val <= 2:
                self._peakshape = val
            else:
                msg = (
                    "invalid peak shape int. "
                    "must be: \n"
                    "1. 0: pseudo voight (Finger, Cox, Jephcoat)\n"
                    "2. 1: pseudo voight (Thompson, Cox, Hastings)\n"
                    "3. 2: Pink beam (Von Dreele)"
                )
                raise ValueError(msg)

        """
        update parameters
        """
        if hasattr(self, "params"):
            params = wppfsupport._generate_default_parameters_Rietveld(
                self.phases, self.peakshape
            )
            for p in params:
                if p in self.params:
                    params[p] = self.params[p]
            self._params = params
            self._set_params_vals_to_class(params, init=True, skip_phases=True)
            self.computespectrum()

    @property
    def computespectrum_fcn(self):
        if self.peakshape == 0:
            return computespectrum_pvfcj
        elif self.peakshape == 1:
            return computespectrum_pvtch
        elif self.peakshape == 2:
            return computespectrum_pvpink

    @property
    def calc_Iobs_fcn(self):
        if self.peakshape == 0:
            return calc_Iobs_pvfcj
        elif self.peakshape == 1:
            return calc_Iobs_pvtch
        elif self.peakshape == 2:
            return calc_Iobs_pvpink

    @property
    def U(self):
        return self._U

    @U.setter
    def U(self, Uinp):
        self._U = Uinp
        return

    @property
    def V(self):
        return self._V

    @V.setter
    def V(self, Vinp):
        self._V = Vinp
        return

    @property
    def W(self):
        return self._W

    @W.setter
    def W(self, Winp):
        self._W = Winp
        return

    @property
    def P(self):
        return self._P

    @P.setter
    def P(self, Pinp):
        self._P = Pinp
        return

    @property
    def X(self):
        return self._X

    @X.setter
    def X(self, Xinp):
        self._X = Xinp
        return

    @property
    def Xe(self):
        return self._Xe

    @Xe.setter
    def Xe(self, Xeinp):
        self._Xe = Xeinp
        return

    @property
    def Xs(self):
        return self._Xs

    @Xs.setter
    def Xs(self, Xsinp):
        self._Xs = Xsinp
        return

    @property
    def Y(self):
        return self._Y

    @Y.setter
    def Y(self, Yinp):
        self._Y = Yinp
        return

    @property
    def Ye(self):
        return self._Ye

    @Ye.setter
    def Ye(self, Yeinp):
        self._Ye = Yeinp
        return

    @property
    def gamma(self):
        return self._gamma

    @gamma.setter
    def gamma(self, val):
        self._gamma = val

    @property
    def Hcag(self):
        return self._Hcag

    @Hcag.setter
    def Hcag(self, val):
        self._Hcag = val

    @property
    def tth_list(self):
        if isinstance(self.spectrum_expt._x, np.ma.MaskedArray):
            return self.spectrum_expt._x.filled()
        else:
            return self.spectrum_expt._x

    @property
    def zero_error(self):
        return self._zero_error

    @zero_error.setter
    def zero_error(self, value):
        self._zero_error = value
        return

    @property
    def scale(self):
        return self._scale

    @scale.setter
    def scale(self, value):
        self._scale = value
        return

    @property
    def Ph(self):
        return self._Ph

    @Ph.setter
    def Ph(self, val):
        self._Ph = val

    @property
    def eta_fwhm(self):
        return self._eta_fwhm

    @eta_fwhm.setter
    def eta_fwhm(self, val):
        self._eta_fwhm = val


def calc_num_variables(params):
    P = 0
    for pp in params:
        if params[pp].vary:
            P += 1
    return P


def separate_regions(masked_spec_array):
    """
    utility function for separating array into separate
    islands as dictated by mask. this function was taken from
    stackoverflow
    https://stackoverflow.com/questions/43385877/
    efficient-numpy-subarrays-extraction-from-a-mask
    """
    array = masked_spec_array.data
    mask = ~masked_spec_array.mask[:, 1]
    m0 = np.concatenate(([False], mask, [False]))
    idx = np.flatnonzero(m0[1:] != m0[:-1])
    gidx = [(idx[i], idx[i + 1]) for i in range(0, len(idx), 2)]
    return [array[idx[i]: idx[i + 1], :] for i in range(0, len(idx), 2)], gidx


def join_regions(vector_list, global_index, global_shape):
    """
    @author Saransh Singh Lawrence Livermore National Lab
    @date 03/08/2021 SS 1.0 original
    @details utility function for joining different pieces of masked array
    into one masked array
    """
    out_vector = np.empty(
        [
            global_shape,
        ]
    )
    out_vector[:] = np.nan
    for s, ids in zip(vector_list, global_index):
        out_vector[ids[0]: ids[1]] = s

    # out_array = np.ma.masked_array(out_array, mask = np.isnan(out_array))
    return out_vector

peakshape_dict = {
    "pvfcj": "pseudo-voight (finger, cox, jephcoat)",
    "pvtch": "pseudo-voight (thompson, cox, hastings)",
    "pvpink": "pseudo-voight (von dreele)",
}


# hexrd/wppf/__init__.py

from hexrd.wppf.WPPF import LeBail
from hexrd.wppf.WPPF import Rietveld

# hexrd/wppf/derivatives.py

import numpy as np
from hexrd.utils.decorators import numba_njit_if_available
from hexrrd.wppf.peakfunctions import _unit_gaussian, _unit_lorentzian
"""
naming convention for the derivative is as follows:
_d_<peakshape>_<parameter>

available <peakshape> :
pvfcl: finger cox jephcoat asymmetric
pv_wppf: symmetric pseudo voight
pvoight_pink_beam: pink beam case

available <parameters>: (only a couple listed 
since the total number is very large)
fwhm: full width half maxima
tth: mean of peak
.....

"""

@numba_njit_if_available(cache=True, nogil=True)
def _d_pvfcj_fwhm():
    pass

@numba_njit_if_available(cache=True, nogil=True)
def _d_pvfcj_tth():
    pass

@numba_njit_if_available(cache=True, nogil=True)
def _d_fwhm_U():
    pass

@numba_njit_if_available(cache=True, nogil=True)
def _d_fwhm_V():
    pass

@numba_njit_if_available(cache=True, nogil=True)
def _d_fwhm_W():
    pass

@numba_njit_if_available(cache=True, nogil=True)
def _d_fwhm_P():
    pass

@numba_njit_if_available(cache=True, nogil=True)
def _d_fwhm_X():
    pass

@numba_njit_if_available(cache=True, nogil=True)
def _d_fwhm_Y():
    pass

@numba_njit_if_available(cache=True, nogil=True)
def _d_fwhm_Xe():
    pass

@numba_njit_if_available(cache=True, nogil=True)
def _d_fwhm_Ye():
    pass

@numba_njit_if_available(cache=True, nogil=True)
def _d_fwhm_Xs():
    pass

@numba_njit_if_available(cache=True, nogil=True)
def _d_fwhm_HL():
    pass

@numba_njit_if_available(cache=True, nogil=True)
def _d_fwhm_SL():
    pass

@numba_njit_if_available(cache=True, nogil=True)
def _d_pvfcj_scale():
    pass

@numba_njit_if_available(cache=True, nogil=True)
def _d_pvfcj_phase_fraction():
    pass

@numba_njit_if_available(cache=True, nogil=True)
def _d_pvfcj_trns():
    pass

@numba_njit_if_available(cache=True, nogil=True)
def _d_pvfcj_shft():
    pass

@numba_njit_if_available(cache=True, nogil=True)
def _d_pvfcj_zero_error():
    pass

@numba_njit_if_available(cache=True, nogil=True)
def _d_pvfcj_shkls():
    pass

@numba_njit_if_available(cache=True, nogil=True)
def _d_pvfcj_a():
    pass

@numba_njit_if_available(cache=True, nogil=True)
def _d_pvfcj_b():
    pass

@numba_njit_if_available(cache=True, nogil=True)
def _d_pvfcj_c():
    pass

@numba_njit_if_available(cache=True, nogil=True)
def _d_pvfcj_alpha():
    pass

@numba_njit_if_available(cache=True, nogil=True)
def _d_pvfcj_beta():
    pass

@numba_njit_if_available(cache=True, nogil=True)
def _d_pvfcj_gamma():
    pass


# hexrd/wppf/parameters.py

import h5py
import numpy as np
import yaml
import warnings
from os import path

class Parameters:
    """
    ==================================================================================
    ==================================================================================

    >> @AUTHOR:     Saransh Singh, Lanwrence Livermore National Lab,
                    saransh1@llnl.gov
    >> @DATE:       05/18/2020 SS 1.0 original
    >> @DETAILS:    this is the parameter class which handles all refinement parameters
        for both the Rietveld and the LeBail refimentment problems

        ===============================================================================
        ===============================================================================
    """

    def __init__(self,
                 name=None,
                 vary=False,
                 value=0.0,
                 lb=-np.Inf,
                 ub=np.Inf):

        self.param_dict = {}

        if(name is not None):
            self.add(name=name,
                     vary=vary,
                     value=value,
                     lb=min,
                     ub=max)

    def add(self,
            name,
            vary=False,
            value=0.0,
            lb=-np.Inf,
            ub=np.Inf):
        """
            >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
            >> @DATE:       05/18/2020 SS 1.0 original
            >> @DETAILS:    add a single named parameter
        """
        self[name] = Parameter(name=name, vary=vary, value=value, lb=lb, ub=ub)

    def add_many(self,
                 names,
                 varies,
                 values,
                 lbs,
                 ubs):
        """
            >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
            >> @DATE:       05/18/2020 SS 1.0 original
            >> @DETAILS:    load a list of named parameters
        """
        assert len(names) == len(varies), "lengths of tuples not consistent"
        assert len(names) == len(values), "lengths of tuples not consistent"
        assert len(names) == len(lbs), "lengths of tuples not consistent"
        assert len(names) == len(ubs), "lengths of tuples not consistent"

        for i, n in enumerate(names):
            self.add(n, vary=varies[i], value=values[i], lb=lbs[i], ub=ubs[i])

    def load(self, fname):
        """
            >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
            >> @DATE:       05/18/2020 SS 1.0 original
            >> @DETAILS:    load parameters from yaml file
        """
        with open(fname) as file:
            dic = yaml.load(file, Loader=yaml.FullLoader)

        for k in dic.keys():
            v = dic[k]
            self.add(k, value=np.float(v[0]), lb=np.float(v[1]),
                     ub=np.float(v[2]), vary=np.bool(v[3]))

    def dump(self, fname):
        """
            >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
            >> @DATE:       05/18/2020 SS 1.0 original
            >> @DETAILS:    dump the class to a yaml looking file. name is the key and the list
                            has [value, lb, ub, vary] in that order
        """
        dic = {}
        for k in self.param_dict.keys():
            dic[k] = [self[k].value, self[k].lb, self[k].ub, self[k].vary]

        with open(fname, 'w') as f:
            data = yaml.dump(dic, f, sort_keys=False)

    def dump_hdf5(self, file):
        """
            >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
            >> @DATE:       01/15/2021 SS 1.0 original
            >> @DETAILS:    dump the class to a hdf5 file. the file argument could either be a
                            string or a h5.File instance. If it is a filename, then HDF5 file
                            is created, a parameter group is created and data is written out
                            with data names being the parameter name. Else data written to Parameter
                            group in existing file object
        """
        if(isinstance(file, str)):
            fexist = path.isfile(file)
            if(fexist):
                fid = h5py.File(file, 'r+')
            else:
                fid = h5py.File(file, 'x')

        elif(isinstance(file, h5py.File)):
            fid = file

        else:
            raise RuntimeError(
                'Parameters: dump_hdf5 Pass in a \
                 filename string or h5py.File object')

        if("/Parameters" in fid):
            del(fid["Parameters"])
        gid_top = fid.create_group("Parameters")

        for p in self:
            param = self[p]
            gid = gid_top.create_group(p)

            # write the value, lower and upper bounds and vary status
            did = gid.create_dataset("value", (1, ), dtype=np.float64)
            did.write_direct(np.array(param.value, dtype=np.float64))

            did = gid.create_dataset("lb", (1, ), dtype=np.float64)
            did.write_direct(np.array(param.lb, dtype=np.float64))

            did = gid.create_dataset("ub", (1, ), dtype=np.float64)
            did.write_direct(np.array(param.ub, dtype=np.float64))

            did = gid.create_dataset("vary", (1, ), dtype=np.bool)
            did.write_direct(np.array(param.vary, dtype=np.bool))

    def __getitem__(self, key):
        if(key in self.param_dict.keys()):
            return self.param_dict[key]
        else:
            raise ValueError(f'variable with name {key} not found')

    def __setitem__(self, key, parm_cls):

        if(isinstance(parm_cls, Parameter)):
            self.param_dict[key] = parm_cls
        else:
            raise ValueError('input not a Parameter class')

    def __iter__(self):
        self.n = 0
        return self

    def __next__(self):
        if(self.n < len(self.param_dict.keys())):
            res = list(self.param_dict.keys())[self.n]
            self.n += 1
            return res
        else:
            raise StopIteration

    def __str__(self):
        retstr = 'Parameters{\n'
        for k in self.param_dict.keys():
            retstr += self[k].__str__()+'\n'

        retstr += '}'
        return retstr


class Parameter:
    """
    ===================================================================================
    ===================================================================================

    >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
    >> @DATE:       05/18/2020 SS 1.0 original
    >> @DETAILS:    the parameters class (previous one) is a collection of this
                    parameter class indexed by the name of each variable

    ================================================================================
    =================================================================================
    """

    def __init__(self,
                 name=None,
                 vary=False,
                 value=0.0,
                 lb=-np.Inf,
                 ub=np.Inf):

        self.name = name
        self.vary = vary
        self.value = value
        self.lb = lb
        self.ub = ub

    def __str__(self):
        retstr = '< Parameter \''+self.name+'\'; value : ' + \
            str(self.value)+'; bounds : ['+str(self.lb)+',' + \
            str(self.ub)+' ]; vary :'+str(self.vary)+' >'

        return retstr

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, name):
        if(isinstance(name, str)):
            self._name = name

    @property
    def value(self):
        return self._value

    @value.setter
    def value(self, val):
        self._value = val

    @property
    def min(self):
        return self._min

    @min.setter
    def min(self, minval):
        self._min = minval

    @property
    def max(self):
        return self._max

    @max.setter
    def max(self, maxval):
        self._max = maxval

    @property
    def vary(self):
        return self._vary

    @vary.setter
    def vary(self, vary):
        if(isinstance(vary, (bool, np.bool_))):
            self._vary = vary


# hexrd/wppf/peakfunctions.py

# ============================================================
# Copyright (c) 2012, Lawrence Livermore National Security, LLC.
# Produced at the Lawrence Livermore National Laboratory.
# Written by Joel Bernier <bernier2@llnl.gov> and others.
# LLNL-CODE-529294.
# All rights reserved.
#
# This file is part of HEXRD. For details on dowloading the source,
# see the file COPYING.
#
# Please also see the file LICENSE.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License (as published by the Free
# Software Foundation) version 2.1 dated February 1999.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY OF MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the terms and conditions of the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this program (see file LICENSE); if not, write to
# the Free Software Foundation, Inc., 59 Temple Place, Suite 330,
# Boston, MA 02111-1307 USA or visit <http://www.gnu.org/licenses/>.
# ============================================================

import numpy as np
import copy
from hexrd import constants
from hexrd.utils.decorators import numba_njit_if_available
from numba import vectorize, float64
from hexrd.fitting.peakfunctions import erfc, exp1exp

if constants.USE_NUMBA:
    from numba import prange
else:
    prange = range

# addr = get_cython_function_address("scipy.special.cython_special", "exp1")
# functype = ctypes.CFUNCTYPE(ctypes.c_double, ctypes.c_double)
# exp1_fn = functype(addr)

gauss_width_fact = constants.sigma_to_fwhm
lorentz_width_fact = 2.

# FIXME: we need this for the time being to be able to parse multipeak fitting
# results; need to wrap all this up in a class in the future!
mpeak_nparams_dict = {
    'gaussian': 3,
    'lorentzian': 3,
    'pvoigt': 4,
    'split_pvoigt': 6
}

"""
Calgliotti and Lorentzian FWHM functions
"""
@numba_njit_if_available(cache=True, nogil=True)
def _gaussian_fwhm(uvw,
                   P,
                   gamma_ani_sqr,
                   eta_mixing,
                   tth,
                   dsp):
    """
    @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
    @DATE:       05/20/2020 SS 1.0 original
    @DETAILS:    calculates the fwhm of gaussian component
                 uvw cagliotti parameters
                 P Scherrer broadening (generally not refined)
                 gamma_ani_sqr anisotropic broadening term
                 eta_mixing mixing factor
                 tth two theta in degrees
                 dsp d-spacing
    """
    U, V, W = uvw
    th = np.radians(0.5*tth)
    tanth = np.tan(th)
    cth2 = np.cos(th)**2.0
    sig2_ani = gamma_ani_sqr*(1.-eta_mixing)**2*dsp**4
    sigsqr = (U+sig2_ani) * tanth**2 + V * tanth + W + P/cth2
    if(sigsqr <= 0.):
        sigsqr = 1.0e-12

    return np.sqrt(sigsqr)*1e-2


@numba_njit_if_available(cache=True, nogil=True)
def _lorentzian_fwhm(xy,
                     xy_sf,
                     gamma_ani_sqr,
                     eta_mixing,
                     tth,
                     dsp,
                     strain_direction_dot_product,
                     is_in_sublattice):
    """
    @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
    @DATE:       07/20/2020 SS 1.0 original
    @DETAILS:    calculates the size and strain broadening for Lorentzian peak
                xy [X, Y] lorentzian broadening
                xy_sf stacking fault dependent peak broadening
                gamma_ani_sqr anisotropic broadening term
                eta_mixing mixing factor
                tth two theta in degrees
                dsp d-spacing
                is_in_sublattice if hkl in sublattice, then extra broadening
                else regular broadening
    """
    X, Y = xy
    Xe, Ye, Xs = xy_sf
    th = np.radians(0.5*tth)
    tanth = np.tan(th)
    cth = np.cos(th)
    sig_ani = np.sqrt(gamma_ani_sqr)*eta_mixing*dsp**2
    if is_in_sublattice:
        xx = Xe*strain_direction_dot_product
    else:
        xx = Xs*strain_direction_dot_product
    gamma = (X+xx)/cth + (Y+Ye+sig_ani)*tanth
    return gamma*1e-2

@numba_njit_if_available(cache=True, nogil=True)
def _anisotropic_peak_broadening(shkl, hkl):
    """
    this function generates the broadening as
    a result of anisotropic broadening. details in
    P.Stephens, J. Appl. Cryst. (1999). 32, 281-289
    a total of 15 terms, some of them zero. in this
    function, we will just use all the terms. it is
    assumed that the user passes on the correct values
    for shkl with appropriate zero values
    The order is the same as the wppfsupport._shkl_name
    variable
    ["s400", "s040", "s004", "s220", "s202", "s022",
              "s310", "s103", "s031", "s130", "s301", "s013",
              "s211", "s121", "s112"]
    """
    h,k,l = hkl
    gamma_sqr = (shkl[0]*h**4 +
                shkl[1]*k**4 +
                shkl[2]*l**4 +
                3.0*(shkl[3]*(h*k)**2 +
                     shkl[4]*(h*l)**2 +
                     shkl[5]*(k*l)**2)+
                2.0*(shkl[6]*k*h**3 +
                     shkl[7]*h*l**3 +
                     shkl[8]*l*k**3 +
                     shkl[9]*h*k**3 +
                     shkl[10]*l*h**3 +
                     shkl[11]*k*l**3) +
                4.0*(shkl[12]*k*l*h**2 +
                     shkl[13]*h*l*k**2 +
                     shkl[14]*h*k*l**2))

    return gamma_sqr

def _anisotropic_gaussian_component(gamma_sqr, eta_mixing):
    """
    gaussian component in anisotropic broadening
    """
    return gamma_sqr*(1. - eta_mixing)**2

def _anisotropic_lorentzian_component(gamma_sqr, eta_mixing):
    """
    lorentzian component in anisotropic broadening
    """
    return np.sqrt(gamma_sqr)*eta_mixing

# =============================================================================
# 1-D Gaussian Functions
# =============================================================================
# Split the unit gaussian so this can be called for 2d and 3d functions


@numba_njit_if_available(cache=True, nogil=True)
def _unit_gaussian(p, x):
    """
    Required Arguments:
    p -- (m) [x0,FWHM]
    x -- (n) ndarray of coordinate positions

    Outputs:
    f -- (n) ndarray of function values at positions x
    """

    x0 = p[0]
    FWHM = p[1]
    sigma = FWHM/gauss_width_fact

    f = np.exp(-(x-x0)**2/(2.*sigma**2.))
    return f

# =============================================================================
# 1-D Lorentzian Functions
# =============================================================================
# Split the unit function so this can be called for 2d and 3d functions
@numba_njit_if_available(cache=True, nogil=True)
def _unit_lorentzian(p, x):
    """
    Required Arguments:
    p -- (m) [x0,FWHM]
    x -- (n) ndarray of coordinate positions

    Outputs:
    f -- (n) ndarray of function values at positions x
    """

    x0 = p[0]
    FWHM = p[1]
    gamma = FWHM/lorentz_width_fact

    f = gamma / ((x-x0)**2 + gamma**2)
    return f

@numba_njit_if_available(cache=True, nogil=True)
def _mixing_factor_pv(fwhm_g, fwhm_l):
    """
    @AUTHOR:  Saransh Singh, Lawrence Livermore National Lab,
    saransh1@llnl.gov
    @DATE: 05/20/2020 SS 1.0 original
           01/29/2021 SS 2.0 updated to depend only on fwhm of profile
           P. Thompson, D.E. Cox & J.B. Hastings, J. Appl. Cryst.,20,79-83,
           1987
    @DETAILS: calculates the mixing factor eta to best approximate voight
    peak shapes
    """
    fwhm = fwhm_g**5 + 2.69269 * fwhm_g**4 * fwhm_l + \
        2.42843 * fwhm_g**3 * fwhm_l**2 + \
        4.47163 * fwhm_g**2 * fwhm_l**3 +\
        0.07842 * fwhm_g * fwhm_l**4 +\
        fwhm_l**5

    fwhm = fwhm**0.20
    eta = 1.36603 * (fwhm_l/fwhm) - \
        0.47719 * (fwhm_l/fwhm)**2 + \
        0.11116 * (fwhm_l/fwhm)**3
    if eta < 0.:
        eta = 0.
    elif eta > 1.:
        eta = 1.

    return eta, fwhm

@numba_njit_if_available(cache=True, nogil=True)
def pvoight_wppf(uvw,
                 p,
                 xy,
                 xy_sf,
                 shkl,
                 eta_mixing,
                 tth,
                 dsp,
                 hkl,
                 strain_direction_dot_product,
                 is_in_sublattice,
                 tth_list):
    """
    @author Saransh Singh, Lawrence Livermore National Lab
    @date 03/22/2021 SS 1.0 original
    @details pseudo voight peak profile for WPPF
    """
    gamma_ani_sqr = _anisotropic_peak_broadening(
        shkl, hkl)
    fwhm_g = _gaussian_fwhm(uvw, p,
        gamma_ani_sqr,
        eta_mixing,
        tth, dsp)
    fwhm_l = _lorentzian_fwhm(xy, xy_sf,
        gamma_ani_sqr, eta_mixing,
        tth, dsp,
        strain_direction_dot_product,
        is_in_sublattice)

    n, fwhm = _mixing_factor_pv(fwhm_g, fwhm_l)

    Ag = 0.9394372787/fwhm  # normalization factor for unit area
    Al = 1.0/np.pi          # normalization factor for unit area

    g = Ag*_unit_gaussian(np.array([tth, fwhm]), tth_list)
    l = Al*_unit_lorentzian(np.array([tth, fwhm]), tth_list)
    
    return n*l + (1.0-n)*g

@numba_njit_if_available(cache=True, nogil=True)
def _func_h(tau, tth_r):
    cph =  np.cos(tth_r - tau)
    ctth = np.cos(tth_r)
    return np.sqrt( (cph/ctth)**2 - 1.)

@numba_njit_if_available(cache=True, nogil=True)
def _func_W(HoL, SoL, tau, tau_min, tau_infl, tth):

    if(tth < np.pi/2.):
        if tau >= 0. and tau <= tau_infl:
            res = 2.0*min(HoL,SoL)
        elif tau > tau_infl and tau <= tau_min:
            res = HoL+SoL+_func_h(tau,tth)
        else:
            res = 0.0
    else:
        if tau <= 0. and tau >= tau_infl:
            res = 2.0*min(HoL,SoL)
        elif tau < tau_infl and tau >= tau_min:
            res = HoL+SoL+_func_h(tau,tth)
        else:
            res = 0.0
    return res

@numba_njit_if_available(cache=True, nogil=True)
def pvfcj(uvw,
          p,
          xy,
          xy_sf,
          shkl,
          eta_mixing,
          tth,
          dsp,
          hkl,
          strain_direction_dot_product,
          is_in_sublattice,
          tth_list,
          HoL,
          SoL,
          xn,
          wn):
    """
    @author Saransh Singh, Lawrence Livermore National Lab
    @date 04/02/2021 SS 1.0 original
    @details pseudo voight convolved with the slit functions
    xn, wn are the Gauss-Legendre weights and abscissae
    supplied using the scipy routine
    """

    # angle of minimum
    tth_r = np.radians(tth)
    ctth = np.cos(tth_r)

    arg = ctth*np.sqrt(((HoL+SoL)**2+1.))
    cinv = np.arccos(arg)
    tau_min = tth_r - cinv

    # two theta of inflection point
    arg = ctth*np.sqrt(((HoL-SoL)**2+1.))
    cinv = np.arccos(arg)
    tau_infl = tth_r - cinv

    tau = tau_min*xn

    cx = np.cos(tau)
    res = np.zeros(tth_list.shape)
    den = 0.0

    for i in np.arange(tau.shape[0]):
        x = tth_r-tau[i]
        xx = tau[i]

        W = _func_W(HoL,SoL,xx,tau_min,tau_infl,tth_r)
        h = _func_h(xx, tth_r)
        fact = wn[i]*(W/h/cx[i])
        den += fact

        pv = pvoight_wppf(uvw,
             p,
             xy,
             xy_sf,
             shkl,
             eta_mixing,
             np.degrees(x),
             dsp,
             hkl,
             strain_direction_dot_product,
             is_in_sublattice,
             tth_list)
        res += pv*fact

    res = np.sin(tth_r)*res/den/4./HoL/SoL
    a = np.trapz(res, tth_list)
    return res/a

@numba_njit_if_available(cache=True, nogil=True)
def _calc_alpha(alpha, tth):
    a0, a1 = alpha
    return (a0 + a1*np.tan(np.radians(0.5*tth)))


@numba_njit_if_available(cache=True, nogil=True)
def _calc_beta(beta, tth):
    b0, b1 = beta
    return b0 + b1*np.tan(np.radians(0.5*tth))

@numba_njit_if_available(cache=True, nogil=True)
def _gaussian_pink_beam(alpha,
                        beta,
                        fwhm_g,
                        tth,
                        tth_list):
    """
    @author Saransh Singh, Lawrence Livermore National Lab
    @date 03/22/2021 SS 1.0 original
    @details the gaussian component of the pink beam peak profile
    obtained by convolution of gaussian with normalized back to back
    exponentials. more details can be found in
    Von Dreele et. al., J. Appl. Cryst. (2021). 54, 3–6
    """
    del_tth = tth_list - tth
    sigsqr = fwhm_g**2
    f1 = alpha*sigsqr + 2.0*del_tth
    f2 = beta*sigsqr - 2.0*del_tth
    f3 = np.sqrt(2.0)*fwhm_g
    u = 0.5*alpha*f1
    v = 0.5*beta*f2
    y = (f1-del_tth)/f3
    z = (f2+del_tth)/f3
    t1 = erfc(y)
    t2 = erfc(z)
    g = np.zeros(tth_list.shape)
    zmask = np.abs(del_tth) > 5.0 
    g[~zmask] = (0.5*(alpha*beta)/(alpha + beta)) \
        * np.exp(u[~zmask])*t1[~zmask] + \
            np.exp(v[~zmask])*t2[~zmask]
    mask = np.isnan(g)
    g[mask] = 0.

    return g


@numba_njit_if_available(cache=True, nogil=True)
def _lorentzian_pink_beam(alpha,
                          beta,
                          fwhm_l,
                          tth,
                          tth_list):
    """
    @author Saransh Singh, Lawrence Livermore National Lab
    @date 03/22/2021 SS 1.0 original
    @details the lorentzian component of the pink beam peak profile
    obtained by convolution of gaussian with normalized back to back
    exponentials. more details can be found in
    Von Dreele et. al., J. Appl. Cryst. (2021). 54, 3–6
    """
    del_tth = tth_list - tth
    p = -alpha*del_tth + 1j * 0.5*alpha*fwhm_l
    q = -beta*del_tth + 1j * 0.5*beta*fwhm_l

    y = np.zeros(tth_list.shape)

    f1 = exp1exp(p)
    f2 = exp1exp(q)

    y = -(alpha*beta)/(np.pi*(alpha+beta))*(f1+f2).imag
    
    mask = np.isnan(y)
    y[mask] = 0.

    return y

@numba_njit_if_available(cache=True, nogil=True)
def pvoight_pink_beam(alpha,
                      beta,
                      uvw,
                      p,
                      xy,
                      xy_sf,
                      shkl,
                      eta_mixing,
                      tth,
                      dsp,
                      hkl,
                      strain_direction_dot_product,
                      is_in_sublattice,
                      tth_list):
    """
    @author Saransh Singh, Lawrence Livermore National Lab
    @date 03/22/2021 SS 1.0 original
    @details compute the pseudo voight peak shape for the pink
    beam using von dreele's function
    """
    alpha_exp = _calc_alpha(alpha, tth)
    beta_exp = _calc_beta(beta, tth)

    gamma_ani_sqr = _anisotropic_peak_broadening(
        shkl, hkl)

    fwhm_g = _gaussian_fwhm(uvw, p,
    gamma_ani_sqr,
    eta_mixing,
    tth, dsp)
    fwhm_l = _lorentzian_fwhm(xy, xy_sf,
        gamma_ani_sqr, eta_mixing,
        tth, dsp,
        strain_direction_dot_product,
        is_in_sublattice)

    n, fwhm = _mixing_factor_pv(fwhm_g, fwhm_l)

    g = _gaussian_pink_beam(alpha_exp, beta_exp,
                            fwhm_g, tth, tth_list)
    l = _lorentzian_pink_beam(alpha_exp, beta_exp,
                              fwhm_l, tth, tth_list)
    ag = np.trapz(g,tth_list)
    al = np.trapz(l,tth_list)
    if np.abs(ag) < 1e-6:
        ag = 1.0
    if np.abs(al) < 1e-6:
        al = 1.0

    return n*l/al + (1.0-n)*g/ag

@numba_njit_if_available(cache=True, nogil=True, parallel=True)
def computespectrum_pvfcj(uvw,
                 p,
                 xy,
                 xy_sf,
                 shkl,
                 eta_mixing,
                 HL,
                 SL,
                 tth,
                 dsp,
                 hkl,
                 strain_direction_dot_product,
                 is_in_sublattice,
                 tth_list,
                 Iobs, 
                 xn, 
                 wn):
    """
    @author Saransh Singh, Lawrence Livermore National Lab
    @date 03/31/2021 SS 1.0 original
    @details compute the spectrum given all the input parameters.
    moved outside of the class to allow numba implementation
    this is called for multiple wavelengths and phases to generate
    the final spectrum
    """

    spec = np.zeros(tth_list.shape)
    nref = np.min(np.array([Iobs.shape[0],
        tth.shape[0],
        dsp.shape[0],hkl.shape[0]]))
    for ii in prange(nref):

        II = Iobs[ii]
        t = tth[ii]
        d = dsp[ii]
        g = hkl[ii]

        pv = pvfcj(uvw,p,xy,xy_sf,
               shkl,eta_mixing,
               t,d,g,
               strain_direction_dot_product,
               is_in_sublattice,
               tth_list,
               HL,SL,xn,wn)

        spec += II * pv
    return spec

@numba_njit_if_available(cache=True, nogil=True, parallel=True)
def computespectrum_pvtch(uvw,
                 p,
                 xy,
                 xy_sf,
                 shkl,
                 eta_mixing,
                 tth,
                 dsp,
                 hkl,
                 strain_direction_dot_product,
                 is_in_sublattice,
                 tth_list,
                 Iobs):
    """
    @author Saransh Singh, Lawrence Livermore National Lab
    @date 03/31/2021 SS 1.0 original
    @details compute the spectrum given all the input parameters.
    moved outside of the class to allow numba implementation
    this is called for multiple wavelengths and phases to generate
    the final spectrum
    """

    spec = np.zeros(tth_list.shape)
    nref = np.min(np.array([Iobs.shape[0],
        tth.shape[0],
        dsp.shape[0],hkl.shape[0]]))
    for ii in prange(nref):

        II = Iobs[ii]
        t = tth[ii]
        d = dsp[ii]
        g = hkl[ii]

        pv = pvoight_wppf(uvw,p,xy,
                 xy_sf,shkl,eta_mixing,
                 t,d,g,
                 strain_direction_dot_product,
                 is_in_sublattice,
                 tth_list)

        spec += II * pv
    return spec

@numba_njit_if_available(cache=True, nogil=True, parallel=True)
def computespectrum_pvpink(alpha,
                 beta,
                 uvw,
                 p,
                 xy,
                 xy_sf,
                 shkl,
                 eta_mixing,
                 tth,
                 dsp,
                 hkl,
                 strain_direction_dot_product,
                 is_in_sublattice,
                 tth_list,
                 Iobs):
    """
    @author Saransh Singh, Lawrence Livermore National Lab
    @date 03/31/2021 SS 1.0 original
    @details compute the spectrum given all the input parameters.
    moved outside of the class to allow numba implementation
    this is called for multiple wavelengths and phases to generate
    the final spectrum
    """

    spec = np.zeros(tth_list.shape)
    nref = np.min(np.array([Iobs.shape[0],
        tth.shape[0],
        dsp.shape[0],hkl.shape[0]]))
    for ii in prange(nref):

        II = Iobs[ii]
        t = tth[ii]
        d = dsp[ii]
        g = hkl[ii]

        pv = pvoight_pink_beam(alpha,beta,
                 uvw,p,xy,
                 xy_sf,shkl,eta_mixing,
                 t,d,g,
                 strain_direction_dot_product,
                 is_in_sublattice,
                 tth_list)

        spec += II * pv
    return spec

@numba_njit_if_available(cache=True, nogil=True)
def calc_Iobs_pvfcj(uvw,
            p,
            xy,
            xy_sf,
            shkl,
            eta_mixing,
            HL,
            SL,
            xn,
            wn,
            tth,
            dsp,
            hkl,
            strain_direction_dot_product,
            is_in_sublattice,
            tth_list,
            Icalc,
            spectrum_expt,
            spectrum_sim):
    """
    @author Saransh Singh, Lawrence Livermore National Lab
    @date 03/31/2021 SS 1.0 original
    @details compute Iobs for each reflection given all parameters.
    moved outside of the class to allow numba implementation
    this is called for multiple wavelengths and phases to compute
    the final intensities
    """
    Iobs = np.zeros(tth.shape)
    nref = np.min(np.array([Icalc.shape[0],
        tth.shape[0],
        dsp.shape[0],hkl.shape[0]]))

    yo = spectrum_expt[:,1]
    yc = spectrum_sim[:,1]
    mask = yc != 0.
    yo = yo[mask]
    yc = yc[mask]
    tth_list_mask = spectrum_expt[:,0]
    tth_list_mask = tth_list_mask[mask]

    for ii in np.arange(nref):
        Ic = Icalc[ii]
        t = tth[ii]
        d = dsp[ii]
        g = hkl[ii]

        pv = pvfcj(uvw,p,xy,xy_sf,
                   shkl,eta_mixing,
                   t,d,g,
                   strain_direction_dot_product,
                   is_in_sublattice,
                   tth_list_mask,
                   HL,SL,xn,wn)

        y = Ic * pv
        y = y[mask]

        Iobs[ii] = np.trapz(yo*y/yc, tth_list_mask)

    return Iobs

@numba_njit_if_available(cache=True, nogil=True)
def calc_Iobs_pvtch(uvw,
            p,
            xy,
            xy_sf,
            shkl,
            eta_mixing,
            tth,
            dsp,
            hkl,
            strain_direction_dot_product,
            is_in_sublattice,
            tth_list,
            Icalc,
            spectrum_expt,
            spectrum_sim):
    """
    @author Saransh Singh, Lawrence Livermore National Lab
    @date 03/31/2021 SS 1.0 original
    @details compute Iobs for each reflection given all parameters.
    moved outside of the class to allow numba implementation
    this is called for multiple wavelengths and phases to compute
    the final intensities
    """
    Iobs = np.zeros(tth.shape)
    nref = np.min(np.array([Icalc.shape[0],
        tth.shape[0],
        dsp.shape[0],hkl.shape[0]]))

    yo = spectrum_expt[:,1]
    yc = spectrum_sim[:,1]
    mask = yc != 0.
    yo = yo[mask]
    yc = yc[mask]
    tth_list_mask = spectrum_expt[:,0]
    tth_list_mask = tth_list_mask[mask]

    for ii in np.arange(nref):
        Ic = Icalc[ii]
        t = tth[ii]
        d = dsp[ii]
        g = hkl[ii]

        pv = pvoight_wppf(uvw,p,xy,
                 xy_sf,shkl,eta_mixing,
                 t,d,g,
                 strain_direction_dot_product,
                 is_in_sublattice,
                 tth_list_mask)

        y = Ic * pv
        y = y[mask]

        Iobs[ii] = np.trapz(yo*y/yc, tth_list_mask)

    return Iobs

@numba_njit_if_available(cache=True, nogil=True)
def calc_Iobs_pvpink(alpha,
            beta,
            uvw,
            p,
            xy,
            xy_sf,
            shkl,
            eta_mixing,
            tth,
            dsp,
            hkl,
            strain_direction_dot_product,
            is_in_sublattice,
            tth_list,
            Icalc,
            spectrum_expt,
            spectrum_sim):
    """
    @author Saransh Singh, Lawrence Livermore National Lab
    @date 03/31/2021 SS 1.0 original
    @details compute Iobs for each reflection given all parameters.
    moved outside of the class to allow numba implementation
    this is called for multiple wavelengths and phases to compute
    the final intensities
    """
    Iobs = np.zeros(tth.shape)
    nref = np.min(np.array([Icalc.shape[0],
        tth.shape[0],
        dsp.shape[0],hkl.shape[0]]))

    yo = spectrum_expt[:,1]
    yc = spectrum_sim[:,1]
    mask = yc != 0.
    yo = yo[mask]
    yc = yc[mask]
    tth_list_mask = spectrum_expt[:,0]
    tth_list_mask = tth_list_mask[mask]

    for ii in prange(nref):
        Ic = Icalc[ii]
        t = tth[ii]
        d = dsp[ii]
        g = hkl[ii]

        pv = pvoight_pink_beam(alpha, beta,
                   uvw,p,xy,xy_sf,
                   shkl,eta_mixing,
                   t,d,g,
                   strain_direction_dot_product,
                   is_in_sublattice,
                   tth_list_mask)

        y = Ic * pv
        y = y[mask]

        Iobs[ii] = np.trapz(yo*y/yc, tth_list_mask)

    return Iobs

@numba_njit_if_available(cache=True, nogil=True)
def calc_rwp(spectrum_sim,
             spectrum_expt,
             weights,
             P):
    """
    @author Saransh Singh, Lawrence Livermore National Lab
    @date 03/31/2021 SS 1.0 original
    @details calculate the rwp given all the input parameters.
    moved outside of the class to allow numba implementation
    P : number of independent parameters in fitting
    """
    err = weights[:,1]* \
    (spectrum_sim[:,1] - \
    spectrum_expt[:,1])**2

    weighted_expt = weights[:,1] * spectrum_expt[:,1] **2

    errvec = np.sqrt(err)

    """ weighted sum of square """
    wss = np.trapz(err, spectrum_expt[:,0])
    den = np.trapz(weighted_expt, spectrum_expt[:,0])

    """ standard Rwp i.e. weighted residual """
    if(den > 0.):
        if(wss/den > 0.):
            Rwp = np.sqrt(wss/den)
        else:
            Rwp = np.inf
    else:
        Rwp = np.inf

    """ number of observations to fit i.e. number of data points """
    N = spectrum_sim.shape[0]

    if den > 0.:
        if (N-P)/den > 0:
            Rexp = np.sqrt((N-P)/den)
        else:
            Rexp = 0.0
    else:
        Rexp = np.inf

    # Rwp and goodness of fit parameters
    if Rexp > 0.:
        gofF = (Rwp / Rexp)**2
    else:
        gofF = np.inf

    return errvec, Rwp, gofF


# hexrd/wppf/phase.py

import numpy as np
from hexrd.valunits import valWUnit
from hexrd.spacegroup import Allowed_HKLs, SpaceGroup
from hexrd import symmetry, symbols, constants
from hexrd.material import Material
from hexrd.unitcell import _rqpDict
from hexrd.wppf import wppfsupport
from hexrd.wppf.xtal import _calc_dspacing, _get_tth, _calcxrsf,\
_calc_extinction_factor, _calc_absorption_factor
import h5py
import importlib.resources
import hexrd.resources

class Material_LeBail:
    """ 
    ========================================================================================
    ========================================================================================

    >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
    >> @DATE:       05/18/2020 SS 1.0 original
                    09/14/2020 SS 1.1 class can now be initialized using
                    a material.Material class instance
    >> @DETAILS:    Material_LeBail class is a stripped down version of the 
                    materials.Material class.this is done to keep the class lightweight 
                    and make sure only the information necessary for the lebail fit is kept

    =========================================================================================
    =========================================================================================
    """

    def __init__(self,
                 fhdf=None,
                 xtal=None,
                 dmin=None,
                 material_obj=None):

        if(material_obj is None):
            self.dmin = dmin.value
            self._readHDF(fhdf, xtal)
            self._calcrmt()

            _, self.SYM_PG_d, self.SYM_PG_d_laue, \
                self.centrosymmetric, self.symmorphic = \
                symmetry.GenerateSGSym(self.sgnum, self.sgsetting)
            self.latticeType = symmetry.latticeType(self.sgnum)
            self.sg_hmsymbol = symbols.pstr_spacegroup[self.sgnum-1].strip()
            self.GenerateRecipPGSym()
            self.CalcMaxGIndex()
            self._calchkls()
            self.sg = SpaceGroup(self.sgnum)

        else:
            if(isinstance(material_obj, Material)):
                self._init_from_materials(material_obj)
            else:
                raise ValueError(
                    "Invalid material_obj argument. \
                    only Material class can be passed here.")
        self._shkl = np.zeros((15,))

    def _readHDF(self, fhdf, xtal):

        # fexist = path.exists(fhdf)
        # if(fexist):
        fid = h5py.File(fhdf, 'r')
        name = xtal
        xtal = "/"+xtal
        if xtal not in fid:
            raise IOError('crystal doesn''t exist in material file.')
        # else:
        #   raise IOError('material file does not exist.')

        gid = fid.get(xtal)

        self.sgnum = np.asscalar(np.array(gid.get('SpaceGroupNumber'),
                                          dtype=np.int32))
        self.sgsetting = np.asscalar(np.array(gid.get('SpaceGroupSetting'),
                                              dtype=np.int32))
        """
            IMPORTANT NOTE:
            note that the latice parameters is nm by default
            hexrd on the other hand uses A as the default units, so we
            need to be careful and convert it right here, so there is no
            confusion later on
        """
        self.lparms = list(gid.get('LatticeParameters'))
        self.name = name
        fid.close()

    def _init_from_materials(self, material_obj):
        """
        this function is used to initialize the materials_lebail class
        from an instance of the material.Material class. this option is
        provided for easy integration of the hexrdgui with WPPF.

        O7/01/2021 SS ADDED DIRECT AND RECIPROCAL STRUCTURE MATRIX AS
        FIELDS IN THE CLASS
        """
        self.name = material_obj.name
        self.dmin = material_obj.dmin.getVal('nm')
        self.sgnum = material_obj.unitcell.sgnum
        self.sgsetting = material_obj.sgsetting
        self.sg = SpaceGroup(self.sgnum)

        if(material_obj.latticeParameters[0].unit == 'nm'):
            self.lparms = [x.value for x in material_obj.latticeParameters]
        elif(material_obj.latticeParameters[0].unit == 'angstrom'):
            lparms = [x.value for x in material_obj.latticeParameters]
            for i in range(3):
                lparms[i] /= 10.0
            self.lparms = lparms

        self.dmt = material_obj.unitcell.dmt
        self.rmt = material_obj.unitcell.rmt
        self.dsm = material_obj.unitcell.dsm
        self.rsm = material_obj.unitcell.rsm
        self.vol = material_obj.unitcell.vol

        self.centrosymmetric = material_obj.unitcell.centrosymmetric
        self.symmorphic = material_obj.unitcell.symmorphic

        self.latticeType = material_obj.unitcell.latticeType
        self.sg_hmsymbol = material_obj.unitcell.sg_hmsymbol

        self.ih = material_obj.unitcell.ih
        self.ik = material_obj.unitcell.ik
        self.il = material_obj.unitcell.il

        self.SYM_PG_d = material_obj.unitcell.SYM_PG_d
        self.SYM_PG_d_laue = material_obj.unitcell.SYM_PG_d_laue
        self.SYM_PG_r = material_obj.unitcell.SYM_PG_r
        self.SYM_PG_r_laue = material_obj.unitcell.SYM_PG_r_laue

        self.hkls = material_obj.planeData.getHKLs()

    def _calcrmt(self):

        """
        O7/01/2021 SS ADDED DIRECT AND RECIPROCAL STRUCTURE MATRIX AS
        FIELDS IN THE CLASS
        """
        a = self.lparms[0]
        b = self.lparms[1]
        c = self.lparms[2]

        alpha = np.radians(self.lparms[3])
        beta = np.radians(self.lparms[4])
        gamma = np.radians(self.lparms[5])

        ca = np.cos(alpha)
        cb = np.cos(beta)
        cg = np.cos(gamma)
        sa = np.sin(alpha)
        sb = np.sin(beta)
        sg = np.sin(gamma)
        tg = np.tan(gamma)

        """
            direct metric tensor
        """
        self.dmt = np.array([[a**2, a*b*cg, a*c*cb],
                             [a*b*cg, b**2, b*c*ca],
                             [a*c*cb, b*c*ca, c**2]])
        self.vol = np.sqrt(np.linalg.det(self.dmt))

        if(self.vol < 1e-5):
            warnings.warn('unitcell volume is suspiciously small')

        """
            reciprocal metric tensor
        """
        self.rmt = np.linalg.inv(self.dmt)

        """
            direct structure matrix
        """
        self.dsm = np.array([[a, b*cg, c*cb],
                              [0., b*sg, -c*(cb*cg - ca)/sg],
                              [0., 0., self.vol/(a*b*sg)]])

        """
            reciprocal structure matrix
        """
        self.rsm = np.array([[1./a, 0., 0.],
                              [-1./(a*tg), 1./(b*sg), 0.],
                              [b*c*(cg*ca - cb)/(self.vol*sg),
                               a*c*(cb*cg - ca)/(self.vol*sg),
                               a*b*sg/self.vol]])

    def _calchkls(self):
        self.hkls = self.getHKLs(self.dmin)

    """ calculate dot product of two vectors in any space 'd' 'r' or 'c' """

    def CalcLength(self, u, space):

        if(space == 'd'):
            vlen = np.sqrt(np.dot(u, np.dot(self.dmt, u)))
        elif(space == 'r'):
            vlen = np.sqrt(np.dot(u, np.dot(self.rmt, u)))
        elif(spec == 'c'):
            vlen = np.linalg.norm(u)
        else:
            raise ValueError('incorrect space argument')

        return vlen

    def CalcDot(self, u, v, space):
        if(space == 'd'):
            dot = np.dot(u, np.dot(self.dmt, v))
        elif(space == 'r'):
            dot = np.dot(u, np.dot(self.rmt, v))
        elif(space == 'c'):
            dot = np.dot(u, v)
        else:
            raise ValueError('space is unidentified')
        return dot

    def getTTh(self, wavelength):

        tth = []
        self.dsp = _calc_dspacing(self.rmt.astype(np.float64),
            self.hkls.astype(np.float64))
        tth, wavelength_allowed_hkls = \
        _get_tth(self.dsp, wavelength)
        self.wavelength_allowed_hkls = \
        wavelength_allowed_hkls.astype(bool)
        return tth

    def GenerateRecipPGSym(self):

        self.SYM_PG_r = self.SYM_PG_d[0, :, :]
        self.SYM_PG_r = np.broadcast_to(self.SYM_PG_r, [1, 3, 3])
        self.SYM_PG_r_laue = self.SYM_PG_d[0, :, :]
        self.SYM_PG_r_laue = np.broadcast_to(self.SYM_PG_r_laue, [1, 3, 3])

        for i in range(1, self.SYM_PG_d.shape[0]):
            g = self.SYM_PG_d[i, :, :]
            g = np.dot(self.dmt, np.dot(g, self.rmt))
            g = np.round(np.broadcast_to(g, [1, 3, 3]))
            self.SYM_PG_r = np.concatenate((self.SYM_PG_r, g))

        for i in range(1, self.SYM_PG_d_laue.shape[0]):
            g = self.SYM_PG_d_laue[i, :, :]
            g = np.dot(self.dmt, np.dot(g, self.rmt))
            g = np.round(np.broadcast_to(g, [1, 3, 3]))
            self.SYM_PG_r_laue = np.concatenate((self.SYM_PG_r_laue, g))

        self.SYM_PG_r = self.SYM_PG_r.astype(np.int32)
        self.SYM_PG_r_laue = self.SYM_PG_r_laue.astype(np.int32)

    def CalcMaxGIndex(self):
        self.ih = 1
        while (1.0 / self.CalcLength(
                np.array([self.ih, 0, 0], dtype=np.float64), 'r')
                > self.dmin):
            self.ih = self.ih + 1
        self.ik = 1
        while (1.0 / self.CalcLength(
                np.array([0, self.ik, 0], dtype=np.float64), 'r')
                > self.dmin):
            self.ik = self.ik + 1
        self.il = 1
        while (1.0 / self.CalcLength(
                np.array([0, 0, self.il], dtype=np.float64), 'r')
                > self.dmin):
            self.il = self.il + 1

    def CalcStar(self, v, space, applyLaue=False):
        """
        this function calculates the symmetrically equivalent hkls (or uvws)
        for the reciprocal (or direct) point group symmetry.
        """
        if(space == 'd'):
            if(applyLaue):
                sym = self.SYM_PG_d_laue
            else:
                sym = self.SYM_PG_d
        elif(space == 'r'):
            if(applyLaue):
                sym = self.SYM_PG_r_laue
            else:
                sym = self.SYM_PG_r
        else:
            raise ValueError('CalcStar: unrecognized space.')
        vsym = np.atleast_2d(v)
        for s in sym:
            vp = np.dot(s, v)
            # check if this is new
            isnew = True
            for vec in vsym:
                if(np.sum(np.abs(vp - vec)) < 1E-4):
                    isnew = False
                    break
            if(isnew):
                vsym = np.vstack((vsym, vp))
        return vsym

    def ChooseSymmetric(self, hkllist, InversionSymmetry=True):
        """
        this function takes a list of hkl vectors and
        picks out a subset of the list picking only one
        of the symmetrically equivalent one. The convention
        is to choose the hkl with the most positive components.
        """
        mask = np.ones(hkllist.shape[0], dtype=np.bool)
        laue = InversionSymmetry
        for i, g in enumerate(hkllist):
            if(mask[i]):
                geqv = self.CalcStar(g, 'r', applyLaue=laue)
                for r in geqv[1:, ]:
                    rid = np.where(np.all(r == hkllist, axis=1))
                    mask[rid] = False
        hkl = hkllist[mask, :].astype(np.int32)
        hkl_max = []
        for g in hkl:
            geqv = self.CalcStar(g, 'r', applyLaue=laue)
            loc = np.argmax(np.sum(geqv, axis=1))
            gmax = geqv[loc, :]
            hkl_max.append(gmax)
        return np.array(hkl_max).astype(np.int32)

    def SortHKL(self, hkllist):
        """
        this function sorts the hkllist by increasing |g|
        i.e. decreasing d-spacing. If two vectors are same
        length, then they are ordered with increasing
        priority to l, k and h
        """
        glen = []
        for g in hkllist:
            glen.append(np.round(self.CalcLength(g, 'r'), 8))
        # glen = np.atleast_2d(np.array(glen,dtype=np.float)).T
        dtype = [('glen', float), ('max', int), ('sum', int),
                 ('h', int), ('k', int), ('l', int)]
        a = []
        for i, gl in enumerate(glen):
            g = hkllist[i, :]
            a.append((gl, np.max(g), np.sum(g), g[0], g[1], g[2]))
        a = np.array(a, dtype=dtype)
        isort = np.argsort(a, order=['glen', 'max', 'sum', 'l', 'k', 'h'])
        return hkllist[isort, :]

    def getHKLs(self, dmin):
        """
        this function generates the symetrically unique set of
        hkls up to a given dmin.
        dmin is in nm
        """
        """
        always have the centrosymmetric condition because of
        Friedels law for xrays so only 4 of the 8 octants
        are sampled for unique hkls. By convention we will
        ignore all l < 0
        """
        hmin = -self.ih-1
        hmax = self.ih
        kmin = -self.ik-1
        kmax = self.ik
        lmin = -1
        lmax = self.il
        hkllist = np.array([[ih, ik, il] for ih in np.arange(hmax, hmin, -1)
                            for ik in np.arange(kmax, kmin, -1)
                            for il in np.arange(lmax, lmin, -1)])
        hkl_allowed = Allowed_HKLs(self.sgnum, hkllist)
        hkl = []
        dsp = []
        hkl_dsp = []
        for g in hkl_allowed:
            # ignore [0 0 0] as it is the direct beam
            if(np.sum(np.abs(g)) != 0):
                dspace = 1./self.CalcLength(g, 'r')
                if(dspace >= dmin):
                    hkl_dsp.append(g)
        """
        we now have a list of g vectors which are all within dmin range
        plus the systematic absences due to lattice centering and glide
        planes/screw axis has been taken care of
        the next order of business is to go through the list and only pick
        out one of the symetrically equivalent hkls from the list.
        """
        hkl_dsp = np.array(hkl_dsp).astype(np.int32)
        """
        the inversionsymmetry switch enforces the application of the inversion
        symmetry regradless of whether the crystal has the symmetry or not
        this is necessary in the case of xrays due to friedel's law
        """
        hkl = self.ChooseSymmetric(hkl_dsp, InversionSymmetry=True)
        """
        finally sort in order of decreasing dspacing
        """
        self.hkl = self.SortHKL(hkl)
        return self.hkl

    def Required_lp(self, p):
        return _rqpDict[self.latticeType][1](p)

    @property
    def shkl(self):
        return self._shkl
    
    @shkl.setter
    def shkl(self, val):
        """
        set the shkl as array
        """
        if(len(val) != 15):
            msg = (f"incorrect shape for shkl. "
                f"shape should be (15, ).")
            raise ValueError(msg)

        self._shkl = val

class Phases_LeBail:
    """
    ========================================================================================
    ========================================================================================
    >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
    >> @DATE:       05/20/2020 SS 1.0 original
    >> @DETAILS:    class to handle different phases in the LeBail fit. this is a stripped down
                    version of main Phase class for efficiency. only the 
                    components necessary for calculating peak positions are retained. further 
                    this will have a slight modification to account for different wavelengths 
                    in the same phase name
    =========================================================================================
    =========================================================================================
    """
    def _kev(x):
        return valWUnit('beamenergy', 'energy', x, 'keV')

    def _nm(x):
        return valWUnit('lp', 'length', x, 'nm')

    def __init__(self, material_file=None,
                 material_keys=None,
                 dmin=_nm(0.05),
                 wavelength={'alpha1': [_nm(0.15406), 1.0],
                             'alpha2': [_nm(0.154443), 0.52]}
                 ):

        self.phase_dict = {}
        self.num_phases = 0

        """
        set wavelength. check if wavelength is supplied in A, if it is
        convert to nm since the rest of the code assumes those units
        """
        wavelength_nm = {}
        for k, v in wavelength.items():
            wavelength_nm[k] = [valWUnit('lp', 'length',
                                         v[0].getVal('nm'), 'nm'), v[1]]

        self.wavelength = wavelength_nm

        self.dmin = dmin

        if(material_file is not None):
            if(material_keys is not None):
                if(type(material_keys) is not list):
                    self.add(material_file, material_keys)
                else:
                    self.add_many(material_file, material_keys)

    def __str__(self):
        resstr = 'Phases in calculation:\n'
        for i, k in enumerate(self.phase_dict.keys()):
            resstr += '\t'+str(i+1)+'. '+k+'\n'
        return resstr

    def __getitem__(self, key):
        if(key in self.phase_dict.keys()):
            return self.phase_dict[key]
        else:
            raise ValueError('phase with name not found')

    def __setitem__(self, key, mat_cls):

        if(key in self.phase_dict.keys()):
            warnings.warn('phase already in parameter \
                list. overwriting ...')
        if(isinstance(mat_cls, Material_LeBail)):
            self.phase_dict[key] = mat_cls
        else:
            raise ValueError('input not a material class')

    def __iter__(self):
        self.n = 0
        return self

    def __next__(self):
        if(self.n < len(self.phase_dict.keys())):
            res = list(self.phase_dict.keys())[self.n]
            self.n += 1
            return res
        else:
            raise StopIteration

    def __len__(self):
        return len(self.phase_dict)

    def add(self, material_file, material_key):

        self[material_key] = Material_LeBail(
            fhdf=material_file, xtal=material_key, dmin=self.dmin)

    def add_many(self, material_file, material_keys):

        for k in material_keys:

            self[k] = Material_LeBail(
                fhdf=material_file, xtal=k, dmin=self.dmin)

            self.num_phases += 1

        for k in self:
            self[k].pf = 1.0/len(self)

        self.material_file = material_file
        self.material_keys = material_keys

    def load(self, fname):
        """
            >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
            >> @DATE:       06/08/2020 SS 1.0 original
            >> @DETAILS:    load parameters from yaml file
        """
        with open(fname) as file:
            dic = yaml.load(file, Loader=yaml.FullLoader)

        for mfile in dic.keys():
            mat_keys = list(dic[mfile])
            self.add_many(mfile, mat_keys)

    def dump(self, fname):
        """
            >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
            >> @DATE:       06/08/2020 SS 1.0 original
            >> @DETAILS:    dump parameters to yaml file
        """
        dic = {}
        k = self.material_file
        dic[k] = [m for m in self]

        with open(fname, 'w') as f:
            data = yaml.dump(dic, f, sort_keys=False)

    def dump_hdf5(self, file):
        """
        >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
        >> @DATE:       01/15/2021 SS 1.0 original
        >> @ DETAILS    dumps the information from each material in the phase class
                        to a hdf5 file specified by filename or h5py.File object
        """
        if(isinstance(file, str)):
            fexist = path.isfile(file)
            if(fexist):
                fid = h5py.File(file, 'r+')
            else:
                fid = h5py.File(file, 'x')

        elif(isinstance(file, h5py.File)):
            fid = file

        else:
            raise RuntimeError(
                'Parameters: dump_hdf5 Pass in a filename \
                string or h5py.File object')

        if("/Phases" in fid):
            del(fid["Phases"])
        gid_top = fid.create_group("Phases")

        for p in self:
            mat = self[p]

            sgnum = mat.sgnum
            sgsetting = mat.sgsetting
            lparms = mat.lparms
            dmin = mat.dmin
            hkls = mat.hkls

            gid = gid_top.create_group(p)

            did = gid.create_dataset("SpaceGroupNumber", (1, ), dtype=np.int32)
            did.write_direct(np.array(sgnum, dtype=np.int32))

            did = gid.create_dataset(
                "SpaceGroupSetting", (1, ), dtype=np.int32)
            did.write_direct(np.array(sgsetting, dtype=np.int32))

            did = gid.create_dataset(
                "LatticeParameters", (6, ), dtype=np.float64)
            did.write_direct(np.array(lparms, dtype=np.float64))

            did = gid.create_dataset("dmin", (1, ), dtype=np.float64)
            did.attrs["units"] = "nm"
            did.write_direct(np.array(dmin, dtype=np.float64))

class Material_Rietveld:
    """
    ===========================================================================================
    ===========================================================================================

    >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
    >> @DATE:       05/18/2020 SS 1.0 original
                    02/01/2021 SS 1.1 class can now be initialized using a 
                    material.Material class instance
    >> @DETAILS:    Material_LeBail class is a stripped down version of the materials.Material
                    class.this is done to keep the class lightweight and make sure only the 
                    information necessary for the Rietveld fit is kept
    ===========================================================================================
     ==========================================================================================
    """

    def __init__(self,
                 fhdf=None,
                 xtal=None,
                 dmin=None,
                 kev=None,
                 material_obj=None):

        self._shkl = np.zeros((15,))
        self.abs_fact = 1e4
        if(material_obj is None):
            """
            dmin in nm
            """
            self.dmin = dmin.value
            """
            voltage in ev
            """
            self.voltage = kev.value * 1000.0

            self._readHDF(fhdf, xtal)
            self._calcrmt()

            if(self.aniU):
                self.calcBetaij()

            self.SYM_SG, self.SYM_PG_d, self.SYM_PG_d_laue, \
                self.centrosymmetric, self.symmorphic = \
                symmetry.GenerateSGSym(self.sgnum, self.sgsetting)
            self.latticeType = symmetry.latticeType(self.sgnum)
            self.sg_hmsymbol = symbols.pstr_spacegroup[self.sgnum-1].strip()
            self.GenerateRecipPGSym()
            self.CalcMaxGIndex()
            self._calchkls()
            self.InitializeInterpTable()
            self.CalcWavelength()
            self.CalcPositions()
            self.sg = SpaceGroup(self.sgnum)

        else:
            if(isinstance(material_obj, Material)):
                self._init_from_materials(material_obj)
            else:
                raise ValueError(
                    "Invalid material_obj argument. \
                    only Material class can be passed here.")

    def _init_from_materials(self, material_obj):
        """

        """
        # name
        self.name = material_obj.name

        # inverse of absorption length
        self.abs_fact = 1e-4 * (1./material_obj.absorption_length)

        # min d-spacing for sampling hkl
        self.dmin = material_obj.dmin

        # acceleration voltage and wavelength
        self.voltage = material_obj.unitcell.voltage
        self.wavelength = material_obj.unitcell.wavelength

        # space group number
        self.sgnum = material_obj.sgnum
        self.sg = SpaceGroup(self.sgnum)
        
        # space group setting
        self.sgsetting = material_obj.sgsetting

        # lattice type from sgnum
        self.latticeType = material_obj.unitcell.latticeType

        # Herman-Maugauin symbol
        self.sg_hmsymbol = material_obj.unitcell.sg_hmsymbol

        # lattice parameters
        self.lparms = np.array([material_obj.unitcell.a,
                                material_obj.unitcell.b,
                                material_obj.unitcell.c,
                                material_obj.unitcell.alpha,
                                material_obj.unitcell.beta,
                                material_obj.unitcell.gamma])

        # asymmetric atomic positions
        self.atom_pos = material_obj.unitcell.atom_pos

        # Debye-Waller factors including anisotropic ones
        self.U = material_obj.unitcell.U
        self.aniU = False
        if(self.U.ndim > 1):
            self.aniU = True
            self.betaij = material_obj.unitcell.betaij

        # atom types i.e. Z and number of different atom types
        self.atom_type = material_obj.unitcell.atom_type
        self.atom_ntype = material_obj.unitcell.atom_ntype

        self._calcrmt()

        """ get all space and point group symmetry operators
         in direct space, including the laue group. reciprocal
         space point group symmetries also included """
        self.SYM_SG = material_obj.unitcell.SYM_SG
        self.SYM_PG_d = material_obj.unitcell.SYM_PG_d
        self.SYM_PG_d_laue = material_obj.unitcell.SYM_PG_d_laue
        self.centrosymmetric = material_obj.unitcell.centrosymmetric
        self.symmorphic = material_obj.unitcell.symmorphic
        self.SYM_PG_r = material_obj.unitcell.SYM_PG_r
        self.SYM_PG_r_laue = material_obj.unitcell.SYM_PG_r_laue

        # get maximum indices for sampling hkl
        self.ih = material_obj.unitcell.ih
        self.ik = material_obj.unitcell.ik
        self.il = material_obj.unitcell.il

        # copy over the hkl but calculate the multiplicities
        self.hkls = material_obj.planeData.getHKLs()
        multiplicity = []
        for g in self.hkls:
            multiplicity.append(self.CalcStar(g, 'r').shape[0])

        multiplicity = np.array(multiplicity)
        self.multiplicity = multiplicity

        # interpolation tables and anomalous form factors
        # self.f1 = material_obj.unitcell.f1
        # self.f2 = material_obj.unitcell.f2
        # self.f_anam = material_obj.unitcell.f_anam

        # final step is to calculate the asymmetric positions in
        # the unit cell
        self.numat = material_obj.unitcell.numat
        self.asym_pos = material_obj.unitcell.asym_pos
        self.InitializeInterpTable()

    def _readHDF(self, fhdf, xtal):

        # fexist = path.exists(fhdf)
        # if(fexist):
        fid = h5py.File(fhdf, 'r')
        name = xtal
        xtal = "/"+xtal
        if xtal not in fid:
            raise IOError('crystal doesn''t exist in material file.')
        # else:
        #   raise IOError('material file does not exist.')

        gid = fid.get(xtal)

        self.sgnum = np.asscalar(np.array(gid.get('SpaceGroupNumber'),
                                          dtype=np.int32))
        self.sgsetting = np.asscalar(np.array(gid.get('SpaceGroupSetting'),
                                              dtype=np.int32))
        """
            IMPORTANT NOTE:
            note that the latice parameters in EMsoft is nm by default
            hexrd on the other hand uses A as the default units, so we
            need to be careful and convert it right here, so there is no
            confusion later on
        """
        self.lparms = list(gid.get('LatticeParameters'))

        # the last field in this is already
        self.atom_pos = np.transpose(
            np.array(gid.get('AtomData'), dtype=np.float64))

        # the U factors are related to B by the relation B = 8pi^2 U
        self.U = np.transpose(np.array(gid.get('U'), dtype=np.float64))

        # read atom types (by atomic number, Z)
        self.atom_type = np.array(gid.get('Atomtypes'), dtype=np.int32)
        self.atom_ntype = self.atom_type.shape[0]
        self.name = name

        fid.close()

    def calcBetaij(self):

        self.betaij = np.zeros([3, 3, self.atom_ntype])
        for i in range(self.U.shape[0]):
            U = self.U[i, :]
            self.betaij[:, :, i] = np.array([[U[0], U[3], U[4]],
                                             [U[3], U[1], U[5]],
                                             [U[4], U[5], U[2]]])

            self.betaij[:, :, i] *= 2. * np.pi**2 * self.aij

    def CalcWavelength(self):
        # wavelength in nm
        self.wavelength = constants.cPlanck * \
            constants.cLight /  \
            constants.cCharge / \
            self.voltage
        self.wavelength *= 1e9
        # self.CalcAnomalous()

    def CalcKeV(self):
        self.kev = constants.cPlanck * \
            constants.cLight /  \
            constants.cCharge / \
            self.wavelength

        self.kev *= 1e-3

    def _calcrmt(self):
        """
        O7/01/2021 SS ADDED DIRECT AND RECIPROCAL STRUCTURE MATRIX AS
        FIELDS IN THE CLASS
        """
        a = self.lparms[0]
        b = self.lparms[1]
        c = self.lparms[2]

        alpha = np.radians(self.lparms[3])
        beta = np.radians(self.lparms[4])
        gamma = np.radians(self.lparms[5])

        ca = np.cos(alpha)
        cb = np.cos(beta)
        cg = np.cos(gamma)
        sa = np.sin(alpha)
        sb = np.sin(beta)
        sg = np.sin(gamma)
        tg = np.tan(gamma)

        """
            direct metric tensor
        """
        self.dmt = np.array([[a**2, a*b*cg, a*c*cb],
                             [a*b*cg, b**2, b*c*ca],
                             [a*c*cb, b*c*ca, c**2]])
        self.vol = np.sqrt(np.linalg.det(self.dmt))

        if(self.vol < 1e-5):
            warnings.warn('unitcell volume is suspiciously small')

        """
            reciprocal metric tensor
        """
        self.rmt = np.linalg.inv(self.dmt)

        """
            direct structure matrix
        """
        self.dsm = np.array([[a, b*cg, c*cb],
                              [0., b*sg, -c*(cb*cg - ca)/sg],
                              [0., 0., self.vol/(a*b*sg)]])
        """
            reciprocal structure matrix
        """
        self.rsm = np.array([[1./a, 0., 0.],
                              [-1./(a*tg), 1./(b*sg), 0.],
                              [b*c*(cg*ca - cb)/(self.vol*sg),
                               a*c*(cb*cg - ca)/(self.vol*sg),
                               a*b*sg/self.vol]])

        ast = self.CalcLength([1, 0, 0], 'r')
        bst = self.CalcLength([0, 1, 0], 'r')
        cst = self.CalcLength([0, 0, 1], 'r')

        self.aij = np.array([[ast**2, ast*bst, ast*cst],
                             [bst*ast, bst**2, bst*cst],
                             [cst*ast, cst*bst, cst**2]])

    def _calchkls(self):
        self.hkls, self.multiplicity = self.getHKLs(self.dmin)

    """ calculate dot product of two vectors in any space 'd' 'r' or 'c' """

    def CalcLength(self, u, space):

        if(space == 'd'):
            vlen = np.sqrt(np.dot(u, np.dot(self.dmt, u)))
        elif(space == 'r'):
            vlen = np.sqrt(np.dot(u, np.dot(self.rmt, u)))
        elif(spec == 'c'):
            vlen = np.linalg.norm(u)
        else:
            raise ValueError('incorrect space argument')

        return vlen

    def getTTh(self, wavelength):

        tth = []
        self.dsp = _calc_dspacing(self.rmt.astype(np.float64),
            self.hkls.astype(np.float64))
        tth, wavelength_allowed_hkls = \
        _get_tth(self.dsp, wavelength)
        self.wavelength_allowed_hkls = wavelength_allowed_hkls.astype(bool)
        return tth

    ''' transform between any crystal space to any other space.
        choices are 'd' (direct), 'r' (reciprocal) and 'c' (cartesian)'''
    def TransSpace(self, v_in, inspace, outspace):
        if(inspace == 'd'):
            if(outspace == 'r'):
                v_out = np.dot(v_in, self.dmt)
            elif(outspace == 'c'):
                v_out = np.dot(self.dsm, v_in)
            else:
                raise ValueError(
                    'inspace in ''d'' but outspace can''t be identified')
        elif(inspace == 'r'):
            if(outspace == 'd'):
                v_out = np.dot(v_in, self.rmt)
            elif(outspace == 'c'):
                v_out = np.dot(self.rsm, v_in)
            else:
                raise ValueError(
                    'inspace in ''r'' but outspace can''t be identified')
        elif(inspace == 'c'):
            if(outspace == 'r'):
                v_out = np.dot(v_in, self.rsm)
            elif(outspace == 'd'):
                v_out = np.dot(v_in, self.dsm)
            else:
                raise ValueError(
                    'inspace in ''c'' but outspace can''t be identified')
        else:
            raise ValueError('incorrect inspace argument')
        return v_out

    def GenerateRecipPGSym(self):

        self.SYM_PG_r = self.SYM_PG_d[0, :, :]
        self.SYM_PG_r = np.broadcast_to(self.SYM_PG_r, [1, 3, 3])
        self.SYM_PG_r_laue = self.SYM_PG_d[0, :, :]
        self.SYM_PG_r_laue = np.broadcast_to(self.SYM_PG_r_laue, [1, 3, 3])

        for i in range(1, self.SYM_PG_d.shape[0]):
            g = self.SYM_PG_d[i, :, :]
            g = np.dot(self.dmt, np.dot(g, self.rmt))
            g = np.round(np.broadcast_to(g, [1, 3, 3]))
            self.SYM_PG_r = np.concatenate((self.SYM_PG_r, g))

        for i in range(1, self.SYM_PG_d_laue.shape[0]):
            g = self.SYM_PG_d_laue[i, :, :]
            g = np.dot(self.dmt, np.dot(g, self.rmt))
            g = np.round(np.broadcast_to(g, [1, 3, 3]))
            self.SYM_PG_r_laue = np.concatenate((self.SYM_PG_r_laue, g))

        self.SYM_PG_r = self.SYM_PG_r.astype(np.int32)
        self.SYM_PG_r_laue = self.SYM_PG_r_laue.astype(np.int32)

    def CalcMaxGIndex(self):
        self.ih = 1
        while (1.0 / self.CalcLength(
                np.array([self.ih, 0, 0], dtype=np.float64), 'r')
                > self.dmin):
            self.ih = self.ih + 1
        self.ik = 1
        while (1.0 / self.CalcLength(
                np.array([0, self.ik, 0], dtype=np.float64), 'r') >
                self.dmin):
            self.ik = self.ik + 1
        self.il = 1
        while (1.0 / self.CalcLength(
                np.array([0, 0, self.il], dtype=np.float64), 'r') >
                self.dmin):
            self.il = self.il + 1

    def CalcStar(self, v, space, applyLaue=False):
        """
        this function calculates the symmetrically equivalent hkls (or uvws)
        for the reciprocal (or direct) point group symmetry.
        """
        if(space == 'd'):
            if(applyLaue):
                sym = self.SYM_PG_d_laue
            else:
                sym = self.SYM_PG_d
        elif(space == 'r'):
            if(applyLaue):
                sym = self.SYM_PG_r_laue
            else:
                sym = self.SYM_PG_r
        else:
            raise ValueError('CalcStar: unrecognized space.')
        vsym = np.atleast_2d(v)
        for s in sym:
            vp = np.dot(s, v)
            # check if this is new
            isnew = True
            for vec in vsym:
                if(np.sum(np.abs(vp - vec)) < 1E-4):
                    isnew = False
                    break
            if(isnew):
                vsym = np.vstack((vsym, vp))
        return vsym

    def ChooseSymmetric(self, hkllist, InversionSymmetry=True):
        """
        this function takes a list of hkl vectors and
        picks out a subset of the list picking only one
        of the symmetrically equivalent one. The convention
        is to choose the hkl with the most positive components.
        """
        mask = np.ones(hkllist.shape[0], dtype=np.bool)
        laue = InversionSymmetry
        for i, g in enumerate(hkllist):
            if(mask[i]):
                geqv = self.CalcStar(g, 'r', applyLaue=laue)
                for r in geqv[1:, ]:
                    rid = np.where(np.all(r == hkllist, axis=1))
                    mask[rid] = False
        hkl = hkllist[mask, :].astype(np.int32)
        hkl_max = []
        for g in hkl:
            geqv = self.CalcStar(g, 'r', applyLaue=laue)
            loc = np.argmax(np.sum(geqv, axis=1))
            gmax = geqv[loc, :]
            hkl_max.append(gmax)
        return np.array(hkl_max).astype(np.int32)

    def SortHKL(self, hkllist):
        """
        this function sorts the hkllist by increasing |g|
        i.e. decreasing d-spacing. If two vectors are same
        length, then they are ordered with increasing
        priority to l, k and h
        """
        glen = []
        for g in hkllist:
            glen.append(np.round(self.CalcLength(g, 'r'), 8))
        # glen = np.atleast_2d(np.array(glen,dtype=np.float)).T
        dtype = [('glen', float), ('max', int), ('sum', int),
                 ('h', int), ('k', int), ('l', int)]
        a = []
        for i, gl in enumerate(glen):
            g = hkllist[i, :]
            a.append((gl, np.max(g), np.sum(g), g[0], g[1], g[2]))
        a = np.array(a, dtype=dtype)
        isort = np.argsort(a, order=['glen', 'max', 'sum', 'l', 'k', 'h'])
        return hkllist[isort, :]

    def getHKLs(self, dmin):
        """
        this function generates the symetrically unique set of
        hkls up to a given dmin.
        dmin is in nm
        """
        """
        always have the centrosymmetric condition because of
        Friedels law for xrays so only 4 of the 8 octants
        are sampled for unique hkls. By convention we will
        ignore all l < 0
        """
        hmin = -self.ih-1
        hmax = self.ih
        kmin = -self.ik-1
        kmax = self.ik
        lmin = -1
        lmax = self.il
        hkllist = np.array([[ih, ik, il] for ih in np.arange(hmax, hmin, -1)
                            for ik in np.arange(kmax, kmin, -1)
                            for il in np.arange(lmax, lmin, -1)])
        hkl_allowed = Allowed_HKLs(self.sgnum, hkllist)
        hkl = []
        dsp = []
        hkl_dsp = []
        for g in hkl_allowed:
            # ignore [0 0 0] as it is the direct beam
            if(np.sum(np.abs(g)) != 0):
                dspace = 1./self.CalcLength(g, 'r')
                if(dspace >= dmin):
                    hkl_dsp.append(g)
        """
        we now have a list of g vectors which are all within dmin range
        plus the systematic absences due to lattice centering and glide
        planes/screw axis has been taken care of
        the next order of business is to go through the list and only pick
        out one of the symetrically equivalent hkls from the list.
        """
        hkl_dsp = np.array(hkl_dsp).astype(np.int32)
        """
        the inversionsymmetry switch enforces the application of the inversion
        symmetry regradless of whether the crystal has the symmetry or not
        this is necessary in the case of xrays due to friedel's law
        """
        hkl = self.ChooseSymmetric(hkl_dsp, InversionSymmetry=True)
        """
        finally sort in order of decreasing dspacing
        """
        hkls = self.SortHKL(hkl)

        multiplicity = []
        for g in hkls:
            multiplicity.append(self.CalcStar(g, 'r').shape[0])

        multiplicity = np.array(multiplicity)
        return hkls, multiplicity

    def CalcPositions(self):
        """
        calculate the asymmetric positions in the fundamental unitcell
        used for structure factor calculations
        """
        numat = []
        asym_pos = []

        # using the wigner-seitz notation
        for i in range(self.atom_ntype):

            n = 1
            r = self.atom_pos[i, 0:3]
            r = np.hstack((r, 1.))

            asym_pos.append(np.broadcast_to(r[0:3], [1, 3]))

            for symmat in self.SYM_SG:
                # get new position
                rnew = np.dot(symmat, r)

                # reduce to fundamental unitcell with fractional
                # coordinates between 0-1
                rr = rnew[0:3]
                rr = np.modf(rr)[0]
                rr[rr < 0.] += 1.
                rr[np.abs(rr) < 1.0E-6] = 0.

                # check if this is new
                isnew = True
                for j in range(n):
                    if(np.sum(np.abs(rr - asym_pos[i][j, :])) < 1E-4):
                        isnew = False
                        break

                # if its new add this to the list
                if(isnew):
                    asym_pos[i] = np.vstack((asym_pos[i], rr))
                    n += 1

            numat.append(n)

        self.numat = np.array(numat)
        self.asym_pos = asym_pos

    def InitializeInterpTable(self):

        f_anomalous_data = []
        data = importlib.resources.open_binary(hexrd.resources, 'Anomalous.h5')
        with h5py.File(data, 'r') as fid:
            for i in range(0, self.atom_ntype):

                Z = self.atom_type[i]
                elem = constants.ptableinverse[Z]
                gid = fid.get('/'+elem)
                data = np.array(gid.get('data'))
                data = data[:,[7,1,2]]
                f_anomalous_data.append(data)

        n = max([x.shape[0] for x in f_anomalous_data])
        self.f_anomalous_data = np.zeros([self.atom_ntype,n,3])
        self.f_anomalous_data_sizes = np.zeros([self.atom_ntype,],
            dtype=np.int32)

        for i in range(self.atom_ntype):
            nd = f_anomalous_data[i].shape[0]
            self.f_anomalous_data_sizes[i] = nd
            self.f_anomalous_data[i,:nd,:] = f_anomalous_data[i]

    def CalcXRSF(self, 
        wavelength, 
        w_int):
        """
        the 1E-2 is to convert to A^-2
        since the fitting is done in those units
        """
        fNT = np.zeros([self.atom_ntype,])
        frel = np.zeros([self.atom_ntype,])
        scatfac = np.zeros([self.atom_ntype,11])
        f_anomalous_data = self.f_anomalous_data

        aniU = self.aniU
        occ = self.atom_pos[:,3]
        if aniU:
            betaij = self.betaij
        else:
            betaij = self.U

        self.numat = np.zeros(self.atom_ntype,dtype=np.int32)
        for i in range(0, self.atom_ntype):
            self.numat[i] = self.asym_pos[i].shape[0]
            Z = self.atom_type[i]
            elem = constants.ptableinverse[Z]
            scatfac[i,:] = constants.scatfac[elem]
            frel[i] = constants.frel[elem]
            fNT[i] = constants.fNT[elem]

        self.asym_pos_arr = np.zeros([self.numat.max(),self.atom_ntype, 3])
        for i in range(0, self.atom_ntype):
            nn = self.numat[i]
            self.asym_pos_arr[:nn,i,:] = self.asym_pos[i]

        nref = self.hkls.shape[0]

        sf, sf_raw = _calcxrsf(self.hkls.astype(np.float64),
              nref,
              self.multiplicity,
              w_int,
              wavelength,
              self.rmt.astype(np.float64),
              self.atom_type,
              self.atom_ntype,
              betaij,
              occ,
              self.asym_pos_arr,
              self.numat,
              scatfac,
              fNT,
              frel,
              f_anomalous_data,
              self.f_anomalous_data_sizes)

        return sf, sf_raw

    def calc_extinction(self,
                        wavelength,
                        tth,
                        f_sqr,
                        shape_factor_K,
                        particle_size_D):

        hkls = self.hkls
        v_unitcell = self.vol

        extinction = _calc_extinction_factor(hkls,
                            tth,
                            v_unitcell*1e3,
                            wavelength,
                            f_sqr,
                            shape_factor_K,
                            particle_size_D)

        return extinction

    def calc_absorption(self,
                        tth,
                        phi,
                        wavelength):
        abs_fact = self.abs_fact
        absorption = _calc_absorption_factor(abs_fact,
                                             tth,
                                             phi,
                                             wavelength)

        return absorption

    def Required_lp(self, p):
        return _rqpDict[self.latticeType][1](p)

    @property
    def shkl(self):
        return self._shkl
    
    @shkl.setter
    def shkl(self, val):
        """
        set the shkl as array
        """
        if(len(val) != 15):
            msg = (f"incorrect shape for shkl. "
                f"shape should be (15, ).")
            raise ValueError(msg)

        self._shkl = val

class Phases_Rietveld:
    """
    ==============================================================================================
    ==============================================================================================

    >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
    >> @DATE:       05/20/2020 SS 1.0 original
    >> @DETAILS:    class to handle different phases in the LeBail fit. this is a stripped down
                    version of main Phase class for efficiency. only the components necessary for
                    calculating peak positions are retained. further this will have a slight
                    modification to account for different wavelengths in the same phase name
    ==============================================================================================
     =============================================================================================
    """
    def _kev(x):
        return valWUnit('beamenergy', 'energy', x, 'keV')

    def _nm(x):
        return valWUnit('lp', 'length', x, 'nm')

    def __init__(self, material_file=None,
                 material_keys=None,
                 dmin=_nm(0.05),
                 wavelength={'alpha1': [_nm(0.15406), 1.], 'alpha2': [
                     _nm(0.154443), 0.52]}
                 ):

        self.phase_dict = {}
        self.num_phases = 0

        """
        set wavelength. check if wavelength is supplied in A, if it is
        convert to nm since the rest of the code assumes those units
        """
        wavelength_nm = {}
        for k, v in wavelength.items():
            if(v[0].unit == 'angstrom'):
                wavelength_nm[k] = [
                    valWUnit('lp', 'length', v[0].getVal("nm"), 'nm'), v[1]]
            else:
                wavelength_nm[k] = v
        self.wavelength = wavelength_nm

        self.dmin = dmin

        if(material_file is not None):
            if(material_keys is not None):
                if(type(material_keys) is not list):
                    self.add(material_file, material_keys)
                else:
                    self.add_many(material_file, material_keys)

    def __str__(self):
        resstr = 'Phases in calculation:\n'
        for i, k in enumerate(self.phase_dict.keys()):
            resstr += '\t'+str(i+1)+'. '+k+'\n'
        return resstr

    def __getitem__(self, key):
        if(key in self.phase_dict.keys()):
            return self.phase_dict[key]
        else:
            raise ValueError('phase with name not found')

    def __setitem__(self, key, mat_cls):

        if(key in self.phase_dict.keys()):
            warnings.warn('phase already in parameter list. overwriting ...')
        # if(isinstance(mat_cls, Material_Rietveld)):
        self.phase_dict[key] = mat_cls
        # else:
        # raise ValueError('input not a material class')

    def __iter__(self):
        self.n = 0
        return self

    def __next__(self):
        if(self.n < len(self.phase_dict.keys())):
            res = list(self.phase_dict.keys())[self.n]
            self.n += 1
            return res
        else:
            raise StopIteration

    def __len__(self):
        return len(self.phase_dict)

    def add(self, material_file, material_key):
        self[material_key] = {}
        self.num_phases += 1
        for l in self.wavelength:
            lam = self.wavelength[l][0].getVal('nm') * 1e-9
            E = constants.cPlanck * constants.cLight / constants.cCharge / lam
            E *= 1e-3
            kev = valWUnit('beamenergy', 'energy', E, 'keV')
            self[material_key][l] = Material_Rietveld(
                material_file, material_key, dmin=self.dmin, kev=kev)

        for k in self:
            for l in self.wavelength:
                self[k][l].pf = 1.0/self.num_phases

    def add_many(self, material_file, material_keys):

        for k in material_keys:
            self[k] = {}
            self.num_phases += 1
            for l in self.wavelength:
                lam = self.wavelength[l][0].getVal('nm') * 1e-9
                E = constants.cPlanck * constants.cLight / \
                    constants.cCharge / lam
                E *= 1e-3
                kev = valWUnit('beamenergy', 'energy', E, 'keV')
                self[k][l] = Material_Rietveld(
                    material_file, k, dmin=self.dmin, kev=kev)

        for k in self:
            for l in self.wavelength:
                self[k][l].pf = 1.0/self.num_phases

        self.material_file = material_file
        self.material_keys = material_keys

    def load(self, fname):
        """
            >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
            >> @DATE:       06/08/2020 SS 1.0 original
            >> @DETAILS:    load parameters from yaml file
        """
        with open(fname) as file:
            dic = yaml.load(file, Loader=yaml.FullLoader)

        for mfile in dic.keys():
            mat_keys = list(dic[mfile])
            self.add_many(mfile, mat_keys)

    def dump(self, fname):
        """
            >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
            >> @DATE:       06/08/2020 SS 1.0 original
            >> @DETAILS:    dump parameters to yaml file
        """
        dic = {}
        k = self.material_file
        dic[k] = [m for m in self]

        with open(fname, 'w') as f:
            data = yaml.dump(dic, f, sort_keys=False)

    @property
    def phase_fraction(self):
        pf = []
        for k in self:
            l = list(self.wavelength.keys())[0]
            pf.append(self[k][l].pf)
        return np.array(pf)
    
    @phase_fraction.setter
    def phase_fraction(self, val):
        msg = (f"phase_fraction setter: "
               f"number of phases does not match"
               f"size of input")

        if isinstance(val, list):
            if len(val) != len(self):
                raise ValueError(msg)
        elif isinstance(val, np.ndarray):
            if val.shape[0] != len(self):
                raise ValueError(msg)

        for ii,k in enumerate(self):
            for l in self.wavelength:
                self[k][l].pf = val[ii]

# hexrd/wppf/spectrum.py

import numpy as np
import h5py
from os import path

class Spectrum:
    """
    ==================================================================================
    ==================================================================================

    >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
    >> @DATE:       05/18/2020 SS 1.0 original
    >> @DETAILS:    spectrum class holds the a pair of x,y data, in this case, would be
                    2theta-intensity values

    ==================================================================================
    ==================================================================================
    """

    def __init__(self, x=None, y=None, name=''):
        if x is None:
            self._x = np.linspace(10., 100., 500)
        else:
            self._x = x
        if y is None:
            self._y = np.log(self._x ** 2) - (self._x * 0.2) ** 2
        else:
            self._y = y
        self.name = name
        self.offset = 0
        self._scaling = 1
        self.smoothing = 0
        self.bkg_Spectrum = None

    @staticmethod
    def from_file(filename, skip_rows=0):
        try:
            if filename.endswith('.chi'):
                skip_rows = 4
            data = np.loadtxt(filename, skiprows=skip_rows)
            x = data.T[0]
            y = data.T[1]
            name = path.basename(filename).split('.')[:-1][0]
            return Spectrum(x, y, name)

        except ValueError:
            print('Wrong data format for spectrum file! - ' + filename)
            return -1

    def save(self, filename, header=''):
        data = np.dstack((self._x, self._y))
        np.savetxt(filename, data[0], header=header)

    def set_background(self, Spectrum):
        self.bkg_spectrum = Spectrum

    def reset_background(self):
        self.bkg_spectrum = None

    def set_smoothing(self, amount):
        self.smoothing = amount

    def rebin(self, bin_size):
        """
        Returns a new Spectrum which is a rebinned version of the current one.
        """
        x, y = self.data
        x_min = np.round(np.min(x) / bin_size) * bin_size
        x_max = np.round(np.max(x) / bin_size) * bin_size
        new_x = np.arange(x_min, x_max + 0.1 * bin_size, bin_size)

        bins = np.hstack((x_min - bin_size * 0.5, new_x + bin_size * 0.5))
        new_y = (np.histogram(x, bins, weights=y)
                 [0] / np.histogram(x, bins)[0])

        return Spectrum(new_x, new_y)

    def dump_hdf5(self, file, name):
        """
            >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
            >> @DATE:       01/15/2021 SS 1.0 original
            >> @DETAILS:    dump the class to a hdf5 file. the file argument could either be a 
                            string or a h5.File instance. If it is a filename, then HDF5 file
                            is created, a Spectrum group is created and data is written out.
                            Else data written to Spectrum group in existing file object
            >> @PARAMS file file name string or h5py.File object
                       name name ID of the spectrum e.g. experimental or simulated or background
        """

        if(isinstance(file, str)):
            fexist = path.isfile(file)
            if(fexist):
                fid = h5py.File(file, 'r+')
            else:
                fid = h5py.File(file, 'x')

        elif(isinstance(file, h5py.File)):
            fid = file

        else:
            raise RuntimeError(
                'Parameters: dump_hdf5 Pass in a filename \
                 string or h5py.File object')

        name_spectrum = 'Spectrum/'+name
        if(name_spectrum in fid):
            del(fid[name_spectrum])
        gid = fid.create_group(name_spectrum)

        tth, I = self.data

        # make sure these arrays are not zero sized
        if(tth.shape[0] > 0):
            did = gid.create_dataset("tth", tth.shape, dtype=np.float64)
            did.write_direct(tth.astype(np.float64))

        if(I.shape[0] > 0):
            did = gid.create_dataset("intensity", I.shape, dtype=np.float64)
            did.write_direct(I.astype(np.float64))

    @property
    def data(self):
        if self.bkg_Spectrum is not None:
            # create background function
            x_bkg, y_bkg = self.bkg_Spectrum.data

            if not np.array_equal(x_bkg, self._x):
                # the background will be interpolated
                f_bkg = interp1d(x_bkg, y_bkg, kind='linear')

                # find overlapping x and y values:
                ind = np.where((self._x <= np.max(x_bkg)) &
                               (self._x >= np.min(x_bkg)))
                x = self._x[ind]
                y = self._y[ind]

                if len(x) == 0:
                    """ if there is no overlapping between background
                     and Spectrum, raise an error """
                    raise BkgNotInRangeError(self.name)

                y = y * self._scaling + self.offset - f_bkg(x)
            else:
                """ if Spectrum and bkg have the same
                 x basis we just delete y-y_bkg"""
                x, y = self._x, self._y * \
                    self._scaling + self.offset - y_bkg
        else:
            x, y = self.original_data

        if self.smoothing > 0:
            y = gaussian_filter1d(y, self.smoothing)
        return x, y

    @data.setter
    def data(self, data):
        (x, y) = data
        self._x = x
        self._y = y
        self.scaling = 1
        self.offset = 0

    @property
    def data_array(self):
        x, y = self.data
        mask = np.isnan(y)
        arr = np.vstack((x[~mask], y[~mask])).T
        if isinstance(arr, np.ma.MaskedArray):
            return arr.filled()
        else:
            return arr

    @property
    def original_data(self):
        return self._x, self._y * self._scaling +\
            self.offset

    @property
    def x(self):
        return self._x

    @x.setter
    def x(self, new_value):
        self._x = new_value

    @property
    def y(self):
        return self._y

    @y.setter
    def y(self, new_y):
        self._y = new_y

    @property
    def scaling(self):
        return self._scaling

    @scaling.setter
    def scaling(self, value):
        if value < 0:
            self._scaling = 0
        else:
            self._scaling = value

    def limit(self, x_min, x_max):
        x, y = self.data
        return Spectrum(x[np.where((x_min < x) & (x < x_max))],
                        y[np.where((x_min < x) & (x < x_max))])

    def extend_to(self, x_value, y_value):
        """
        Extends the current Spectrum to a specific x_value by filling it 
        with the y_value. Does not modify inplace but returns a new filled 
        Spectrum
        :param x_value: Point to which extend the Spectrum should be smaller
        than the lowest x-value in the Spectrum or vice versa
        :param y_value: number to fill the Spectrum with
        :return: extended Spectrum
        """
        x_step = np.mean(np.diff(self.x))
        x_min = np.min(self.x)
        x_max = np.max(self.x)
        if x_value < x_min:
            x_fill = np.arange(x_min - x_step, x_value -
                               x_step*0.5, -x_step)[::-1]
            y_fill = np.zeros(x_fill.shape)
            y_fill.fill(y_value)

            new_x = np.concatenate((x_fill, self.x))
            new_y = np.concatenate((y_fill, self.y))
        elif x_value > x_max:
            x_fill = np.arange(x_max + x_step, x_value+x_step*0.5, x_step)
            y_fill = np.zeros(x_fill.shape)
            y_fill.fill(y_value)

            new_x = np.concatenate((self.x, x_fill))
            new_y = np.concatenate((self.y, y_fill))
        else:
            return self

        return Spectrum(new_x, new_y)

    def plot(self, show=False, *args, **kwargs):
        import matplotlib.pyplot as plt
        plt.plot(self.x, self.y, *args, **kwargs)
        if show:
            plt.show()

    def nan_to_zero(self):
        """
        set the nan in spectrum to zero
        sometimes integrated spectrum in data can
        have some nans, so need to catch those
        """
        self._y = np.nan_to_num(self._y, copy=False, nan=0.0)

    # Operators:
    def __sub__(self, other):
        orig_x, orig_y = self.data
        other_x, other_y = other.data

        if orig_x.shape != other_x.shape:
            # todo different shape subtraction of spectra
            # seems the fail somehow...
            # the background will be interpolated
            other_fcn = interp1d(other_x, other_x, kind='linear')

            # find overlapping x and y values:
            ind = np.where((orig_x <= np.max(other_x)) &
                           (orig_x >= np.min(other_x)))
            x = orig_x[ind]
            y = orig_y[ind]

            if len(x) == 0:
                # if there is no overlapping between
                # background and Spectrum, raise an error
                raise BkgNotInRangeError(self.name)
            return Spectrum(x, y - other_fcn(x))
        else:
            return Spectrum(orig_x, orig_y - other_y)

    def __add__(self, other):
        orig_x, orig_y = self.data
        other_x, other_y = other.data

        if orig_x.shape != other_x.shape:
            # the background will be interpolated
            other_fcn = interp1d(other_x, other_x, kind='linear')

            # find overlapping x and y values:
            ind = np.where((orig_x <= np.max(other_x)) &
                           (orig_x >= np.min(other_x)))
            x = orig_x[ind]
            y = orig_y[ind]

            if len(x) == 0:
                # if there is no overlapping between
                # background and Spectrum, raise an error
                raise BkgNotInRangeError(self.name)
            return Spectrum(x, y + other_fcn(x))
        else:
            return Spectrum(orig_x, orig_y + other_y)

    def __rmul__(self, other):
        orig_x, orig_y = self.data
        return Spectrum(np.copy(orig_x), np.copy(orig_y) * other)

    def __eq__(self, other):
        if not isinstance(other, Spectrum):
            return False
        if np.array_equal(self.data, other.data):
            return True
        return False


# hexrd/wppf/texture.py

import copy

import importlib.resources

from warnings import warn

# 3rd party imports
import h5py

from lmfit import Parameters, Minimizer

import numpy as np
from numpy.polynomial.polynomial import Polynomial

from scipy.spatial import Delaunay

# HEXRD imports
import hexrd.resources
# FIXME: unused imports @saransh13?
# from hexrd.transforms.xfcapi import anglesToGVec
# from hexrd.wppf import phase

"""
===============================================================================

>> @AUTHOR: Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
>> @DATE: 06/14/2021 SS 1.0 original

>> @DETAILS: this module deals with the texture computations for the wppf
    package.  two texture models employed are the March-Dollase model and the
    spherical harmonics model. the spherical harmonics model uses the axis
    distribution function for computing the scale factors for each reflection.
    maybe it makes sense to use symmetrized FEM mesh functions for 2-sphere for
    this. probably use the two theta-eta array from the instrument class to
    precompute the values of k_l^m(y) and we already know what the reflections
    are so k_l^m(h) can also be pre-computed.

>> @PARAMETERS:  ymmetry symmetry of the mesh
===============================================================================
"""

# FIXME: these are available in hexrd.constants @saransh13
I3 = np.eye(3)

Xl = np.ascontiguousarray(I3[:, 0].reshape(3, 1))     # X in the lab frame
Yl = np.ascontiguousarray(I3[:, 1].reshape(3, 1))     # Z in the lab frame
Zl = np.ascontiguousarray(I3[:, 2].reshape(3, 1))     # Z in the lab frame

bVec_ref = -Zl
eta_ref = Xl

class mesh_s2:
    """
    this class deals with the basic functions of the s2 mesh. the
    class is initialized just based on the symmetry. the main functions
    are the interpolation of harmonic values for a given set of points
    and the number of invariant harmonics up to a maximum degree. this
    is the main class used for computing the general axis distribution
    function.
    """
    def __init__(self,
                 symmetry):

        data = importlib.resources.open_binary(hexrd.resources, "surface_harmonics.h5")
        with h5py.File(data, 'r') as fid:

            gname = f"{symmetry}"
            self.symmetry = symmetry
            self.ncrd = fid[gname].attrs["ncrd"]
            self.neqv = fid[gname].attrs["neqv"]
            self.nindp = fid[gname].attrs["nindp"]

            dname = f"/{symmetry}/harmonics"
            self.harmonics = np.array(fid[dname])

            dname = f"/{symmetry}/eqv"
            self.eqv = np.array(fid[dname]).astype(np.int32)

            dname = f"/{symmetry}/crd"

            pts = np.array(fid[dname])
            n = np.linalg.norm(pts, axis=1)
            self.points = pts/np.tile(n, [3,1]).T

            points_st = self.points[:,:2]/np.tile(
                (1.+np.abs(self.points[:,2])),[2,1]).T

            self.mesh = Delaunay(points_st, qhull_options="QJ")

    def _get_simplices(self,
                       points):
        """
        this function is used to get the index of the simplex
        in which the point lies. this is the first step to
        calculating the barycentric coordinates, which are the
        weights to linear interpolation.
        """

        n = np.linalg.norm(points, axis=1)
        points = points/np.tile(n, [3,1]).T

        points_st = points[:,:2]/np.tile(
                (1.+np.abs(points[:,2])),[2,1]).T

        simplices = self.mesh.find_simplex(points_st)

        """
        there might be some points which end up being slighty outside
        the circle due to numerical precision. just scale those points
        by a number close to one to bring them closer to the origin.
        the program should ideally never get here
        """
        mask = (simplices == -1)
        simplices[mask] = self.mesh.find_simplex(points_st[mask,:]*0.995)

        if -1 in simplices:
            msg = (f"some points seem to not be in the "
            f"mesh. please check input")
            raise RuntimeError(msg)

        return simplices

    def _get_barycentric_coordinates(self,
                                    points):

        """
        get the barycentric coordinates of points
        this is used for linear interpolation of
        the harmonic function on the mesh

        points is nx3 shaped

        first make sure that the points are all normalized
        to unit length then take the stereographic projection
        """
        n = np.linalg.norm(points, axis=1)
        points = points/np.tile(n, [3,1]).T

        points_st = points[:,:2]/np.tile(
                (1.+points[:,2]),[2,1]).T

        """
        next get the simplices. a value of -1 is returned
        when the point can not be found inside any simplex.
        those points will be handled separately
        """
        simplices = self._get_simplices(points)

        """
        we have the simplex index for each point now. move on to
        computing the barycentric coordinates. the logic is simple
        inverse of the T matrix is in mesh.transforms, we use that
        to compute T^{-1}(r-r3)
        see wikipedia for more details
        """
        bary_center = [self.mesh.transform[simplices[i],:2].dot(
        (np.transpose(points_st[i,:] -
        self.mesh.transform[simplices[i],2])))
        for i in np.arange(points.shape[0])]

        bary_center = np.array(bary_center)

        """
        the fourth coordinate is 1 - sum of the other three
        """
        bary_center = np.hstack((bary_center,
        1. - np.atleast_2d(bary_center.sum(axis=1)).T))

        bary_center[np.abs(bary_center)<1e-9] = 0.

        return np.array(bary_center), simplices

    def _get_equivalent_node(self,
                             node_id):
        """
        given the index of the node, find out the
        equivalent node. if the node is already one
        of the independent nodes, then the same value
        is returned. Note that the index is all 1 based
        in the surface harmonic file and all 0 based in
        the scipy Delaunay function
        """
        node_id = node_id
        mask = node_id+1 > self.nindp
        eqv_node_id = np.zeros(node_id.shape).astype(np.int32)

        """
        for the independent node, return the same value
        """
        eqv_node_id[~mask] = node_id[~mask]

        """
        for the other node, calculate the equivalent index
        using the eqv array
        """
        if not np.all(mask == False):
            eqv_id = np.array([np.where(self.eqv[:,0] == i+1)
                for i in node_id[mask]]).astype(np.int32)
            eqv_id = np.squeeze(eqv_id)
            eqv_node_id[mask] = self.eqv[eqv_id,1]-1

        return eqv_node_id


    def _get_harmonic_values(self,
                            points_inp):
        """
        this is the main function which compute the
        value of the harmonic function at a given set
        of points using linear interpolation of the
        values on the fem mesh. the sequence of function
        calls is as follows:

        1. compute the barycentric coordinates
        2. compute the equivalent nodes
        3. perform weighted mean

        the value of harmonics upto the nodal degree of
        freedom is return. the user can then select how many
        to use and where to truncate

        Note that if z component is negative, an inversion
        symmetry is automatically applied to the point
        """
        points = copy.deepcopy(points_inp)
        # mask = points[:,-1] < 0.
        # points[mask,:] = -points[mask,:]
        bary_center, simplex_id = self._get_barycentric_coordinates(points)
        node_id = self.mesh.simplices[simplex_id]

        eqv_node_id = np.array([self._get_equivalent_node(nid)
            for nid in node_id]).astype(np.int32)

        fval = np.array([self.harmonics[nid,:] for nid in eqv_node_id])
        nharm = self.harmonics.shape[1]

        fval_points = np.array([np.sum(np.tile(bary_center[i,:],[nharm,1]).T*
            fval[i,:,:],axis=0) for i in range(points.shape[0])])

        return np.atleast_2d(fval_points)

    def num_invariant_harmonic(self,
                           max_degree):

        """
        >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
        >> @DATE:       06/29/2021 SS 1.0 original

        >> @DETAILS:    this function computes the number of symmetrized harmonic coefficients
                        for a given degree. this is essential for computing the number of
                        terms that is in the summation of the general axis distribution function.
                        the generating function of the dimension of the invariant subspace is
                        given by Meyer and Polya and enumerated in
                        "ON THE SYMMETRIES OF SPHERICAL HARMONICS", Burnett Meyer
        >> @PARAMETERS:  symmetry, degree
        """

        """
        first get the polynomials in the
        denominator
        """
        if self.symmetry == "cylindrical":
            v = []
            for i in range(0,max_degree+1,2):
                v.append([i,1])
            v = np.array(v).astype(np.int32)
            return v
        else:
            powers = Polya[self.symmetry]
            den = powers["denominator"]

            polyd = []
            if not den:
                pass
            else:
                for m in den:
                    polyd.append(self.denominator(m, max_degree))

            num = powers["numerator"]
            polyn = []
            if not num:
                pass
            else:
                nn = [x[0] for x in num]
                mmax = max(nn)
                coef = np.zeros([mmax+1,])
                coef[0] = 1.
                for m in num:
                    coef[m[0]] = m[1]
                    polyn.append(Polynomial(coef))

            """
            multiply all of the polynomials in the denominator
            with the ones in the numerator
            """
            poly = Polynomial([1.])
            for pd in polyd:
                if not pd:
                    break
                else:
                    poly = poly*pd

            for pn in polyn:
                if not pn:
                    break
                else:
                    poly = poly*pn

            poly = poly.truncate(max_degree+1)
            idx = np.nonzero(poly.coef)
            v = poly.coef[idx]

            return np.vstack((idx,v)).T.astype(np.int32)

    def denominator(self,
                    m,
                    max_degree):
        """
        this function computes the Maclaurin expansion
        of the function 1/(1-t^m)
        this is just the binomial expansion with negative
        exponent

        1/(1-x^m) = 1 + x^m + x^2m + x^3m ...
        """
        coeff = np.zeros([max_degree+1, ])
        ideg = 1+int(max_degree/m)
        for i in np.arange(ideg):
            idx = i*m
            coeff[idx] = 1.0

        return Polynomial(coeff)


class harmonic_model:
    """
    this class brings all the elements together to compute the
    texture model given the sample and crystal symmetry.
    """
    def __init__(self,
                pole_figures,
                sample_symmetry,
                max_degree):

        self.pole_figures = pole_figures
        self.crystal_symmetry = pole_figures.material.sg.laueGroup_international
        self.sample_symmetry = sample_symmetry
        self.max_degree = max_degree
        self.mesh_crystal = mesh_s2(self.crystal_symmetry)
        self.mesh_sample = mesh_s2(self.sample_symmetry)

        self.init_harmonic_values()
        self.itercounter = 0

        ncoeff = self._num_coefficients()
        self.coeff = np.zeros([ncoeff,])

    def init_harmonic_values(self):
        """
        once the harmonic model is initialized, initialize
        the values of the harmonic functions for different
        values of hkl and sample directions. the hkl are the
        keys and the sample_dir are the values of the sample_dir
        dictionary.
        """
        self.V_c_allowed = {}
        self.V_s_allowed = {}
        self.allowed_degrees = {}

        pole_figures = self.pole_figures
        for ii in np.arange(pole_figures.num_pfs):
            key = str(pole_figures.hkls[ii,:])[1:-1].replace(" ", "")
            hkl = np.atleast_2d(pole_figures.hkls_c[ii,:])
            sample_dir = pole_figures.gvecs[key]

            self.V_c_allowed[key], self.V_s_allowed[key] = \
            self._compute_harmonic_values_grid(hkl,
                                               sample_dir)
        self.allowed_degrees = self._allowed_degrees()

    def init_equiangular_grid(self):
        """
        this function initializes sample directions
        for an 5x5 equiangular grid of points. the harmonic
        functions will be calculated using the
        calc_pole_figures function instead of here.
        """
        angs = []
        for tth in np.arange(0,91,5):
            for eta in np.arange(0, 360, 5):
                angs.append([np.radians(tth), np.radians(eta), 0.])
                if tth == 0:
                    break
        angs = np.array(angs)

        return angs

    def init_coeff_params(self):
        """
        this function initializes a lmfit
        parameter class with all the coefficients.
        this will be passed to the minimize
        routine
        """
        params = Parameters()
        self.coeff_loc = {}
        self.coeff_loc_inv = {}
        ctr = 0
        for ii,(k,v) in enumerate(self.allowed_degrees.items()):
            if k > 0:
                for icry in np.arange(v[0]):
                    for isamp in np.arange(v[1]):
                        vname = f"c_{k}_{icry}_{isamp}"
                        self.coeff_loc[vname] = ctr
                        self.coeff_loc_inv[ctr] = vname
                        idx = self.coeff_loc[vname]
                        ctr += 1
                        params.add(vname,value=self.coeff[idx],
                        vary=True,min=-np.inf,max=np.inf)

        if hasattr(self, 'phon'):
            val = self.phon
        else:
            val = 0.0

        params.add("phon",value=val,vary=True,min=0.0)

        return params

    def set_coeff_from_param(self, params):
        """
        this function takes the the values in the
        parameters and sets the values of the coefficients
        """
        self.coeff = np.zeros([len(self.coeff_loc),])
        for ii,k in enumerate(params):
            if k.lower() != "phon":
                loc = self.coeff_loc[k]
                self.coeff[loc] = params[k].value

        self.phon = params["phon"].value

    def residual_function(self, params):
        """
        calculate the residual between input pole figure
        data and generalized axis distribution function
        """
        self.set_coeff_from_param(params)
        pf_recalc = self.recalculate_pole_figures()

        for ii,(k,v) in enumerate(self.pole_figures.pfdata.items()):
            inp_intensity = np.squeeze(v[:,3])
            calc_intensity = np.squeeze(pf_recalc[k])
            diff = (inp_intensity-calc_intensity)
            if ii == 0:
                residual = diff
                vals = inp_intensity
                weights = 1./np.sqrt(inp_intensity)
                weights[np.isnan(weights)] = 0.0
            else:
                residual = np.hstack((residual,diff))
                vals = np.hstack((vals,inp_intensity))
                ww = 1./np.sqrt(inp_intensity)
                ww[np.isnan(ww)] = 0.0
                weights = np.hstack((weights,ww))

        err = (weights*residual)**2
        wss = np.sum(err)
        den = np.sum((weights*vals)**2)

        Rwp = np.sqrt(wss/den)
        if np.mod(self.itercounter,100) == 0:
            msg = f"iteration# {self.itercounter}, Rwp error = {Rwp*100} %"
            print(msg)
        self.itercounter += 1
        return err

    def _compute_harmonic_values_grid(self,
                                      hkl,
                                      sample_dir):
        """
        compute the dictionary of invariant harmonic values for
        a given set of sample directions and hkls
        """
        ninv_c = self.mesh_crystal.num_invariant_harmonic(
        self.max_degree)

        ninv_s = self.mesh_sample.num_invariant_harmonic(
                 self.max_degree)

        V_c = self.mesh_crystal._get_harmonic_values(hkl)
        V_s = self.mesh_sample._get_harmonic_values(sample_dir)

        """
        some degrees for which the crystal symmetry has
        fewer terms than sample symmetry or vice versa
        needs to be weeded out
        """
        V_c_allowed = {}
        V_s_allowed = {}
        for i in np.arange(0,self.max_degree+1,2):
            if i in ninv_c[:,0] and i in ninv_s[:,0]:

                istc, ienc = self._index_of_harmonics(i, "crystal")
                V_c_allowed[i] = V_c[:,istc:ienc]

                ists, iens = self._index_of_harmonics(i, "sample")
                V_s_allowed[i] = V_s[:,ists:iens]

        return V_c_allowed, V_s_allowed

    def compute_texture_factor(self,
                               coeff):
        """
        first check if the size of the coef vector is
        consistent with the max degree argumeents for
        crystal and sample.
        """
        ncoeff = coeff.shape[0]+1

        ncoeff_inv = self._num_coefficients()
        if ncoeff < ncoeff_inv:
            msg = (f"inconsistent number of entries in "
                   f"coefficients based on the degree of "
                   f"crystal and sample harmonic degrees. "
                   f"needed {ncoeff_inv}, found {ncoeff}")
            raise ValueError(msg)
        elif ncoeff > ncoeff_inv:
            msg = (f"more coefficients passed than required "
                   f"based on the degree of crystal and "
                   f"sample harmonic degrees. "
                   f"needed {ncoeff_inv}, found {ncoeff}. "
                   f"ignoring extra terms.")
            warn(msg)
            coeff = coeff[:ncoeff_inv]

        tex_fact = {}
        for g in self.pole_figures.hkls:
            key = str(g)[1:-1].replace(" ","")
            nsamp = self.pole_figures.gvecs[key].shape[0]
            tex_fact[key] = np.zeros([nsamp,])

            tex_fact[key] = self._compute_sum(nsamp,
                                              coeff,
                                              self.allowed_degrees,
                                              self.V_c_allowed[key],
                                              self.V_s_allowed[key])
        return tex_fact

    def _index_of_harmonics(self,
                            deg,
                            c_or_s):
        """
        calculate the start and end index of harmonics
        of a given degree and crystal or sample symmetry
        returns the start and end index
        """
        ninv_c = self.mesh_crystal.num_invariant_harmonic(
                 self.max_degree)

        ninv_s = self.mesh_sample.num_invariant_harmonic(
                 self.max_degree)

        ninv_c_csum = np.r_[0,np.cumsum(ninv_c[:,1])]
        ninv_s_csum = np.r_[0,np.cumsum(ninv_s[:,1])]

        if c_or_s.lower() == "crystal":
            idx = np.where(ninv_c[:,0] == deg)[0]
            return int(ninv_c_csum[idx]), int(ninv_c_csum[idx+1])
        elif c_or_s.lower() == "sample":
            idx = np.where(ninv_s[:,0] == deg)[0]
            return int(ninv_s_csum[idx]), int(ninv_s_csum[idx+1])
        else:
            msg = f"unknown input to c_or_s"
            raise ValueError(msg)

    def _compute_sum(self,
                    nsamp,
                    coeff,
                    allowed_degrees,
                    V_c_allowed,
                    V_s_allowed):
        """
        compute the degree by degree sum in the
        generalized axis distribution function
        """
        tmp = copy.deepcopy(allowed_degrees)
        del tmp[0]
        nn = np.cumsum(np.array([tmp[k][0]*tmp[k][1]
            for k in tmp]))
        ncoeff_csum = np.r_[0,nn]

        val = np.ones([nsamp,])+self.phon
        for i,(k,v) in enumerate(tmp.items()):
            deg = k
            kc = V_c_allowed[deg]

            ks = V_s_allowed[deg].T

            mu = kc.shape[1]
            nu = ks.shape[0]

            ist = ncoeff_csum[i]
            ien = ncoeff_csum[i+1]

            C = np.reshape(coeff[ist:ien],[mu, nu])

            val = val + np.squeeze(np.dot(kc,np.dot(C,ks))*4*np.pi/(2*k+1))

        return val

    def _num_coefficients(self):
        """
        utility function to compute the number of
        independent coefficients required for the
        given maximum degree of harmonics
        """
        ninv_c = self.mesh_crystal.num_invariant_harmonic(
         self.max_degree)

        ninv_s = self.mesh_sample.num_invariant_harmonic(
                 self.max_degree)
        ncoef_inv = 0

        for i in np.arange(0,self.max_degree+1,2):
            if i in ninv_c[:,0] and i in ninv_s[:,0]:
                idc = int(np.where(ninv_c[:,0] == i)[0])
                ids = int(np.where(ninv_s[:,0] == i)[0])

                ncoef_inv += ninv_c[idc,1]*ninv_s[ids,1]
        return ncoef_inv

    def _allowed_degrees(self):
        """
        utility function to get the allowed degrees
        and the corresponding number of harmonics for
        crystal and sample symmetry
        """
        ninv_c = self.mesh_crystal.num_invariant_harmonic(
         self.max_degree)

        ninv_s = self.mesh_sample.num_invariant_harmonic(
                 self.max_degree)

        """
        some degrees for which the crystal symmetry has
        fewer terms than sample symmetry or vice versa
        needs to be weeded out
        """
        allowed_degrees = {}

        for i in np.arange(0,self.max_degree+1,2):
            if i in ninv_c[:,0] and i in ninv_s[:,0]:
                idc = int(np.where(ninv_c[:,0] == i)[0])
                ids = int(np.where(ninv_s[:,0] == i)[0])

                allowed_degrees[i] = [ninv_c[idc,1], ninv_s[ids,1]]

        return allowed_degrees

    def refine(self):

        params = self.init_coeff_params()
        fdict = {'ftol': 1e-6, 'xtol': 1e-6, 'gtol': 1e-6,
         'verbose': 0, 'max_nfev': 20000, 'method':'trf',
         'jac':'3-point'}

        fitter = Minimizer(self.residual_function, params)

        res = fitter.least_squares(**fdict)
        params_res = res.params
        self.set_coeff_from_param(params_res)

    def recalculate_pole_figures(self):
        """
        this function calculates the pole figures for
        the sample directions in the pole figure used
        to initialize the model. this can be used to
        test how well the harmonic model fit the data.
        """
        return self.compute_texture_factor(self.coeff)



    def calc_pole_figures(self,
                      hkls,
                      grid="equiangular"):
        """
        given a set of hkl, coefficients and maximum degree of
        harmonic function to use for both crystal and sample
        symmetries, compute the pole figures for full coverage.
        the default grid is the equiangular grid, but other
        options include computing it on the s2 mesh or modified
        lambert grid or custom (theta, phi) coordinates.

        this uses the general axis distributiin function. other
        formalisms such as direct pole figure inversion is also
        possible using quadratic programming, but for that explicit
        pole figure operators are needed. the axis distributuion
        function is easy to integrate with the rietveld method.
        """

        """
        first check if the dimensions of coef is consistent with
        the maximum degrees of the harmonics
        """

        """
        check if class already exists with the same
        hkl values. if it does, then do nothing.
        otherwise initialize a new instance
        """
        init = True
        if hasattr(self, "pf_equiangular"):
            if self.pf_equiangular.hkls.shape == hkls.shape:
                if np.sum(np.abs(hkls - self.pf_equiangular.hkls)) < 1e-6:
                    init = False

        if init:

            angs = self.init_equiangular_grid()
            mat = self.pole_figures.material
            bHat_l = self.pole_figures.bHat_l
            eHat_l = self.pole_figures.eHat_l
            chi = self.pole_figures.chi

            t = angs[:,0]
            r = angs[:,1]
            st = np.sin(t)
            ct = np.cos(t)
            sr = np.sin(r)
            cr = np.cos(r)
            pfdata = {}
            for g in hkls:
                key = str(g)[1:-1].replace(" ","")
                xyz = np.vstack((st*cr,st*sr,ct)).T
                v = angs[:,2]
                pfdata[key] = np.vstack((xyz.T,v)).T

            args   = (mat, hkls, pfdata)
            kwargs = {"bHat_l":bHat_l,
                      "eHat_l":eHat_l,
                      "chi":chi}
            self.pf_equiangular = pole_figures(*args, **kwargs)

        model = harmonic_model(self.pf_equiangular,
                               self.sample_symmetry,
                               self.max_degree)

        model.coeff = self.coeff
        model.phon = self.phon

        pf = model.recalculate_pole_figures()
        pfdata = {}
        for k,v in self.pf_equiangular.pfdata.items():
            pfdata[k] = np.hstack((
                        np.degrees(model.pole_figures.angs[k]),
                        np.atleast_2d(pf[k]).T ))

        return pfdata

    def calc_inverse_pole_figures(self,
                                  sample_dir="ND",
                                  grid="equiangular",
                                  resolution = 5.0):
        """
        given a sample direction such as TD, RD and ND,
        calculate the distribution of crystallographic
        axes aligned with that direction. this is sometimes
        more useful than the regular pole figures especially
        for cylindrical sample symmetry where the PFs are
        usually "Bulls Eye" type. this follows the same
        flow as the pole figure calculation.
        sample_dir can have "RD", "TD" and "ND" as string
        arguments or can also have a nx3 array
        grid can be "equiangular" or "FEM"
        resolution in degrees. only used for equiangular grid

        instead of sampling asymmetric unit of stereogram,
        we will sample the entire northern hemisphere. the
        resulting IPDF will have symmetry of crystal
        """
        ipf = inverse_pole_figures(sample_dir,
                                   sampling=grid,
                                   resolution=resolution)
        vc, vs = self._compute_ipdf_mesh_vals(ipf)
        ipdf = self._compute_ipdf(ipf,vc,vs)
        angs = ipf.angs
        return np.hstack((angs,np.atleast_2d(ipdf).T))

    def _compute_ipdf_mesh_vals(self,
                    ipf):
        """
        compute the inverse pole density function.
        """
        ninv_c = self.mesh_crystal.num_invariant_harmonic(
        self.max_degree)

        ninv_s = self.mesh_sample.num_invariant_harmonic(
                 self.max_degree)

        V_c = self.mesh_crystal._get_harmonic_values(ipf.crystal_dir)
        V_s = self.mesh_sample._get_harmonic_values(ipf.sample_dir)

        """
        some degrees for which the crystal symmetry has
        fewer terms than sample symmetry or vice versa
        needs to be weeded out
        """
        V_c_allowed = {}
        V_s_allowed = {}
        for i in np.arange(0,self.max_degree+1,2):
            if i in ninv_c[:,0] and i in ninv_s[:,0]:

                istc, ienc = self._index_of_harmonics(i, "crystal")
                V_c_allowed[i] = V_c[:,istc:ienc]

                ists, iens = self._index_of_harmonics(i, "sample")
                V_s_allowed[i] = V_s[:,ists:iens]

        return V_c_allowed,V_s_allowed


    def _compute_ipdf(self,
                      ipf,
                      vc,
                      vs):
        """
        compute the generalized axis distribution
        function sum for the coefficients
        """
        allowed_degrees = self.allowed_degrees
        tmp = copy.deepcopy(allowed_degrees)
        del tmp[0]
        nn = np.cumsum(np.array([tmp[k][0]*tmp[k][1]
            for k in tmp]))
        ncoeff_csum = np.r_[0,nn]
        nsamp = ipf.sample_dir.shape[0]
        ncryst = ipf.crystal_dir.shape[0]
        coeff = self.coeff

        val = np.ones([ncryst,])+self.phon
        for i,(k,v) in enumerate(tmp.items()):
            deg = k
            kc = vc[deg]

            ks = vs[deg].T

            mu = kc.shape[1]
            nu = ks.shape[0]

            ist = ncoeff_csum[i]
            ien = ncoeff_csum[i+1]

            C = np.reshape(coeff[ist:ien],[mu, nu])

            val = val + np.squeeze(np.dot(kc,np.dot(C,ks))*4*np.pi/(2*k+1))

        return val

    def write_pole_figures(self, pfdata):
        """
        take output of the calc_pole_figures routine and write
        it out as text files
        """
        for k,v in pfdata.items():
            fname = f"pf_{k}.txt"
            np.savetxt(fname, v, fmt="%10.4f", delimiter="\t")


    @property
    def phon(self):
        return self._phon

    @phon.setter
    def phon(self, val):
        self._phon = val


    @property
    def J(self):
        tmp = copy.deepcopy(self.allowed_degrees)
        del tmp[0]
        nn = np.cumsum(np.array([tmp[k][0]*tmp[k][1]
            for k in tmp]))
        ncoeff_csum = np.r_[0,nn]
        J = 1.0
        for ii,k in enumerate(tmp):
            ist = ncoeff_csum[ii]
            ien = ncoeff_csum[ii+1]
            J += np.sum(self.coeff[ist:ien]**2)/(2*k+1)

        return J


class pole_figures:
    """
    this class deals with everything related to pole figures.
    pole figures can be initialized in a number of ways. the most
    basic being the (x,y,z, intensities) array. There are
    other formats which will be slowly added to this class. a list of
    hkl and a material/material_rietveld class is also supplied along
    with the (angle, intensity) info to get a class holding all the
    information.
    @DATE 10/06/2021 SS changes input from angles to unit vectors
                        and added routines to compute the angles
    """
    def __init__(self,
                 material,
                 hkls,
                 pfdata,
                 bHat_l=bVec_ref,
                 eHat_l=eta_ref,
                 chi=0.):
        """
        material Either a Material object of Material_Rietveld object
        hkl      reciprocal lattice vectors for which pole figures are
                 available (size nx3)
        pfdata   dictionary containing (tth, eta, omega, intensities)
                 array for each hkl specified as last input. key is hkl
                 and value are the arrays with size m x 4. m for each hkl
                 can be different. A length check is performed during init
        bHat_l   unit vector of xray beam in the lab frame. default value
                 is -Z direction
        eHat_l   direction which defines zero azimuth. default value is
                 x direction
        chi      inclination of sample frame about x direction. default
                 value is 0, corresponding to no tilt.
        """
        self.hkls = hkls
        self.material = material
        self.convert_hkls_to_cartesian()

        self.bHat_l = bHat_l
        self.eHat_l = eHat_l
        self.chi = chi

        if hkls.shape[0] != len(pfdata):
            msg = (f"pole figure initialization.\n"
                f"# reciprocal reflections = {hkls.shape[0]}.\n"
                f"# of entries in pfdata = {len(pfdata)}.")
            raise RuntimeError(msg)

        self.pfdata = pfdata

    def convert_hkls_to_cartesian(self):
        """
        this routine converts hkls in the crystallographic frame to
        the cartesian frame and normalizes them
        """
        self.hkls_c = np.atleast_2d(np.zeros(self.hkls.shape))

        for ii, g in enumerate(self.hkls):
            v = self.material.TransSpace(g, "r", "c")
            v = v/np.linalg.norm(v)
            self.hkls_c[ii,:] = v

    def write_data(self, prefix):
        """
        write out the data in text format
        the prefix goes in front of the names
        name will be "<prefix>_hkl.txt"
        """
        for k in self.pfdata:
            fname = f"{prefix}_{k}.txt"
            angs = np.degrees(self.angs[k])
            intensities = np.atleast_2d(self.intensities[k]).T
            data = np.hstack((angs,intensities))
            np.savetxt(fname, data, delimiter="\t")

    @property
    def num_pfs(self):
        """ number of pole figures (read only) """
        return len(self.pfdata)

    """
    the pfdata property returns the pole figure data
    in the form of a dictionary with keys as the hkl
    values and the value as the (tth, eta, omega) array.
    """
    @property
    def pfdata(self):
        return self._pfdata

    @pfdata.setter
    def pfdata(self, val):
        self._pfdata = {}
        self._intensities = {}
        self._gvecs = {}
        self._angs = {}
        for k,v in val.items():
            norm = np.linalg.norm(v[:,0:3],axis=1)
            v[:,0:3] = v[:,0:3]/np.tile(norm, [3,1]).T
            t = np.arccos(v[:,2])
            rho = np.arctan2(v[:,1],v[:,0])

            self._gvecs[k] = v[:,0:3]
            self._pfdata[k] = v
            self._intensities[k] = v[:,3]
            self._angs[k] = np.vstack((t,rho)).T

    @property
    def gvecs(self):
        return self._gvecs

    @property
    def angs(self):
        return self._angs

    @property
    def intensities(self):
        return self._intensities

class inverse_pole_figures:
    """
    this class deals with everything related to inverse pole figures.

    """
    def __init__(self,
                 sample_dir,
                 sampling="equiangular",
                 resolution=5.0):
        """
        this is the initialization of the class. the inputs are
        1. laue_sym for laue symmetry
        2. sample_dir the sample direction for pole figure
        3. sampling sampling strategy for the IPF stereogram.
           optios are "equiangular" and "FEM"
        4. resolution grid resolution in degrees. only valid for
           equiangular sampling type
        """
        self.sample_dir = sample_dir
        # self.laue_sym = laue_sym
        if sampling == "equiangular":
            self.resolution = resolution
        self.sampling = sampling

    def initialize_crystal_dir(self,
                               samplingtype,
                               resolution=5.0):
        """
        this function prepares the unit vectors
        of the stereogram
        """
        if samplingtype.lower() == "equiangular":
            angs = []
            for tth in np.arange(0,91,resolution):
                for eta in np.arange(0, 360, resolution):
                    angs.append([np.radians(tth), np.radians(eta)])
                    if tth == 0:
                        break
            angs = np.array(angs)
            self.crystal_dir = np.zeros([angs.shape[0],3])
            for i,a in enumerate(angs):
                t, r = a
                st = np.sin(t)
                ct = np.cos(t)
                sr = np.sin(r)
                cr = np.cos(r)
                self.crystal_dir[i,:] = np.array([st*cr,st*sr,ct])

        if samplingtype.lower() == "fem":
            msg = "sampling type FEM not implemented yet."
            raise ValueError(msg)
            pass

    @property
    def sample_dir(self):
        """ sample direction for IPF """
        return self._sample_dir

    @sample_dir.setter
    def sample_dir(self, val):
        # interpret sample_dir input
        # sample_dir size = nx3
        if isinstance(val, str):
            if val.upper() == "RD":
                self._sample_dir = np.atleast_2d([1.,0.,0.])
                self._sample_dir_name = "RD"
            elif val.upper() == "TD":
                self._sample_dir = np.atleast_2d([0.,1.,0.])
                self._sample_dir_name = "TD"
            elif val.upper() == "ND":
                self._sample_dir = np.atleast_2d([0.,0.,1.])
                self._sample_dir_name = "ND"
            else:
                msg = f"unknown direction."
                raise ValueError(msg)
        elif isinstance(val, np.array):
            v = np.atleast_2d(val)
            if v.shape[1] != 3:
                msg = (f"incorrect shape for sample_dir input.\n"
                       f"expected nx3, got {val.shape[0]}x{val.shape[1]}")
                raise ValueError(msg)
            self._sample_dir = v
            self._sample_dir_name = "array"

    @property
    def resolution(self):
        return self._resolution

    @resolution.setter
    def resolution(self, val):
        if val < 1.0:
            msg = (f"the resolution appears to be very fine.\n"
                f"Are you sure the value is in degrees?")
            warn(msg)
        self._resolution = val

    @property
    def sampling(self):
        return self._sampling

    @sampling.setter
    def sampling(self, val):
        if val.lower() == "equiangular":
            self.initialize_crystal_dir("equiangular",
                                     resolution=self.resolution)

        elif val.lower() == "fem":
            self.initialize_crystal_dir("FEM")

    @property
    def angs(self):
        polar = np.arccos(self.crystal_dir[:,2])
        az = np.arctan2(self.crystal_dir[:,1],self.crystal_dir[:,0])
        return np.degrees(np.vstack((polar,az)).T)

Polya = {
        "m35":
        {"numerator":[],
        "denominator":[6, 10]},

        "532":
        {"numerator":[[15, 1.]],
        "denominator":[6, 10]},

        "m3m":
        {"numerator":[],
        "denominator":[4, 6]},

        "432":
        {"numerator":[[9, 1.]],
        "denominator":[4, 6]},

        "1":
        {"numerator":[[1, 1.]],
        "denominator":[1, 1]},

        "-1":
        {"numerator":[[2, 3.]],
        "denominator":[2, 2]},

        }


# hexrd/wppf/wppfsupport.py

# ============================================================
# Copyright (c) 2021, Lawrence Livermore National Security, LLC.
# Produced at the Lawrence Livermore National Laboratory.
# Written by Saransh Singh <saransh1@llnl.gov>/Joel Bernier
# <bernier2@llnl.gov> and others.
# LLNL-CODE-819716.
# All rights reserved.
#
# This file is part of HEXRD. For details on dowloading the source,
# see the file COPYING.
#
# Please also see the file LICENSE.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License (as published by the Free
# Software Foundation) version 2.1 dated February 1999.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY OF MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the terms and conditions of the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this program (see file LICENSE); if not, write to
# the Free Software Foundation, Inc., 59 Temple Place, Suite 330,
# Boston, MA 02111-1307 USA or visit <http://www.gnu.org/licenses/>.
# ============================================================

"""
this function contains some helper functions for the WPPF module
the functions which are common to both the Rietveld and LeBail
classes are put here to minimize code duplication. Some examples
include initialize background, generate_default_parameter list etc.
"""
from hexrd.symbols import pstr_spacegroup
from hexrd.wppf.parameters import Parameters
from lmfit import Parameters as Parameters_lmfit
from hexrd.wppf.phase import Phases_LeBail, Phases_Rietveld
from hexrd.material import Material
from hexrd.unitcell import _rqpDict
import hexrd
import numpy as np
from hexrd import constants

def _generate_default_parameters_pseudovoight(params):
    """
    generate some default values of peak profile
    for the Thompson et. al. model. A total of
    18 parameters are genrated which includes the
    following:
    4 -> cagliotti + Scherrer broadening
    5 -> lorentzian width with stacking fault
    2 to 15 -> anisotropic hkl broadening depending on
    symmetry
    1 --> width_mixing of anisotropic broadening
    """
    p = {"zero_error":[0., -1., 1., False],
         "trns":[0.0, -1.0, 1.0, False],
         "shft":[0.0,-1.0,1.0,False],
         "U": [81.5, 0., 5000, False],
         "V": [1.0337, 0., 5000, False],
         "W": [5.18275, 0., 5000, False],
         "P": [0., 0., 5000, False],
         "X": [0.5665, 0., 100., False],
         "Y": [1.90994, 0., 100., False],
         "Xe": [0., 0., 1, False],
         "Ye": [0., 0., 1, False],
         "Xs": [0., 0., 1, False]
         }

    for k, v in p.items():
        if isinstance(params, Parameters):
            params.add(name=k,
                       value=v[0],
                       lb=v[1],
                       ub=v[2],
                       vary=v[3])
        elif isinstance(params, Parameters_lmfit):
            params.add(name=k,
                       value=v[0],
                       min=v[1],
                       max=v[2],
                       vary=v[3])

def _add_pvfcj_parameters(params):
    p = {"HL":[1e-3,1e-7,1e-1,False],
         "SL":[1e-3,1e-7,1e-1,False]
         }
    for k, v in p.items():
        if isinstance(params, Parameters):
            params.add(name=k,
                       value=v[0],
                       lb=v[1],
                       ub=v[2],
                       vary=v[3])
        elif isinstance(params, Parameters_lmfit):
            params.add(name=k,
                       value=v[0],
                       min=v[1],
                       max=v[2],
                       vary=v[3])

def _add_pvpink_parameters(params):
    p = {"alpha0":[14.4, -100., 100., False],
         "alpha1":[0., -100., 100., False],
         "beta0":[3.016, -100., 100., False],
         "beta1":[-2.0, -100., 100., False]
         }
    for k, v in p.items():
        if isinstance(params, Parameters):
            params.add(name=k,
                       value=v[0],
                       lb=v[1],
                       ub=v[2],
                       vary=v[3])
        elif isinstance(params, Parameters_lmfit):
            params.add(name=k,
                       value=v[0],
                       min=v[1],
                       max=v[2],
                       vary=v[3])

def _add_Shkl_terms(params,
                    mat,
                    return_dict=None):
    """
    add the SHKL terms in the anisotropic peak
    broadening contribution. this depends on the
    lattice type. details can be found in
    P. Stephens, J. Appl. Cryst. (1999). 32, 281-289

    @NOTE: the rhombohedral lattices are assumed to be in
    the hexagonal setting
    """
    mname = mat.name
    valid_shkl,\
    eq_const,\
    rqd_index, \
    trig_ptype = \
    _required_shkl_names(mat)

    if return_dict is None:

        for s in valid_shkl:
            n = f"{mname}_{s}"
            ne = f"{mname}_eta_fwhm"

            if isinstance(params, Parameters):
                params.add(name=n,
                       value=0.0,
                       lb=0.0,
                       ub=np.inf,
                       vary=False)
            elif isinstance(params, Parameters_lmfit):
                params.add(name=n,
                       value=0.0,
                       min=0.0,
                       max=np.inf,
                       vary=False)
        if isinstance(params, Parameters):
            params.add(name=ne,
                   value=0.5,
                   lb=0.0,
                   ub=1.0,
                   vary=False)
        elif isinstance(params, Parameters_lmfit):
            params.add(name=ne,
                   value=0.5,
                   min=0.0,
                   max=1.0,
                   vary=False)
    else:
        res = {}
        for s in valid_shkl:
            res[s] = 0.0
        return res, trig_ptype

def _add_lp_to_params(params,
                      mat):
    """
    03/12/2021 SS 1.0 original
    given a material, add the required
    lattice parameters
    """
    lp = mat.lparms
    rid = _rqpDict[mat.latticeType][0]
    lp = [lp[i] for i in rid]
    name = [_lpname[i] for i in rid]
    phase_name = mat.name
    for n, l in zip(name, lp):
        nn = phase_name+'_'+n
        """
        is n is a,b,c, it is one of the length units
        else it is an angle
        """
        if(n in ['a', 'b', 'c']):
            if isinstance(params, Parameters):
                params.add(nn, value=l, lb=l-0.05,
                           ub=l+0.05, vary=False)
            elif isinstance(params, Parameters_lmfit):
                params.add(nn, value=l, min=l-0.05,
                           max=l+0.05, vary=False)
        else:
            if isinstance(params, Parameters):
                params.add(nn, value=l, lb=l-1.,
                           ub=l+1., vary=False)
            elif isinstance(params, Parameters_lmfit):
                params.add(nn, value=l, min=l-1.,
                           max=l+1., vary=False)

def _add_atominfo_to_params(params, mat):
    """
    03/12/2021 SS 1.0 original
    given a material, add the required
    lattice parameters, atom positions,
    occupancy, DW factors etc.
    """
    phase_name = mat.name
    atom_pos = mat.atom_pos[:, 0:3]
    occ = mat.atom_pos[:, 3]
    atom_type = mat.atom_type

    atom_label = _getnumber(atom_type)

    for i in range(atom_type.shape[0]):

        Z = atom_type[i]
        elem = constants.ptableinverse[Z]

        nn = f"{phase_name}_{elem}{atom_label[i]}_x"
        if isinstance(params, Parameters):
            params.add(
                nn, value=atom_pos[i, 0],
                lb=0.0, ub=1.0,
                vary=False)
        elif isinstance(params, Parameters_lmfit):
            params.add(
                nn, value=atom_pos[i, 0],
                min=0.0, max=1.0,
                vary=False)
        nn = f"{phase_name}_{elem}{atom_label[i]}_y"
        if isinstance(params, Parameters):
            params.add(
                nn, value=atom_pos[i, 1],
                lb=0.0, ub=1.0,
                vary=False)
        elif isinstance(params, Parameters_lmfit):
            params.add(
                nn, value=atom_pos[i, 1],
                min=0.0, max=1.0,
                vary=False)
        nn = f"{phase_name}_{elem}{atom_label[i]}_z"
        if isinstance(params, Parameters):
            params.add(
                nn, value=atom_pos[i, 2],
                lb=0.0, ub=1.0,
                vary=False)
        elif isinstance(params, Parameters_lmfit):
            params.add(
                nn, value=atom_pos[i, 2],
                min=0.0, max=1.0,
                vary=False)
        nn = f"{phase_name}_{elem}{atom_label[i]}_occ"
        if isinstance(params, Parameters):
            params.add(nn, value=occ[i],
                       lb=0.0, ub=1.0,
                       vary=False)
        elif isinstance(params, Parameters_lmfit):
            params.add(nn, value=occ[i],
                       min=0.0, max=1.0,
                       vary=False)
        if(mat.aniU):
            U = mat.U
            for j in range(6):
                nn = (f"{phase_name}_{elem}{atom_label[i]}"
                       f"_{_nameU[j]}")
                if isinstance(params, Parameters):
                    params.add(
                        nn, value=U[i, j],
                        lb=-1e-3,
                        ub=np.inf,
                        vary=False)
                elif isinstance(params, Parameters_lmfit):
                    params.add(
                        nn, value=U[i, j],
                        min=-1e-3,
                        max=np.inf,
                        vary=False)
        else:
            nn = f"{phase_name}_{elem}{atom_label[i]}_dw"
            if isinstance(params, Parameters):
                params.add(
                    nn, value=mat.U[i],
                    lb=0.0, ub=np.inf,
                    vary=False)
            elif isinstance(params, Parameters_lmfit):
                params.add(
                    nn, value=mat.U[i],
                    min=0.0, max=np.inf,
                    vary=False)
def _generate_default_parameters_LeBail(mat, peakshape, ptype="wppf"):
    """
    @author:  Saransh Singh, Lawrence Livermore National Lab
    @date:    03/12/2021 SS 1.0 original
    @details: generate a default parameter class given a list/dict/
    single instance of material class
    """
    if ptype == "wppf":
        params = Parameters()
    elif ptype == "lmfit":
        params = Parameters_lmfit()
    _generate_default_parameters_pseudovoight(params)

    if peakshape == 0:
        _add_pvfcj_parameters(params)
    elif peakshape == 1:
        pass
    elif peakshape == 2:
        _add_pvpink_parameters(params)
    else:
        msg = (f"_generate_default_parameters_LeBail: "
            f"unknown peak shape.")
        raise ValueError(msg)

    if isinstance(mat, Phases_LeBail):
        """
        phase file
        """
        for p in mat:
            m = mat[p]
            _add_Shkl_terms(params, m)
            _add_lp_to_params(params, m)

    elif isinstance(mat, Phases_Rietveld):
        """
        phase file
        """
        for p in mat:
            m = mat[p]
            k = list(m.keys())
            mm = m[k[0]]
            _add_Shkl_terms(params, mm)
            _add_lp_to_params(params, mm)

    elif isinstance(mat, Material):
        """
        just an instance of Materials class
        this part initializes the lattice parameters in the
        """
        _add_Shkl_terms(params, mat)
        _add_lp_to_params(params, mat)

    elif isinstance(mat, list):
        """
        a list of materials class
        """
        for m in mat:
            _add_Shkl_terms(params, m)
            _add_lp_to_params(params, m)

    elif isinstance(mat, dict):
        """
        dictionary of materials class
        """
        for k, m in mat.items():
            _add_Shkl_terms(params, m)
            _add_lp_to_params(params, m)

    else:
        msg = (f"_generate_default_parameters: "
               f"incorrect argument. only list, dict or "
               f"Material is accpeted.")
        raise ValueError(msg)

    return params

def _add_phase_fractions(mat, params):
    """
     @author:  Saransh Singh, Lawrence Livermore National Lab
     @date:    04/01/2021 SS 1.0 original
     @details: ass phase fraction to params class
     given a list/dict/single instance of material class
    """
    if isinstance(mat, Phases_Rietveld):
        """
        phase file
        """
        pf = mat.phase_fraction
        for ii,p in enumerate(mat):
            name=f"{p}_phase_fraction"
            if isinstance(params, Parameters):
                params.add(
                    name=name, value=pf[ii],
                    lb=0.0, ub=1.0,
                    vary=False)
            elif isinstance(params, Parameters_lmfit):
                params.add(
                    name=name, value=pf[ii],
                    min=0.0, max=1.0,
                    vary=False)
    elif isinstance(mat, Material):
        """
        just an instance of Materials class
        this part initializes the lattice parameters in the
        """
        p = mat.name
        name=f"{p}_phase_fraction"
        if isinstance(params, Parameters):
            params.add(
                name=name, value=1.0,
                lb=0.0, ub=1.0,
                vary=False)
        elif isinstance(params, Parameters_lmfit):
            params.add(
                name=name, value=1.0,
                min=0.0, max=1.0,
                vary=False)
    elif isinstance(mat, list):
        """
        a list of materials class
        """
        pf = [1./len(mat)]*len(mat)
        for ii,m in enumerate(mat):
            p = m.name
            name=f"{p}_phase_fraction"
            if isinstance(params, Parameters):
                params.add(
                    name=name, value=pf[ii],
                    lb=0.0, ub=1.0,
                    vary=False)
            elif isinstance(params, Parameters_lmfit):
                params.add(
                    name=name, value=pf[ii],
                    min=0.0, max=1.0,
                    vary=False)
    elif isinstance(mat, dict):
        """
        dictionary of materials class
        """
        pf = [1./len(mat)]*len(mat)
        for ii, (k,m) in enumerate(mat.items()):
            p = m.name
            name=f"{p}_phase_fraction"
            if isinstance(params, Parameters):
                params.add(
                    name=name, value=pf[ii],
                    lb=0.0, ub=1.0,
                    vary=False)
            elif isinstance(params, Parameters_lmfit):
                params.add(
                    name=name, value=pf[ii],
                    min=0.0, max=1.0,
                    vary=False)
    else:
        msg = (f"_generate_default_parameters: "
               f"incorrect argument. only list, dict or "
               f"Material is accpeted.")
        raise ValueError(msg)

def _add_extinction_parameters(mat, params):
    return params

def _add_absorption_parameters(mat, params):
    return params

def _generate_default_parameters_Rietveld(mat, peakshape, ptype="wppf"):
    """
    @author:  Saransh Singh, Lawrence Livermore National Lab
    @date:    03/12/2021 SS 1.0 original
    @details: generate a default parameter class given a list/dict/
    single instance of material class
    """
    params = _generate_default_parameters_LeBail(mat, peakshape, ptype=ptype)

    if ptype == "wppf":
        params.add(name="scale",
                   value=1.0,
                   lb=0.0,
                   ub=np.inf,
                   vary=False)

        params.add(name="Ph",
                   value=1.0,
                   lb=0.0,
                   ub=1.0,
                   vary=False)

    elif ptype == "lmfit":
        params.add(name="scale",
                   value=1.0,
                   min=0.0,
                   max=np.inf,
                   vary=False)

        params.add(name="Ph",
                   value=1.0,
                   min=0.0,
                   max=1.0,
                   vary=False)

    _add_phase_fractions(mat, params)
    _add_extinction_parameters(mat, params)
    _add_absorption_parameters(mat, params)

    if isinstance(mat, Phases_Rietveld):
        """
        phase file
        """
        for p in mat:
            m = mat[p]
            k = list(m.keys())
            mm = m[k[0]]
            _add_atominfo_to_params(params, mm)

    elif isinstance(mat, Material):
        """
        just an instance of Materials class
        this part initializes the lattice parameters in the
        """
        _add_atominfo_to_params(params, mat)

    elif isinstance(mat, list):
        """
        a list of materials class
        """
        for m in mat:
            _add_atominfo_to_params(params, m)

    elif isinstance(mat, dict):
        """
        dictionary of materials class
        """
        for k, m in mat.items():
            _add_atominfo_to_params(params, m)

    else:
        msg = (f"_generate_default_parameters: "
               f"incorrect argument. only list, dict or "
               f"Material is accpeted.")
        raise ValueError(msg)

    return params

_shkl_name = ["s400", "s040", "s004", "s220", "s202", "s022",
              "s310", "s103", "s031", "s130", "s301", "s013",
              "s211", "s121", "s112"]
_lpname = ['a', 'b', 'c', 'alpha', 'beta', 'gamma']
_nameU = ['U11', 'U22', 'U33', 'U12', 'U13', 'U23']

"""
function to take care of equality constraints
"""
def _fill_shkl(x, eq_const):
    """
    fill all values of shkl when only reduced set
    is passed
    """
    x_ret = np.zeros([15,])
    for ii,n in enumerate(_shkl_name):
        if n in x:
            x_ret[ii] = x[n]
        else:
            x_ret[ii] = 0.0
    if not eq_const:
        pass
    else:
        for c in eq_const:
            x_ret[c[1]] = c[2]*x_ret[c[0]]

    return x_ret

def _required_shkl_names(mat):
    latticetype = mat.latticeType
    sgnum = mat.sgnum
    mname = mat.name
    hmsym = pstr_spacegroup[sgnum-1].strip()
    trig_ptype = False

    if latticetype == "trigonal" and hmsym[0] == "P":
        """
        this is a trigonal group so the hexagonal
        constants are valid
        """
        latticetype = "haxagonal"
        trig_ptype = True

    rqd_index = _rqd_shkl[latticetype][0]
    eq_constraints = _rqd_shkl[latticetype][1]
    valid_shkl = [_shkl_name[i] for i in rqd_index]

    return valid_shkl, eq_constraints, rqd_index, trig_ptype

def _add_texture_coefficients(crystal_sym, sample_sym, name, degree):
    """
    add the texture coefficients for a particular phase
    given its laue group. the crystal sym decides what the
    symmetry of the crystal is and what coefficients to add. the
    sample symmetry decides what the sample symmtry is. allowed ones
    are
    triclinic : -1
    monoclinic: 2/m
    orthorhombic: mmm
    cylindrical: inf/mmm

    if cylindrical symmetry is used, then the total coefficients used
    are drastically reduced
    """
    pass

def _add_texture_parameters(mat, degree):
    """
    @SS 06/22/2021 1.0 original
    this routine adds the texture coefficients to the wppf
    parameter list based on the material list and the
    degree of harmonic coefficients passed. Also required is the
    assumed sample symmetry. The same sample symmetry will be used
    for each of the phases.
    """
    if isinstance(mat, Phases_Rietveld):
        """
        phase file
        """
        for p in mat:
            m = mat[p]
            k = list(m.keys())
            mm = m[k[0]]
            _add_atominfo_to_params(params, mm)

    elif isinstance(mat, Material):
        """
        just an instance of Materials class
        this part initializes the lattice parameters in the
        """
        _add_atominfo_to_params(params, mat)

    elif isinstance(mat, list):
        """
        a list of materials class
        """
        for m in mat:
            _add_atominfo_to_params(params, m)

    elif isinstance(mat, dict):
        """
        dictionary of materials class
        """
        for k, m in mat.items():
            _add_atominfo_to_params(params, m)

    else:
        msg = (f"_generate_default_parameters: "
               f"incorrect argument. only list, dict or "
               f"Material is accpeted.")
        raise ValueError(msg)

"""
this dictionary structure holds information for the shkl
coefficeints needed for anisotropic broadening of peaks
first component of list are the required shkl components
second component of ist are the equality constraints with
a weight factor (sometimes theres a factor of 2 or 3.)
"""
_rqd_shkl = {
"cubic": [(0, 3),
          ((0,1,1.),(0,2,1.),(3,4,1.),(3,5,1.))],
"hexagonal": [(0, 2, 4),
((0,1,1.),(0,6,2.),(0,9,2.),(0,3,3.),
(4,5,1.),(4,14,1.))],
"trigonal": [(0, 2, 4, 10),
((0,1,1.),(0,6,2.),(0,9,2.),(0,3,3.),
(4,5,1.),(4,14,1.),
(10,8,-1.),(10,12,1.5),(10,13,-1.5))],
"tetragonal": [(0, 2, 3, 4),((0,1,1.),(4,5,1.))],
"orthorhombic": [tuple(range(6)),()],
"monoclinic": [tuple(range(6))+(7, 10, 13),()],
"triclinic": [tuple(range(15)),()]
}

def _getnumber(arr):

    res = np.ones(arr.shape)
    for i in range(arr.shape[0]):
        res[i] = np.sum(arr[0:i+1] == arr[i])
    res = res.astype(np.int32)

    return res

def _add_detector_geometry(params, instr):
    """
    this function adds the geometry of the
    detector as a parameter to the LeBail class
    such that those can be refined as well
    """
    if isinstance(instr, hexrd.instrument.HEDMInstrument):
        for key,det in instr.detectors.items():
            tvec = det.tvec
            tilt = det.tilt
            pnametvec = [f"{key}_tvec{i}" for i in range(3)]
            pnametilt = [f"{key}_tilt{i}" for i in range(3)]
            if isinstance(params, Parameters):
                [params.add(name=pnametvec[i],value=tvec[i]) for i in range(3)]
                [params.add(name=pnametilt[i],value=tilt[i]) for i in range(3)]
            elif isinstance(params, Parameters_lmfit):
                [params.add(name=pnametvec[i],value=tvec[i]) for i in range(3)]
                [params.add(name=pnametilt[i],value=tilt[i]) for i in range(3)]
    else:
        msg = "input is not an HEDMInstrument class"
        raise ValueError(msg)

def _add_background(params,lineouts,bkgdegree):
    for k in lineouts:
        pname = [f"{k}_bkg_C{ii}" for ii in range(bkgdegree)]
        shape = len(pname)
        if isinstance(params, Parameters):
            [params.add(name=pname[i],value=0.0) for i in range(shape)]
        elif isinstance(params, Parameters_lmfit):
            [params.add(name=pname[i],value=0.0) for i in range(shape)]

def striphkl(g):
    return str(g)[1:-1].replace(" ","")

def _add_intensity_parameters(params,hkls,Icalc,prefix):
    """
    this routine adds the Icalc values as refinable
    parameters in the params parameter class
    """
    for p in Icalc:
        for k in Icalc[p]:
            shape = Icalc[p][k].shape[0]

            pname = [f"{prefix}_{p}_{k}_I{striphkl(g)}"
            for i,g in zip(range(shape),hkls[p][k])]
            if isinstance(params, Parameters):
                [params.add(name=pname[i],
                    value=Icalc[p][k][i],
                    lb=0.0) for i in range(shape)]
            elif isinstance(params, Parameters_lmfit):
                [params.add(name=pname[i],
                    value=Icalc[p][k][i],
                    min=0.0) for i in range(shape)]
background_methods = {
    'spline': None,

    'chebyshev': [
        {
            'label': 'Chebyshev Polynomial Degree',
            'type': int,
            'min': 0,
            'max': 99,
            'value': 3,
            'tooltip': 'The polynomial degree used '
            'for polynomial fit.',
        }
    ],
    'snip1d': [
        {
            'label': 'Snip Width',
            'type': float,
            'min': 0.,
            'value': 1.0,
            'tooltip': 'Maximum width of peak to retain for '
            'background estimation (in degrees).'
        },
        {
            'label': 'Snip Num Iterations',
            'type': int,
            'min': 1,
            'max': 99,
            'value':2,
            'tooltip': 'number of snip iterations.'
        }
    ],
}


# hexrd/wppf/xtal.py

import numpy as np
from hexrd.utils.decorators import numba_njit_if_available
from hexrd import constants
import numba

if constants.USE_NUMBA:
    from numba import prange
else:
    prange = range

@numba_njit_if_available(cache=True, nogil=True)
def _calc_dspacing(rmt, hkls):
    nhkls = hkls.shape[0]
    dsp = np.zeros(hkls.shape[0])

    for ii in np.arange(nhkls):
        g = hkls[ii,:]
        dsp[ii] = 1.0/np.sqrt(np.dot(g, 
            np.dot(rmt, g)))
    return dsp

@numba_njit_if_available(cache=True, nogil=True)
def _get_tth(dsp, wavelength):
    nn = dsp.shape[0]
    tth = np.zeros(dsp.shape[0])
    wavelength_allowed_hkls = np.zeros(dsp.shape[0])
    for ii in np.arange(nn):
        d = dsp[ii]
        glen = 1./d
        sth = glen*wavelength/2.
        if(np.abs(sth) <= 1.0):
            t = 2. * np.degrees(np.arcsin(sth))
            tth[ii] = t
            wavelength_allowed_hkls[ii] = 1
        else:
            tth[ii] = np.nan
            wavelength_allowed_hkls[ii] = 0

    return tth, wavelength_allowed_hkls

@numba_njit_if_available(cache=True, nogil=True)
def _calcanomalousformfactor(atom_type,
    wavelength,
    frel,
    f_anomalous_data,
    f_anomalous_data_sizes):

    f_anam = np.zeros(atom_type.shape,dtype=np.complex64)

    for i in range(atom_type.shape[0]):
        nd = f_anomalous_data_sizes[i]
        Z = atom_type[i]
        fr = frel[i]
        f_data = f_anomalous_data[i,:,:]
        xp = f_data[:nd,0]
        yp = f_data[:nd,1]
        ypp = f_data[:nd,2]

        f1 = np.interp(wavelength,xp,yp)
        f2 = np.interp(wavelength,xp,ypp)

        f_anam[i] = np.complex(f1+fr-Z,f2)
    return f_anam

@numba_njit_if_available(cache=True, nogil=True)
def _calcxrayformfactor(wavelength,
    s, 
    atom_type,
    scatfac, 
    fNT, 
    frel, 
    f_anomalous_data,
    f_anomalous_data_sizes):

    f_anomalous = _calcanomalousformfactor(atom_type,
                  wavelength,
                  frel,
                  f_anomalous_data,
                  f_anomalous_data_sizes)
    ff = np.zeros(atom_type.shape,dtype=np.complex64)
    for ii in range(atom_type.shape[0]):
        sfact = scatfac[ii,:]
        fe = sfact[5]
        for jj in range(5):
            fe += sfact[jj] * np.exp(-sfact[jj+6]*s)

        ff[ii] = fe+fNT[ii]+f_anomalous[ii]

    return ff


@numba_njit_if_available(cache=True, nogil=True, parallel=True)
def _calcxrsf(hkls,
              nref,
              multiplicity,
              w_int,
              wavelength,
              rmt,
              atom_type,
              atom_ntype,
              betaij,
              occ,
              asym_pos_arr,
              numat,
              scatfac,
              fNT,
              frel,
              f_anomalous_data,
              f_anomalous_data_sizes):

    struct_factors = np.zeros(multiplicity.shape,
        dtype=np.float64)

    struct_factors_raw = np.zeros(multiplicity.shape,
        dtype=np.float64)

    for ii in prange(nref):
        g = hkls[ii,:]
        mm = multiplicity[ii]
        glen = np.dot(g,np.dot(rmt,g))
        s = 0.25 * glen * 1E-2
        sf = np.complex(0., 0.)
        formfact = _calcxrayformfactor(wavelength,
             s, 
             atom_type,
             scatfac, 
             fNT, 
             frel, 
             f_anomalous_data,
             f_anomalous_data_sizes)

        for jj in range(atom_ntype):
            natom = numat[jj]
            apos = asym_pos_arr[:natom,jj,:]
            if betaij.ndim > 1:
                b = betaij[:,:,jj]
                arg = b[0,0]*g[0]**2+\
                b[1,1]*g[1]**2+\
                b[2,2]*g[2]**2+\
                2.0*(b[0,1]*g[0]*g[1]+\
                    b[0,2]*g[0]*g[2]+\
                    b[1,2]*g[1]*g[2])
                arg = -arg
            else:
                arg = -8.0*np.pi**2 * betaij[jj]*s

            T = np.exp(arg)
            ff = formfact[jj]*occ[jj]*T

            for kk in range(natom):
                r = apos[kk,:]
                arg = 2.0 * np.pi * np.sum(g*r)
                sf = sf + ff*np.complex(np.cos(arg), -np.sin(arg))

        struct_factors_raw[ii] = np.abs(sf)**2
        struct_factors[ii] = w_int*mm*struct_factors_raw[ii]

    # ma = struct_factors.max()
    # struct_factors = 100.0*struct_factors/ma
    # ma = struct_factors_raw.max()
    # struct_factors_raw = 100.0*struct_factors_raw/ma
    return struct_factors, struct_factors_raw

@numba_njit_if_available(cache=True, nogil=True)
def _calc_x_factor(K,
                   v_unitcell,
                   wavelength,
                   f_sqr,
                   D):
    return f_sqr*(K*wavelength*D/v_unitcell)**2

@numba_njit_if_available(cache=True, nogil=True)
def _calc_bragg_factor(x,tth):
    stth = np.sin(np.radians(tth*0.5))**2
    return stth/np.sqrt(1.+x)


@numba_njit_if_available(cache=True, nogil=True)
def _calc_laue_factor(x,tth):
    ctth = np.cos(np.radians(tth*0.5))**2
    if x <= 1.:
      El = (1.-0.5*x+0.25*x**2-(5./48.)*x**3+(7./192.)*x**4)
    elif x > 1.:
      El = (2./np.pi/x)**2 * (1.-(1/8./x)-(3./128.)*(1./x)**2-\
            (15./1024.)*(1/x)**3)
    return El*ctth

@numba_njit_if_available(cache=True, nogil=True, parallel=True)
def _calc_extinction_factor(hkls,
                            tth,
                            v_unitcell,
                            wavelength,
                            f_sqr,
                            K,
                            D):
    nref = np.min(np.array([hkls.shape[0],\
           tth.shape[0]]))

    extinction = np.zeros(nref)

    for ii in prange(nref):
      fs = f_sqr[ii]
      t = tth[ii]
      x = _calc_x_factor(K,v_unitcell,
                         wavelength,
                         fs,D)
      extinction[ii] = _calc_bragg_factor(x,t)+\
                   _calc_laue_factor(x,t)

    return extinction

@numba_njit_if_available(cache=True, nogil=True, parallel=True)
def _calc_absorption_factor(abs_fact,
                            tth,
                            phi,
                            wavelength):
    nref = tth.shape[0]
    absorption = np.zeros(nref)
    phir = np.radians(phi)

    abl = -abs_fact*wavelength
    for ii in prange(nref):
      t = np.radians(tth[ii])*0.5

      if(np.abs(phir)  > 1e-3):
        c1 = np.cos(t+phir)
        c2 = np.cos(t-phir)

        f1 = np.exp(abl/c1)
        f2 = np.exp(abl/c2)
        if np.abs(c2) > 1e-3:
          f3 = abl*(1. - c1/c2)
        else:
          f3 = np.inf

        absorption[ii] = (f1-f2)/f3
      else:
        c = np.cos(t)
        absorption[ii] = np.exp(abl/c)
    return absorption
    

# hexrd/xrdutil/__init__.py

from .utils import *

# For now import these private members!
from .utils import _project_on_detector_plane
from .utils import _project_on_detector_cylinder
from .utils import _fetch_hkls_from_planedata
from .utils import _filter_hkls_eta_ome


# hexrd/xrdutil/phutil.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Created on Mon May 23 11:29:50 2022

@author: jbernier
"""
import copy
from functools import partial
from concurrent.futures import ThreadPoolExecutor

import numpy as np
import numpy.dual

from hexrd import constants as ct
from hexrd.instrument import PlanarDetector
from hexrd.transforms import xfcapi
from hexrd.utils.concurrent import distribute_tasks
from hexrd.utils.decorators import numba_njit_if_available

detector_classes = (PlanarDetector, )


class SampleLayerDistortion:
    def __init__(self, detector,
                 layer_standoff, layer_thickness,
                 pinhole_thickness, source_distance):
        self._detector = detector
        self._standoff = layer_standoff
        self._thickness = layer_thickness
        self._ph_thickness = pinhole_thickness
        self._source_dist = source_distance

    @property
    def detector(self):
        return self._detector

    @detector.setter
    def detector(self, x):
        assert isinstance(x, detector_classes), \
            f"input must be one of {detector_classes}"
        self._detector = x

    @property
    def standoff(self):
        return self._standoff

    @standoff.setter
    def standoff(self, x):
        self._standoff = float(x)

    @property
    def thickness(self):
        return self._thickness

    @thickness.setter
    def thickness(self, x):
        self._thickness = float(x)

    @property
    def ph_thickness(self):
        return self._ph_thickness

    @ph_thickness.setter
    def ph_thickness(self, x):
        self._ph_thickness = float(x)

    @property
    def source_dist(self):
        return self._source_dist

    @source_dist.setter
    def source_dist(self, x):
        self._source_dist = float(x)

    def apply(self, xy_pts, return_nominal=True):
        """
        """
        return tth_corr_sample_layer(self.detector, xy_pts,
                                     self.standoff, self.thickness,
                                     self.ph_thickness, self.source_dist,
                                     return_nominal=return_nominal)


class PinholeDistortion:
    def __init__(self, detector,
                 pinhole_thickness, pinhole_radius):
        self._detector = detector
        self._ph_thickness = pinhole_thickness
        self._ph_radius = pinhole_radius

    @property
    def detector(self):
        return self._detector

    @detector.setter
    def detector(self, x):
        assert isinstance(x, detector_classes), \
            f"input must be one of {detector_classes}"
        self._detector = x

    @property
    def ph_thickness(self):
        return self._ph_thickness

    @ph_thickness.setter
    def ph_thickness(self, x):
        self._ph_thickness = float(x)

    @property
    def ph_radius(self):
        return self._ph_radius

    @ph_radius.setter
    def ph_radius(self, x):
        self._ph_radius = float(x)

    def apply(self, xy_pts, return_nominal=True):
        """
        """
        return tth_corr_pinhole(self.detector, xy_pts,
                                self.ph_thickness, self.ph_radius,
                                return_nominal=return_nominal)


class RyggPinholeDistortion:
    def __init__(self, detector, material,
                 pinhole_thickness, pinhole_radius, num_phi_elements=60):

        self.detector = detector
        self.material = material
        self.ph_thickness = pinhole_thickness
        self.ph_radius = pinhole_radius
        self.num_phi_elements = num_phi_elements

    def apply(self, xy_pts, return_nominal=True):
        return tth_corr_rygg_pinhole(self.detector, self.material, xy_pts,
                                     self.ph_thickness, self.ph_radius,
                                     return_nominal=return_nominal,
                                     num_phi_elements=self.num_phi_elements)


def tth_corr_sample_layer(detector, xy_pts,
                          layer_standoff, layer_thickness,
                          pinhole_thickness, source_distance,
                          return_nominal=True):
    """
    Compute the Bragg angle distortion associated with a specific sample
    layer in a pinhole camera.

    Parameters
    ----------
    detector : hexrd.instrument.PlanarDetector
        A detector instance.
    xy_pts : array_like
        The (n, 2) array of n (x, y) coordinates to be transformed in the raw
        detector coordinates (cartesian plane, origin at center).
    layer_standoff : scalar
        The sample layer standoff from the upstream face of the pinhole
        in mm.
    layer_thickness : scalar
        The thickness of the sample layer in mm.
    pinhole_thickness : scalar
        The thickenss (height) of the pinhole (cylinder) in mm
    source_distance : scalar
        The distance from the pinhole center to the X-ray source in mm.

    Returns
    -------
    TYPE
        DESCRIPTION.

    """

    xy_pts = np.atleast_2d(xy_pts)
    npts = len(xy_pts)

    # !!! full z offset from center of pinhole to center of layer
    zs = layer_standoff + 0.5*layer_thickness + 0.5*pinhole_thickness

    ref_angs, _ = detector.cart_to_angles(xy_pts,
                                          rmat_s=None, tvec_s=None,
                                          tvec_c=None, apply_distortion=True)
    ref_tth = ref_angs[:, 0]

    crds = np.hstack([xy_pts, np.zeros((npts, 1))])
    dhats = xfcapi.unitRowVector(
        np.dot(crds, detector.rmat.T) + detector.tvec
    )
    cos_beta = -dhats[:, 2]
    cos_tthn = np.cos(ref_tth)
    sin_tthn = np.sin(ref_tth)
    tth_corr = np.arctan(sin_tthn/(source_distance*cos_beta/zs - cos_tthn))
    if return_nominal:
        return np.vstack([ref_tth - tth_corr, ref_angs[:, 1]]).T
    else:
        # !!! NEED TO CHECK THIS
        return np.vstack([-tth_corr, ref_angs[:, 1]]).T


def tth_corr_map_sample_layer(instrument,
                              layer_standoff, layer_thickness,
                              pinhole_thickness):
    """
    Compute the Bragg angle distortion fields for an instrument associated
    with a specific sample layer in a pinhole camera.

    Parameters
    ----------
    instrument : hexrd.instrument.HEDMInstrument
        The pionhole camera instrument object.
    layer_standoff : scalar
        The sample layer standoff from the upstream face of the pinhole
        in mm.
    layer_thickness : scalar
        The thickness of the sample layer in mm.
    pinhole_thickness : scalar
        The thickenss (height) of the pinhole (cylinder) in mm

    Returns
    -------
    tth_corr : dict
        The Bragg angle correction fields for each detector in `instrument`
        as 2θ_sam - 2θ_nom in radians.

    Notes
    -----
    source_distance : The distance from the pinhole center to
                      the X-ray source in mm.  Comes from the instr
                      attribute of the same name.

    """
    zs = layer_standoff + 0.5*layer_thickness + 0.5*pinhole_thickness
    tth_corr = dict.fromkeys(instrument.detectors)
    for det_key, det in instrument.detectors.items():
        ref_ptth, _ = det.pixel_angles()
        py, px = det.pixel_coords
        crds = np.vstack([px.flatten(), py.flatten(), np.zeros(px.size)])
        dhats = xfcapi.unitRowVector(
            (np.dot(det.rmat, crds) + det.tvec.reshape(3, 1)).T
        )
        cos_beta = -dhats[:, 2]
        cos_tthn = np.cos(ref_ptth.flatten())
        sin_tthn = np.sin(ref_ptth.flatten())
        tth_corr[det_key] = np.arctan(
            sin_tthn/(instrument.source_distance*cos_beta/zs - cos_tthn)
        ).reshape(det.shape)
    return tth_corr


def tth_corr_pinhole(detector, xy_pts,
                     pinhole_thickness, pinhole_radius,
                     return_nominal=True):
    """
    Compute the Bragg angle distortion associated with the pinhole as a source.

    Parameters
    ----------
    detector : hexrd.instrument.PlanarDetector
        A detector instance.
    xy_pts : array_like
        The (n, 2) array of n (x, y) coordinates to be transformed in the raw
        detector coordinates (cartesian plane, origin at center).
    pinhole_thickness : scalar
        The thickenss (height) of the pinhole (cylinder) in mm
    pinhole_radius : scalar
        The radius ofhte pinhole in mm.

    Returns
    -------
    TYPE
        DESCRIPTION.

    Notes
    -----
    The follows a slightly modified version of Jon Eggert's pinhole correction.

    """

    xy_pts = np.atleast_2d(xy_pts)
    npts = len(xy_pts)

    # first we need the reference etas of the points wrt the pinhole axis
    cp_det = copy.deepcopy(detector)
    cp_det.bvec = ct.beam_vec  # !!! [0, 0, -1]
    ref_angs, _ = cp_det.cart_to_angles(
        xy_pts,
        rmat_s=None, tvec_s=None,
        tvec_c=None, apply_distortion=True
    )
    ref_eta = ref_angs[:, 1]

    # These are the nominal tth values
    nom_angs, _ = detector.cart_to_angles(
        xy_pts,
        rmat_s=None, tvec_s=None,
        tvec_c=None, apply_distortion=True
    )
    nom_tth = nom_angs[:, 0]

    pin_tth = np.zeros(npts)
    for i, (pxy, reta) in enumerate(zip(xy_pts, ref_eta)):
        # !!! JHE used pinhole center, but the back surface
        #     seems to hew a bit closer to JRR's solution
        origin = -pinhole_radius*np.array(
            [np.cos(reta), np.sin(reta), 0.5*pinhole_thickness]
        )
        angs, _ = xfcapi.detectorXYToGvec(
            np.atleast_2d(pxy), detector.rmat, ct.identity_3x3,
            detector.tvec, ct.zeros_3, origin,
            beamVec=detector.bvec,
            etaVec=detector.evec)
        pin_tth[i] = angs[0]
    tth_corr = pin_tth - nom_tth
    if return_nominal:
        return np.vstack([nom_tth - tth_corr, nom_angs[:, 1]]).T
    else:
        # !!! NEED TO CHECK THIS
        return np.vstack([-tth_corr, nom_angs[:, 1]]).T


def tth_corr_map_pinhole(instrument, pinhole_thickness, pinhole_radius):
    """
    Compute the Bragg angle distortion fields for pinhole diffraction.

    Parameters
    ----------
    instrument : hexrd.instrument.HEDMInstrument
        The pionhole camera instrument object.
    pinhole_thickness : scalar
        The thickenss (height) of the pinhole (cylinder) in mm
    pinhole_radius : scalar
        The radius of the pinhole in mm

    Returns
    -------
    tth_corr : dict
        The Bragg angle correction fields for each detector in `instrument`
        as 2θ_pin - 2θ_nom in radians.

    Notes
    -----
    The follows a slightly modified version of Jon Eggert's pinhole correction.
    """
    cp_instr = copy.deepcopy(instrument)
    cp_instr.beam_vector = ct.beam_vec  # !!! [0, 0, -1]

    tth_corr = dict.fromkeys(instrument.detectors)
    for det_key, det in instrument.detectors.items():
        ref_ptth, ref_peta = cp_instr.detectors[det_key].pixel_angles()
        nom_ptth, _ = det.pixel_angles()

        dpy, dpx = det.pixel_coords
        pcrds = np.ascontiguousarray(
            np.vstack([dpx.flatten(), dpy.flatten()]).T
        )
        ref_peta = ref_peta.flatten()

        new_ptth = np.zeros(len(ref_peta))
        for i, (pxy, reta) in enumerate(zip(pcrds, ref_peta)):
            # !!! JHE used pinhole center, but the back surface
            #     seems to hew a bit closer to JRR's solution
            origin = -pinhole_radius*np.array(
                [np.cos(reta), np.sin(reta), 0.5*pinhole_thickness]
            )
            angs, g_vec = xfcapi.detectorXYToGvec(
                np.atleast_2d(pxy), det.rmat, ct.identity_3x3,
                det.tvec, ct.zeros_3, origin,
                beamVec=instrument.beam_vector,
                etaVec=instrument.eta_vector)
            new_ptth[i] = angs[0]
        tth_corr[det_key] = new_ptth.reshape(det.shape) - nom_ptth
    return tth_corr


def calc_phi_x(bvec, eHat_l):
    """
    returns phi_x in RADIANS
    """
    bv = np.array(bvec)
    bv[2] = 0.
    bv = bv/np.linalg.norm(bv)
    return np.arccos(np.dot(bv, -eHat_l)).item()


def azimuth(vv, v0, v1):
    """Return azimuthal angle btwn vv and v0, with v1 defining phi=0.

    Originally written by Ryan Rygg. This is a modified version.
    """
    with np.errstate(divide='ignore', invalid='ignore'):
        n0 = np.cross(v0, v1)
        n0 /= np.dual.norm(n0, axis=-1)[...,np.newaxis]
        nn = np.cross(v0, vv)
        nn /= np.dual.norm(nn, axis=-1)[...,np.newaxis]

    azi = np.arccos(np.sum(nn * n0, -1))
    if len(np.shape(azi)) > 0:
        azi[np.dot(vv, n0) < 0] *= -1
        azi[np.isnan(azi)] = 0 # arbitrary angle where vv is (anti)parallel to v0
    elif np.isnan(azi):
        return 0
    elif np.dot(vv, v0) < 1 and azi > 0:
        azi *= -1

    return azi


def _infer_instrument_type(panel):
    tardis_names = [
        'IMAGE-PLATE-1',
        'IMAGE-PLATE-2',
        'IMAGE-PLATE-3',
        'IMAGE-PLATE-4',
    ]

    pxrdip_names = [
        'IMAGE-PLATE-B',
        'IMAGE-PLATE-D',
        'IMAGE-PLATE-L',
        'IMAGE-PLATE-R',
        'IMAGE-PLATE-U',
    ]

    if panel.name in tardis_names:
        return 'TARDIS'
    elif panel.name in pxrdip_names:
        return 'PXRDIP'

    raise NotImplementedError(f'Unknown detector name: {panel.name}')


def _infer_eHat_l(panel):
    instr_type = _infer_instrument_type(panel)

    eHat_l_dict = {
        'TARDIS': -xfcapi.Xl,
        'PXRDIP': xfcapi.Yl,
    }

    return eHat_l_dict[instr_type]


def _infer_eta_shift(panel):
    instr_type = _infer_instrument_type(panel)

    eta_shift_dict = {
        'TARDIS': -np.radians(180),
        'PXRDIP': -np.radians(90),
    }

    return eta_shift_dict[instr_type]


def calc_tth_rygg_pinhole(panels, material, tth, eta, pinhole_thickness,
                          pinhole_radius, num_phi_elements=60,
                          clip_to_panel=True):
    """Return pinhole twotheta [rad] and effective scattering volume [mm3].

    num_phi_elements: number of pinhole phi elements for integration
    """
    # Make sure these are at least 2D
    original_shape = tth.shape

    if len(tth.shape) == 1:
        # Do these instead of atleast_2d(), because we want the 1
        # on the column, not on the row.
        tth = tth.reshape(tth.shape[0], 1)
        eta = eta.reshape(eta.shape[0], 1)

    if not isinstance(panels, (list, tuple)):
        panels = [panels]

    # ------ Determine geometric parameters ------

    # Grab info from the first panel that should be same across all panels
    first_panel = panels[0]
    bvec = first_panel.bvec
    eHat_l = _infer_eHat_l(first_panel)
    max_workers = first_panel.max_workers

    eta_shift = _infer_eta_shift(first_panel)
    # This code expects eta to be shifted by a certain amount
    # !! do not modify the original eta array
    eta = eta + eta_shift

    # distance of xray source from origin (i. e., center of pinhole) [mm]
    r_x = first_panel.xrs_dist

    # zenith angle of the x-ray source from (negative) pinhole axis
    alpha = np.arccos(np.dot(bvec, [0, 0, -1]))

    # azimuthal angle of the x-ray source around the pinhole axis
    phi_x = calc_phi_x(bvec, eHat_l)

    # pinhole substrate thickness [mm]
    h_p = pinhole_thickness

    # pinhole aperture diameter [mm]
    d_p = pinhole_radius * 2

    # mu_p is the attenuation coefficent [um^-1]
    # This is the inverse of the absorption length, which is in [um]
    mu_p = 1 / material.absorption_length
    mu_p = 1000 * mu_p  # convert to [mm^-1]

    # Convert tth and eta to phi_d, beta, and r_d
    dvec_arg = np.vstack((tth.flatten(), eta.flatten(),
                          np.zeros(np.prod(eta.shape))))
    dvectors = xfcapi.anglesToDVec(dvec_arg.T, bvec, eHat_l=eHat_l)

    v0 = np.array([0, 0, 1])
    v1 = np.squeeze(eHat_l)
    phi_d = azimuth(dvectors, v0, v1).reshape(tth.shape)
    beta = np.arccos(np.dot(dvectors, [0, 0, -1])).reshape(tth.shape)

    # Compute r_d
    # We will first convert to Cartesian, then clip to the panel, add the
    # extra Z dimension, apply the rotation matrix, add the tvec, and then
    # compute the distance.
    angles_full = np.stack((tth, eta)).reshape((2, np.prod(tth.shape))).T

    r_d = np.full(tth.shape, np.nan)
    for panel in panels:
        try:
            # Set the evec to eHat_l while converting to cartesian
            # This is important so that the r_d values end up in the right spots
            old_evec = panel.evec
            panel.evec = eHat_l
            cart = panel.angles_to_cart(angles_full)
        finally:
            panel.evec = old_evec

        if clip_to_panel:
            _, on_panel = panel.clip_to_panel(cart)
            cart[~on_panel] = np.nan

        cart = cart.T.reshape((2, *tth.shape))
        full_cart = np.stack((cart[0], cart[1], np.zeros(tth.shape)))
        flat_coords = full_cart.reshape((3, np.prod(tth.shape)))
        rotated = panel.rmat.dot(flat_coords).reshape(full_cart.shape).T
        full_vector = panel.tvec + rotated
        panel_r_d = np.sqrt(np.sum((full_vector)**2, axis=2)).T

        # Only overwrite positions that are still nan on r_d
        r_d[np.isnan(r_d)] = panel_r_d[np.isnan(r_d)]

    # Store the nan values so we can use them again later
    is_nan = np.isnan(r_d)

    # reshape so D grids are on axes indices 2 and 3 [1 x 1 x Nu x Nv]
    r_d = np.atleast_2d(r_d)[None, None, :, :]
    beta = np.atleast_2d(beta)[None, None, :, :]
    phi_d = np.atleast_2d(phi_d)[None, None, :, :]

    Np = num_phi_elements

    # ------ define pinhole grid ------
    # approximately square pinhole surface elements
    Nz = max(3, int(Np * h_p / (np.pi * d_p)))
    dphi = 2 * np.pi / Np  # [rad] phi interval
    dl = d_p * dphi  # [mm] azimuthal distance increment
    dz = h_p / Nz  # [mm] axial distance increment
    dA = dz * dl  # [mm^2] area element
    dV_s = dA * mu_p**-1  # [mm^3] volume of surface element
    dV_e = dl * mu_p**-2  # [mm^3] volume of edge element

    phi_vec = np.arange(dphi / 2, 2 * np.pi, dphi)
    # includes elements for X and D edges
    z_vec = np.arange(-h_p/2 - dz/2, h_p/2 + dz/1.999, dz)
    z_vec[0] = -h_p/2  # X-side edge (negative z)
    z_vec[-1] = h_p/2  # D-side edge (positive z)
    phi_i, z_i = np.meshgrid(phi_vec, z_vec)  # [Nz x Np]
    phi_i = phi_i[:, :, None, None]    # [Nz x Np x 1 x 1]
    z_i = z_i[:, :, None, None]      # axes 0,1 => P; axes 2,3 => D

    # ------ calculate twotheta_i [a.k.a. qq_i], for each grid element ------
    bx, bd = (d_p / (2 * r_x),  d_p / (2 * r_d))
    sin_a, cos_a, tan_a = np.sin(alpha), np.cos(alpha), np.tan(alpha)
    sin_b, cos_b, tan_b = np.sin(beta),  np.cos(beta),  np.tan(beta)
    sin_phii, cos_phii = np.sin(phi_i), np.cos(phi_i)
    cos_dphi_x = np.cos(phi_i - phi_x + np.pi)  # [Nz x Np x Nu x Nv]

    alpha_i = np.arctan2(np.sqrt(sin_a**2 + 2*bx*sin_a*cos_dphi_x + bx**2),
                         cos_a + z_i/r_x)
    phi_xi = np.arctan2(sin_a * np.sin(phi_x) - bx*sin_phii,
                        sin_a * np.cos(phi_x) - bx * cos_phii)

    # !!! This section used 4D arrays before, which was very time consuming
    # for large grids. Instead, we now loop over the columns and do them
    # one at a time. For large arrays, this saves a huge amount of time and
    # memory.
    tasks = distribute_tasks(phi_d.shape[3], max_workers)
    func = partial(
        _run_compute_qq_p,
        # 4D variables (all have some axes == 1)
        phi_d=phi_d,
        sin_b=sin_b,
        bd=bd,
        cos_b=cos_b,
        tan_b=tan_b,
        r_d=r_d,
        # The variables with less than 4 dimensions
        sin_phii=sin_phii,
        cos_phii=cos_phii,
        alpha_i=alpha_i,
        phi_xi=phi_xi,
        sin_a=sin_a,
        cos_dphi_x=cos_dphi_x,
        cos_a=cos_a,
        dV_s=dV_s,
        dV_e=dV_e,
        z_i=z_i,
        h_p=h_p,
        d_p=d_p,
        tan_a=tan_a,
        phi_i=phi_i,
    )

    if len(tasks) == 1:
        # Don't use a thread pool if there is just one task
        # Also don't use numba, it appears to be slower for the
        # serial version.
        results = []
        for task in tasks:
            results.append(func(task, use_numba=False))
    else:
        with ThreadPoolExecutor(max_workers=max_workers) as executor:
            results = executor.map(func, tasks)

    i = 0
    qq_p = np.full(tth.shape, np.nan)
    for worker_output in results:
        for output in worker_output:
            qq_p[:, [i]] = output
            i += 1

    # Set the nan values back to nan so it is clear they were not computed
    qq_p[is_nan] = np.nan

    return qq_p.reshape(original_shape)


def _run_compute_qq_p(indices, *args, **kwargs):
    # We will reduce these to one column to avoid making the full 4D arrays
    reduce_column_vars = [
        'phi_d',
        'sin_b',
        'bd',
        'cos_b',
        'tan_b',
        'r_d',
    ]
    original_kwargs = kwargs.copy()

    output = []
    for i in range(*indices):
        for var_name in reduce_column_vars:
            kwargs[var_name] = original_kwargs[var_name][:, :, :, [i]]

        output.append(_compute_qq_p(*args, **kwargs))

    return output


def _compute_qq_p(use_numba=True, *args, **kwargs):
    # This function is separate from the `_compute_vi_qq_i` one because
    # this does the np.nansum(..., axis=(0, 1)), which numba cannot do.
    if use_numba:
        # The numbafied version is faster if we are computing a grid
        f = _compute_vi_qq_i_numba
    else:
        # The non-numbafied version is faster if we don't have a grid.
        f = _compute_vi_qq_i

    V_i, qq_i = f(*args, **kwargs)
    V_p = np.nansum(V_i, axis=(0, 1))  # [Nu x Nv] <= detector

    with np.errstate(divide='ignore', invalid='ignore'):
        # Ignore the errors this will inevitably produce
        return np.nansum(V_i * qq_i, axis=(0, 1)) / V_p  # [Nu x Nv] <= detector


def _compute_vi_qq_i(phi_d, sin_b, bd, sin_phii, cos_phii, alpha_i, phi_xi,
                     sin_a, cos_dphi_x, cos_a, cos_b, dV_s, dV_e, z_i, h_p,
                     d_p, tan_b, tan_a, phi_i, r_d):
    # This function can be numbafied, and has a numbafied version below.

    # Compute V_i and qq_i

    cos_dphi_d = np.cos(phi_i - phi_d + np.pi)
    beta_i = np.arctan2(np.sqrt(sin_b**2 + 2*bd*sin_b*cos_dphi_d + bd**2),
                        cos_b - z_i/r_d)

    phi_di = np.arctan2(sin_b * np.sin(phi_d) - bd*sin_phii,
                        sin_b * np.cos(phi_d) - bd * cos_phii)

    arg = (np.cos(alpha_i) * np.cos(beta_i) - np.sin(alpha_i) *
           np.sin(beta_i) * np.cos(phi_di - phi_xi))
    # scattering angle for each P to each D
    qq_i = np.arccos(np.clip(arg, -1, 1))

    # ------ calculate effective volumes: 1 (surface), 2 (Xedge), 3 (Dedge) ---
    sec_psi_x = 1 / (sin_a * cos_dphi_x)
    sec_psi_d = 1 / (sin_b * cos_dphi_d)
    sec_alpha = 1 / cos_a
    sec_beta = 1 / cos_b
    tan_eta_x = np.where(cos_dphi_x[0] <= 0, 0, cos_a * cos_dphi_x[0])
    tan_eta_d = np.where(cos_dphi_d[-1] <= 0, 0, cos_b[0] * cos_dphi_d[-1])

    V_i = dV_s / (sec_psi_x + sec_psi_d)  # [mm^3]
    # X-side edge (z = -h_p / 2)
    V_i[0] = dV_e / (sec_psi_d[0] * (sec_alpha + sec_psi_d[0] * tan_eta_x))
    # D-side edge (z = +h_p / 2)
    V_i[-1] = dV_e / (sec_psi_x[-1] * (sec_beta + sec_psi_x[-1] * tan_eta_d))

    # ------ visibility of each grid element ------
    # pinhole surface
    is_seen = np.logical_and(z_i > h_p/2 - d_p/tan_b * cos_dphi_d,
                             z_i < -h_p/2 + d_p/tan_a * cos_dphi_x)
    # X-side edge
    is_seen[0] = np.where(h_p/d_p * tan_b < cos_dphi_d[0], 1, 0)
    # D-side edge
    is_seen[-1] = np.where(h_p/d_p * tan_a < cos_dphi_x[-1], 1, 0)

    # ------ weighted sum over elements to obtain average ------
    V_i *= is_seen  # zero weight to elements with no view of both X and D
    return V_i, qq_i


# The numba version (works better in conjunction with multi-threading)
_compute_vi_qq_i_numba = numba_njit_if_available(nogil=True, cache=True)(_compute_vi_qq_i)


def tth_corr_rygg_pinhole(panel, material, xy_pts,
                          pinhole_thickness, pinhole_radius,
                          return_nominal=True, num_phi_elements=60):
    # These are the nominal tth values
    nom_angs, _ = panel.cart_to_angles(
        xy_pts,
        rmat_s=None, tvec_s=None,
        tvec_c=None, apply_distortion=True
    )
    nom_tth, nom_eta = nom_angs[:, :2].T

    # Don't clip these values to the panel because they will be shifted
    qq_p = calc_tth_rygg_pinhole(
        panel, material, nom_tth, nom_eta, pinhole_thickness,
        pinhole_radius, num_phi_elements, clip_to_panel=False)

    # Make the distortion shift to the left instead of the right
    # FIXME: why is qq_p shifting the data to the right instead of the left?
    qq_p = nom_tth - (qq_p - nom_tth)

    angs = np.vstack([qq_p, nom_eta]).T
    new_xy_pts = panel.angles_to_cart(angs)
    # Clip these to the panel now
    _, on_panel = panel.clip_to_panel(new_xy_pts)
    angs[~on_panel] = np.nan

    if return_nominal:
        return angs
    else:
        angs[:, 0] -= nom_tth
        return angs


def tth_corr_map_rygg_pinhole(instrument, material, pinhole_thickness,
                              pinhole_radius, num_phi_elements=60):
    tth_corr = {}
    for det_key, panel in instrument.detectors.items():
        nom_ptth, nom_peta = panel.pixel_angles()
        qq_p = calc_tth_rygg_pinhole(
            panel, material, nom_ptth, nom_peta, pinhole_thickness,
            pinhole_radius, num_phi_elements)
        tth_corr[det_key] = nom_ptth - qq_p
    return tth_corr


def polar_tth_corr_map_rygg_pinhole(tth, eta, instrument, material, pinhole_thickness,
                                    pinhole_radius, num_phi_elements=60):
    """Generate a polar tth corr map directly for all panels"""
    panels = list(instrument.detectors.values())
    return calc_tth_rygg_pinhole(panels, material, tth, eta, pinhole_thickness,
                                 pinhole_radius, num_phi_elements) - tth


# hexrd/xrdutil/utils.py

#! /usr/bin/env python3
# ============================================================
# Copyright (c) 2012, Lawrence Livermore National Security, LLC.
# Produced at the Lawrence Livermore National Laboratory.
# Written by Joel Bernier <bernier2@llnl.gov> and others.
# LLNL-CODE-529294.
# All rights reserved.
#
# This file is part of HEXRD. For details on dowloading the source,
# see the file COPYING.
#
# Please also see the file LICENSE.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License (as published by the Free
# Software Foundation) version 2.1 dated February 1999.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY OF MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the terms and conditions of the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this program (see file LICENSE); if not, write to
# the Free Software Foundation, Inc., 59 Temple Place, Suite 330,
# Boston, MA 02111-1307 USA or visit <http://www.gnu.org/licenses/>.
# ============================================================

import numpy as np

from hexrd import constants
from hexrd import matrixutil as mutil
from hexrd import gridutil as gutil

from hexrd.crystallography import processWavelength, PlaneData
from hexrd import instrument

from hexrd.transforms import xf
from hexrd.transforms import xfcapi

from hexrd.valunits import valWUnit

from hexrd import distortion as distortion_pkg

from hexrd.constants import USE_NUMBA
if USE_NUMBA:
    import numba

from skimage.transform import PiecewiseAffineTransform, warp


# =============================================================================
# PARAMETERS
# =============================================================================

distortion_key = 'distortion'

d2r = piby180 = constants.d2r
r2d = constants.r2d

epsf = constants.epsf            # ~2.2e-16
ten_epsf = 10 * epsf             # ~2.2e-15
sqrt_epsf = constants.sqrt_epsf  # ~1.5e-8

bHat_l_DFLT = constants.beam_vec.flatten()
eHat_l_DFLT = constants.eta_vec.flatten()

nans_1x2 = np.nan*np.ones((1, 2))

# =============================================================================
# CLASSES
# =============================================================================


class PolarView(object):
    """
    Create (two-theta, eta) plot of detector images.
    """

    def __init__(self, plane_data, instrument,
                 eta_min=0., eta_max=360.,
                 pixel_size=(0.1, 0.25)):
        """
        Instantiates a PolarView class.

        Parameters
        ----------
        plane_data : PlaneData object or array_like
            Specification for the 2theta ranges.  If a PlaneData instance, the
            min/max is set to the lowermost/uppermost values from the tThTanges
            as defined but the active hkls and the tThWidth (or strainMag).
            If array_like, the input must be (2, ) specifying the [min, maz]
            2theta values explicitly in degrees.
        instrument : hexrd.instrument.HEDMInstrument
            The instruemnt object.
        eta_min : scalar, optional
            The minimum azimuthal extent in degrees. The default is 0.
        eta_max : scalar, optional
            The minimum azimuthal extent in degrees. The default is 360.
        pixel_size : array_like, optional
            The angular pixels sizes (2theta, eta) in degrees.
            The default is (0.1, 0.25).

        Returns
        -------
        None.

        Notes
        -----
        Currently there is no check on the eta range, which should be strictly
        less than 360 degrees.

        """
        # tth stuff
        if isinstance(plane_data, PlaneData):
            tth_ranges = plane_data.getTThRanges()
            self._tth_min = np.min(tth_ranges)
            self._tth_max = np.max(tth_ranges)
        else:
            self._tth_min = np.radians(plane_data[0])
            self._tth_max = np.radians(plane_data[1])

        # etas
        self._eta_min = np.radians(eta_min)
        self._eta_max = np.radians(eta_max)

        assert np.all(np.asarray(pixel_size) > 0), \
            'pixel sizes must be non-negative'
        self._tth_pixel_size = pixel_size[0]
        self._eta_pixel_size = pixel_size[1]

        self._instrument = instrument

    @property
    def instrument(self):
        return self._instrument

    @property
    def detectors(self):
        return self._instrument.detectors

    @property
    def tvec(self):
        return self._instrument.tvec

    @property
    def chi(self):
        return self._instrument.chi

    @property
    def tth_min(self):
        return self._tth_min

    @tth_min.setter
    def tth_min(self, x):
        assert x < self.tth_max,\
          'tth_min must be < tth_max (%f)' % (self._tth_max)
        self._tth_min = x

    @property
    def tth_max(self):
        return self._tth_max

    @tth_max.setter
    def tth_max(self, x):
        assert x > self.tth_min,\
          'tth_max must be < tth_min (%f)' % (self._tth_min)
        self._tth_max = x

    @property
    def tth_range(self):
        return self.tth_max - self.tth_min

    @property
    def tth_pixel_size(self):
        return self._tth_pixel_size

    @tth_pixel_size.setter
    def tth_pixel_size(self, x):
        assert(x > 0), "pixel size must be non-negative"
        self._tth_pixel_size = float(x)

    @property
    def eta_min(self):
        return self._eta_min

    @eta_min.setter
    def eta_min(self, x):
        assert x < self.eta_max,\
          'eta_min must be < eta_max (%f)' % (self.eta_max)
        self._eta_min = x

    @property
    def eta_max(self):
        return self._eta_max

    @eta_max.setter
    def eta_max(self, x):
        assert x > self.eta_min,\
          'eta_max must be > eta_min (%f)' % (self.eta_min)
        self._eta_max = x

    @property
    def eta_range(self):
        return self.eta_max - self.eta_min

    @property
    def eta_pixel_size(self):
        return self._eta_pixel_size

    @eta_pixel_size.setter
    def eta_pixel_size(self, x):
        assert(x > 0), "pixel size must be non-negative"
        self._eta_pixel_size = float(x)

    @property
    def ntth(self):
        # return int(np.ceil(np.degrees(self.tth_range)/self.tth_pixel_size))
        return int(round(np.degrees(self.tth_range)/self.tth_pixel_size))

    @property
    def neta(self):
        # return int(np.ceil(np.degrees(self.eta_range)/self.eta_pixel_size))
        return int(round(np.degrees(self.eta_range)/self.eta_pixel_size))

    @property
    def shape(self):
        return (self.neta, self.ntth)

    @property
    def angular_grid(self):
        tth_vec = np.radians(self.tth_pixel_size*(np.arange(self.ntth)))\
            + self.tth_min + 0.5*np.radians(self.tth_pixel_size)
        eta_vec = np.radians(self.eta_pixel_size*(np.arange(self.neta)))\
            + self.eta_min + 0.5*np.radians(self.eta_pixel_size)
        return np.meshgrid(eta_vec, tth_vec, indexing='ij')

    @property
    def extent(self):
        ev, tv = self.angular_grid
        heps = np.radians(0.5*self.eta_pixel_size)
        htps = np.radians(0.5*self.tth_pixel_size)
        return [np.min(tv) - htps, np.max(tv) + htps,
                np.max(ev) + heps, np.min(ev) - heps]

    def _func_project_on_detector(self, detector):
        '''
        helper function to decide which function to 
        use for mapping of g-vectors to detector
        '''
        if isinstance(detector, instrument.CylindricalDetector):
            return _project_on_detector_cylinder
        else:
            return _project_on_detector_plane

    def _args_project_on_detector(self, gvec_angs, detector):
        kwargs = {'beamVec': detector.bvec}
        arg = (gvec_angs,
               detector.rmat, 
               constants.identity_3x3, 
               self.chi,
               detector.tvec, 
               constants.zeros_3, 
               self.tvec,
               detector.distortion)
        if isinstance(detector, instrument.CylindricalDetector):
            arg = (gvec_angs,
                   detector.rmat,
                   self.chi,
                   detector.tvec,
                   detector.caxis,
                   detector.paxis,
                   detector.radius,
                   detector.physical_size,
                   detector.angle_extent,
                   detector.distortion)

        return arg, kwargs


    # =========================================================================
    #                         ####### METHODS #######
    # =========================================================================
    def warp_image(self, image_dict, pad_with_nans=False,
                   do_interpolation=True):
        """
        Performs the polar mapping of the input images.

        Parameters
        ----------
        image_dict : dict
            DIctionary of image arrays, 1 per detector.

        Returns
        -------
        wimg : numpy.ndarray
            The composite polar mapping of the detector images.  Dimensions are
            self.shape

        Notes
        -----
        Tested ouput using Maud.

        """

        angpts = self.angular_grid
        dummy_ome = np.zeros((self.ntth*self.neta))

        # lcount = 0
        img_dict = dict.fromkeys(self.detectors)
        for detector_id, panel in self.detectors.items():
            _project_on_detector = self._func_project_on_detector(panel)
            img = image_dict[detector_id]

            gvec_angs = np.vstack([
                    angpts[1].flatten(),
                    angpts[0].flatten(),
                    dummy_ome]).T

            args, kwargs = self._args_project_on_detector(gvec_angs,
                                                          panel)

            xypts = np.nan*np.ones((len(gvec_angs), 2))
            valid_xys, rmats_s, on_plane = _project_on_detector(*args, 
                                                                **kwargs)
            xypts[on_plane,:] = valid_xys

            if do_interpolation:
                this_img = panel.interpolate_bilinear(
                    xypts, img,
                    pad_with_nans=pad_with_nans).reshape(self.shape)
            else:
                this_img = panel.interpolate_nearest(
                    xypts, img,
                    pad_with_nans=pad_with_nans).reshape(self.shape)
            nan_mask = np.isnan(this_img)
            img_dict[detector_id] = np.ma.masked_array(
                data=this_img, mask=nan_mask, fill_value=0.
            )
        maimg = np.ma.sum(np.ma.stack(img_dict.values()), axis=0)
        return maimg

    def tth_to_pixel(self, tth):
        """
        convert two-theta value to pixel value (float) along two-theta axis
        """
        return np.degrees(tth - self.tth_min)/self.tth_pixel_size


class SphericalView(object):
    """
    Creates a spherical mapping of detector images.
    """
    MAPPING_TYPES = ('stereographic', 'equal-area')
    VECTOR_TYPES = ('d', 'q')
    PROJ_IMG_DIM = 3.  # 2*np.sqrt(2) rounded up

    def __init__(self, mapping='stereographic', vector_type='d',
                 output_dim=512, rmat=constants.identity_3x3):
        self._mapping = mapping
        self._vector_type = vector_type

        # ??? maybe promote invert_z to a prop for protection?
        if self._vector_type == 'd':
            self.invert_z = False
        elif self._vector_type == 'q':
            self.invert_z = True
        pass

        self._output_dim = output_dim
        self._rmat = rmat

    @property
    def mapping(self):
        return self._mapping

    @mapping.setter
    def mapping(self, s):
        if s not in self.MAPPING_TYPES:
            raise RuntimeError("mapping specification '%s' is invalid" % s)

    @property
    def vector_type(self):
        return self._vector_type

    @vector_type.setter
    def vector_type(self, s):
        if s not in self.VECTOR_TYPES:
            raise RuntimeError("vector type specification '%s' is invalid" % s)

    @property
    def output_dim(self):
        return self._output_dim

    @output_dim.setter
    def output_dim(self, x):
        self._output_dim = int(x)

    @property
    def rmat(self):
        return self._rmat

    @rmat.setter
    def rmat(self, x):
        x = np.atleast_2d(x)
        assert x.shape == (3, 3), "rmat must be (3, 3)"
        assert np.linalg.norm(np.dot(x.T, x) - constants.identity_3x3) \
            < constants.ten_epsf, "input matrix is not orthogonal"
        self._rmat = x

    def warp_eta_ome_map(self, eta_ome, map_ids=None, skip=10):
        paxf = PiecewiseAffineTransform()

        nrows_in = len(eta_ome.etas)
        ncols_in = len(eta_ome.omegas)

        # grab tth values
        tths = eta_ome.planeData.getTTh()

        # grab (undersampled) points
        omes = eta_ome.omegas[::skip]
        etas = eta_ome.etas[::skip]

        # make grid of angular values
        op, ep = np.meshgrid(omes,
                             etas,
                             indexing='ij')

        # make grid of output pixel values
        oc, ec = np.meshgrid(np.arange(nrows_in)[::skip],
                             np.arange(ncols_in)[::skip],
                             indexing='ij')

        ps = self.PROJ_IMG_DIM / self.output_dim  # output pixel size

        if map_ids is None:
            map_ids = list(range(len(eta_ome.dataStore)))

        wimgs = []
        for map_id in map_ids:
            img = eta_ome.dataStore[map_id]

            # ??? do we need to use iHKLlist?
            angs = np.vstack([
                tths[map_id]*np.ones_like(ep.flatten()),
                ep.flatten(),
                op.flatten()
            ]).T

            ppts, nmask = zproject_sph_angles(
                angs, method=self.mapping, source=self.vector_type,
                invert_z=self.invert_z, use_mask=True
            )

            # pixel coords in output image
            rp = 0.5*self.output_dim - ppts[:, 1]/ps
            cp = ppts[:, 0]/ps + 0.5*self.output_dim

            # compute piecewise affine transform
            src = np.vstack([ec.flatten(), oc.flatten(), ]).T
            dst = np.vstack([cp.flatten(), rp.flatten(), ]).T
            paxf.estimate(src, dst)

            wimg = warp(
                img,
                inverse_map=paxf.inverse,
                output_shape=(self.output_dim, self.output_dim)
            )
            if len(map_ids) == 1:
                return wimg
            else:
                wimgs.append[wimg]
        return wimgs

    def warp_polar_image(self, pimg, skip=10):
        paxf = PiecewiseAffineTransform()

        img = np.array(pimg['intensities'])

        # remove SNIP bg if there
        if 'snip_background' in pimg:
            # !!! these are float64 so we should be good
            img -= np.array(pimg['snip_background'])

        nrows_in, ncols_in = img.shape

        tth_cen = np.array(pimg['tth_coordinates'])[0, :]
        eta_cen = np.array(pimg['eta_coordinates'])[:, 0]

        tp, ep = np.meshgrid(tth_cen[::skip],
                             eta_cen[::skip])
        tc, ec = np.meshgrid(np.arange(ncols_in)[::skip],
                             np.arange(nrows_in)[::skip])
        op = np.zeros_like(tp.flatten())

        angs = np.radians(
            np.vstack([tp.flatten(),
                       ep.flatten(),
                       op.flatten()]).T
        )

        ppts = zproject_sph_angles(
            angs, method='stereographic', source='d', invert_z=self.invert_z,
            rmat=self.rmat
        )

        # output pixel size
        ps = self.PROJ_IMG_DIM / self.output_dim

        # pixel coords in output image
        rp = 0.5*self.output_dim - ppts[:, 1]/ps
        cp = ppts[:, 0]/ps + 0.5*self.output_dim

        src = np.vstack([tc.flatten(), ec.flatten(), ]).T
        dst = np.vstack([cp.flatten(), rp.flatten(), ]).T
        paxf.estimate(src, dst)

        wimg = warp(
            img,
            inverse_map=paxf.inverse,
            output_shape=(self.output_dim, self.output_dim)
        )

        return wimg


class EtaOmeMaps(object):
    """
    find-orientations loads pickled eta-ome data, but CollapseOmeEta is not
    pickleable, because it holds a list of ReadGE, each of which holds a
    reference to an open file object, which is not pickleable.
    """

    def __init__(self, ome_eta_archive):

        ome_eta = np.load(ome_eta_archive, allow_pickle=True)

        planeData_args = ome_eta['planeData_args']
        planeData_hkls = ome_eta['planeData_hkls']
        self.planeData = PlaneData(planeData_hkls, *planeData_args)
        self.planeData.exclusions = ome_eta['planeData_excl']
        self.dataStore = ome_eta['dataStore']
        self.iHKLList = ome_eta['iHKLList']
        self.etaEdges = ome_eta['etaEdges']
        self.omeEdges = ome_eta['omeEdges']
        self.etas = ome_eta['etas']
        self.omegas = ome_eta['omegas']

    def save(self, filename):
        self.save_eta_ome_maps(self, filename)

    @staticmethod
    def save_eta_ome_maps(eta_ome, filename):
        """
        eta_ome.dataStore
        eta_ome.planeData
        eta_ome.iHKLList
        eta_ome.etaEdges
        eta_ome.omeEdges
        eta_ome.etas
        eta_ome.omegas
        """
        args = np.array(eta_ome.planeData.getParams(), dtype=object)[:4]
        args[2] = valWUnit('wavelength', 'length', args[2], 'angstrom')
        hkls = np.vstack([i['hkl'] for i in eta_ome.planeData.hklDataList]).T
        save_dict = {'dataStore': eta_ome.dataStore,
                     'etas': eta_ome.etas,
                     'etaEdges': eta_ome.etaEdges,
                     'iHKLList': eta_ome.iHKLList,
                     'omegas': eta_ome.omegas,
                     'omeEdges': eta_ome.omeEdges,
                     'planeData_args': args,
                     'planeData_hkls': hkls,
                     'planeData_excl': eta_ome.planeData.exclusions}
        np.savez_compressed(filename, **save_dict)
    pass  # end of class: EtaOmeMaps


# =============================================================================
# FUNCTIONS
# =============================================================================


def _zproject(x, y):
    return np.cos(x) * np.sin(y) - np.sin(x) * np.cos(y)


def _convert_angles(tth_eta, detector,
                    rmat_s, tvec_s, tvec_c,
                    beam_vector=constants.beam_vec,
                    eta_vector=constants.eta_vec):
    """
    Coverts frame-local angles to effective angles in the LAB reference frame.

    Operates on a detector instance in lieu of instrument.

    Parameters
    ----------
    tth_eta : TYPE
        DESCRIPTION.
    detector : TYPE
        DESCRIPTION.
    rmat_s : TYPE
        DESCRIPTION.
    tvec_c : TYPE
        DESCRIPTION.
    beam_vector : TYPE, optional
        DESCRIPTION. The default is constants.beam_vec.
    eta_vector : TYPE, optional
        DESCRIPTION. The default is constants.eta_vec.

    Returns
    -------
    tth_eta_ref : TYPE
        DESCRIPTION.

    Notes
    -----
    FIXME: This API won't work for rotation series data
    """

    tth_eta = np.atleast_2d(tth_eta)

    chi = np.arctan2(rmat_s[2, 1], rmat_s[1, 1])
    ome = np.arctan2(rmat_s[0, 2], rmat_s[0, 0])

    # !!! reform rmat_s to be consistent with def in geometric model
    rmat_s = xfcapi.makeOscillRotMat(np.r_[chi, ome])
    rmat_c = constants.identity_3x3
    # tvec_s = constants.zeros_3
    tvec_c_ref = constants.zeros_3

    # FIXME: doesn't work for rotation series with different ome yet.
    full_angs = np.hstack([tth_eta, ome*np.ones((len(tth_eta), 1))])

    # convert to gvectors using trivial crystal frame
    gvec_s = xfcapi.anglesToGVec(
        full_angs, bHat_l=beam_vector, eHat_l=eta_vector, chi=chi
    )

    # convert to detector points
    det_xys = xfcapi.gvecToDetectorXY(
        gvec_s,
        detector.rmat, rmat_s, rmat_c,
        detector.tvec, tvec_s, tvec_c,
        beamVec=beam_vector
    )

    # convert to angles in LAB ref
    tth_eta_ref, _ = xfcapi.detectorXYToGvec(
        det_xys, detector.rmat, rmat_s, detector.tvec, tvec_s, tvec_c_ref,
        beamVec=beam_vector, etaVec=eta_vector
    )

    return np.vstack(tth_eta_ref).T


def zproject_sph_angles(invecs, chi=0.,
                        method='stereographic',
                        source='d',
                        use_mask=False,
                        invert_z=False,
                        rmat=None):
    """
    Projects spherical angles to 2-d mapping.

    Parameters
    ----------
    invec : array_like
        The (n, 3) array of input points, interpreted via the 'source' kwarg.
    chi : scalar, optional
        The inclination angle of the sample frame. The default is 0..
    method : str, optional
        Mapping type spec, either 'stereographic' or 'equal-area'.
        The default is 'stereographic'.
    source : str, optional
        The type specifier of the input vectors, either 'd', 'q', or 'g'.
            'd' signifies unit diffraction vectors as (2theta, eta, omega),
            'q' specifies unit scattering vectors as (2theta, eta, omega),
            'g' specifies unit vectors in the sample frame as (x, y, z).
        The default is 'd'.
    use_mask : bool, optional
        If True, trim points not on the +z hemishpere (polar angles > 90).
        The default is False.
    invert_z : bool, optional
        If True, invert the Z-coordinates of the unit vectors calculated from
        the input angles. The default is False.
    rmat : numpy.ndarry, shape=(3, 3), optional
        Array representing a change of basis (rotation) to appy to the
        calculated unit vectors. The default is None.

    Raises
    ------
    RuntimeError
        If method not in ('stereographic', 'equal-area').

    Returns
    -------
    numpy.ndarray or tuple
        If use_mask = False, then the array of n mapped input points with shape
        (n, 2).  If use_mask = True, then the first element is the ndarray of
        mapped points with shape (<=n, 2), and the second is a bool array with
        shape (n,) marking the point that fell on the upper hemishpere.
        .

    Notes
    -----
    CAVEAT: +Z axis projections only!!!
    TODO: check mask application.
    """
    assert isinstance(source, str), "source kwarg must be a string"

    invecs = np.atleast_2d(invecs)
    if source.lower() == 'd':
        spts_s = xfcapi.anglesToDVec(invecs, chi=chi)
    elif source.lower() == 'q':
        spts_s = xfcapi.anglesToGVec(invecs, chi=chi)
    elif source.lower() == 'g':
        spts_s = invecs

    if rmat is not None:
        spts_s = np.dot(spts_s, rmat.T)

    if invert_z:
        spts_s[:, 2] = -spts_s[:, 2]

    # filter based on hemisphere
    if use_mask:
        pzi = spts_s[:, 2] <= 0
        spts_s = spts_s[pzi, :]
    npts_s = len(spts_s)

    if method.lower() == 'stereographic':
        ppts = np.vstack([
            spts_s[:, 0]/(1. - spts_s[:, 2]),
            spts_s[:, 1]/(1. - spts_s[:, 2])
        ]).T
    elif method.lower() == 'equal-area':
        chords = spts_s + np.tile([0, 0, 1], (npts_s, 1))
        scl = np.tile(xfcapi.rowNorm(chords), (2, 1)).T
        ucrd = mutil.unitVector(
                np.hstack([
                        chords[:, :2],
                        np.zeros((len(spts_s), 1))
                ]).T
        )

        ppts = ucrd[:2, :].T * scl
    else:
        raise RuntimeError("method '%s' not recognized" % method)

    if use_mask:
        return ppts, pzi
    else:
        return ppts


def make_polar_net(ndiv=24, projection='stereographic', max_angle=120.):
    """
    TODO: options for generating net boundaries; fixed to Z proj.
    """
    ndiv_tth = int(np.floor(0.5*ndiv)) + 1
    wtths = np.radians(
        np.linspace(0, 1, num=ndiv_tth, endpoint=True)*max_angle
    )
    wetas = np.radians(
        np.linspace(-1, 1, num=ndiv+1, endpoint=True)*180.
    )
    weta_gen = np.radians(
        np.linspace(-1, 1, num=181, endpoint=True)*180.
    )
    pts = []
    for eta in wetas:
        net_angs = np.vstack([[wtths[0], wtths[-1]],
                              np.tile(eta, 2),
                              np.zeros(2)]).T
        projp = zproject_sph_angles(net_angs, method=projection, source='d')
        pts.append(projp)
        pts.append(np.nan*np.ones((1, 2)))
    for tth in wtths[1:]:
        net_angs = np.vstack([tth*np.ones_like(weta_gen),
                              weta_gen,
                              np.zeros_like(weta_gen)]).T
        projp = zproject_sph_angles(net_angs, method=projection, source='d')
        pts.append(projp)
        pts.append(nans_1x2)
    '''
    # old method
    for tth in wtths:
        net_angs = np.vstack([tth*np.ones_like(wetas),
                              wetas,
                              piby2*np.ones_like(wetas)]).T
        projp = zproject_sph_angles(net_angs, method=projection)
        pts.append(projp)
    '''
    pts = np.vstack(pts)
    return pts


def validateAngleRanges(angList, startAngs, stopAngs, ccw=True):
    """
    Indetify angles that fall within specified ranges.

    A better way to go.  find out if an angle is in the range
    CCW or CW from start to stop

    There is, of course an ambigutiy if the start and stop angle are
    the same; we treat them as implying 2*pi
    """
    angList = np.atleast_1d(angList).flatten()      # needs to have len
    startAngs = np.atleast_1d(startAngs).flatten()  # needs to have len
    stopAngs = np.atleast_1d(stopAngs).flatten()    # needs to have len

    n_ranges = len(startAngs)
    assert len(stopAngs) == n_ranges, \
        "length of min and max angular limits must match!"

    # to avoid warnings in >=, <= later down, mark nans;
    # need these to trick output to False in the case of nan input
    nan_mask = np.isnan(angList)

    reflInRange = np.zeros(angList.shape, dtype=bool)

    # bin length for chunking
    binLen = np.pi / 2.

    # in plane vectors defining wedges
    x0 = np.vstack([np.cos(startAngs), np.sin(startAngs)])
    x1 = np.vstack([np.cos(stopAngs), np.sin(stopAngs)])

    # dot products
    dp = np.sum(x0 * x1, axis=0)
    if np.any(dp >= 1. - sqrt_epsf) and n_ranges > 1:
        # ambiguous case
        raise RuntimeError(
            "Improper usage; " +
            "at least one of your ranges is alread 360 degrees!")
    elif dp[0] >= 1. - sqrt_epsf and n_ranges == 1:
        # trivial case!
        reflInRange = np.ones(angList.shape, dtype=bool)
        reflInRange[nan_mask] = False
    else:
        # solve for arc lengths
        # ...note: no zeros should have made it here
        a = x0[0, :]*x1[1, :] - x0[1, :]*x1[0, :]
        b = x0[0, :]*x1[0, :] + x0[1, :]*x1[1, :]
        phi = np.arctan2(b, a)

        arclen = 0.5*np.pi - phi          # these are clockwise
        cw_phis = arclen < 0
        arclen[cw_phis] = 2*np.pi + arclen[cw_phis]   # all positive (CW) now
        if not ccw:
            arclen = 2*np.pi - arclen

        if sum(arclen) > 2*np.pi:
            raise RuntimeWarning(
                "Specified angle ranges sum to > 360 degrees, " +
                "which is suspect...")

        # check that there are no more thandp = np.zeros(n_ranges)
        for i in range(n_ranges):
            # number or subranges using 'binLen'
            numSubranges = int(np.ceil(arclen[i]/binLen))

            # check remaider
            binrem = np.remainder(arclen[i], binLen)
            if binrem == 0:
                finalBinLen = binLen
            else:
                finalBinLen = binrem

            # if clockwise, negate bin length
            if not ccw:
                binLen = -binLen
                finalBinLen = -finalBinLen

            # Create sub ranges on the fly to avoid ambiguity in dot product
            # for wedges >= 180 degrees
            subRanges = np.array(
                [startAngs[i] + binLen*j for j in range(numSubranges)]
                + [startAngs[i] + binLen*(numSubranges - 1) + finalBinLen]
                )

            for k in range(numSubranges):
                zStart = _zproject(angList, subRanges[k])
                zStop = _zproject(angList, subRanges[k + 1])
                if ccw:
                    zStart[nan_mask] = 999.
                    zStop[nan_mask] = -999.
                    reflInRange = \
                        reflInRange | np.logical_and(zStart <= 0, zStop >= 0)
                else:
                    zStart[nan_mask] = -999.
                    zStop[nan_mask] = 999.
                    reflInRange = \
                        reflInRange | np.logical_and(zStart >= 0, zStop <= 0)
    return reflInRange


def simulateOmeEtaMaps(omeEdges, etaEdges, planeData, expMaps,
                       chi=0.,
                       etaTol=None, omeTol=None,
                       etaRanges=None, omeRanges=None,
                       bVec=constants.beam_vec, eVec=constants.eta_vec,
                       vInv=constants.identity_6x1):
    """
    Simulate spherical maps.

    Parameters
    ----------
    omeEdges : TYPE
        DESCRIPTION.
    etaEdges : TYPE
        DESCRIPTION.
    planeData : TYPE
        DESCRIPTION.
    expMaps : (3, n) ndarray
        DESCRIPTION.
    chi : TYPE, optional
        DESCRIPTION. The default is 0..
    etaTol : TYPE, optional
        DESCRIPTION. The default is None.
    omeTol : TYPE, optional
        DESCRIPTION. The default is None.
    etaRanges : TYPE, optional
        DESCRIPTION. The default is None.
    omeRanges : TYPE, optional
        DESCRIPTION. The default is None.
    bVec : TYPE, optional
        DESCRIPTION. The default is [0, 0, -1].
    eVec : TYPE, optional
        DESCRIPTION. The default is [1, 0, 0].
    vInv : TYPE, optional
        DESCRIPTION. The default is [1, 1, 1, 0, 0, 0].

    Returns
    -------
    eta_ome : TYPE
        DESCRIPTION.

    Notes
    -----
    all angular info is entered in degrees

    ??? might want to creat module-level angluar unit flag
    ??? might want to allow resvers delta omega

    """
    # convert to radians
    etaEdges = np.radians(np.sort(etaEdges))
    omeEdges = np.radians(np.sort(omeEdges))

    omeIndices = list(range(len(omeEdges)))
    etaIndices = list(range(len(etaEdges)))

    i_max = omeIndices[-1]
    j_max = etaIndices[-1]

    etaMin = etaEdges[0]
    etaMax = etaEdges[-1]
    omeMin = omeEdges[0]
    omeMax = omeEdges[-1]
    if omeRanges is None:
        omeRanges = [[omeMin, omeMax], ]

    if etaRanges is None:
        etaRanges = [[etaMin, etaMax], ]

    # signed deltas IN RADIANS
    del_ome = omeEdges[1] - omeEdges[0]
    del_eta = etaEdges[1] - etaEdges[0]

    delOmeSign = np.sign(del_eta)

    # tolerances are in degrees (easier)
    if omeTol is None:
        omeTol = abs(del_ome)
    else:
        omeTol = np.radians(omeTol)
    if etaTol is None:
        etaTol = abs(del_eta)
    else:
        etaTol = np.radians(etaTol)

    # pixel dialtions
    dpix_ome = round(omeTol / abs(del_ome))
    dpix_eta = round(etaTol / abs(del_eta))

    i_dil, j_dil = np.meshgrid(np.arange(-dpix_ome, dpix_ome + 1),
                               np.arange(-dpix_eta, dpix_eta + 1))

    # get symmetrically expanded hkls from planeData
    sym_hkls = planeData.getSymHKLs()
    nhkls = len(sym_hkls)

    # make things C-contiguous for use in xfcapi functions
    expMaps = np.array(expMaps.T, order='C')
    nOrs = len(expMaps)

    bMat = np.array(planeData.latVecOps['B'], order='C')
    wlen = planeData.wavelength

    bVec = np.array(bVec.flatten(), order='C')
    eVec = np.array(eVec.flatten(), order='C')
    vInv = np.array(vInv.flatten(), order='C')

    eta_ome = np.zeros((nhkls, max(omeIndices), max(etaIndices)), order='C')
    for iHKL in range(nhkls):
        these_hkls = np.ascontiguousarray(sym_hkls[iHKL].T, dtype=float)
        for iOr in range(nOrs):
            rMat_c = xfcapi.makeRotMatOfExpMap(expMaps[iOr, :])
            angList = np.vstack(
                xfcapi.oscillAnglesOfHKLs(these_hkls, chi, rMat_c, bMat, wlen,
                                          beamVec=bVec, etaVec=eVec, vInv=vInv)
                )
            if not np.all(np.isnan(angList)):
                #
                angList[:, 1] = xfcapi.mapAngle(
                        angList[:, 1],
                        [etaEdges[0], etaEdges[0]+2*np.pi])
                angList[:, 2] = xfcapi.mapAngle(
                        angList[:, 2],
                        [omeEdges[0], omeEdges[0]+2*np.pi])
                #
                # do eta ranges
                angMask_eta = np.zeros(len(angList), dtype=bool)
                for etas in etaRanges:
                    angMask_eta = np.logical_or(
                        angMask_eta,
                        xf.validateAngleRanges(angList[:, 1], etas[0], etas[1])
                    )

                # do omega ranges
                ccw = True
                angMask_ome = np.zeros(len(angList), dtype=bool)
                for omes in omeRanges:
                    if omes[1] - omes[0] < 0:
                        ccw = False
                    angMask_ome = np.logical_or(
                        angMask_ome,
                        xf.validateAngleRanges(
                                angList[:, 2], omes[0], omes[1], ccw=ccw)
                    )

                # mask angles list, hkls
                angMask = np.logical_and(angMask_eta, angMask_ome)

                culledTTh = angList[angMask, 0]
                culledEta = angList[angMask, 1]
                culledOme = angList[angMask, 2]

                for iTTh in range(len(culledTTh)):
                    culledEtaIdx = np.where(etaEdges - culledEta[iTTh] > 0)[0]
                    if len(culledEtaIdx) > 0:
                        culledEtaIdx = culledEtaIdx[0] - 1
                        if culledEtaIdx < 0:
                            culledEtaIdx = None
                    else:
                        culledEtaIdx = None
                    culledOmeIdx = np.where(omeEdges - culledOme[iTTh] > 0)[0]
                    if len(culledOmeIdx) > 0:
                        if delOmeSign > 0:
                            culledOmeIdx = culledOmeIdx[0] - 1
                        else:
                            culledOmeIdx = culledOmeIdx[-1]
                        if culledOmeIdx < 0:
                            culledOmeIdx = None
                    else:
                        culledOmeIdx = None

                    if culledEtaIdx is not None and culledOmeIdx is not None:
                        if dpix_ome > 0 or dpix_eta > 0:
                            i_sup = omeIndices[culledOmeIdx] + \
                                np.array([i_dil.flatten()], dtype=int)
                            j_sup = etaIndices[culledEtaIdx] + \
                                np.array([j_dil.flatten()], dtype=int)

                            # catch shit that falls off detector...
                            # maybe make this fancy enough to wrap at 2pi?
                            idx_mask = np.logical_and(
                                np.logical_and(i_sup >= 0, i_sup < i_max),
                                np.logical_and(j_sup >= 0, j_sup < j_max))
                            eta_ome[iHKL,
                                    i_sup[idx_mask],
                                    j_sup[idx_mask]] = 1.
                        else:
                            eta_ome[iHKL,
                                    omeIndices[culledOmeIdx],
                                    etaIndices[culledEtaIdx]] = 1.
                            pass  # close conditional on pixel dilation
                        pass  # close conditional on ranges
                    pass  # close for loop on valid reflections
                pass  # close conditional for valid angles
    return eta_ome


def _fetch_hkls_from_planedata(pd):
    return np.hstack(pd.getSymHKLs(withID=True)).T


def _filter_hkls_eta_ome(hkls, angles, eta_range, ome_range,
                         return_mask=False):
    """
    given a set of hkls and angles, filter them by the
    eta and omega ranges
    """
    # do eta ranges
    angMask_eta = np.zeros(len(angles), dtype=bool)
    for etas in eta_range:
        angMask_eta = np.logical_or(
            angMask_eta,
            xf.validateAngleRanges(angles[:, 1], etas[0], etas[1])
        )

    # do omega ranges
    ccw = True
    angMask_ome = np.zeros(len(angles), dtype=bool)
    for omes in ome_range:
        if omes[1] - omes[0] < 0:
            ccw = False
        angMask_ome = np.logical_or(
            angMask_ome,
            xf.validateAngleRanges(angles[:, 2], omes[0], omes[1], ccw=ccw)
        )

    # mask angles list, hkls
    angMask = np.logical_and(angMask_eta, angMask_ome)

    allAngs = angles[angMask, :]
    allHKLs = np.vstack([hkls, hkls])[angMask, :]

    if return_mask:
        return allAngs, allHKLs, angMask
    else:
        return allAngs, allHKLs


def _project_on_detector_plane(allAngs,
                               rMat_d, rMat_c, chi,
                               tVec_d, tVec_c, tVec_s,
                               distortion,
                               beamVec=constants.beam_vec):
    """
    utility routine for projecting a list of (tth, eta, ome) onto the
    detector plane parameterized by the args
    """
    gVec_cs = xfcapi.anglesToGVec(allAngs,
                                  chi=chi,
                                  rMat_c=rMat_c,
                                  bHat_l=beamVec)

    rMat_ss = xfcapi.makeOscillRotMatArray(chi, allAngs[:, 2])

    tmp_xys = xfcapi.gvecToDetectorXYArray(
        gVec_cs, rMat_d, rMat_ss, rMat_c,
        tVec_d, tVec_s, tVec_c,
        beamVec=beamVec)

    valid_mask = ~(np.isnan(tmp_xys[:, 0]) | np.isnan(tmp_xys[:, 1]))

    det_xy = np.atleast_2d(tmp_xys[valid_mask, :])

    # apply distortion if specified
    if distortion is not None:
        det_xy = distortion.apply_inverse(det_xy)

    return det_xy, rMat_ss, valid_mask


def _project_on_detector_cylinder(allAngs,
                                  rMat_d,
                                  chi,
                                  tVec_d,
                                  caxis,
                                  paxis,
                                  radius,
                                  physical_size,
                                  angle_extent,
                                  distortion,
                                  beamVec=constants.beam_vec):
    """
    utility routine for projecting a list of (tth, eta, ome) onto the
    detector plane parameterized by the args. this function does the
    computation for a cylindrical detector
    """
    dVec_cs = xfcapi.anglesToDVec(allAngs,
                                  chi=chi,
                                  rMat_c=np.eye(3),
                                  bHat_l=beamVec)

    rMat_ss = np.tile(np.eye(3), [allAngs.shape[0], 1, 1])

    caxis = np.dot(rMat_d, [0., 1., 0.])
    paxis = np.dot(rMat_d, [1., 0., 0.])

    tmp_xys, valid_mask = _dvecToDetectorXYcylinder(dVec_cs,
                                                    tVec_d,
                                                    caxis,
                                                    paxis,
                                                    radius,
                                                    physical_size,
                                                    angle_extent)

    det_xy = np.atleast_2d(tmp_xys[valid_mask, :])

    # apply distortion if specified
    if distortion is not None:
        det_xy = distortion.apply_inverse(det_xy)

    return det_xy, rMat_ss, valid_mask

def _dvecToDetectorXYcylinder(dVec_cs,
                              tVec_d, 
                              caxis, 
                              paxis, 
                              radius,
                              physical_size,
                              angle_extent):
    
    cvec = _unitvec_to_cylinder(dVec_cs, 
                                caxis, 
                                radius)

    cvec_det, valid_mask = _clip_to_cylindrical_detector(cvec, 
                                             tVec_d, 
                                             caxis,
                                             paxis, 
                                             radius, 
                                             physical_size, 
                                             angle_extent)

    xy_det = _dewarp_from_cylinder(cvec_det, 
                                   tVec_d, 
                                   caxis,
                                   paxis, 
                                   radius)

    return xy_det, valid_mask

def _unitvec_to_cylinder(uvw, caxis,
                         radius):
    """
    get point where unitvector uvw
    intersect the cylindrical detector.
    this will give points which are 
    outside the actual panel. the points
    will be clipped to the panel later

    Parameters
    ----------
    uvw : numpy.ndarray
    unit vectors stacked row wise (nx3) shape

    Returns
    -------
    numpy.ndarray
    (x,y,z) vectors point which intersect with 
    the cylinder with (nx3) shape
    """
    num = uvw.shape[0]
    cx = np.atleast_2d(caxis).T
    dp = np.dot(uvw, cx)
    den = np.squeeze(np.sqrt(1 - dp**2))
    mask = den < 1E-8
    beta = np.zeros([num, ])
    beta[~mask] = radius/den[~mask]
    beta[mask] = np.nan

    return np.tile(beta, [3, 1]).T * uvw

def _clip_to_cylindrical_detector(uvw, tVec_d, caxis,
                                  paxis, radius, 
                                  physical_size, angle_extent):
    """
    takes in the intersection points uvw
    with the cylindrical detector and 
    prunes out points which don't actually
    hit the actual panel

    Parameters
    ----------
    uvw : numpy.ndarray
    unit vectors stacked row wise (nx3) shape

    Returns
    -------
    numpy.ndarray
    (x,y,z) vectors point which fall on panel
    with (mx3) shape
    """
    # first get rid of points which are above
    # or below the detector
    size = physical_size
    tvec = np.atleast_2d(tVec_d).T
    cx = np.atleast_2d(caxis).T
    num = uvw.shape[0]
    ycomp = uvw - np.tile(tVec_d,[num, 1])
    ylen = np.squeeze(np.dot(ycomp, cx))
    mask1 = np.abs(ylen) > size[0]*0.5
    res = uvw.copy()
    res[mask1,:] = np.nan
    # res = uvw[mask, :]

    # next get rid of points that fall outside 
    # the polar angle range
    num = res.shape[0]
    dp = np.squeeze(np.dot(res, cx))
    v = np.tile(cx,[1, num])*np.tile(dp, [3,1])
    v = v.T
    xcomp = res - v
    magxcomp = np.linalg.norm(xcomp,axis=1)
    ang = np.squeeze(np.dot(xcomp, tvec))/radius/magxcomp
    ang[np.abs(ang)>1.] = np.sign(ang[np.abs(ang)>1.])
    ang = np.arccos(ang)
    mask2 = ang >= angle_extent
    mask = np.logical_or(mask1, mask2)
    res = uvw.copy()
    res[mask,:] = np.nan
    # res = res[mask,:]

    return res, ~mask

def _dewarp_from_cylinder(uvw, tVec_d, caxis,
                          paxis, radius):
    """
    routine to convert cylindrical coordinates
    to cartesian coordinates in image frame
    """
    cx = np.atleast_2d(caxis).T
    px = np.atleast_2d(paxis).T
    num = uvw.shape[0]

    vx = uvw - np.tile(np.dot(uvw, cx), [1, 3]) * np.tile(cx, [1, num]).T
    vx = vx/np.tile(np.linalg.norm(vx, axis=1), [3,1]).T
    sgn = np.sign(np.dot(paxis, vx.T)); sgn[sgn==0.] = 1.
    ang = np.abs(np.arccos(np.dot(tVec_d, vx.T)/radius))
    xcrd = radius*ang*sgn
    ycrd = np.dot(caxis, uvw.T)
    return np.vstack((xcrd, ycrd)).T

def _warp_to_cylinder(cart, tVec_d,
                      radius, rMat_d,
                      normalize=True):
    """
    routine to convert cartesian coordinates
    in image frame to cylindrical coordinates
    """
    tvec = np.atleast_2d(tVec_d).T
    num = cart.shape[0]
    zcrd = np.zeros([num, 1])
    cart3d = np.hstack((cart, zcrd))
    cart3d = np.dot(rMat_d, cart3d.T).T

    res = cart3d + np.tile(tvec, [1, num]).T

    if normalize:
        return res/np.tile(np.linalg.norm(res, axis=1), [3, 1]).T
    else:
        return res

def _dvec_to_angs(dvecs, bvec, evec):
    """
    convert diffraction vectors to (tth, eta) 
    angles in the 'eta' frame
    dvecs is assumed to have (nx3) shape
    """
    num = dvecs.shape[0]
    bxe = np.cross(bvec, evec)
    bxe = bxe/np.linalg.norm(bxe)

    dp = np.dot(bvec, dvecs.T)
    dp[np.abs(dp) > 1.] = np.sign(dp[np.abs(dp) > 1.])
    tth = np.arccos(dp)

    dvecs_p = np.tile(dp, [3, 1]).T * dvecs - np.tile(bvec, [num, 1])
    dvecs_p = dvecs_p/np.tile(np.linalg.norm(dvecs_p, axis=1), [3, 1]).T

    dpx = np.dot(evec, dvecs_p.T)
    dpy = np.dot(bxe, dvecs_p.T)
    eta = np.arctan2(dpy, dpx)

    return (tth, eta)


def simulateGVecs(pd, detector_params, grain_params,
                  ome_range=[(-np.pi, np.pi), ],
                  ome_period=(-np.pi, np.pi),
                  eta_range=[(-np.pi, np.pi), ],
                  panel_dims=[(-204.8, -204.8), (204.8, 204.8)],
                  pixel_pitch=(0.2, 0.2),
                  distortion=None):
    """
    returns valid_ids, valid_hkl, valid_ang, valid_xy, ang_ps

    panel_dims are [(xmin, ymin), (xmax, ymax)] in mm

    pixel_pitch is [row_size, column_size] in mm

    simulate the monochormatic scattering for a specified

        - space group
        - wavelength
        - orientation
        - strain
        - position
        - detector parameters
        - oscillation axis tilt (chi)

    subject to

        - omega (oscillation) ranges (list of (min, max) tuples)
        - eta (azimuth) ranges

    pd................a hexrd.crystallography.PlaneData instance
    detector_params...a (10,) ndarray containing the tilt angles (3),
                      translation (3), chi (1), and sample frame translation
                      (3) parameters
    grain_params......a (12,) ndarray containing the exponential map (3),
                      translation (3), and inverse stretch tensor compnents
                      in Mandel-Voigt notation (6).

    * currently only one panel is supported, but this will likely change soon
    """
    bMat = pd.latVecOps['B']
    wlen = pd.wavelength
    full_hkls = _fetch_hkls_from_planedata(pd)

    # extract variables for convenience
    rMat_d = xfcapi.makeDetectorRotMat(detector_params[:3])
    tVec_d = np.ascontiguousarray(detector_params[3:6])
    chi = detector_params[6]
    tVec_s = np.ascontiguousarray(detector_params[7:10])
    rMat_c = xfcapi.makeRotMatOfExpMap(grain_params[:3])
    tVec_c = np.ascontiguousarray(grain_params[3:6])
    vInv_s = np.ascontiguousarray(grain_params[6:12])

    # first find valid G-vectors
    angList = np.vstack(
        xfcapi.oscillAnglesOfHKLs(
            full_hkls[:, 1:], chi, rMat_c, bMat, wlen, vInv=vInv_s
            )
        )
    allAngs, allHKLs = _filter_hkls_eta_ome(
        full_hkls, angList, eta_range, ome_range
        )

    if len(allAngs) == 0:
        valid_ids = []
        valid_hkl = []
        valid_ang = []
        valid_xy = []
        ang_ps = []
    else:
        # ??? preallocate for speed?
        det_xy, rMat_s, on_plane = _project_on_detector_plane(
            allAngs,
            rMat_d, rMat_c, chi,
            tVec_d, tVec_c, tVec_s,
            distortion
            )
        #
        on_panel_x = np.logical_and(
            det_xy[:, 0] >= panel_dims[0][0],
            det_xy[:, 0] <= panel_dims[1][0]
            )
        on_panel_y = np.logical_and(
            det_xy[:, 1] >= panel_dims[0][1],
            det_xy[:, 1] <= panel_dims[1][1]
            )
        on_panel = np.logical_and(on_panel_x, on_panel_y)
        #
        op_idx = np.where(on_panel)[0]
        #
        valid_ang = allAngs[op_idx, :]
        valid_ang[:, 2] = xfcapi.mapAngle(valid_ang[:, 2], ome_period)
        valid_ids = allHKLs[op_idx, 0]
        valid_hkl = allHKLs[op_idx, 1:]
        valid_xy = det_xy[op_idx, :]
        ang_ps = angularPixelSize(valid_xy, pixel_pitch,
                                  rMat_d, rMat_s,
                                  tVec_d, tVec_s, tVec_c,
                                  distortion=distortion)

    return valid_ids, valid_hkl, valid_ang, valid_xy, ang_ps


def simulateLauePattern(hkls, bMat,
                        rmat_d, tvec_d,
                        panel_dims, panel_buffer=5,
                        minEnergy=8, maxEnergy=24,
                        rmat_s=np.eye(3),
                        grain_params=None,
                        distortion=None,
                        beamVec=None):

    if beamVec is None:
        beamVec = xfcapi.bVec_ref

    # parse energy ranges
    multipleEnergyRanges = False
    if hasattr(maxEnergy, '__len__'):
        assert len(maxEnergy) == len(minEnergy), \
            'energy cutoff ranges must have the same length'
        multipleEnergyRanges = True
        lmin = []
        lmax = []
        for i in range(len(maxEnergy)):
            lmin.append(processWavelength(maxEnergy[i]))
            lmax.append(processWavelength(minEnergy[i]))
    else:
        lmin = processWavelength(maxEnergy)
        lmax = processWavelength(minEnergy)

    # process crystal rmats and inverse stretches
    if grain_params is None:
        grain_params = np.atleast_2d(
            [0., 0., 0.,
             0., 0., 0.,
             1., 1., 1., 0., 0., 0.
             ]
        )

    n_grains = len(grain_params)

    # dummy translation vector... make input
    tvec_s = np.zeros((3, 1))

    # number of hkls
    nhkls_tot = hkls.shape[1]

    # unit G-vectors in crystal frame
    ghat_c = mutil.unitVector(np.dot(bMat, hkls))

    # pre-allocate output arrays
    xy_det = np.nan*np.ones((n_grains, nhkls_tot, 2))
    hkls_in = np.nan*np.ones((n_grains, 3, nhkls_tot))
    angles = np.nan*np.ones((n_grains, nhkls_tot, 2))
    dspacing = np.nan*np.ones((n_grains, nhkls_tot))
    energy = np.nan*np.ones((n_grains, nhkls_tot))

    """
    LOOP OVER GRAINS
    """

    for iG, gp in enumerate(grain_params):
        rmat_c = xfcapi.makeRotMatOfExpMap(gp[:3])
        tvec_c = gp[3:6].reshape(3, 1)
        vInv_s = mutil.vecMVToSymm(gp[6:].reshape(6, 1))

        # stretch them: V^(-1) * R * Gc
        ghat_s_str = mutil.unitVector(
            np.dot(vInv_s, np.dot(rmat_c, ghat_c))
        )
        ghat_c_str = np.dot(rmat_c.T, ghat_s_str)

        # project
        dpts = xfcapi.gvecToDetectorXY(ghat_c_str.T,
                                       rmat_d, rmat_s, rmat_c,
                                       tvec_d, tvec_s, tvec_c,
                                       beamVec=beamVec).T

        # check intersections with detector plane
        canIntersect = ~np.isnan(dpts[0, :])
        npts_in = sum(canIntersect)

        if np.any(canIntersect):
            dpts = dpts[:, canIntersect].reshape(2, npts_in)
            dhkl = hkls[:, canIntersect].reshape(3, npts_in)

            # back to angles
            tth_eta, gvec_l = xfcapi.detectorXYToGvec(
                dpts.T,
                rmat_d, rmat_s,
                tvec_d, tvec_s, tvec_c,
                beamVec=beamVec)
            tth_eta = np.vstack(tth_eta).T

            # warp measured points
            if distortion is not None:
                dpts = distortion.apply_inverse(dpts)

            # plane spacings and energies
            dsp = 1. / mutil.columnNorm(np.dot(bMat, dhkl))
            wlen = 2*dsp*np.sin(0.5*tth_eta[:, 0])

            # find on spatial extent of detector
            xTest = np.logical_and(
                dpts[0, :] >= -0.5*panel_dims[1] + panel_buffer,
                dpts[0, :] <= 0.5*panel_dims[1] - panel_buffer)
            yTest = np.logical_and(
                dpts[1, :] >= -0.5*panel_dims[0] + panel_buffer,
                dpts[1, :] <= 0.5*panel_dims[0] - panel_buffer)

            onDetector = np.logical_and(xTest, yTest)
            if multipleEnergyRanges:
                validEnergy = np.zeros(len(wlen), dtype=bool)
                for i in range(len(lmin)):
                    validEnergy = validEnergy | \
                        np.logical_and(wlen >= lmin[i], wlen <= lmax[i])
                    pass
            else:
                validEnergy = np.logical_and(wlen >= lmin, wlen <= lmax)
                pass

            # index for valid reflections
            keepers = np.where(np.logical_and(onDetector, validEnergy))[0]

            # assign output arrays
            xy_det[iG][keepers, :] = dpts[:, keepers].T
            hkls_in[iG][:, keepers] = dhkl[:, keepers]
            angles[iG][keepers, :] = tth_eta[keepers, :]
            dspacing[iG, keepers] = dsp[keepers]
            energy[iG, keepers] = processWavelength(wlen[keepers])
            pass
        pass
    return xy_det, hkls_in, angles, dspacing, energy


if USE_NUMBA:
    @numba.njit(nogil=True, cache=True)
    def _expand_pixels(original, w, h, result):
        hw = 0.5 * w
        hh = 0.5 * h
        for el in range(len(original)):
            x, y = original[el, 0], original[el, 1]
            result[el*4 + 0, 0] = x - hw
            result[el*4 + 0, 1] = y - hh
            result[el*4 + 1, 0] = x + hw
            result[el*4 + 1, 1] = y - hh
            result[el*4 + 2, 0] = x + hw
            result[el*4 + 2, 1] = y + hh
            result[el*4 + 3, 0] = x - hw
            result[el*4 + 3, 1] = y + hh

        return result

    @numba.njit(nogil=True, cache=True)
    def _compute_max(tth, eta, result):
        period = 2.0 * np.pi
        hperiod = np.pi
        for el in range(0, len(tth), 4):
            max_tth = np.abs(tth[el + 0] - tth[el + 3])
            eta_diff = eta[el + 0] - eta[el + 3]
            max_eta = np.abs(
                np.remainder(eta_diff + hperiod, period) - hperiod
            )
            for i in range(3):
                curr_tth = np.abs(tth[el + i] - tth[el + i + 1])
                eta_diff = eta[el + i] - eta[el + i + 1]
                curr_eta = np.abs(
                    np.remainder(eta_diff + hperiod, period) - hperiod
                )
                max_tth = np.maximum(curr_tth, max_tth)
                max_eta = np.maximum(curr_eta, max_eta)
            result[el//4, 0] = max_tth
            result[el//4, 1] = max_eta

        return result

    def angularPixelSize(
            xy_det, xy_pixelPitch,
            rMat_d, rMat_s,
            tVec_d, tVec_s, tVec_c,
            distortion=None, beamVec=None, etaVec=None):
        """
        Calculate angular pixel sizes on a detector.

        * choices to beam vector and eta vector specs have been supressed
        * assumes xy_det in UNWARPED configuration
        """
        xy_det = np.atleast_2d(xy_det)
        if distortion is not None:  # !!! check this logic
            xy_det = distortion.apply(xy_det)
        if beamVec is None:
            beamVec = xfcapi.bVec_ref
        if etaVec is None:
            etaVec = xfcapi.eta_ref

        xy_expanded = np.empty((len(xy_det) * 4, 2), dtype=xy_det.dtype)
        xy_expanded = _expand_pixels(
            xy_det,
            xy_pixelPitch[0], xy_pixelPitch[1],
            xy_expanded)
        gvec_space, _ = xfcapi.detectorXYToGvec(
            xy_expanded,
            rMat_d, rMat_s,
            tVec_d, tVec_s, tVec_c,
            beamVec=beamVec, etaVec=etaVec)
        result = np.empty_like(xy_det)
        return _compute_max(gvec_space[0], gvec_space[1], result)
else:
    def angularPixelSize(xy_det, xy_pixelPitch,
                         rMat_d, rMat_s,
                         tVec_d, tVec_s, tVec_c,
                         distortion=None, beamVec=None, etaVec=None):
        """
        Calculate angular pixel sizes on a detector.

        * choices to beam vector and eta vector specs have been supressed
        * assumes xy_det in UNWARPED configuration
        """
        xy_det = np.atleast_2d(xy_det)
        if distortion is not None:  # !!! check this logic
            xy_det = distortion.apply(xy_det)
        if beamVec is None:
            beamVec = xfcapi.bVec_ref
        if etaVec is None:
            etaVec = xfcapi.eta_ref

        xp = np.r_[-0.5,  0.5,  0.5, -0.5] * xy_pixelPitch[0]
        yp = np.r_[-0.5, -0.5,  0.5,  0.5] * xy_pixelPitch[1]

        diffs = np.array([[3, 3, 2, 1],
                          [2, 0, 1, 0]])

        ang_pix = np.zeros((len(xy_det), 2))

        for ipt, xy in enumerate(xy_det):
            xc = xp + xy[0]
            yc = yp + xy[1]

            tth_eta, gHat_l = xfcapi.detectorXYToGvec(
                np.vstack([xc, yc]).T,
                rMat_d, rMat_s,
                tVec_d, tVec_s, tVec_c,
                beamVec=beamVec, etaVec=etaVec)
            delta_tth = np.zeros(4)
            delta_eta = np.zeros(4)
            for j in range(4):
                delta_tth[j] = abs(
                    tth_eta[0][diffs[0, j]] - tth_eta[0][diffs[1, j]]
                )
                delta_eta[j] = xfcapi.angularDifference(
                    tth_eta[1][diffs[0, j]], tth_eta[1][diffs[1, j]]
                )

            ang_pix[ipt, 0] = np.amax(delta_tth)
            ang_pix[ipt, 1] = np.amax(delta_eta)
        return ang_pix


if USE_NUMBA:
    @numba.njit(nogil=True, cache=True)
    def _coo_build_window_jit(frame_row, frame_col, frame_data,
                              min_row, max_row, min_col, max_col,
                              result):
        n = len(frame_row)
        for i in range(n):
            if ((min_row <= frame_row[i] <= max_row) and
                    (min_col <= frame_col[i] <= max_col)):
                new_row = frame_row[i] - min_row
                new_col = frame_col[i] - min_col
                result[new_row, new_col] = frame_data[i]

        return result

    def _coo_build_window(frame_i, min_row, max_row, min_col, max_col):
        window = np.zeros(
            ((max_row - min_row + 1), (max_col - min_col + 1)),
            dtype=np.int16
        )

        return _coo_build_window_jit(frame_i.row, frame_i.col, frame_i.data,
                                     min_row, max_row, min_col, max_col,
                                     window)
else:  # not USE_NUMBA
    def _coo_build_window(frame_i, min_row, max_row, min_col, max_col):
        mask = ((min_row <= frame_i.row) & (frame_i.row <= max_row) &
                (min_col <= frame_i.col) & (frame_i.col <= max_col))
        new_row = frame_i.row[mask] - min_row
        new_col = frame_i.col[mask] - min_col
        new_data = frame_i.data[mask]
        window = np.zeros(
            ((max_row - min_row + 1), (max_col - min_col + 1)),
            dtype=np.int16
        )
        window[new_row, new_col] = new_data

        return window


def make_reflection_patches(instr_cfg,
                            tth_eta, ang_pixel_size, omega=None,
                            tth_tol=0.2, eta_tol=1.0,
                            rmat_c=np.eye(3), tvec_c=np.zeros((3, 1)),
                            npdiv=1, quiet=False,
                            compute_areas_func=gutil.compute_areas):
    """
    Make angular patches on a detector.

    panel_dims are [(xmin, ymin), (xmax, ymax)] in mm

    pixel_pitch is [row_size, column_size] in mm

    FIXME: DISTORTION HANDING IS STILL A KLUDGE!!!

    patches are:

                 delta tth
    d  ------------- ... -------------
    e  | x | x | x | ... | x | x | x |
    l  ------------- ... -------------
    t                 .
    a                 .
                     .
    e  ------------- ... -------------
    t  | x | x | x | ... | x | x | x |
    a  ------------- ... -------------

    outputs are:
        (tth_vtx, eta_vtx),
        (x_vtx, y_vtx),
        connectivity,
        subpixel_areas,
        (x_center, y_center),
        (i_row, j_col)
    """
    npts = len(tth_eta)

    # detector quantities
    rmat_d = xfcapi.makeRotMatOfExpMap(
        np.r_[instr_cfg['detector']['transform']['tilt']]
        )
    tvec_d = np.r_[instr_cfg['detector']['transform']['translation']]
    pixel_size = instr_cfg['detector']['pixels']['size']

    frame_nrows = instr_cfg['detector']['pixels']['rows']
    frame_ncols = instr_cfg['detector']['pixels']['columns']

    panel_dims = (
        -0.5*np.r_[frame_ncols*pixel_size[1], frame_nrows*pixel_size[0]],
        0.5*np.r_[frame_ncols*pixel_size[1], frame_nrows*pixel_size[0]]
        )
    row_edges = np.arange(frame_nrows + 1)[::-1]*pixel_size[1] \
        + panel_dims[0][1]
    col_edges = np.arange(frame_ncols + 1)*pixel_size[0] \
        + panel_dims[0][0]

    # handle distortion
    distortion = None
    if distortion_key in instr_cfg['detector']:
        distortion_cfg = instr_cfg['detector'][distortion_key]
        if distortion_cfg is not None:
            try:
                func_name = distortion_cfg['function_name']
                dparams = distortion_cfg['parameters']
                distortion = distortion_pkg.get_mapping(
                    func_name, dparams
                )
            except(KeyError):
                raise RuntimeError(
                    "problem with distortion specification"
                )

    # sample frame
    chi = instr_cfg['oscillation_stage']['chi']
    tvec_s = np.r_[instr_cfg['oscillation_stage']['translation']]

    # beam vector
    bvec = np.r_[instr_cfg['beam']['vector']]

    # data to loop
    # ??? WOULD IT BE CHEAPER TO CARRY ZEROS OR USE CONDITIONAL?
    if omega is None:
        full_angs = np.hstack([tth_eta, np.zeros((npts, 1))])
    else:
        full_angs = np.hstack([tth_eta, omega.reshape(npts, 1)])

    patches = []
    for angs, pix in zip(full_angs, ang_pixel_size):
        # calculate bin edges for patch based on local angular pixel size
        # tth
        ntths, tth_edges = gutil.make_tolerance_grid(
            bin_width=np.degrees(pix[0]),
            window_width=tth_tol,
            num_subdivisions=npdiv
        )

        # eta
        netas, eta_edges = gutil.make_tolerance_grid(
            bin_width=np.degrees(pix[1]),
            window_width=eta_tol,
            num_subdivisions=npdiv
        )

        # FOR ANGULAR MESH
        conn = gutil.cellConnectivity(
            netas,
            ntths,
            origin='ll'
        )

        # meshgrid args are (cols, rows), a.k.a (fast, slow)
        m_tth, m_eta = np.meshgrid(tth_edges, eta_edges)
        npts_patch = m_tth.size

        # calculate the patch XY coords from the (tth, eta) angles
        # !!! will CHEAT and ignore the small perturbation the different
        #     omega angle values causes and simply use the central value
        gVec_angs_vtx = np.tile(angs, (npts_patch, 1)) \
            + np.radians(np.vstack([m_tth.flatten(),
                                    m_eta.flatten(),
                                    np.zeros(npts_patch)]).T)

        xy_eval_vtx, rmats_s, on_plane = _project_on_detector_plane(
                gVec_angs_vtx,
                rmat_d, rmat_c,
                chi,
                tvec_d, tvec_c, tvec_s,
                distortion,
                beamVec=bvec)

        areas = compute_areas_func(xy_eval_vtx, conn)

        # EVALUATION POINTS
        # !!! for lack of a better option will use centroids
        tth_eta_cen = gutil.cellCentroids(
            np.atleast_2d(gVec_angs_vtx[:, :2]),
            conn
        )

        gVec_angs = np.hstack(
            [tth_eta_cen,
             np.tile(angs[2], (len(tth_eta_cen), 1))]
        )

        xy_eval, rmats_s, on_plane = _project_on_detector_plane(
                gVec_angs,
                rmat_d, rmat_c,
                chi,
                tvec_d, tvec_c, tvec_s,
                distortion,
                beamVec=bvec)

        row_indices = gutil.cellIndices(row_edges, xy_eval[:, 1])
        col_indices = gutil.cellIndices(col_edges, xy_eval[:, 0])

        # append patch data to list
        patches.append(
            ((gVec_angs_vtx[:, 0].reshape(m_tth.shape),
              gVec_angs_vtx[:, 1].reshape(m_tth.shape)),
             (xy_eval_vtx[:, 0].reshape(m_tth.shape),
              xy_eval_vtx[:, 1].reshape(m_tth.shape)),
             conn,
             areas.reshape(netas, ntths),
             (xy_eval[:, 0].reshape(netas, ntths),
              xy_eval[:, 1].reshape(netas, ntths)),
             (row_indices.reshape(netas, ntths),
              col_indices.reshape(netas, ntths)))
        )
        pass    # close loop over angles
    return patches


def extract_detector_transformation(detector_params):
    """
    Construct arrays from detector parameters.

    goes from 10 vector of detector parames OR instrument config dictionary
    (from YAML spec) to affine transformation arrays

    Parameters
    ----------
    detector_params : TYPE
        DESCRIPTION.

    Returns
    -------
    rMat_d : TYPE
        DESCRIPTION.
    tVec_d : TYPE
        DESCRIPTION.
    chi : TYPE
        DESCRIPTION.
    tVec_s : TYPE
        DESCRIPTION.

    """
    # extract variables for convenience
    if isinstance(detector_params, dict):
        rMat_d = xfcapi.makeRotMatOfExpMap(
            np.array(detector_params['detector']['transform']['tilt'])
            )
        tVec_d = np.r_[detector_params['detector']['transform']['translation']]
        chi = detector_params['oscillation_stage']['chi']
        tVec_s = np.r_[detector_params['oscillation_stage']['translation']]
    else:
        assert len(detector_params >= 10), \
            "list of detector parameters must have length >= 10"
        rMat_d = xfcapi.makeRotMatOfExpMap(detector_params[:3])
        tVec_d = np.ascontiguousarray(detector_params[3:6])
        chi = detector_params[6]
        tVec_s = np.ascontiguousarray(detector_params[7:10])
    return rMat_d, tVec_d, chi, tVec_s


# setup.py

# -*- coding: utf-8 -*-
import os
import sys
from setuptools import setup, find_packages, Extension
from pathlib import Path
import numpy
np_include_dir = os.path.join(numpy.get_include(), 'numpy')

install_reqs = [
    'fabio>=0.11',
    'fast-histogram',
    'h5py',
    'lmfit',
    'numba',
    'numpy<1.24',  # NOTE: bump this to support the latest version numba supports
    'psutil',
    'pycifrw',
    'pyyaml',
    'scikit-learn',
    'scipy',
    'tqdm',
    'xxhash',
]

# This a hack to get around the fact that scikit-image on conda-forge doesn't install
# dist info so setuptools can't find it, even though its there, which results in
# pkg_resources.DistributionNotFound, even though the package is available. So we
# only added it if we aren't building with conda.
# appdirs has the same issue.
if os.environ.get('CONDA_BUILD') != '1':
    install_reqs.append('scikit-image')
    install_reqs.append('appdirs')


# extension for convolution from astropy
def get_convolution_extensions():
    c_convolve_pkgdir = Path('hexrd') / 'convolution'

    src_files = [str(c_convolve_pkgdir / 'src/convolve.c')]

    extra_compile_args=['-UNDEBUG']
    if not sys.platform.startswith('win'):
        extra_compile_args.append('-fPIC')
    # Add '-Rpass-missed=.*' to ``extra_compile_args`` when compiling with clang
    # to report missed optimizations
    _convolve_ext = Extension(name='hexrd.convolution._convolve', sources=src_files,
                              extra_compile_args=extra_compile_args,
                              include_dirs=[numpy.get_include()],
                              language='c')

    return [_convolve_ext]

def get_extension_modules():
    # for transforms
    srclist = ['transforms_CAPI.c', 'transforms_CFUNC.c']
    srclist = [os.path.join('hexrd/transforms', f) for f in srclist]
    transforms_mod = Extension(
        'hexrd.extensions._transforms_CAPI',
        sources=srclist,
        include_dirs=[np_include_dir]
        )

    return [transforms_mod] + get_convolution_extensions()

ext_modules = get_extension_modules()

# use entry_points, not scripts:
entry_points = {
    'console_scripts': ["hexrd = hexrd.cli.main:main"]
    }

setup(
    name='hexrd',
    use_scm_version=True,
    setup_requires=['setuptools-scm'],
    description = 'hexrd X-ray diffraction data analysis tool',
    long_description = open('README.md').read(),
    author='The hexrd Development Team',
    author_email='joelvbernier@me.com',
    url='https://github.com/HEXRD/hexrd',
    license='BSD',
    classifiers=[
        'Development Status :: 3 - Alpha',
        'Environment :: Web Environment',
        'License :: OSI Approved :: BSD License',
        'Operating System :: OS Independent',
        'Programming Language :: Python',
        'Programming Language :: Python :: 3',
        'Programming Language :: Python :: 3.8',
        'Programming Language :: Python :: 3.9',
        'Programming Language :: Python :: 3.10'

    ],
    entry_points = entry_points,
    ext_modules=ext_modules,
    packages=find_packages(),
    include_package_data=True,
    package_data={'':['Anomalous.h5']},
    python_requires='>=3.8',
    install_requires=install_reqs
)

# ext_modules = get_extension_modulesf()
# setupF(
#     name='hexrd',
#     url='https://github.com/cryos/hexrd',
#     license='BSD',
#     ext_modules=ext_modules,
#     packages=find_packages(),
# )


# hexrd/__init__.py

__path__ = __import__('pkgutil').extend_path(__path__, __name__)


# hexrd/copyright.py

# ============================================================
# Copyright (c) 2012, Lawrence Livermore National Security, LLC. 
# Produced at the Lawrence Livermore National Laboratory. 
# Written by Joel Bernier <bernier2@llnl.gov> and others. 
# LLNL-CODE-529294. 
# All rights reserved.
# 
# This file is part of HEXRD. For details on dowloading the source,
# see the file COPYING.
# 
# Please also see the file LICENSE.
# 
# This program is free software; you can redistribute it and/or modify it under the
# terms of the GNU Lesser General Public License (as published by the Free Software
# Foundation) version 2.1 dated February 1999.
# 
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY OF MERCHANTABILITY 
# or FITNESS FOR A PARTICULAR PURPOSE. See the terms and conditions of the 
# GNU General Public License for more details.
# 
# You should have received a copy of the GNU Lesser General Public
# License along with this program (see file LICENSE); if not, write to
# the Free Software Foundation, Inc., 59 Temple Place, Suite 330,
# Boston, MA 02111-1307 USA or visit <http://www.gnu.org/licenses/>.
# ============================================================
"""License information

This module provides two variables:

COPYRIGHT_FILE - the name of the file with copyright information
COPYRIGHT_TEXT - the text of that file

"""
import os

COPYRIGHT_FILE = 'COPYING'
with open(os.path.join(os.path.dirname(__file__), COPYRIGHT_FILE), 'r') as f:
     COPYRIGHT_TEXT = f.read()


# hexrd/crystallography.py

# -*- coding: utf-8 -*-
# =============================================================================
# Copyright (c) 2012, Lawrence Livermore National Security, LLC.
# Produced at the Lawrence Livermore National Laboratory.
# Written by Joel Bernier <bernier2@llnl.gov> and others.
# LLNL-CODE-529294.
# All rights reserved.
#
# This file is part of HEXRD. For details on dowloading the source,
# see the file COPYING.
#
# Please also see the file LICENSE.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License (as published by the Free
# Software Foundation) version 2.1 dated February 1999.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY OF MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the terms and conditions of the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this program (see file LICENSE); if not, write to
# the Free Software Foundation, Inc., 59 Temple Place, Suite 330,
# Boston, MA 02111-1307 USA or visit <http://www.gnu.org/licenses/>.
# =============================================================================
import re
import copy
from math import pi

import numpy as np
import csv
import os

from hexrd import constants
from hexrd.matrixutil import unitVector
from hexrd.rotations import \
    rotMatOfExpMap, mapAngle, applySym, \
    ltypeOfLaueGroup, quatOfLaueGroup
from hexrd.transforms import xfcapi
from hexrd import valunits
from hexrd.valunits import toFloat
from hexrd.constants import d2r, r2d, sqrt3by2, epsf, sqrt_epsf

"""module vars"""

# units
dUnit = 'angstrom'
outputDegrees = False
outputDegrees_bak = outputDegrees


def hklToStr(x):
    return re.sub(r'[\[\]\(\)\{\},]', '', str(x))


def tempSetOutputDegrees(val):
    global outputDegrees, outputDegrees_bak
    outputDegrees_bak = outputDegrees
    outputDegrees = val
    return


def revertOutputDegrees():
    global outputDegrees, outputDegrees_bak
    outputDegrees = outputDegrees_bak
    return


def cosineXform(a, b, c):
    """
    Spherical trig transform to take alpha, beta, gamma to expressions
    for cos(alpha*).  See ref below.

    [1] R. J. Neustadt, F. W. Cagle, Jr., and J. Waser, ``Vector algebra and
        the relations between direct and reciprocal lattice quantities''. Acta
        Cryst. (1968), A24, 247--248

    """
    cosar = (np.cos(b)*np.cos(c) - np.cos(a)) / (np.sin(b)*np.sin(c))
    sinar = np.sqrt(1 - cosar**2)
    return cosar, sinar


def processWavelength(arg):
    """
    Convert an energy value to a wavelength.  If argument has units of length
    or energy, will convert to globally specified unit type for wavelength
    (dUnit).  If argument is a scalar, assumed input units are keV.
    """
    if hasattr(arg, 'getVal'):
        if arg.isLength():
            retval = arg.getVal(dUnit)
        elif arg.isEnergy():
            e = arg.getVal('keV')
            retval = valunits.valWUnit(
                'wavelength', 'length', constants.keVToAngstrom(e), 'angstrom'
            ).getVal(dUnit)
        else:
            raise RuntimeError('do not know what to do with '+str(arg))
    else:
        # !!! assuming arg is in keV
        retval = valunits.valWUnit(
            'wavelength', 'length', constants.keVToAngstrom(arg), 'angstrom'
        ).getVal(dUnit)

    return retval


def latticeParameters(lvec):
    """
    Generates direct and reciprocal lattice vector components in a
    crystal-relative RHON basis, X. The convention for fixing X to the
    lattice is such that a || x1 and c* || x3, where a and c* are
    direct and reciprocal lattice vectors, respectively.
    """
    lnorm = np.sqrt(np.sum(lvec**2, 0))

    a = lnorm[0]
    b = lnorm[1]
    c = lnorm[2]

    ahat = lvec[:, 0]/a
    bhat = lvec[:, 1]/b
    chat = lvec[:, 2]/c

    gama = np.arccos(np.dot(ahat, bhat))
    beta = np.arccos(np.dot(ahat, chat))
    alfa = np.arccos(np.dot(bhat, chat))
    if outputDegrees:
        gama = r2d*gama
        beta = r2d*beta
        alfa = r2d*alfa

    return [a, b, c, alfa, beta, gama]


def latticePlanes(hkls, lparms,
                  ltype='cubic', wavelength=1.54059292, strainMag=None):
    """
    Generates lattice plane data in the direct lattice for a given set
    of Miller indices.  Vector components are written in the
    crystal-relative RHON basis, X. The convention for fixing X to the
    lattice is such that a || x1 and c* || x3, where a and c* are
    direct and reciprocal lattice vectors, respectively.

    USAGE:

    planeInfo = latticePlanes(hkls, lparms, **kwargs)

    INPUTS:

    1) hkls (3 x n float ndarray) is the array of Miller indices for
       the planes of interest.  The vectors are assumed to be
       concatenated along the 1-axis (horizontal).

    2) lparms (1 x m float list) is the array of lattice parameters,
       where m depends on the symmetry group (see below).

    3) The following optional keyword arguments are recognized:

       *) ltype=(string) is a string representing the symmetry type of
          the implied Laue group.  The 11 available choices are shown
          below.  The default value is 'cubic'. Note that each group
          expects a lattice parameter array of the indicated length
          and order.

          latticeType      lparms
          -----------      ------------
          'cubic'          a
          'hexagonal'      a, c
          'trigonal'       a, c
          'rhombohedral'   a, alpha (in degrees)
          'tetragonal'     a, c
          'orthorhombic'   a, b, c
          'monoclinic'     a, b, c, beta (in degrees)
          'triclinic'      a, b, c, alpha, beta, gamma (in degrees)

       *) wavelength=<float> is a value represented the wavelength in
          Angstroms to calculate bragg angles for.  The default value
          is for Cu K-alpha radiation (1.54059292 Angstrom)

       *) strainMag=None

    OUTPUTS:

    1) planeInfo is a dictionary containing the following keys/items:

       normals   (3, n) double array    array of the components to the
                                        unit normals for each {hkl} in
                                        X (horizontally concatenated)

       dspacings (n,  ) double array    array of the d-spacings for
                                        each {hkl}

       2thetas   (n,  ) double array    array of the Bragg angles for
                                        each {hkl} relative to the
                                        specified wavelength

    NOTES:

    *) This function is effectively a wrapper to 'latticeVectors'.
       See 'help(latticeVectors)' for additional info.

    *) Lattice plane d-spacings are calculated from the reciprocal
       lattice vectors specified by {hkl} as shown in Appendix 1 of
       [1].

    REFERENCES:

    [1] B. D. Cullity, ``Elements of X-Ray Diffraction, 2
        ed.''. Addison-Wesley Publishing Company, Inc., 1978. ISBN
        0-201-01174-3

    """
    location = 'latticePlanes'

    assert hkls.shape[0] == 3, \
        "hkls aren't column vectors in call to '%s'!" % location

    tag = ltype
    wlen = wavelength

    # get B
    L = latticeVectors(lparms, tag)

    # get G-vectors -- reciprocal vectors in crystal frame
    G = np.dot(L['B'], hkls)

    # magnitudes
    d = 1 / np.sqrt(np.sum(G**2, 0))

    aconv = 1.
    if outputDegrees:
        aconv = r2d

    # two thetas
    sth = wlen / 2. / d
    mask = (np.abs(sth) < 1.)
    tth = np.zeros(sth.shape)

    tth[~mask] = np.nan
    tth[mask] = aconv * 2. * np.arcsin(sth[mask])

    p = dict(normals=unitVector(G),
             dspacings=d,
             tThetas=tth)

    if strainMag is not None:
        p['tThetasLo'] = np.zeros(sth.shape)
        p['tThetasHi'] = np.zeros(sth.shape)

        mask = (
            (np.abs(wlen / 2. / (d * (1. + strainMag))) < 1.) &
            (np.abs(wlen / 2. / (d * (1. - strainMag))) < 1.)
        )

        p['tThetasLo'][~mask] = np.nan
        p['tThetasHi'][~mask] = np.nan

        p['tThetasLo'][mask] = \
            aconv * 2 * np.arcsin(wlen/2./(d[mask]*(1. + strainMag)))
        p['tThetasHi'][mask] = \
            aconv * 2 * np.arcsin(wlen/2./(d[mask]*(1. - strainMag)))

    return p


def latticeVectors(lparms, tag='cubic', radians=False, debug=False):
    """
    Generates direct and reciprocal lattice vector components in a
    crystal-relative RHON basis, X. The convention for fixing X to the
    lattice is such that a || x1 and c* || x3, where a and c* are
    direct and reciprocal lattice vectors, respectively.

    USAGE:

    lattice = LatticeVectors(lparms, <symmTag>)

    INPUTS:

    1) lparms (1 x n float list) is the array of lattice parameters,
       where n depends on the symmetry group (see below).

    2) symTag (string) is a case-insensitive string representing the
       symmetry type of the implied Laue group.  The 11 available choices
       are shown below.  The default value is 'cubic'. Note that each
       group expects a lattice parameter array of the indicated length
       and order.

       latticeType      lparms
       -----------      ------------
       'cubic'          a
       'hexagonal'      a, c
       'trigonal'       a, c
       'rhombohedral'   a, alpha (in degrees)
       'tetragonal'     a, c
       'orthorhombic'   a, b, c
       'monoclinic'     a, b, c, beta (in degrees)
       'triclinic'      a, b, c, alpha, beta, gamma (in degrees)

    OUTPUTS:

    1) lattice is a dictionary containing the following keys/items:

       F         (3, 3) double array    transformation matrix taking
                                        componenents in the direct
                                        lattice (i.e. {uvw}) to the
                                        reference, X

       B         (3, 3) double array    transformation matrix taking
                                        componenents in the reciprocal
                                        lattice (i.e. {hkl}) to X

       BR        (3, 3) double array    transformation matrix taking
                                        componenents in the reciprocal
                                        lattice to the Fable reference
                                        frame (see notes)

       U0        (3, 3) double array    transformation matrix
                                        (orthogonal) taking
                                        componenents in the
                                        Fable reference frame to X

       vol       double                 the unit cell volume


       dparms    (6, ) double list      the direct lattice parameters:
                                        [a b c alpha beta gamma]

       rparms    (6, ) double list      the reciprocal lattice
                                        parameters:
                                        [a* b* c* alpha* beta* gamma*]

    NOTES:

    *) The conventions used for assigning a RHON basis,
       X -> {x1, x2, x3}, to each point group are consistent with
       those published in Appendix B of [1]. Namely: a || x1 and
       c* || x3.  This differs from the convention chosen by the Fable
       group, where a* || x1 and c || x3 [2].

    *) The unit cell angles are defined as follows:
       alpha=acos(b'*c/|b||c|), beta=acos(c'*a/|c||a|), and
       gamma=acos(a'*b/|a||b|).

    *) The reciprocal lattice vectors are calculated using the
       crystallographic convention, where the prefactor of 2*pi is
       omitted. In this convention, the reciprocal lattice volume is
       1/V.

    *) Several relations from [3] were employed in the component
       calculations.

    REFERENCES:

    [1] J. F. Nye, ``Physical Properties of Crystals: Their
        Representation by Tensors and Matrices''. Oxford University
        Press, 1985. ISBN 0198511655

    [2] E. M. Lauridsen, S. Schmidt, R. M. Suter, and H. F. Poulsen,
        ``Tracking: a method for structural characterization of grains
        in powders or polycrystals''. J. Appl. Cryst. (2001). 34,
        744--750

    [3] R. J. Neustadt, F. W. Cagle, Jr., and J. Waser, ``Vector
        algebra and the relations between direct and reciprocal
        lattice quantities''. Acta Cryst. (1968), A24, 247--248


    """

    # build index for sorting out lattice parameters
    lattStrings = [
        'cubic',
        'hexagonal',
        'trigonal',
        'rhombohedral',
        'tetragonal',
        'orthorhombic',
        'monoclinic',
        'triclinic'
        ]

    if radians:
        aconv = 1.
    else:
        aconv = pi/180.  # degToRad
    deg90 = pi/2.
    deg120 = 2.*pi/3.
    #
    if tag == lattStrings[0]:
        # cubic
        cellparms = np.r_[np.tile(lparms[0], (3,)), deg90*np.ones((3,))]
    elif tag == lattStrings[1] or tag == lattStrings[2]:
        # hexagonal | trigonal (hex indices)
        cellparms = np.r_[lparms[0], lparms[0], lparms[1],
                          deg90, deg90, deg120]
    elif tag == lattStrings[3]:
        # rhombohedral
        cellparms = np.r_[np.tile(lparms[0], (3,)),
                          np.tile(aconv*lparms[1], (3,))]
    elif tag == lattStrings[4]:
        # tetragonal
        cellparms = np.r_[lparms[0], lparms[0], lparms[1],
                          deg90, deg90, deg90]
    elif tag == lattStrings[5]:
        # orthorhombic
        cellparms = np.r_[lparms[0], lparms[1], lparms[2],
                          deg90, deg90, deg90]
    elif tag == lattStrings[6]:
        # monoclinic
        cellparms = np.r_[lparms[0], lparms[1], lparms[2],
                          deg90, aconv*lparms[3], deg90]
    elif tag == lattStrings[7]:
        # triclinic
        # FIXME: fixed DP 2/24/16
        cellparms = np.r_[lparms[0], lparms[1], lparms[2],
                          aconv*lparms[3], aconv*lparms[4], aconv*lparms[5]]
    else:
        raise RuntimeError('lattice tag \'%s\' is not recognized' % (tag))

    if debug:
        print((str(cellparms[0:3]) + ' ' + str(r2d*cellparms[3:6])))
    alfa = cellparms[3]
    beta = cellparms[4]
    gama = cellparms[5]

    cosalfar, sinalfar = cosineXform(alfa, beta, gama)

    a = cellparms[0]*np.r_[1, 0, 0]
    b = cellparms[1]*np.r_[np.cos(gama), np.sin(gama), 0]
    c = cellparms[2]*np.r_[np.cos(beta),
                           -cosalfar*np.sin(beta),
                           sinalfar*np.sin(beta)]

    ad = np.sqrt(np.sum(a**2))
    bd = np.sqrt(np.sum(b**2))
    cd = np.sqrt(np.sum(c**2))

    # Cell volume
    V = np.dot(a, np.cross(b, c))

    # F takes components in the direct lattice to X
    F = np.c_[a, b, c]

    # Reciprocal lattice vectors
    astar = np.cross(b, c)/V
    bstar = np.cross(c, a)/V
    cstar = np.cross(a, b)/V

    # and parameters
    ar = np.sqrt(np.sum(astar**2))
    br = np.sqrt(np.sum(bstar**2))
    cr = np.sqrt(np.sum(cstar**2))

    alfar = np.arccos(np.dot(bstar, cstar)/br/cr)
    betar = np.arccos(np.dot(cstar, astar)/cr/ar)
    gamar = np.arccos(np.dot(astar, bstar)/ar/br)

    # B takes components in the reciprocal lattice to X
    B = np.c_[astar, bstar, cstar]

    cosalfar2, sinalfar2 = cosineXform(alfar, betar, gamar)

    afable = ar*np.r_[1, 0, 0]
    bfable = br*np.r_[np.cos(gamar), np.sin(gamar), 0]
    cfable = cr*np.r_[np.cos(betar),
                      -cosalfar2*np.sin(betar),
                      sinalfar2*np.sin(betar)]

    BR = np.c_[afable, bfable, cfable]
    U0 = np.dot(B, np.linalg.inv(BR))
    if outputDegrees:
        dparms = np.r_[ad, bd, cd, r2d*np.r_[alfa, beta, gama]]
        rparms = np.r_[ar, br, cr, r2d*np.r_[alfar, betar, gamar]]
    else:
        dparms = np.r_[ad, bd, cd, np.r_[alfa, beta, gama]]
        rparms = np.r_[ar, br, cr, np.r_[alfar, betar, gamar]]

    L = {'F': F,
         'B': B,
         'BR': BR,
         'U0': U0,
         'vol': V,
         'dparms': dparms,
         'rparms': rparms}

    return L


def hexagonalIndicesFromRhombohedral(hkl):
    """
    converts rhombohedral hkl to hexagonal indices
    """
    HKL = np.zeros((3, hkl.shape[1]), dtype='int')

    HKL[0, :] = hkl[0, :] - hkl[1, :]
    HKL[1, :] = hkl[1, :] - hkl[2, :]
    HKL[2, :] = hkl[0, :] + hkl[1, :] + hkl[2, :]

    return HKL


def rhombohedralIndicesFromHexagonal(HKL):
    """
    converts hexagonal hkl to rhombohedral indices
    """
    hkl = np.zeros((3, HKL.shape[1]), dtype='int')

    hkl[0, :] = 2 * HKL[0, :] + HKL[1, :] + HKL[2, :]
    hkl[1, :] = -HKL[0, :] + HKL[1, :] + HKL[2, :]
    hkl[2, :] = -HKL[0, :] - 2 * HKL[1, :] + HKL[2, :]

    hkl = hkl / 3.
    return hkl


def rhombohedralParametersFromHexagonal(a_h, c_h):
    """
    converts hexagonal lattice parameters (a, c) to rhombohedral
    lattice parameters (a, alpha)
    """
    a_r = np.sqrt(3 * a_h**2 + c_h**2) / 3.
    alfa_r = 2 * np.arcsin(3. / (2 * np.sqrt(3 + (c_h / a_h)**2)))
    if outputDegrees:
        alfa_r = r2d * alfa_r
    return a_r, alfa_r


def convert_Miller_direction_to_cartesian(uvw, a=1., c=1., normalize=False):
    """
    Converts 3-index hexagonal Miller direction indices to components in the
    crystal reference frame.

    Parameters
    ----------
    uvw : array_like
        The (n, 3) array of 3-index hexagonal indices to convert.
    a : scalar, optional
        The `a` lattice parameter.  The default value is 1.
    c : scalar, optional
        The `c` lattice parameter.  The default value is 1.
    normalize : bool, optional
        Flag for whether or not to normalize output vectors

    Returns
    -------
    numpy.ndarray
        The (n, 3) array of cartesian components associated with the input
        direction indices.

    Notes
    -----
    1) The [uv.w] the Miller-Bravais convention is in the hexagonal basis
       {a1, a2, a3, c}.  The basis for the output, {o1, o2, o3}, is
       chosen such that

       o1 || a1
       o3 || c
       o2 = o3 ^ o1

    """
    u, v, w = np.atleast_2d(uvw).T
    retval = np.vstack([1.5*u*a, sqrt3by2*a*(2*v + u), w*c])
    if normalize:
        return unitVector(retval).T
    else:
        return retval.T


def convert_Miller_direction_to_MillerBravias(uvw, suppress_redundant=True):
    """
    Converts 3-index hexagonal Miller direction indices to 4-index
    Miller-Bravais direction indices.

    Parameters
    ----------
    uvw : array_like
        The (n, 3) array of 3-index hexagonal Miller indices to convert.
    suppress_redundant : bool, optional
        Flag to suppress the redundant 3rd index.  The default is True.

    Returns
    -------
    numpy.ndarray
        The (n, 3) or (n, 4) array -- depending on kwarg -- of Miller-Bravis
        components associated with the input Miller direction indices.

    Notes
    -----
    * NOT for plane normals!!!

    """
    u, v, w = np.atleast_2d(uvw).T
    retval = np.vstack([(2*u - v)/3, (2*v - u)/3, w]).T
    rem = np.vstack([np.mod(np.tile(i[0], 2), i[1:]) for i in retval])
    rem[abs(rem) < epsf] = np.nan
    lcm = np.nanmin(rem, axis=1)
    lcm[np.isnan(lcm)] = 1
    retval = retval / np.tile(lcm, (3, 1)).T
    if suppress_redundant:
        return retval
    else:
        t = np.atleast_2d(1 - np.sum(retval[:2], axis=1)).T
        return np.hstack([retval[:, :2], t, np.atleast_2d(retval[:, 2]).T])


def convert_MillerBravias_direction_to_Miller(UVW):
    """
    Converts 4-index hexagonal Miller-Bravais direction indices to
    3-index Miller direction indices.

    Parameters
    ----------
    UVW : array_like
        The (n, 3) array of **non-redundant** Miller-Bravais direction indices
        to convert.

    Returns
    -------
    numpy.ndarray
        The (n, 3) array of Miller direction indices associated with the
        input Miller-Bravais indices.

    Notes
    -----
    * NOT for plane normals!!!

    """
    U, V, W = np.atleast_2d(UVW).T
    return np.vstack([2*U + V, 2*V + U, W])


class PlaneData(object):
    """
    Careful with ordering: Outputs are ordered by the 2-theta for the
    hkl unless you get self.__hkls directly, and this order can change
    with changes in lattice parameters (lparms); setting and getting
    exclusions works on the current hkl ordering, not the original
    ordering (in self.__hkls), but exclusions are stored in the
    original ordering in case the hkl ordering does change with
    lattice parameters

    if not None, tThWidth takes priority over strainMag in setting
    two-theta ranges; changing strainMag automatically turns off
    tThWidth
    """

    def __init__(self,
                 hkls,
                 *args,
                 **kwargs):

        self.phaseID = None
        self.__doTThSort = True
        self.__exclusions = None
        self.__tThMax = None
        #
        if len(args) == 4:
            lparms, laueGroup, wavelength, strainMag = args
            tThWidth = None
            self.__wavelength = processWavelength(wavelength)
            self.__lparms = self.__parseLParms(lparms)
        elif len(args) == 1 and hasattr(args[0], 'getParams'):
            other = args[0]
            lparms, laueGroup, wavelength, strainMag, tThWidth = \
                other.getParams()
            self.__wavelength = wavelength
            self.__lparms = lparms
            self.phaseID = other.phaseID
            self.__doTThSort = other.__doTThSort
            self.__exclusions = other.__exclusions
            self.__tThMax = other.__tThMax
            if hkls is None:
                hkls = other.__hkls
        else:
            raise NotImplementedError('args : '+str(args))

        self.__laueGroup = laueGroup
        self.__qsym = quatOfLaueGroup(self.__laueGroup)
        self.__hkls = copy.deepcopy(hkls)
        self.__strainMag = strainMag
        self.__structFact = np.ones(self.__hkls.shape[1])
        self.tThWidth = tThWidth

        # ... need to implement tThMin too
        if 'phaseID' in kwargs:
            self.phaseID = kwargs.pop('phaseID')
        if 'doTThSort' in kwargs:
            self.__doTThSort = kwargs.pop('doTThSort')
        if 'exclusions' in kwargs:
            self.__exclusions = kwargs.pop('exclusions')
        if 'tThMax' in kwargs:
            self.__tThMax = toFloat(kwargs.pop('tThMax'), 'radians')
        if 'tThWidth' in kwargs:
            self.tThWidth = kwargs.pop('tThWidth')
        if len(kwargs) > 0:
            raise RuntimeError('have unparsed keyword arguments with keys: '
                               + str(list(kwargs.keys())))

        self.__calc()

        return

    def __calc(self):
        symmGroup = ltypeOfLaueGroup(self.__laueGroup)
        latPlaneData, latVecOps, hklDataList = PlaneData.makePlaneData(
            self.__hkls, self.__lparms, self.__qsym,
            symmGroup, self.__strainMag, self.wavelength)
        'sort by tTheta'
        tThs = np.array(
            [hklDataList[iHKL]['tTheta'] for iHKL in range(len(hklDataList))]
        )
        if self.__doTThSort:
            # sorted hkl -> __hkl
            # __hkl -> sorted hkl
            self.tThSort = np.argsort(tThs)
            self.tThSortInv = np.empty(len(hklDataList), dtype=int)
            self.tThSortInv[self.tThSort] = np.arange(len(hklDataList))
            self.hklDataList = [hklDataList[iHKL] for iHKL in self.tThSort]
        else:
            self.tThSort = np.arange(len(hklDataList))
            self.tThSortInv = np.arange(len(hklDataList))
            self.hklDataList = hklDataList
        self._latVecOps = latVecOps
        self.nHKLs = len(self.getHKLs())
        return

    def __str__(self):
        s = '========== plane data ==========\n'
        s += 'lattice parameters:\n   ' + str(self.lparms) + '\n'
        s += 'two theta width: (%s)\n' % str(self.tThWidth)
        s += 'strain magnitude: (%s)\n' % str(self.strainMag)
        s += 'beam energy (%s)\n' % str(self.wavelength)
        s += 'hkls: (%d)\n' % self.nHKLs
        s += str(self.getHKLs())
        return s

    def getNHKLs(self):
        return self.nHKLs

    def getPhaseID(self):
        'may return None if not set'
        return self.phaseID

    def getParams(self):
        return (self.__lparms, self.__laueGroup, self.__wavelength,
                self.__strainMag, self.tThWidth)

    def getNhklRef(self):
        'does not use exclusions or the like'
        retval = len(self.hklDataList)
        return retval

    def get_hkls(self):
        """
        do not do return self.__hkls, as everywhere else hkls are returned
        in 2-theta order; transpose is to comply with lparm convention
        """
        return self.getHKLs().T

    def set_hkls(self, hkls):
        raise RuntimeError('for now, not allowing hkls to be reset')
        # self.__exclusions = None
        # self.__hkls = hkls
        # self.__calc()
        return
    hkls = property(get_hkls, set_hkls, None)

    def get_tThMax(self):
        return self.__tThMax

    def set_tThMax(self, tThMax):
        self.__tThMax = toFloat(tThMax, 'radians')
        # self.__calc() # no need to redo calc for tThMax
        return
    tThMax = property(get_tThMax, set_tThMax, None)

    def get_exclusions(self):
        retval = np.zeros(self.getNhklRef(), dtype=bool)
        if self.__exclusions is not None:
            # report in current hkl ordering
            retval[:] = self.__exclusions[self.tThSortInv]
        if self.__tThMax is not None:
            for iHKLr, hklData in enumerate(self.hklDataList):
                if hklData['tTheta'] > self.__tThMax:
                    retval[iHKLr] = True
        return retval

    def set_exclusions(self, exclusions):
        excl = np.zeros(len(self.hklDataList), dtype=bool)
        if exclusions is not None:
            exclusions = np.atleast_1d(exclusions)
            if len(exclusions) == len(self.hklDataList):
                assert exclusions.dtype == 'bool', \
                    'exclusions should be bool if full length'
                # convert from current hkl ordering to __hkl ordering
                excl[:] = exclusions[self.tThSort]
            else:
                if len(exclusions.shape) == 1:
                    # treat exclusions as indices
                    excl[self.tThSort[exclusions]] = True
                elif len(exclusions.shape) == 2:
                    raise NotImplementedError(
                        'have not yet coded treating exclusions as ranges'
                    )
                else:
                    raise RuntimeError(
                        'do not now what to do with exclusions with shape '
                        + str(exclusions.shape)
                    )
        self.__exclusions = excl
        self.nHKLs = np.sum(np.logical_not(self.__exclusions))
        return
    exclusions = property(get_exclusions, set_exclusions, None)

    def get_lparms(self):
        return self.__lparms

    def __parseLParms(self, lparms):
        lparmsDUnit = []
        for lparmThis in lparms:
            if hasattr(lparmThis, 'getVal'):
                if lparmThis.isLength():
                    lparmsDUnit.append(lparmThis.getVal(dUnit))
                elif lparmThis.isAngle():
                    # plumbing set up to default to degrees
                    # for lattice parameters
                    lparmsDUnit.append(lparmThis.getVal('degrees'))
                else:
                    raise RuntimeError(
                        'do not know what to do with '
                        + str(lparmThis)
                    )
            else:
                lparmsDUnit.append(lparmThis)
        return lparmsDUnit

    def set_lparms(self, lparms):
        self.__lparms = self.__parseLParms(lparms)
        self.__calc()
        return
    lparms = property(get_lparms, set_lparms, None)

    def get_strainMag(self):
        return self.__strainMag

    def set_strainMag(self, strainMag):
        self.__strainMag = strainMag
        self.tThWidth = None
        self.__calc()
        return
    strainMag = property(get_strainMag, set_strainMag, None)

    def get_wavelength(self):
        return self.__wavelength

    def set_wavelength(self, wavelength):
        wavelength = processWavelength(wavelength)
        if np.isclose(self.__wavelength, wavelength):
            # Do not re-compute if it is almost the same
            return

        self.__wavelength = wavelength
        self.__calc()

    wavelength = property(get_wavelength, set_wavelength, None)

    def get_structFact(self):
        return self.__structFact[~self.exclusions]

    def set_structFact(self, structFact):
        self.__structFact = structFact
        multiplicity = self.getMultiplicity(allHKLs=True)
        tth = self.getTTh(allHKLs=True)
        lp = (1 + np.cos(tth)**2)/np.cos(0.5*tth)/np.sin(0.5*tth)**2/2.0

        powderI = structFact*multiplicity*lp
        powderI = 100.0*powderI/np.nanmax(powderI)

        self._powder_intensity = powderI

    structFact = property(get_structFact, set_structFact, None)

    @property
    def powder_intensity(self):
        return self._powder_intensity[~self.exclusions]

    @staticmethod
    def makePlaneData(hkls, lparms, qsym, symmGroup, strainMag, wavelength):
        """
        hkls       : need to work with crystallography.latticePlanes
        lparms     : need to work with crystallography.latticePlanes
        laueGroup  : see symmetry module
        wavelength : wavelength
        strainMag  : swag of strian magnitudes
        """

        tempSetOutputDegrees(False)
        latPlaneData = latticePlanes(hkls, lparms,
                                     ltype=symmGroup,
                                     strainMag=strainMag,
                                     wavelength=wavelength)

        latVecOps = latticeVectors(lparms, symmGroup)

        hklDataList = []
        for iHKL in range(len(hkls.T)):
            # need transpose because of convention for hkls ordering

            """
            latVec = latPlaneData['normals'][:,iHKL]
            # ... if not spots, may be able to work with a subset of these
            latPlnNrmlList = applySym(
                np.c_[latVec], qsym, csFlag=True, cullPM=False
            )
            """
            # returns UN-NORMALIZED lattice plane normals
            latPlnNrmls = applySym(
                np.dot(latVecOps['B'], hkls[:, iHKL].reshape(3, 1)),
                qsym,
                csFlag=True,
                cullPM=False)

            # check for +/- in symmetry group
            latPlnNrmlsM = applySym(
                np.dot(latVecOps['B'], hkls[:, iHKL].reshape(3, 1)),
                qsym,
                csFlag=False,
                cullPM=False)

            csRefl = latPlnNrmls.shape[1] == latPlnNrmlsM.shape[1]

            # added this so that I retain the actual symmetric
            # integer hkls as well
            symHKLs = np.array(
                np.round(
                    np.dot(latVecOps['F'].T, latPlnNrmls)
                ), dtype='int'
            )

            hklDataList.append(
                dict(hklID=iHKL,
                     hkl=hkls[:, iHKL],
                     tTheta=latPlaneData['tThetas'][iHKL],
                     dSpacings=latPlaneData['dspacings'][iHKL],
                     tThetaLo=latPlaneData['tThetasLo'][iHKL],
                     tThetaHi=latPlaneData['tThetasHi'][iHKL],
                     latPlnNrmls=unitVector(latPlnNrmls),
                     symHKLs=symHKLs,
                     centrosym=csRefl
                     )
                )

        revertOutputDegrees()
        return latPlaneData, latVecOps, hklDataList

    def getLatticeType(self):
        """This is the lattice type"""
        return ltypeOfLaueGroup(self.__laueGroup)

    def getLaueGroup(self):
        """This is the Schoenflies tag"""
        return self.__laueGroup

    def setLaueGroup(self, laueGroup):
        self.__laueGroup = laueGroup
        self.__calc()

    laueGroup = property(getLaueGroup, setLaueGroup, None)

    def set_laue_and_lparms(self, laueGroup, lparms):
        """Set the Laue group and lattice parameters simultaneously

        When the Laue group changes, the lattice parameters may be
        incompatible, and cause an error in self.__calc(). This function
        allows us to update both the Laue group and lattice parameters
        simultaneously to avoid this issue.
        """
        self.__laueGroup = laueGroup
        self.__lparms = self.__parseLParms(lparms)
        self.__calc()

    def getQSym(self):
        return self.__qsym  # rotations.quatOfLaueGroup(self.__laueGroup)

    def getPlaneSpacings(self):
        """
        gets plane spacings
        """
        dspacings = []
        for iHKLr, hklData in enumerate(self.hklDataList):
            if not self.__thisHKL(iHKLr):
                continue
            dspacings.append(hklData['dSpacings'])
        return dspacings

    def getPlaneNormals(self):
        """
        gets both +(hkl) and -(hkl) normals
        """
        plnNrmls = []
        for iHKLr, hklData in enumerate(self.hklDataList):
            if not self.__thisHKL(iHKLr):
                continue
            plnNrmls.append(hklData['latPlnNrmls'])
        return plnNrmls

    @property
    def latVecOps(self):
        """
        gets lattice vector operators as a new (deepcopy)
        """
        return copy.deepcopy(self._latVecOps)

    def __thisHKL(self, iHKLr):
        retval = True
        hklData = self.hklDataList[iHKLr]
        if self.__exclusions is not None:
            if self.__exclusions[self.tThSortInv[iHKLr]]:
                retval = False
        if self.__tThMax is not None:
            # FIXME: check for nans here???
            if hklData['tTheta'] > self.__tThMax \
              or np.isnan(hklData['tTheta']):
                retval = False
        return retval

    def __getTThRange(self, iHKLr):
        hklData = self.hklDataList[iHKLr]
        if self.tThWidth is not None:  # tThHi-tThLo < self.tThWidth
            tTh = hklData['tTheta']
            tThHi = tTh + self.tThWidth * 0.5
            tThLo = tTh - self.tThWidth * 0.5
        else:
            tThHi = hklData['tThetaHi']
            tThLo = hklData['tThetaLo']
        return (tThLo, tThHi)

    def getTThRanges(self, strainMag=None, lparms=None):
        """
        Return 2-theta ranges for included hkls

        return array is n x 2
        """
        if lparms is None:
            tThRanges = []
            for iHKLr, hklData in enumerate(self.hklDataList):
                if not self.__thisHKL(iHKLr):
                    continue
                # tThRanges.append([hklData['tThetaLo'], hklData['tThetaHi']])
                if strainMag is None:
                    tThRanges.append(self.__getTThRange(iHKLr))
                else:
                    hklData = self.hklDataList[iHKLr]
                    d = hklData['dSpacings']
                    tThLo = 2.0 * np.arcsin(
                        self.__wavelength / 2. / (d*(1.+strainMag))
                    )
                    tThHi = 2.0 * np.arcsin(
                        self.__wavelength / 2. / (d*(1.-strainMag))
                    )
                    tThRanges.append((tThLo, tThHi))
        else:
            new = self.__class__(self.__hkls, self)
            new.lparms = lparms
            tThRanges = new.getTThRanges(strainMag=strainMag)
        return np.array(tThRanges)

    def getMergedRanges(self, cullDupl=False):
        """
        return indices and ranges for specified planeData, merging where
        there is overlap based on the tThWidth and line positions
        """
        tThs = self.getTTh()
        tThRanges = self.getTThRanges()

        # if you end exlcusions in a doublet (or multiple close rings)
        # then this will 'fail'.  May need to revisit...
        nonoverlapNexts = np.hstack(
            (tThRanges[:-1, 1] < tThRanges[1:, 0], True)
        )
        iHKLLists = []
        mergedRanges = []
        hklsCur = []
        tThLoIdx = 0
        tThHiCur = 0.
        for iHKL, nonoverlapNext in enumerate(nonoverlapNexts):
            tThHi = tThRanges[iHKL, -1]
            if not nonoverlapNext:
                if cullDupl and abs(tThs[iHKL] - tThs[iHKL+1]) < sqrt_epsf:
                    continue
                else:
                    hklsCur.append(iHKL)
                    tThHiCur = tThHi
            else:
                hklsCur.append(iHKL)
                tThHiCur = tThHi
                iHKLLists.append(hklsCur)
                mergedRanges.append([tThRanges[tThLoIdx, 0], tThHiCur])
                tThLoIdx = iHKL + 1
                hklsCur = []
        return iHKLLists, mergedRanges

    def makeNew(self):
        new = self.__class__(None, self)
        return new

    def getTTh(self, lparms=None, allHKLs=False):
        if lparms is None:
            tTh = []
            for iHKLr, hklData in enumerate(self.hklDataList):
                if not allHKLs:
                    if not self.__thisHKL(iHKLr):
                        continue
                    tTh.append(hklData['tTheta'])
                else:
                    tTh.append(hklData['tTheta'])
        else:
            new = self.makeNew()
            new.lparms = lparms
            tTh = new.getTTh()
        return np.array(tTh)

    def getDD_tThs_lparms(self):
        """
        derivatives of tThs with respect to lattice parameters;
        have not yet done coding for analytic derivatives, just wimp out
        and finite difference
        """
        pert = 1.0e-5  # assume they are all around unity
        pertInv = 1.0/pert

        lparmsRef = copy.deepcopy(self.__lparms)
        tThRef = self.getTTh()
        ddtTh = np.empty((len(tThRef), len(lparmsRef)))

        for iLparm in range(len(lparmsRef)):
            self.__lparms = copy.deepcopy(lparmsRef)
            self.__lparms[iLparm] += pert
            self.__calc()

            iTTh = 0
            for iHKLr, hklData in enumerate(self.hklDataList):
                if not self.__thisHKL(iHKLr):
                    continue
                ddtTh[iTTh, iLparm] = \
                    np.r_[hklData['tTheta'] - tThRef[iTTh]]*pertInv
                iTTh += 1

        'restore'
        self.__lparms = lparmsRef
        self.__calc()

        return ddtTh

    def getMultiplicity(self, allHKLs=False):
        # ... JVB: is this incorrect?
        multip = []
        for iHKLr, hklData in enumerate(self.hklDataList):
            if not allHKLs:
                if not self.__thisHKL(iHKLr):
                    continue
                multip.append(hklData['symHKLs'].shape[1])
            else:
                multip.append(hklData['symHKLs'].shape[1])
        return np.array(multip)

    def getHKLID(self, hkl, master=False):
        """
        Return the unique ID of a list of hkls.

        Parameters
        ----------
        hkl : int | tuple | list | numpy.ndarray
            The input hkl.  If an int, or a list of ints, it just passes
            through (FIXME).
            If a tuple, treated as a single (h, k, l).
            If a list of lists/tuples, each is treated as an (h, k, l).
            If an numpy.ndarray, it is assumed to have shape (3, N) with the
            N (h, k, l) vectors stacked column-wise

        master : bool, optional
            If True, return the master hklID, else return the index from the
            external (sorted and reduced) list.

        Returns
        -------
        retval : list
            The list of requested hklID values associate with the input.

        Notes
        -----
        TODO: revisit this weird API???

        Changes:
        -------
        2020-05-21 (JVB) -- modified to handle all symmetric equavlent reprs.
        """
        if hasattr(hkl, '__setitem__'):  # tuple does not have __setitem__
            if hasattr(hkl, 'shape'):
                # if is ndarray, assume is 3xN
                # retval = list(map(self.__getHKLID, hkl.T))
                retval = [self.__getHKLID(x, master=master) for x in hkl.T]
            else:
                # retval = list(map(self.__getHKLID, hkl))
                retval = [self.__getHKLID(x, master=master) for x in hkl]
        else:
            retval = self.__getHKLID(hkl, master=master)
        return retval

    def __getHKLID(self, hkl, master=False):
        """
        for hkl that is a tuple, return externally visible hkl index
        """
        if isinstance(hkl, int):
            retval = hkl
        else:
            hklList = self.getSymHKLs()  # !!! list, reduced by exclusions
            intl_hklIDs = np.asarray([i['hklID'] for i in self.hklDataList])
            intl_hklIDs_sorted = intl_hklIDs[~self.exclusions[self.tThSortInv]]
            dHKLInv = {}
            for iHKL, symHKLs in enumerate(hklList):
                idx = intl_hklIDs_sorted[iHKL] if master else iHKL
                for thisHKL in symHKLs.T:
                    dHKLInv[tuple(thisHKL)] = idx
            try:
                retval = dHKLInv[tuple(hkl)]
            except(KeyError):
                raise RuntimeError(
                    f"hkl '{tuple(hkl)}' is not present in this material!"
                )
        return retval

    def getHKLs(self, *hkl_ids, **kwargs):
        """
        Returns the powder HKLs subject to specified options.

        Parameters
        ----------
        *hkl_ids : int
            Optional list of specific master hklIDs.
        **kwargs : dict
            One or more of the following keyword arguments:
                asStr : bool
                    If True, return a list of strings.  The default is False.
                thisTTh : scalar | None
                    If not None, only return hkls overlapping the specified
                    2-theta (in radians).  The default is None.
                allHKLs : bool
                    If True, then ignore exlcusions.  The default is False.

        Raises
        ------
        TypeError
            If an unknown kwarg is passed.
        RuntimeError
            If an invalid hklID is passed.

        Returns
        -------
        retval : list | numpy.ndarray
            Either a list of hkls as strings (if asStr=True) or a vstacked
            array of hkls.

        Notes
        -----
        !!! the shape of the return value when asStr=False is the _transpose_
            of the typical return value for self.get_hkls() and self.hkls!
            This _may_ change to avoid confusion, but going to leave it for
            now so as not to break anything.

        2022/08/05 JVB:
            - Added functionality to handle optional hklID args
            - Updated docstring
        """
        # kwarg parsing
        opts = dict(asStr=False, thisTTh=None, allHKLs=False)
        if len(kwargs) > 0:
            # check keys
            for k, v in kwargs.items():
                if k not in opts:
                    raise TypeError(
                        f"getHKLs() got an unexpected keyword argument '{k}'"
                    )
            opts.update(kwargs)

        hkls = []
        if len(hkl_ids) == 0:
            for iHKLr, hklData in enumerate(self.hklDataList):
                if not opts['allHKLs']:
                    if not self.__thisHKL(iHKLr):
                        continue
                if opts['thisTTh'] is not None:
                    tThLo, tThHi = self.__getTThRange(iHKLr)
                    if opts['thisTTh'] < tThHi and opts['thisTTh'] > tThLo:
                        hkls.append(hklData['hkl'])
                else:
                    hkls.append(hklData['hkl'])
        else:
            # !!! changing behavior here; if the hkl_id is invalid, raises
            #     RuntimeError, and if allHKLs=True and the hkl_id is
            #     excluded, it also raises a RuntimeError
            all_hkl_ids = np.asarray([i['hklID'] for i in self.hklDataList])
            sorted_excl = self.exclusions[self.tThSortInv]
            idx = np.zeros(len(self.hklDataList), dtype=int)
            for i, hkl_id in enumerate(hkl_ids):
                # find ordinal index of current hklID
                try:
                    idx[i] = int(np.where(all_hkl_ids == hkl_id)[0])
                except(TypeError):
                    raise RuntimeError(
                        f"Requested hklID '{hkl_id}'is invalid!"
                    )
                if sorted_excl[idx[i]] and not opts['allHKLs']:
                    raise RuntimeError(
                        f"Requested hklID '{hkl_id}' is excluded!"
                    )
                hkls.append(self.hklDataList[idx[i]]['hkl'])

        # handle output kwarg
        if opts['asStr']:
            retval = list(map(hklToStr, np.array(hkls)))
        else:
            retval = np.array(hkls)
        return retval

    def getSymHKLs(self, asStr=False, withID=False, indices=None):
        """
        new function that returns all symmetric hkls
        """
        retval = []
        iRetval = 0
        if indices is not None:
            indB = np.zeros(self.nHKLs, dtype=bool)
            indB[np.array(indices)] = True
        else:
            indB = np.ones(self.nHKLs, dtype=bool)
        for iHKLr, hklData in enumerate(self.hklDataList):
            if not self.__thisHKL(iHKLr):
                continue
            if indB[iRetval]:
                hkls = hklData['symHKLs']
                if asStr:
                    retval.append(list(map(hklToStr, np.array(hkls).T)))
                elif withID:
                    retval.append(
                        np.vstack(
                            [np.tile(hklData['hklID'], (1, hkls.shape[1])),
                             hkls]
                        )
                    )
                else:
                    retval.append(np.array(hkls))
            iRetval += 1
        return retval

    def getCentroSymHKLs(self):
        retval = []
        for iHKLr, hklData in enumerate(self.hklDataList):
            if not self.__thisHKL(iHKLr):
                continue
            retval.append(hklData['centrosym'])
        return retval

    def makeTheseScatteringVectors(self, hklList, rMat,
                                   bMat=None, wavelength=None, chiTilt=None):
        iHKLList = np.atleast_1d(self.getHKLID(hklList))
        fHKLs = np.hstack(self.getSymHKLs(indices=iHKLList))
        if bMat is None:
            bMat = self._latVecOps['B']
        if wavelength is None:
            wavelength = self.__wavelength
        retval = PlaneData.makeScatteringVectors(
            fHKLs, rMat, bMat, wavelength, chiTilt=chiTilt
        )
        return retval

    def makeAllScatteringVectors(self, rMat,
                                 bMat=None, wavelength=None, chiTilt=None):
        fHKLs = np.hstack(self.getSymHKLs())
        if bMat is None:
            bMat = self._latVecOps['B']
        if wavelength is None:
            wavelength = self.__wavelength
        retval = PlaneData.makeScatteringVectors(
            fHKLs, rMat, bMat, wavelength, chiTilt=chiTilt
        )
        return retval

    @staticmethod
    def makeScatteringVectors(hkls, rMat_c, bMat, wavelength, chiTilt=None):
        """
        Static method for calculating g-vectors and scattering vector angles
        for specified hkls, subject to the bragg conditions specified by
        lattice vectors, orientation matrix, and wavelength

        FIXME: must do testing on strained bMat
        """
        # arg munging
        if chiTilt is None:
            chi = 0.
        else:
            chi = float(chiTilt)
        rMat_c = rMat_c.squeeze()

        # these are the reciprocal lattice vectors in the SAMPLE FRAME
        # ** NOTE **
        #   if strained, assumes that you handed it a bMat calculated from
        #   strained [a, b, c] in the CRYSTAL FRAME
        gVec_s = np.dot(rMat_c, np.dot(bMat, hkls))

        dim0, nRefl = gVec_s.shape
        assert dim0 == 3, \
            "Looks like something is wrong with your lattice plane normals"

        # call model from transforms now
        oangs0, oangs1 = xfcapi.oscillAnglesOfHKLs(
            hkls.T, chi, rMat_c, bMat, wavelength
        )

        return gVec_s, oangs0.T, oangs1.T

    def __makeScatteringVectors(self, rMat, bMat=None, chiTilt=None):
        """
        modeled after QFromU.m
        """

        if bMat is None:
            bMat = self._latVecOps['B']

        Qs_vec = []
        Qs_ang0 = []
        Qs_ang1 = []
        for iHKLr, hklData in enumerate(self.hklDataList):
            if not self.__thisHKL(iHKLr):
                continue
            thisQs, thisAng0, thisAng1 = PlaneData.makeScatteringVectors(
                hklData['symHKLs'], rMat, bMat,
                self.__wavelength, chiTilt=chiTilt
            )
            Qs_vec.append(thisQs)
            Qs_ang0.append(thisAng0)
            Qs_ang1.append(thisAng1)

        return Qs_vec, Qs_ang0, Qs_ang1

    def calcStructFactor(self, atominfo):
        """
        Calculates unit cell structure factors as a function of hkl

        USAGE:

        FSquared = calcStructFactor(atominfo,hkls,B)

        INPUTS:

        1) atominfo (m x 1 float ndarray) the first threee columns of the
        matrix contain fractional atom positions [uvw] of atoms in the unit
        cell. The last column contains the number of electrons for a given atom

        2) hkls (3 x n float ndarray) is the array of Miller indices for
        the planes of interest.  The vectors are assumed to be
        concatenated along the 1-axis (horizontal)

        3) B (3 x 3 float ndarray) is a matrix of reciprocal lattice basis
        vectors,where each column contains a reciprocal lattice basis vector
        ({g}=[B]*{hkl})


        OUTPUTS:

        1) FSquared (n x 1 float ndarray) array of structure factors,
        one for each hkl passed into the function
        """
        r = atominfo[:, 0:3]
        elecNum = atominfo[:, 3]
        hkls = self.hkls
        B = self.latVecOps['B']
        sinThOverLamdaList, ffDataList = LoadFormFactorData()
        FSquared = np.zeros(hkls.shape[1])

        for jj in np.arange(0, hkls.shape[1]):
            # ???: probably have other functions for this
            # Calculate G for each hkl
            # Calculate magnitude of G for each hkl
            G = hkls[0, jj]*B[:, 0] + hkls[1, jj]*B[:, 1] + hkls[2, jj]*B[:, 2]
            magG = np.sqrt(G[0]**2 + G[1]**2 + G[2]**2)

            # Begin calculating form factor
            F = 0
            for ii in np.arange(0, r.shape[0]):
                ff = RetrieveAtomicFormFactor(
                    elecNum[ii], magG, sinThOverLamdaList, ffDataList
                )
                exparg = complex(
                    0.,
                    2.*np.pi*(hkls[0, jj]*r[ii, 0]
                              + hkls[1, jj]*r[ii, 1]
                              + hkls[2, jj]*r[ii, 2])
                )
                F = F + ff*np.exp(exparg)

            """
            F = sum_atoms(ff(Q)*e^(2*pi*i(hu+kv+lw)))
            """
            FSquared[jj] = np.real(F*np.conj(F))

        return FSquared


def getFriedelPair(tth0, eta0, *ome0, **kwargs):
    """
    Get the diffractometer angular coordinates in degrees for
    the Friedel pair of a given reflection (min angular distance).

    AUTHORS:

    J. V. Bernier -- 10 Nov 2009

    USAGE:

    ome1, eta1 = getFriedelPair(tth0, eta0, *ome0,
                                display=False,
                                units='degrees',
                                convention='hexrd')

    INPUTS:

    1) tth0 is a list (or ndarray) of 1 or n the bragg angles (2theta) for
       the n reflections (tiled to match eta0 if only 1 is given).

    2) eta0 is a list (or ndarray) of 1 or n azimuthal coordinates for the n
       reflections  (tiled to match tth0 if only 1 is given).

    3) ome0 is a list (or ndarray) of 1 or n reference oscillation
       angles for the n reflections (denoted omega in [1]).  This argument
       is optional.

    4) Keyword arguments may be one of the following:

    Keyword             Values|{default}        Action
    --------------      --------------          --------------
    'display'           True|{False}            toggles display to cmd line
    'units'             'radians'|{'degrees'}   sets units for input angles
    'convention'        'fable'|{'hexrd'}       sets conventions defining
                                                the angles (see below)
    'chiTilt'           None                    the inclination (about Xlab) of
                                                the oscillation axis

    OUTPUTS:

    1) ome1 contains the oscialltion angle coordinates of the
       Friedel pairs associated with the n input reflections, relative to ome0
       (i.e. ome1 = <result> + ome0).  Output is in DEGREES!

    2) eta1 contains the azimuthal coordinates of the Friedel
       pairs associated with the n input reflections.  Output units are
       controlled via the module variable 'outputDegrees'

    NOTES:

    !!!: The ouputs ome1, eta1 are written using the selected convention, but
         the units are alway degrees.  May change this to work with Nathan's
         global...

    !!!: In the 'fable' convention [1], {XYZ} form a RHON basis where X is
         downstream, Z is vertical, and eta is CCW with +Z defining eta = 0.

    !!!: In the 'hexrd' convention [2], {XYZ} form a RHON basis where Z is
         upstream, Y is vertical, and eta is CCW with +X defining eta = 0.

    REFERENCES:

    [1] E. M. Lauridsen, S. Schmidt, R. M. Suter, and H. F. Poulsen,
        ``Tracking: a method for structural characterization of grains in
        powders or polycrystals''. J. Appl. Cryst. (2001). 34, 744--750

    [2] J. V. Bernier, M. P. Miller, J. -S. Park, and U. Lienert,
        ``Quantitative Stress Analysis of Recrystallized OFHC Cu Subject
        to Deformed In Situ'', J. Eng. Mater. Technol. (2008). 130.
        DOI:10.1115/1.2870234
    """

    dispFlag = False
    fableFlag = False
    chi = None
    c1 = 1.
    c2 = pi/180.

    # cast to arrays (in case they aren't)
    if np.isscalar(eta0):
        eta0 = [eta0]

    if np.isscalar(tth0):
        tth0 = [tth0]

    if np.isscalar(ome0):
        ome0 = [ome0]

    eta0 = np.asarray(eta0)
    tth0 = np.asarray(tth0)
    ome0 = np.asarray(ome0)

    if eta0.ndim != 1:
        raise RuntimeError('azimuthal input must be 1-D')

    npts = len(eta0)

    if tth0.ndim != 1:
        raise RuntimeError('Bragg angle input must be not 1-D')
    else:
        if len(tth0) != npts:
            if len(tth0) == 1:
                tth0 = tth0*np.ones(npts)
            elif npts == 1:
                npts = len(tth0)
                eta0 = eta0*np.ones(npts)
            else:
                raise RuntimeError(
                    'the azimuthal and Bragg angle inputs are inconsistent'
                )

    if len(ome0) == 0:
        ome0 = np.zeros(npts)                  # dummy ome0
    elif len(ome0) == 1 and npts > 1:
        ome0 = ome0*np.ones(npts)
    else:
        if len(ome0) != npts:
            raise RuntimeError(
                'your oscialltion angle input is inconsistent; '
                + 'it has length %d while it should be %d'
                % (len(ome0), npts)
            )

    # keyword args processing
    kwarglen = len(kwargs)
    if kwarglen > 0:
        argkeys = list(kwargs.keys())
        for i in range(kwarglen):
            if argkeys[i] == 'display':
                dispFlag = kwargs[argkeys[i]]
            elif argkeys[i] == 'convention':
                if kwargs[argkeys[i]].lower() == 'fable':
                    fableFlag = True
            elif argkeys[i] == 'units':
                if kwargs[argkeys[i]] == 'radians':
                    c1 = 180./pi
                    c2 = 1.
            elif argkeys[i] == 'chiTilt':
                if kwargs[argkeys[i]] is not None:
                    chi = kwargs[argkeys[i]]

    # a little talkback...
    if dispFlag:
        if fableFlag:
            print('\nUsing Fable angle convention\n')
        else:
            print('\nUsing image-based angle convention\n')

    # mapped eta input
    #   - in DEGREES, thanks to c1
    eta0 = mapAngle(c1*eta0, [-180, 180], units='degrees')
    if fableFlag:
        eta0 = 90 - eta0

    # must put args into RADIANS
    #   - eta0 is in DEGREES,
    #   - the others are in whatever was entered, hence c2
    eta0 = d2r*eta0
    tht0 = c2*tth0/2
    if chi is not None:
        chi = c2*chi
    else:
        chi = 0

    """
    SYSTEM SOLVE


    cos(chi)cos(eta)cos(theta)sin(x) - cos(chi)sin(theta)cos(x) \
        = sin(theta) - sin(chi)sin(eta)cos(theta)


    Identity: a sin x + b cos x = sqrt(a**2 + b**2) sin (x + alfa)

           /
           |      atan(b/a) for a > 0
     alfa <
           | pi + atan(b/a) for a < 0
           \

     => sin (x + alfa) = c / sqrt(a**2 + b**2)

     must use both branches for sin(x) = n:
     x = u (+ 2k*pi) | x = pi - u (+ 2k*pi)
    """
    cchi = np.cos(chi)
    schi = np.sin(chi)
    ceta = np.cos(eta0)
    seta = np.sin(eta0)
    ctht = np.cos(tht0)
    stht = np.sin(tht0)

    nchi = np.c_[0., cchi, schi].T

    gHat0_l = -np.vstack([ceta * ctht,
                          seta * ctht,
                          stht])

    a = cchi*ceta*ctht
    b = -cchi*stht
    c = stht + schi*seta*ctht

    # form solution
    abMag = np.sqrt(a*a + b*b)
    assert np.all(abMag > 0), \
        "Beam vector specification is infeasible!"
    phaseAng = np.arctan2(b, a)
    rhs = c / abMag
    rhs[abs(rhs) > 1.] = np.nan
    rhsAng = np.arcsin(rhs)

    # write ome angle output arrays (NaNs persist here)
    ome1 = rhsAng - phaseAng
    ome2 = np.pi - rhsAng - phaseAng

    ome1 = mapAngle(ome1, [-np.pi, np.pi], units='radians')
    ome2 = mapAngle(ome2, [-np.pi, np.pi], units='radians')

    ome_stack = np.vstack([ome1, ome2])

    min_idx = np.argmin(abs(ome_stack), axis=0)

    ome_min = ome_stack[min_idx, list(range(len(ome1)))]
    eta_min = np.nan * np.ones_like(ome_min)

    # mark feasible reflections
    goodOnes = ~np.isnan(ome_min)

    numGood = np.sum(goodOnes)
    tmp_eta = np.empty(numGood)
    tmp_gvec = gHat0_l[:, goodOnes]
    for i in range(numGood):
        rchi = rotMatOfExpMap(
            np.tile(ome_min[goodOnes][i], (3, 1)) * nchi
        )
        gHat_l = np.dot(rchi, tmp_gvec[:, i].reshape(3, 1))
        tmp_eta[i] = np.arctan2(gHat_l[1], gHat_l[0])
        pass
    eta_min[goodOnes] = tmp_eta

    # everybody back to DEGREES!
    #     - ome1 is in RADIANS here
    #     - convert and put into [-180, 180]
    ome1 = mapAngle(
        mapAngle(
            r2d*ome_min, [-180, 180], units='degrees'
        ) + c1*ome0, [-180, 180], units='degrees'
    )

    # put eta1 in [-180, 180]
    eta1 = mapAngle(r2d*eta_min, [-180, 180], units='degrees')

    if not outputDegrees:
        ome1 = d2r * ome1
        eta1 = d2r * eta1

    return ome1, eta1


def getDparms(lp, lpTag, radians=True):
    """
    Utility routine for getting dparms, that is the lattice parameters
    without symmetry -- 'triclinic'
    """
    latVecOps = latticeVectors(lp, tag=lpTag, radians=radians)
    return latVecOps['dparms']


def LoadFormFactorData():
    """
    Script to read in a csv file containing information relating the
    magnitude of Q (sin(th)/lambda) to atomic form factor


    Notes:
    Atomic form factor data gathered from the International Tables of
    Crystallography:

     P. J. Brown, A. G. Fox,  E. N. Maslen, M. A. O'Keefec and B. T. M. Willis,
    "Chapter 6.1. Intensity of diffracted intensities", International Tables
     for Crystallography (2006). Vol. C, ch. 6.1, pp. 554-595
    """

    dir1 = os.path.split(valunits.__file__)
    dataloc = os.path.join(dir1[0], 'data', 'FormFactorVsQ.csv')

    data = np.zeros((62, 99), float)

    # FIXME: marked broken by DP
    jj = 0
    with open(dataloc, 'rU') as csvfile:
        datareader = csv.reader(csvfile, dialect=csv.excel)
        for row in datareader:
            ii = 0
            for val in row:
                data[jj, ii] = float(val)
                ii += 1
            jj += 1

    sinThOverLamdaList = data[:, 0]
    ffDataList = data[:, 1:]

    return sinThOverLamdaList, ffDataList


def RetrieveAtomicFormFactor(elecNum, magG, sinThOverLamdaList, ffDataList):
    """ Interpolates between tabulated data to find the atomic form factor
    for an atom with elecNum electrons for a given magnitude of Q

    USAGE:

    ff = RetrieveAtomicFormFactor(elecNum,magG,sinThOverLamdaList,ffDataList)

    INPUTS:

    1) elecNum, (1 x 1 float) number of electrons for atom of interest

    2) magG (1 x 1 float) magnitude of G

    3) sinThOverLamdaList (n x 1 float ndarray) form factor data is tabulated
       in terms of sin(theta)/lambda (A^-1).

    3) ffDataList (n x m float ndarray) form factor data is tabulated in terms
       of sin(theta)/lambda (A^-1). Each column corresponds to a different
       number of electrons

    OUTPUTS:

    1) ff (n x 1 float) atomic form factor for atom and hkl of interest

    NOTES:
    Data should be calculated in terms of G at some point

    """
    sinThOverLambda = 0.5*magG
    # lambda=2*d*sin(th)
    # lambda=2*sin(th)/G
    # 1/2*G=sin(th)/lambda

    ff = np.interp(
        sinThOverLambda, sinThOverLamdaList, ffDataList[:, (elecNum - 1)]
    )

    return ff


# hexrd/findorientations.py

import copy
import logging
import multiprocessing as mp
import os
import timeit

import numpy as np
# np.seterr(over='ignore', invalid='ignore')

# import tqdm

import scipy.cluster as cluster
from scipy import ndimage

from hexrd import constants as const
from hexrd import matrixutil as mutil
from hexrd import indexer
from hexrd import instrument
from hexrd.imageutil import find_peaks_2d
from hexrd import rotations as rot
from hexrd.transforms import xfcapi
from hexrd.xrdutil import EtaOmeMaps

# just require scikit-learn?
have_sklearn = False
try:
    from sklearn.cluster import dbscan
    from sklearn.metrics.pairwise import pairwise_distances
    have_sklearn = True
except ImportError:
    pass


save_as_ascii = False  # FIXME LATER...
filter_stdev_DFLT = 1.0

logger = logging.getLogger(__name__)


# =============================================================================
# FUNCTIONS
# =============================================================================


def _process_omegas(omegaimageseries_dict):
    """Extract omega period and ranges from an OmegaImageseries dictionary."""
    oims = next(iter(omegaimageseries_dict.values()))
    ome_period = oims.omega[0, 0] + np.r_[0., 360.]
    ome_ranges = [
        ([i['ostart'], i['ostop']])
        for i in oims.omegawedges.wedges
    ]
    return ome_period, ome_ranges


def clean_map(this_map):
    # !!! need to remove NaNs from map in case of eta gaps
    # !!! doing offset and truncation by median value now
    nan_mask = np.isnan(this_map)
    med_val = np.median(this_map[~nan_mask])
    this_map[nan_mask] = med_val
    this_map[this_map <= med_val] = med_val
    this_map -= np.min(this_map)


def generate_orientation_fibers(cfg, eta_ome):
    """
    From ome-eta maps and hklid spec, generate list of
    quaternions from fibers
    """
    # grab the relevant parameters from the root config
    ncpus = cfg.multiprocessing
    chi = cfg.instrument.hedm.chi
    seed_hkl_ids = cfg.find_orientations.seed_search.hkl_seeds
    fiber_ndiv = cfg.find_orientations.seed_search.fiber_ndiv
    method_dict = cfg.find_orientations.seed_search.method

    # strip out method name and kwargs
    # !!! note that the config enforces that method is a dict with length 1
    # TODO: put a consistency check on required kwargs, or otherwise specify
    #       default values for each case?  They must be specified as of now.
    method = next(iter(method_dict.keys()))
    method_kwargs = method_dict[method]
    logger.info('\tusing "%s" method for fiber generation'
                % method)

    # crystallography data from the pd object
    pd = eta_ome.planeData
    tTh = pd.getTTh()
    bMat = pd.latVecOps['B']
    csym = pd.getLaueGroup()

    # !!! changed recently where iHKLList are now master hklIDs
    pd_hkl_ids = eta_ome.iHKLList[seed_hkl_ids]
    pd_hkl_idx = pd.getHKLID(
        pd.getHKLs(*eta_ome.iHKLList).T,
        master=False
    )
    seed_hkls = pd.getHKLs(*pd_hkl_ids)
    seed_tths = tTh[pd_hkl_idx][seed_hkl_ids]
    logger.info('\tusing seed hkls: %s'
                % [str(i) for i in seed_hkls])

    # grab angular grid infor from maps
    del_ome = eta_ome.omegas[1] - eta_ome.omegas[0]
    del_eta = eta_ome.etas[1] - eta_ome.etas[0]

    params = dict(
        bMat=bMat,
        chi=chi,
        csym=csym,
        fiber_ndiv=fiber_ndiv)

    # =========================================================================
    # Labeling of spots from seed hkls
    # =========================================================================

    numSpots = []
    coms = []
    for i in seed_hkl_ids:
        this_map = copy.deepcopy(eta_ome.dataStore[i])
        clean_map(this_map)  # !!! need to get rid of NaNs for blob detection
        numSpots_t, coms_t = find_peaks_2d(this_map, method, method_kwargs)
        numSpots.append(numSpots_t)
        coms.append(coms_t)

    input_p = []
    for i, (this_hkl, this_tth) in enumerate(zip(seed_hkls, seed_tths)):
        for ispot in range(numSpots[i]):
            if not np.isnan(coms[i][ispot][0]):
                ome_c = eta_ome.omeEdges[0] + (0.5 + coms[i][ispot][0])*del_ome
                eta_c = eta_ome.etaEdges[0] + (0.5 + coms[i][ispot][1])*del_eta
                input_p.append(np.hstack([this_hkl, this_tth, eta_c, ome_c]))
                pass
            pass
        pass

    # do the mapping
    start = timeit.default_timer()
    qfib = None
    if ncpus > 1:
        # multiple process version
        # ???: Need a chunksize in map?
        chunksize = max(1, len(input_p)//(10*ncpus))
        pool = mp.Pool(ncpus, discretefiber_init, (params, ))
        qfib = pool.map(
            discretefiber_reduced, input_p,
            chunksize=chunksize
        )
        '''
        # This is an experiment...
        ntotal= 10*ncpus + np.remainder(len(input_p), 10*ncpus) > 0
        for _ in tqdm.tqdm(
                pool.imap_unordered(
                    discretefiber_reduced, input_p, chunksize=chunksize
                ), total=ntotal
            ):
            pass
        print(_.shape)
        '''
        pool.close()
        pool.join()
    else:
        # single process version.
        discretefiber_init(params)  # sets paramMP

        # We convert to a list to ensure the map is full iterated before
        # clean up. Otherwise discretefiber_reduced will be called
        # after cleanup.
        qfib = list(map(discretefiber_reduced, input_p))

        discretefiber_cleanup()
    elapsed = (timeit.default_timer() - start)
    logger.info("\tfiber generation took %.3f seconds", elapsed)
    return np.hstack(qfib)


def discretefiber_init(params):
    global paramMP
    paramMP = params


def discretefiber_cleanup():
    global paramMP
    del paramMP


def discretefiber_reduced(params_in):
    """
    input parameters are [hkl_id, com_ome, com_eta]
    """
    global paramMP
    bMat = paramMP['bMat']
    chi = paramMP['chi']
    csym = paramMP['csym']
    fiber_ndiv = paramMP['fiber_ndiv']

    hkl = params_in[:3].reshape(3, 1)

    gVec_s = xfcapi.anglesToGVec(
        np.atleast_2d(params_in[3:]),
        chi=chi,
        ).T

    tmp = mutil.uniqueVectors(
        rot.discreteFiber(
            hkl,
            gVec_s,
            B=bMat,
            ndiv=fiber_ndiv,
            invert=False,
            csym=csym
            )[0]
        )
    return tmp


def run_cluster(compl, qfib, qsym, cfg,
                min_samples=None, compl_thresh=None, radius=None):
    """
    """
    algorithm = cfg.find_orientations.clustering.algorithm

    cl_radius = cfg.find_orientations.clustering.radius
    min_compl = cfg.find_orientations.clustering.completeness

    # check for override on completeness threshold
    if compl_thresh is not None:
        min_compl = compl_thresh

    # check for override on radius
    if radius is not None:
        cl_radius = radius

    start = timeit.default_timer()  # timeit this

    num_above = sum(np.array(compl) > min_compl)
    if num_above == 0:
        # nothing to cluster
        qbar = cl = np.array([])
    elif num_above == 1:
        # short circuit
        qbar = qfib[:, np.array(compl) > min_compl]
        cl = [1]
    else:
        # use compiled module for distance
        # just to be safe, must order qsym as C-contiguous
        qsym = np.array(qsym.T, order='C').T

        def quat_distance(x, y):
            return xfcapi.quat_distance(
                np.array(x, order='C'), np.array(y, order='C'),
                qsym
            )

        qfib_r = qfib[:, np.array(compl) > min_compl]

        num_ors = qfib_r.shape[1]

        if num_ors > 25000:
            if algorithm == 'sph-dbscan' or algorithm == 'fclusterdata':
                logger.info("falling back to euclidean DBSCAN")
                algorithm = 'ort-dbscan'
            # raise RuntimeError(
            #     "Requested clustering of %d orientations, "
            #     + "which would be too slow!" % qfib_r.shape[1]
            # )

        logger.info(
            "Feeding %d orientations above %.1f%% to clustering",
            num_ors, 100*min_compl
            )

        if algorithm == 'dbscan' and not have_sklearn:
            algorithm = 'fclusterdata'
            logger.warning(
                "sklearn >= 0.14 required for dbscan; using fclusterdata"
                )

        if algorithm in ['dbscan', 'ort-dbscan', 'sph-dbscan']:
            # munge min_samples according to options
            if min_samples is None \
                    or cfg.find_orientations.use_quaternion_grid is not None:
                min_samples = 1

            if algorithm == 'sph-dbscan':
                logger.info("using spherical DBSCAN")
                # compute distance matrix
                pdist = pairwise_distances(
                    qfib_r.T, metric=quat_distance, n_jobs=1
                    )

                # run dbscan
                core_samples, labels = dbscan(
                    pdist,
                    eps=np.radians(cl_radius),
                    min_samples=min_samples,
                    metric='precomputed'
                    )
            else:
                if algorithm == 'ort-dbscan':
                    logger.info("using euclidean orthographic DBSCAN")
                    pts = qfib_r[1:, :].T
                    eps = 0.25*np.radians(cl_radius)
                else:
                    logger.info("using euclidean DBSCAN")
                    pts = qfib_r.T
                    eps = 0.5*np.radians(cl_radius)

                # run dbscan
                core_samples, labels = dbscan(
                    pts,
                    eps=eps,
                    min_samples=min_samples,
                    metric='minkowski', p=2,
                    )

            # extract cluster labels
            cl = np.array(labels, dtype=int)  # convert to array
            noise_points = cl == -1  # index for marking noise
            cl += 1  # move index to 1-based instead of 0
            cl[noise_points] = -1  # re-mark noise as -1
            logger.info("dbscan found %d noise points", sum(noise_points))
        elif algorithm == 'fclusterdata':
            logger.info("using spherical fclusetrdata")
            cl = cluster.hierarchy.fclusterdata(
                qfib_r.T,
                np.radians(cl_radius),
                criterion='distance',
                metric=quat_distance
                )
        else:
            raise RuntimeError(
                "Clustering algorithm %s not recognized" % algorithm
                )

        # extract number of clusters
        if np.any(cl == -1):
            nblobs = len(np.unique(cl)) - 1
        else:
            nblobs = len(np.unique(cl))

        """ PERFORM AVERAGING TO GET CLUSTER CENTROIDS """
        qbar = np.zeros((4, nblobs))
        for i in range(nblobs):
            npts = sum(cl == i + 1)
            qbar[:, i] = rot.quatAverageCluster(
                qfib_r[:, cl == i + 1], qsym
            ).flatten()
            pass
        pass

    if algorithm in ('dbscan', 'ort-dbscan') and qbar.size/4 > 1:
        logger.info("\tchecking for duplicate orientations...")
        cl = cluster.hierarchy.fclusterdata(
            qbar.T,
            np.radians(cl_radius),
            criterion='distance',
            metric=quat_distance)
        nblobs_new = len(np.unique(cl))
        if nblobs_new < nblobs:
            logger.info(
                "\tfound %d duplicates within %f degrees",
                nblobs - nblobs_new, cl_radius
            )
            tmp = np.zeros((4, nblobs_new))
            for i in range(nblobs_new):
                npts = sum(cl == i + 1)
                tmp[:, i] = rot.quatAverageCluster(
                    qbar[:, cl == i + 1].reshape(4, npts), qsym
                ).flatten()
                pass
            qbar = tmp
            pass
        pass

    logger.info("clustering took %f seconds", timeit.default_timer() - start)
    logger.info(
        "Found %d orientation clusters with >=%.1f%% completeness"
        " and %2f misorientation",
        qbar.size/4,
        100.*min_compl,
        cl_radius
        )

    return np.atleast_2d(qbar), cl


def load_eta_ome_maps(cfg, pd, image_series, hkls=None, clean=False):
    """
    Load the eta-ome maps specified by the config and CLI flags.

    Parameters
    ----------
    cfg : TYPE
        DESCRIPTION.
    pd : TYPE
        DESCRIPTION.
    image_series : TYPE
        DESCRIPTION.
    hkls : TYPE, optional
        DESCRIPTION. The default is None.
    clean : TYPE, optional
        DESCRIPTION. The default is False.

    Returns
    -------
    TYPE
        DESCRIPTION.

    """
    # check maps filename
    if cfg.find_orientations.orientation_maps.file is None:
        maps_fname = '_'.join([cfg.analysis_id, "eta-ome_maps.npz"])
    else:
        maps_fname = cfg.find_orientations.orientation_maps.file

    fn = os.path.join(cfg.working_dir, maps_fname)

    # ???: necessary?
    if fn.split('.')[-1] != 'npz':
        fn = fn + '.npz'

    if not clean:
        try:
            res = EtaOmeMaps(fn)
            pd = res.planeData
            logger.info('loaded eta/ome orientation maps from %s', fn)
            shkls = pd.getHKLs(*res.iHKLList, asStr=True)
            logger.info(
                'hkls used to generate orientation maps: %s',
                [f'[{i}]' for i in shkls]
            )
        except (AttributeError, IOError):
            logger.info("specified maps file '%s' not found "
                        + "and clean option specified; "
                        + "recomputing eta/ome orientation maps",
                        fn)
            res = generate_eta_ome_maps(cfg, hkls=hkls)
    else:
        logger.info('clean option specified; '
                    + 'recomputing eta/ome orientation maps')
        res = generate_eta_ome_maps(cfg, hkls=hkls)
    filter_maps_if_requested(res, cfg)
    return res


def filter_maps_if_requested(eta_ome, cfg):
    # filter if requested
    filter_maps = cfg.find_orientations.orientation_maps.filter_maps
    # !!! current logic:
    #  if False/None don't do anything
    #  if True, only do median subtraction
    #  if scalar, do median + LoG filter with that many pixels std dev
    if filter_maps:
        if not isinstance(filter_maps, bool):
            sigm = const.fwhm_to_sigma * filter_maps
            logger.info("filtering eta/ome maps incl LoG with %.2f std dev",
                        sigm)
            _filter_eta_ome_maps(
                eta_ome,
                filter_stdev=sigm
            )
        else:
            logger.info("filtering eta/ome maps")
            _filter_eta_ome_maps(eta_ome)


def generate_eta_ome_maps(cfg, hkls=None, save=True):
    """
    Generates the eta-omega maps specified in the input config.

    Parameters
    ----------
    cfg : hexrd.config.root.RootConfig
        A hexrd far-field HEDM config instance.
    hkls : array_like, optional
        If not None, an override for the hkls used to generate maps. This can
        be either a list of unique hklIDs, or a list of [h, k, l] vectors.
        The default is None.
    save : bool, optional
        If True, write map archive to npz format according to path spec in cfg.
        The default is True.

    Raises
    ------
    RuntimeError
        DESCRIPTION.

    Returns
    -------
    eta_ome : TYPE
        DESCRIPTION.

    """
    # extract PlaneData from config and set active hkls
    plane_data = cfg.material.plane_data

    # all active hkl ids masked by exclusions
    active_hklIDs = plane_data.getHKLID(plane_data.hkls, master=True)

    # need the below
    use_all = False

    # handle optional override
    if hkls:
        # overriding hkls are specified
        hkls = np.asarray(hkls)

        # if input is 2-d list of hkls, convert to hklIDs
        if hkls.ndim == 2:
            hkls = plane_data.getHKLID(hkls.tolist(), master=True)
    else:
        # handle logic for active hkl spec in config
        # !!!: default to all hkls defined for material,
        #      override with hkls from config, if specified;
        temp = np.asarray(cfg.find_orientations.orientation_maps.active_hkls)
        if temp.ndim == 0:
            # !!! this is only possible if active_hkls is None
            use_all = True
        elif temp.ndim == 1:
            # we have hklIDs
            hkls = temp
        elif temp.ndim == 2:
            # we have actual hkls
            hkls = plane_data.getHKLID(temp.tolist(), master=True)
        else:
            raise RuntimeError('active_hkls spec must be 1-d or 2-d, not %d-d'
                               % temp.ndim)

    # apply some checks to active_hkls specificaton
    if not use_all:
        # !!! hkls --> list of hklIDs now
        # catch duplicates
        assert len(np.unique(hkls)) == len(hkls), "duplicate hkls specified!"

        # catch excluded hkls
        excluded = np.zeros_like(hkls, dtype=bool)
        for i, hkl in enumerate(hkls):
            if hkl not in active_hklIDs:
                excluded[i] = True
        if np.any(excluded):
            raise RuntimeError(
                "The following requested hkls are marked as excluded: "
                + f"{hkls[excluded]}"
            )

        # ok, now re-assign active_hklIDs
        active_hklIDs = hkls

    # logging output
    shkls = plane_data.getHKLs(*active_hklIDs, asStr=True)
    logger.info(
        "building eta_ome maps using hkls: %s",
        [f'[{i}]' for i in shkls]
    )

    # grad imageseries dict from cfg
    imsd = cfg.image_series

    # handle omega period
    ome_period, _ = _process_omegas(imsd)

    start = timeit.default_timer()

    # make eta_ome maps
    eta_ome = instrument.GenerateEtaOmeMaps(
        imsd, cfg.instrument.hedm, plane_data,
        active_hkls=active_hklIDs,
        threshold=cfg.find_orientations.orientation_maps.threshold,
        ome_period=ome_period)

    logger.info("\t\t...took %f seconds", timeit.default_timer() - start)

    if save:
        # save maps
        # ???: should perhaps set default maps name at module level
        map_fname = cfg.find_orientations.orientation_maps.file \
            or '_'.join([cfg.analysis_id, "eta-ome_maps.npz"])

        if not os.path.exists(cfg.working_dir):
            os.mkdir(cfg.working_dir)

        fn = os.path.join(
            cfg.working_dir,
            map_fname
        )

        eta_ome.save(fn)

        logger.info('saved eta/ome orientation maps to "%s"', fn)

    return eta_ome


def _filter_eta_ome_maps(eta_ome, filter_stdev=False):
    """
    Apply median and gauss-laplace filtering to remove streak artifacts.

    Parameters
    ----------
    eta_ome : TYPE
        DESCRIPTION.
    filter_stdev : TYPE, optional
        DESCRIPTION. The default is 1.

    Returns
    -------
    eta_ome : TYPE
        DESCRIPTION.

    """
    gl_filter = ndimage.filters.gaussian_laplace
    for i, pf in enumerate(eta_ome.dataStore):
        # first compoute row-wise median over omega channel
        ome_median = np.tile(np.nanmedian(pf, axis=0), (len(pf), 1))

        # subtract
        # !!! this changes the reference obj, but fitlering does not
        pf -= ome_median

        # filter
        # !!! False/None: only row median subtraction
        #     True: use default
        #     scalar: stdev for filter in pixels
        # ??? simplify this behavior
        if filter_stdev:
            if isinstance(filter_stdev, bool):
                filter_stdev = filter_stdev_DFLT
            pf[:] = -gl_filter(pf, filter_stdev)


def create_clustering_parameters(cfg, eta_ome):
    """
    Compute min samples and mean reflections per grain for clustering

    Parameters
    ----------
    cfg : TYPE
        DESCRIPTION.
    eta_ome : TYPE
        DESCRIPTION.

    Returns
    -------
    Tuple of (min_samples, mean_rpg)

    """

    # grab objects from config
    plane_data = cfg.material.plane_data
    imsd = cfg.image_series
    instr = cfg.instrument.hedm
    eta_ranges = cfg.find_orientations.eta.range
    compl_thresh = cfg.find_orientations.clustering.completeness

    # handle omega period
    ome_period, ome_ranges = _process_omegas(imsd)

    # grab the active hkl ids
    # !!! these are master hklIDs
    active_hkls = eta_ome.iHKLList

    # !!! These are indices into the active hkls
    fiber_seeds = cfg.find_orientations.seed_search.hkl_seeds

    # Simulate N random grains to get neighborhood size
    seed_hkl_ids = active_hkls[fiber_seeds]

    # !!! default to use 100 grains
    ngrains = 100
    rand_q = mutil.unitVector(np.random.randn(4, ngrains))
    rand_e = np.tile(2.*np.arccos(rand_q[0, :]), (3, 1)) \
        * mutil.unitVector(rand_q[1:, :])
    grain_param_list = np.vstack(
            [rand_e,
             np.zeros((3, ngrains)),
             np.tile(const.identity_6x1, (ngrains, 1)).T]
        ).T
    sim_results = instr.simulate_rotation_series(
            plane_data, grain_param_list,
            eta_ranges=np.radians(eta_ranges),
            ome_ranges=np.radians(ome_ranges),
            ome_period=np.radians(ome_period)
    )

    refl_per_grain = np.zeros(ngrains)
    seed_refl_per_grain = np.zeros(ngrains)
    for sim_result in sim_results.values():
        for i, refl_ids in enumerate(sim_result[0]):
            refl_per_grain[i] += len(refl_ids)
            seed_refl_per_grain[i] += np.sum(
                    [sum(refl_ids == hkl_id) for hkl_id in seed_hkl_ids]
                )

    min_samples = max(
        int(np.floor(0.5*compl_thresh*min(seed_refl_per_grain))),
        2
    )
    mean_rpg = int(np.round(np.average(refl_per_grain)))

    return min_samples, mean_rpg


def find_orientations(cfg,
                      hkls=None, clean=False, profile=False,
                      use_direct_testing=False):
    """


    Parameters
    ----------
    cfg : TYPE
        DESCRIPTION.
    hkls : TYPE, optional
        DESCRIPTION. The default is None.
    clean : TYPE, optional
        DESCRIPTION. The default is False.
    profile : TYPE, optional
        DESCRIPTION. The default is False.
    use_direct_search : TYPE, optional
        DESCRIPTION. The default is False.

    Returns
    -------
    None.

    """
    # grab objects from config
    plane_data = cfg.material.plane_data
    imsd = cfg.image_series
    instr = cfg.instrument.hedm
    eta_ranges = cfg.find_orientations.eta.range

    # tolerances
    tth_tol = plane_data.tThWidth
    eta_tol = np.radians(cfg.find_orientations.eta.tolerance)
    ome_tol = np.radians(cfg.find_orientations.omega.tolerance)

    # handle omega period
    ome_period, ome_ranges = _process_omegas(imsd)

    # for multiprocessing
    ncpus = cfg.multiprocessing

    # thresholds
    image_threshold = cfg.find_orientations.orientation_maps.threshold
    on_map_threshold = cfg.find_orientations.threshold
    compl_thresh = cfg.find_orientations.clustering.completeness

    # clustering
    cl_algorithm = cfg.find_orientations.clustering.algorithm
    cl_radius = cfg.find_orientations.clustering.radius

    # =========================================================================
    # ORIENTATION SCORING
    # =========================================================================
    do_grid_search = cfg.find_orientations.use_quaternion_grid is not None

    if use_direct_testing:
        npdiv_DFLT = 2
        params = dict(
                plane_data=plane_data,
                instrument=instr,
                imgser_dict=imsd,
                tth_tol=tth_tol,
                eta_tol=eta_tol,
                ome_tol=ome_tol,
                eta_ranges=np.radians(eta_ranges),
                ome_period=np.radians(ome_period),
                npdiv=npdiv_DFLT,
                threshold=image_threshold)

        logger.info("\tusing direct search on %d processes", ncpus)

        # handle search space
        if cfg.find_orientations.use_quaternion_grid is None:
            # doing seeded search
            logger.info("Will perform seeded search")
            logger.info(
                "\tgenerating search quaternion list using %d processes",
                ncpus
            )
            start = timeit.default_timer()

            # need maps
            eta_ome = load_eta_ome_maps(cfg, plane_data, imsd,
                                        hkls=hkls, clean=clean)

            # generate trial orientations
            qfib = generate_orientation_fibers(cfg, eta_ome)

            logger.info("\t\t...took %f seconds",
                        timeit.default_timer() - start)
        else:
            # doing grid search
            try:
                qfib = np.load(cfg.find_orientations.use_quaternion_grid)
            except(IOError):
                raise RuntimeError(
                    "specified quaternion grid file '%s' not found!"
                    % cfg.find_orientations.use_quaternion_grid
                )

        # execute direct search
        pool = mp.Pool(
            ncpus,
            indexer.test_orientation_FF_init,
            (params, )
        )
        completeness = pool.map(indexer.test_orientation_FF_reduced, qfib.T)
        pool.close()
        pool.join()
    else:
        logger.info("\tusing map search with paintGrid on %d processes", ncpus)

        start = timeit.default_timer()

        # handle eta-ome maps
        eta_ome = load_eta_ome_maps(cfg, plane_data, imsd,
                                    hkls=hkls, clean=clean)

        # handle search space
        if cfg.find_orientations.use_quaternion_grid is None:
            # doing seeded search
            logger.info(
                "\tgenerating search quaternion list using %d processes",
                ncpus
            )
            start = timeit.default_timer()

            qfib = generate_orientation_fibers(cfg, eta_ome)
            logger.info("\t\t...took %f seconds",
                        timeit.default_timer() - start)
        else:
            # doing grid search
            try:
                qfib = np.load(cfg.find_orientations.use_quaternion_grid)
            except(IOError):
                raise RuntimeError(
                    "specified quaternion grid file '%s' not found!"
                    % cfg.find_orientations.use_quaternion_grid
                )
        # do map-based indexing
        start = timeit.default_timer()

        logger.info("will test %d quaternions using %d processes",
                    qfib.shape[1], ncpus)

        completeness = indexer.paintGrid(
            qfib,
            eta_ome,
            etaRange=np.radians(cfg.find_orientations.eta.range),
            omeTol=np.radians(cfg.find_orientations.omega.tolerance),
            etaTol=np.radians(cfg.find_orientations.eta.tolerance),
            omePeriod=np.radians(cfg.find_orientations.omega.period),
            threshold=on_map_threshold,
            doMultiProc=ncpus > 1,
            nCPUs=ncpus
            )
        logger.info("\t\t...took %f seconds",
                    timeit.default_timer() - start)
    completeness = np.array(completeness)

    logger.info("\tSaving %d scored orientations with max completeness %f%%",
                qfib.shape[1], 100*np.max(completeness))

    results = {}
    results['scored_orientations'] = {
        'test_quaternions': qfib,
        'score': completeness
    }

    # =========================================================================
    # CLUSTERING AND GRAINS OUTPUT
    # =========================================================================

    logger.info("\trunning clustering using '%s'", cl_algorithm)

    start = timeit.default_timer()

    if do_grid_search:
        min_samples = 1
        mean_rpg = 1
    else:
        min_samples, mean_rpg = create_clustering_parameters(cfg, eta_ome)

    logger.info("\tmean reflections per grain: %d", mean_rpg)
    logger.info("\tneighborhood size: %d", min_samples)

    qbar, cl = run_cluster(
        completeness, qfib, plane_data.getQSym(), cfg,
        min_samples=min_samples,
        compl_thresh=compl_thresh,
        radius=cl_radius)

    logger.info("\t\t...took %f seconds", (timeit.default_timer() - start))
    logger.info("\tfound %d grains", qbar.shape[1])

    results['qbar'] = qbar

    return results


# hexrd/fitgrains.py

#!/usr/bin/env python2
# -*- coding: utf-8 -*-
"""
Created on Wed Mar 22 19:04:10 2017

@author: bernier2
"""
import os
import logging
import multiprocessing
import numpy as np
import timeit

from hexrd import instrument
from hexrd.transforms import xfcapi
from hexrd.fitting import fitGrain, objFuncFitGrain, gFlag_ref

logger = logging.getLogger(__name__)


# multiprocessing fit funcs
def fit_grain_FF_init(params):
    """
    Broadcast the fitting parameters as globals for multiprocessing

    Parameters
    ----------
    params : dict
        The dictionary of fitting parameters.

    Returns
    -------
    None.

    Notes
    -----
    See fit_grain_FF_reduced for specification.
    """
    global paramMP
    paramMP = params


def fit_grain_FF_cleanup():
    """
    Tears down the global fitting parameters.
    """
    global paramMP
    del paramMP


def fit_grain_FF_reduced(grain_id):
    """
    Perform non-linear least-square fit for the specified grain.

    Parameters
    ----------
    grain_id : int
        The grain id.

    Returns
    -------
    grain_id : int
        The grain id.
    completeness : float
        The ratio of predicted to measured (observed) Bragg reflections.
    chisq: float
        Figure of merit describing the sum of squared residuals for each Bragg
        reflection in the form (x, y, omega) normalized by the total number of
        degrees of freedom.
    grain_params : array_like
        The optimized grain parameters
        [<orientation [3]>, <centroid [3]> <inverse stretch [6]>].

    Notes
    -----
    input parameters are
    [plane_data, instrument, imgser_dict,
    tth_tol, eta_tol, ome_tol, npdiv, threshold]
    """
    grains_table = paramMP['grains_table']
    plane_data = paramMP['plane_data']
    instrument = paramMP['instrument']
    imgser_dict = paramMP['imgser_dict']
    tth_tol = paramMP['tth_tol']
    eta_tol = paramMP['eta_tol']
    ome_tol = paramMP['ome_tol']
    npdiv = paramMP['npdiv']
    refit = paramMP['refit']
    threshold = paramMP['threshold']
    eta_ranges = paramMP['eta_ranges']
    ome_period = paramMP['ome_period']
    analysis_dirname = paramMP['analysis_dirname']
    prefix = paramMP['spots_filename']
    spots_filename = None if prefix is None else prefix % grain_id

    grain = grains_table[grain_id]
    grain_params = grain[3:15]

    for tols in zip(tth_tol, eta_tol, ome_tol):
        complvec, results = instrument.pull_spots(
            plane_data, grain_params,
            imgser_dict,
            tth_tol=tols[0],
            eta_tol=tols[1],
            ome_tol=tols[2],
            npdiv=npdiv, threshold=threshold,
            eta_ranges=eta_ranges,
            ome_period=ome_period,
            dirname=analysis_dirname, filename=spots_filename,
            return_spot_list=False,
            quiet=True, check_only=False, interp='nearest')

        # ======= DETERMINE VALID REFLECTIONS =======

        culled_results = dict.fromkeys(results)
        num_refl_tot = 0
        num_refl_valid = 0
        for det_key in culled_results:
            panel = instrument.detectors[det_key]

            '''
            grab panel results:
                peak_id
                hkl_id
                hkl
                sum_int
                max_int,
                pred_angs,
                meas_angs,
                meas_xy
            '''
            presults = results[det_key]
            nrefl = len(presults)

            # make data arrays
            refl_ids = np.empty(nrefl)
            max_int = np.empty(nrefl)
            for i, spot_data in enumerate(presults):
                refl_ids[i] = spot_data[0]
                max_int[i] = spot_data[4]

            valid_refl_ids = refl_ids >= 0

            # find unsaturated spots on this panel
            unsat_spots = np.ones(len(valid_refl_ids), dtype=bool)
            if panel.saturation_level is not None:
                unsat_spots[valid_refl_ids] = \
                    max_int[valid_refl_ids] < panel.saturation_level

            idx = np.logical_and(valid_refl_ids, unsat_spots)

            # if an overlap table has been written, load it and use it
            overlaps = np.zeros_like(idx, dtype=bool)
            try:
                ot = np.load(
                    os.path.join(
                        analysis_dirname, os.path.join(
                            det_key, 'overlap_table.npz'
                        )
                    )
                )
                for key in ot.keys():
                    for this_table in ot[key]:
                        these_overlaps = np.where(
                            this_table[:, 0] == grain_id)[0]
                        if len(these_overlaps) > 0:
                            mark_these = np.array(
                                this_table[these_overlaps, 1], dtype=int
                            )
                            otidx = [
                                np.where(refl_ids == mt)[0]
                                for mt in mark_these
                            ]
                            overlaps[otidx] = True
                idx = np.logical_and(idx, ~overlaps)
                # logger.info("found overlap table for '%s'", det_key)
            except(IOError, IndexError):
                # logger.info("no overlap table found for '%s'", det_key)
                pass

            # attach to proper dict entry
            # FIXME: want to avoid looping again here
            culled_results[det_key] = [presults[i] for i in np.where(idx)[0]]
            num_refl_tot += len(valid_refl_ids)
            num_refl_valid += sum(valid_refl_ids)

            pass  # now we have culled data

        # CAVEAT: completeness from pullspots only; incl saturated and overlaps
        # <JVB 2015-12-15>
        try:
            completeness = num_refl_valid / float(num_refl_tot)
        except(ZeroDivisionError):
            raise RuntimeError(
                "simulated number of relfections is 0; "
                + "check instrument config or grain parameters"
            )

        # ======= DO LEASTSQ FIT =======

        if num_refl_valid <= 12:    # not enough reflections to fit... exit
            return grain_id, completeness, np.inf, grain_params
        else:
            grain_params = fitGrain(
                    grain_params, instrument, culled_results,
                    plane_data.latVecOps['B'], plane_data.wavelength
                )
            # get chisq
            # TODO: do this while evaluating fit???
            chisq = objFuncFitGrain(
                    grain_params[gFlag_ref], grain_params, gFlag_ref,
                    instrument,
                    culled_results,
                    plane_data.latVecOps['B'], plane_data.wavelength,
                    ome_period,
                    simOnly=False, return_value_flag=2)
            pass  # end conditional on fit
        pass  # end tolerance looping

    if refit is not None:
        # first get calculated x, y, ome from previous solution
        # NOTE: this result is a dict
        xyo_det_fit_dict = objFuncFitGrain(
            grain_params[gFlag_ref], grain_params, gFlag_ref,
            instrument,
            culled_results,
            plane_data.latVecOps['B'], plane_data.wavelength,
            ome_period,
            simOnly=True, return_value_flag=2)

        # make dict to contain new culled results
        culled_results_r = dict.fromkeys(culled_results)
        num_refl_valid = 0
        for det_key in culled_results_r:
            presults = culled_results[det_key]

            if not presults:
                culled_results_r[det_key] = []
                continue

            ims = next(iter(imgser_dict.values()))  # grab first for the omes
            ome_step = sum(np.r_[-1, 1]*ims.metadata['omega'][0, :])

            xyo_det = np.atleast_2d(
                np.vstack([np.r_[x[7], x[6][-1]] for x in presults])
            )

            xyo_det_fit = xyo_det_fit_dict[det_key]

            xpix_tol = refit[0]*panel.pixel_size_col
            ypix_tol = refit[0]*panel.pixel_size_row
            fome_tol = refit[1]*ome_step

            # define difference vectors for spot fits
            x_diff = abs(xyo_det[:, 0] - xyo_det_fit['calc_xy'][:, 0])
            y_diff = abs(xyo_det[:, 1] - xyo_det_fit['calc_xy'][:, 1])
            ome_diff = np.degrees(
                xfcapi.angularDifference(xyo_det[:, 2],
                                         xyo_det_fit['calc_omes'])
                )

            # filter out reflections with centroids more than
            # a pixel and delta omega away from predicted value
            idx_new = np.logical_and(
                x_diff <= xpix_tol,
                np.logical_and(y_diff <= ypix_tol,
                               ome_diff <= fome_tol)
                               )

            # attach to proper dict entry
            culled_results_r[det_key] = [
                presults[i] for i in np.where(idx_new)[0]
            ]

            num_refl_valid += sum(idx_new)
            pass

        # only execute fit if left with enough reflections
        if num_refl_valid > 12:
            grain_params = fitGrain(
                grain_params, instrument, culled_results_r,
                plane_data.latVecOps['B'], plane_data.wavelength
            )
            # get chisq
            # TODO: do this while evaluating fit???
            chisq = objFuncFitGrain(
                    grain_params[gFlag_ref],
                    grain_params, gFlag_ref,
                    instrument,
                    culled_results_r,
                    plane_data.latVecOps['B'], plane_data.wavelength,
                    ome_period,
                    simOnly=False, return_value_flag=2)
            pass
        pass  # close refit conditional
    return grain_id, completeness, chisq, grain_params


def fit_grains(cfg,
               grains_table,
               show_progress=False,
               ids_to_refine=None,
               write_spots_files=True):
    """
    Performs optimization of grain parameters.

    operates on a single HEDM config block
    """

    # grab imageseries dict
    imsd = cfg.image_series

    # grab instrument
    instr = cfg.instrument.hedm

    # process plane data
    plane_data = cfg.material.plane_data
    tth_max = cfg.fit_grains.tth_max
    if isinstance(tth_max, bool):
        if tth_max:
            max_tth = instrument.max_tth(instr)
            plane_data.exclusions = None
            plane_data.tThMax = max_tth
            logger.info("\tsetting the maximum 2theta to instrument"
                        + " maximum: %.2f degrees",
                        np.degrees(max_tth))
        else:
            logger.info("\tnot adjusting exclusions in planeData")
    else:
        # a value for tth max has been specified
        plane_data.exclusions = None
        plane_data.tThMax = np.radians(tth_max)
        logger.info("\tsetting the maximum 2theta to %.2f degrees",
                    tth_max)

    # grab eta ranges and ome_period
    eta_ranges = np.radians(cfg.find_orientations.eta.range)

    # handle omega period
    # !!! we assume all detector ims have the same ome ranges, so any will do!
    oims = next(iter(imsd.values()))
    ome_period = np.radians(oims.omega[0, 0] + np.r_[0., 360.])

    # number of processes
    ncpus = cfg.multiprocessing

    # threshold for fitting
    threshold = cfg.fit_grains.threshold

    if ids_to_refine is not None:
        grains_table = np.atleast_2d(grains_table[ids_to_refine, :])
    spots_filename = "spots_%05d.out" if write_spots_files else None
    params = dict(
            grains_table=grains_table,
            plane_data=plane_data,
            instrument=instr,
            imgser_dict=imsd,
            tth_tol=cfg.fit_grains.tolerance.tth,
            eta_tol=cfg.fit_grains.tolerance.eta,
            ome_tol=cfg.fit_grains.tolerance.omega,
            npdiv=cfg.fit_grains.npdiv,
            refit=cfg.fit_grains.refit,
            threshold=threshold,
            eta_ranges=eta_ranges,
            ome_period=ome_period,
            analysis_dirname=cfg.analysis_dir,
            spots_filename=spots_filename)

    # =====================================================================
    # EXECUTE MP FIT
    # =====================================================================

    # DO FIT!
    if len(grains_table) == 1 or ncpus == 1:
        logger.info("\tstarting serial fit")
        start = timeit.default_timer()
        fit_grain_FF_init(params)
        fit_results = list(
            map(fit_grain_FF_reduced,
                np.array(grains_table[:, 0], dtype=int))
        )
        fit_grain_FF_cleanup()
        elapsed = timeit.default_timer() - start
    else:
        nproc = min(ncpus, len(grains_table))
        chunksize = max(1, len(grains_table)//ncpus)
        logger.info("\tstarting fit on %d processes with chunksize %d",
                    nproc, chunksize)
        start = timeit.default_timer()
        pool = multiprocessing.Pool(
            nproc,
            fit_grain_FF_init,
            (params, )
        )
        fit_results = pool.map(
            fit_grain_FF_reduced,
            np.array(grains_table[:, 0], dtype=int),
            chunksize=chunksize
        )
        pool.close()
        pool.join()
        elapsed = timeit.default_timer() - start
    logger.info("fitting took %f seconds", elapsed)
    return fit_results


# hexrd/gridutil.py

# -*- coding: utf-8 -*-
# =============================================================================
# Copyright (c) 2012, Lawrence Livermore National Security, LLC.
# Produced at the Lawrence Livermore National Laboratory.
# Written by Joel Bernier <bernier2@llnl.gov> and others.
# LLNL-CODE-529294.
# All rights reserved.
#
# This file is part of HEXRD. For details on dowloading the source,
# see the file COPYING.
#
# Please also see the file LICENSE.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License (as published by the Free
# Software Foundation) version 2.1 dated February 1999.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY OF MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the terms and conditions of the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this program (see file LICENSE); if not, write to
# the Free Software Foundation, Inc., 59 Temple Place, Suite 330,
# Boston, MA 02111-1307 USA or visit <http://www.gnu.org/licenses/>.
# =============================================================================
import numpy as np
from numpy.linalg import det

from hexrd.constants import USE_NUMBA, sqrt_epsf
if USE_NUMBA:
    import numba


def cellIndices(edges, points_1d):
    """
    get indices in a 1-d regular grid.

    edges are just that:

    point:            x (2.5)
                      |
    edges:   |1    |2    |3    |4    |5
             -------------------------
    indices: |  0  |  1  |  2  |  3  |
             -------------------------

    above the deltas are + and the index for the point is 1

    point:                  x (2.5)
                            |
    edges:   |5    |4    |3    |2    |1
             -------------------------
    indices: |  0  |  1  |  2  |  3  |
             -------------------------

    here the deltas are - and the index for the point is 2

    * can handle grids with +/- deltas
    * be careful when using with a cyclical angular array!  edges and points
      must be mapped to the same branch cut, and
      abs(edges[0] - edges[-1]) = 2*pi
    """
    ztol = sqrt_epsf

    assert len(edges) >= 2, "must have at least 2 edges"

    points_1d = np.r_[points_1d].flatten()
    delta = float(edges[1] - edges[0])

    if delta > 0:
        on_last_rhs = points_1d >= edges[-1] - ztol
        points_1d[on_last_rhs] = points_1d[on_last_rhs] - ztol
        idx = np.floor((points_1d - edges[0]) / delta)
    elif delta < 0:
        on_last_rhs = points_1d <= edges[-1] + ztol
        points_1d[on_last_rhs] = points_1d[on_last_rhs] + ztol
        idx = np.ceil((points_1d - edges[0]) / delta) - 1
    else:
        raise RuntimeError("edges array gives delta of 0")

    # # mark points outside range with NaN
    # off_lo = idx < 0
    # off_hi = idx >= len(edges) - 1
    # if np.any(off_lo):
    #     idx[off_lo] = np.nan
    # if np.any(off_hi):
    #     idx[off_hi] = np.nan
    return np.array(idx, dtype=int)


def _fill_connectivity(out, m, n, p):
    i_con = 0
    for k in range(p):
        for j in range(m):
            for i in range(n):
                extra = k*(n+1)*(m+1)
                out[i_con, 0] = i + j*(n + 1) + 1 + extra
                out[i_con, 1] = i + j*(n + 1) + extra
                out[i_con, 2] = i + j + n*(j+1) + 1 + extra
                out[i_con, 3] = i + j + n*(j+1) + 2 + extra
                i_con += 1


if USE_NUMBA:
    _fill_connectivity = numba.njit(nogil=True, cache=True)(_fill_connectivity)


def cellConnectivity(m, n, p=1, origin='ul'):
    """
    p x m x n (layers x rows x cols)

    origin can be upper left -- 'ul' <default> or lower left -- 'll'

    choice will affect handedness (cw or ccw)
    """
    nele = p*m*n
    con = np.empty((nele, 4), dtype=int)

    _fill_connectivity(con, m, n, p)

    if p > 1:
        nele = m*n*(p-1)
        tmp_con3 = con.reshape(p, m*n, 4)
        hex_con = []
        for layer in range(p - 1):
            hex_con.append(np.hstack([tmp_con3[layer], tmp_con3[layer + 1]]))
        con = np.vstack(hex_con)
        pass
    if origin.lower().strip() == 'll':
        con = con[:, ::-1]
    return con


if USE_NUMBA:
    @numba.njit(nogil=True, cache=True)  # relies on loop extraction
    def cellCentroids(crd, con):
        nele, conn_count = con.shape
        dim = crd.shape[1]
        out = np.empty((nele, dim))
        inv_conn = 1.0/conn_count
        for i in range(nele):
            for j in range(dim):
                acc = 0.0
                for k in range(conn_count):
                    acc += crd[con[i, k], j]
                out[i, j] = acc * inv_conn
        return out

    @numba.njit(nogil=True, cache=True)
    def compute_areas(xy_eval_vtx, conn):
        areas = np.empty(len(conn))
        for i in range(len(conn)):
            c0, c1, c2, c3 = conn[i]
            vtx0x, vtx0y = xy_eval_vtx[conn[i, 0]]
            vtx1x, vtx1y = xy_eval_vtx[conn[i, 1]]
            v0x, v0y = vtx1x-vtx0x, vtx1y-vtx0y
            acc = 0
            for j in range(2, 4):
                vtx_x, vtx_y = xy_eval_vtx[conn[i, j]]
                v1x = vtx_x - vtx0x
                v1y = vtx_y - vtx0y
                acc += v0x*v1y - v1x*v0y

            areas[i] = 0.5 * acc
        return areas
else:
    def cellCentroids(crd, con):
        """
        con.shape = (nele, 4)
        crd.shape = (ncrd, 2)

        con.shape = (nele, 8)
        crd.shape = (ncrd, 3)
        """
        nele = con.shape[0]
        dim = crd.shape[1]
        centroid_xy = np.zeros((nele, dim))
        for i in range(len(con)):
            el_crds = crd[con[i, :], :]  # (4, 2)
            centroid_xy[i, :] = (el_crds).mean(axis=0)
        return centroid_xy

    def compute_areas(xy_eval_vtx, conn):
        areas = np.zeros(len(conn))
        for i in range(len(conn)):
            polygon = [[xy_eval_vtx[conn[i, j], 0],
                        xy_eval_vtx[conn[i, j], 1]] for j in range(4)]
            areas[i] = computeArea(polygon)
        return areas


def computeArea(polygon):
    """
    must be ORDERED and CONVEX!
    """
    n_vertices = len(polygon)
    polygon = np.array(polygon)

    triv = np.array([[[0, i - 1], [0, i]] for i in range(2, n_vertices)])

    area = 0
    for i in range(len(triv)):
        tvp = np.diff(
            np.hstack([polygon[triv[i][0], :],
                       polygon[triv[i][1], :]]), axis=0).flatten()
        area += 0.5 * np.cross(tvp[:2], tvp[2:])
    return area


def make_tolerance_grid(bin_width, window_width, num_subdivisions,
                        adjust_window=False, one_sided=False):
    if bin_width > window_width:
        bin_width = window_width
    if adjust_window:
        window_width = np.ceil(window_width/bin_width)*bin_width
    if one_sided:
        ndiv = abs(int(window_width/bin_width))
        grid = (np.arange(0, 2*ndiv+1) - ndiv)*bin_width
        ndiv = 2*ndiv
    else:
        ndiv = int(num_subdivisions*np.ceil(window_width/float(bin_width)))
        grid = np.arange(0, ndiv+1)*window_width/float(ndiv) - 0.5*window_width
    return ndiv, grid


def computeIntersection(line1, line2):
    """
    compute intersection of two-dimensional line intersection

    this is an implementation of two lines:

    line1 = [ [x0, y0], [x1, y1] ]
    line1 = [ [x3, y3], [x4, y4] ]


     <http://en.wikipedia.org/wiki/Line-line_intersection>
    """
    intersection = np.zeros(2)

    l1 = np.array(line1)
    l2 = np.array(line2)

    det_l1 = det(l1)
    det_l2 = det(l2)

    det_l1_x = det(np.vstack([l1[:, 0], np.ones(2)]).T)
    det_l1_y = det(np.vstack([l1[:, 1], np.ones(2)]).T)

    det_l2_x = det(np.vstack([l2[:, 0], np.ones(2)]).T)
    det_l2_y = det(np.vstack([l2[:, 1], np.ones(2)]).T)

    denominator = det(
        np.vstack([[det_l1_x, det_l1_y], [det_l2_x, det_l2_y]])
    )

    if denominator == 0:
        intersection = []
    else:
        intersection[0] = det(
            np.vstack(
                [[det_l1, det_l1_x], [det_l2, det_l2_x]]
            )
        ) / denominator
        intersection[1] = det(
            np.vstack(
                [[det_l1, det_l1_y], [det_l2, det_l2_y]]
            )
        ) / denominator
    return intersection


def isinside(point, boundary, ccw=True):
    """
    Assumes CCW boundary ordering
    """
    pointPositionVector = np.hstack([point - boundary[0, :], 0.])
    boundaryVector = np.hstack([boundary[1, :] - boundary[0, :], 0.])

    crossVector = np.cross(pointPositionVector, boundaryVector)

    inside = False
    if crossVector[2] > 0:
        if ccw:
            inside = True
    elif crossVector[2] < 0:
        if not ccw:
            inside = True
    else:
        inside = True

    return inside


def sutherlandHodgman(subjectPolygon, clipPolygon):
    """
    """
    subjectPolygon = np.array(subjectPolygon)
    clipPolygon = np.array(clipPolygon)

    numClipEdges = len(clipPolygon)

    prev_clipVertex = clipPolygon[-1, :]

    # loop over clipping edges
    outputList = np.array(subjectPolygon)
    for iClip in range(numClipEdges):

        curr_clipVertex = clipPolygon[iClip, :]

        clipBoundary = np.vstack(
            [curr_clipVertex,
             prev_clipVertex]
        )

        inputList = np.array(outputList)
        if len(inputList) > 0:
            prev_subjectVertex = inputList[-1, :]

        outputList = []

        for iInput in range(len(inputList)):

            curr_subjectVertex = inputList[iInput, :]

            if isinside(curr_subjectVertex, clipBoundary):
                if not isinside(prev_subjectVertex, clipBoundary):
                    subjectLineSegment = np.vstack(
                        [curr_subjectVertex, prev_subjectVertex]
                    )
                    outputList.append(
                        computeIntersection(subjectLineSegment, clipBoundary)
                    )
                    pass
                outputList.append(curr_subjectVertex)
            elif isinside(prev_subjectVertex, clipBoundary):
                subjectLineSegment = np.vstack(
                    [curr_subjectVertex, prev_subjectVertex]
                )
                outputList.append(
                    computeIntersection(subjectLineSegment, clipBoundary)
                )
                pass
            prev_subjectVertex = curr_subjectVertex
            prev_clipVertex = curr_clipVertex
            pass
        pass
    return outputList


# hexrd/imageutil.py

import numpy as np
from scipy import signal, ndimage

from skimage.feature import blob_dog, blob_log
from skimage.exposure import rescale_intensity

from hexrd import convolution
from hexrd.constants import fwhm_to_sigma


# =============================================================================
# BACKGROUND REMOVAL
# =============================================================================

def _scale_image_snip(y, offset, invert=False):
    """
    Log-Log scale image for snip

    Parameters
    ----------
    y : TYPE
        DESCRIPTION.
    offset : TYPE
        DESCRIPTION.
    invert : TYPE, optional
        DESCRIPTION. The default is False.

    Returns
    -------
    TYPE
        DESCRIPTION.

    Notes
    -----
    offset should be <= min of the original image

    """
    if invert:
        return (np.exp(np.exp(y) - 1.) - 1.)**2 + offset
    else:
        return np.log(np.log(np.sqrt(y - offset) + 1.) + 1.)


def fast_snip1d(y, w=4, numiter=2):
    """
    """
    bkg = np.zeros_like(y)
    min_val = np.nanmin(y)
    zfull = _scale_image_snip(y, min_val, invert=False)
    for k, z in enumerate(zfull):
        b = z
        for i in range(numiter):
            for p in range(w, 0, -1):
                kernel = np.zeros(p*2 + 1)
                kernel[0] = 0.5
                kernel[-1] = 0.5
                b = np.minimum(b, signal.fftconvolve(z, kernel, mode='same'))
            z = b
        bkg[k, :] = _scale_image_snip(b, min_val, invert=True)
    return bkg


def snip1d(y, w=4, numiter=2, threshold=None):
    """
    Return SNIP-estimated baseline-background for given spectrum y.

    !!!: threshold values get marked as NaN in convolution
    !!!: mask in astropy's convolve is True for masked; set to NaN
    """
    # scale input
    bkg = np.zeros_like(y)
    min_val = np.nanmin(y)
    zfull = _scale_image_snip(y, min_val, invert=False)

    # handle mask
    if threshold is not None:
        mask = y <= threshold
    else:
        mask = np.zeros_like(y, dtype=bool)

    # step through rows
    for k, z in enumerate(zfull):
        if np.all(mask[k]):
            bkg[k, :] = np.nan
        else:
            b = z
            for i in range(numiter):
                for p in range(w, 0, -1):
                    kernel = np.zeros(p*2 + 1)
                    kernel[0] = kernel[-1] = 1./2.
                    b = np.minimum(
                        b,
                        convolution.convolve(
                            z, kernel, boundary='extend', mask=mask[k],
                            nan_treatment='interpolate', preserve_nan=True
                        )
                    )
                z = b
            bkg[k, :] = _scale_image_snip(b, min_val, invert=True)
    nan_idx = np.isnan(bkg)
    bkg[nan_idx] = threshold
    return bkg


def snip1d_quad(y, w=4, numiter=2):
    """Return SNIP-estimated baseline-background for given spectrum y.

    Adds a quadratic kernel convolution in parallel with the linear kernel."""
    min_val = np.nanmin(y)
    kernels = []
    for p in range(w, 1, -2):
        N = p * 2 + 1
        # linear kernel
        kern1 = np.zeros(N)
        kern1[0] = kern1[-1] = 1./2.

        # quadratic kernel
        kern2 = np.zeros(N)
        kern2[0] = kern2[-1] = -1./6.
        kern2[int(p/2.)] = kern2[int(3.*p/2.)] = 4./6.
        kernels.append([kern1, kern2])

    z = b = _scale_image_snip(y, min_val, invert=False)
    for i in range(numiter):
        for (kern1, kern2) in kernels:
            c = np.maximum(ndimage.convolve1d(z, kern1, mode='nearest'),
                           ndimage.convolve1d(z, kern2, mode='nearest'))
            b = np.minimum(b, c)
        z = b

    return _scale_image_snip(b, min_val, invert=True)


def snip2d(y, w=4, numiter=2, order=1):
    """
    Return estimate of 2D-array background by "clipping" peak-like structures.

    2D adaptation of the peak-clipping component of the SNIP algorithm.

    Parameters
    ----------
    y : 2-D input array
    w : integer (default 4)
        kernel size (maximum kernel extent actually = 2 * w * order + 1)
    numiter : integer (default 2)
        number of iterations
    order : integer (default 1)
        maximum order of filter kernel, either 1 (linear) or 2 (quadratic)

    Returns
    -------
    out : 2-D array with SNIP-estimated background of y

    References!!!
    -----
    [1] C.G. Ryan et al, "SNIP, A statistics-sensitive background treatment
          for the quantitative analysis of PIXE spectra in geoscience
          applications," Nucl. Instr. and Meth. B 34, 396 (1988).
    [2] M. Morhac et al., "Background elimination methods for multidimensional
          coincidence gamma-ray spectra," Nucl. Instr. and Meth. A 401, 113
          (1997).
    """
    maximum, minimum = np.fmax, np.fmin
    min_val = np.nanmin(y)

    # create list of kernels
    kernels = []
    for p in range(w, 0, -1):  # decrement window starting from w
        N = 2 * p * order + 1  # size of filter kernels
        p1 = order * p

        # linear filter kernel
        kern1 = np.zeros((N, N))  # initialize a kernel with all zeros
        xx, yy = np.indices(kern1.shape)  # x-y indices of kernel points
        ij = np.round(
            np.hypot(xx - p1, yy - p1)
        ) == p1  # select circular shape
        kern1[ij] = 1 / ij.sum()  # normalize so sum of kernel elements is 1
        kernels.append([kern1])

        if order >= 2:  # add quadratic filter kernel
            p2 = p1 // 2
            kern2 = np.zeros_like(kern1)
            radii, norms = (p2, 2 * p2), (4/3, -1/3)
            for radius, norm in zip(radii, norms):
                ij = np.round(np.hypot(xx - p1, yy - p1)) == radius
                kern2[ij] = norm / ij.sum()
            kernels[-1].append(kern2)

    # convolve kernels with input array (in log space)
    z = b = _scale_image_snip(y, min_val, invert=False)
    for i in range(numiter):
        for kk in kernels:
            if order > 1:
                c = maximum(ndimage.convolve(z, kk[0], mode='nearest'),
                            ndimage.convolve(z, kk[1], mode='nearest'))
            else:
                c = ndimage.convolve(z, kk[0], mode='nearest')
            b = minimum(b, c)
        z = b

    return _scale_image_snip(b, min_val, invert=True)


# =============================================================================
# FEATURE DETECTION
# =============================================================================


def find_peaks_2d(img, method, method_kwargs):
    if method == 'label':
        # labeling mask
        structureNDI_label = ndimage.generate_binary_structure(2, 1)

        # First apply filter if specified
        filter_fwhm = method_kwargs['filter_radius']
        if filter_fwhm:
            filt_stdev = fwhm_to_sigma * filter_fwhm
            img = -ndimage.filters.gaussian_laplace(
                img, filt_stdev
            )

        labels_t, numSpots_t = ndimage.label(
            img > method_kwargs['threshold'],
            structureNDI_label
            )
        coms_t = np.atleast_2d(
            ndimage.center_of_mass(
                img,
                labels=labels_t,
                index=np.arange(1, np.amax(labels_t) + 1)
                )
            )
    elif method in ['blob_log', 'blob_dog']:
        # must scale map
        # TODO: we should so a parameter study here
        scl_map = rescale_intensity(img, out_range=(-1, 1))

        # TODO: Currently the method kwargs must be explicitly specified
        #       in the config, and there are no checks
        # for 'blob_log': min_sigma=0.5, max_sigma=5,
        #                 num_sigma=10, threshold=0.01, overlap=0.1
        # for 'blob_dog': min_sigma=0.5, max_sigma=5,
        #                 sigma_ratio=1.6, threshold=0.01, overlap=0.1
        if method == 'blob_log':
            blobs = np.atleast_2d(
                blob_log(scl_map, **method_kwargs)
            )
        else:  # blob_dog
            blobs = np.atleast_2d(
                blob_dog(scl_map, **method_kwargs)
            )
        numSpots_t = len(blobs)
        coms_t = blobs[:, :2]

    return numSpots_t, coms_t


# hexrd/indexer.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# =============================================================================
# Copyright (c) 2012, Lawrence Livermore National Security, LLC.
# Produced at the Lawrence Livermore National Laboratory.
# Written by Joel Bernier <bernier2@llnl.gov> and others.
# LLNL-CODE-529294.
# All rights reserved.
#
# This file is part of HEXRD. For details on dowloading the source,
# see the file COPYING.
#
# Please also see the file LICENSE.
#
# This program is free software; you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License (as published by
# the Free Software Foundation) version 2.1 dated February 1999.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY OF MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the terms and conditions of the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this program (see file LICENSE); if not, write to
# the Free Software Foundation, Inc., 59 Temple Place, Suite 330,
# Boston, MA 02111-1307 USA or visit <http://www.gnu.org/licenses/>.
# =============================================================================

import logging

import multiprocessing

import numpy as np

import timeit

from hexrd import constants
from hexrd.transforms import xfcapi
from hexrd.constants import USE_NUMBA
if USE_NUMBA:
    import numba

# =============================================================================
# Parameters
# =============================================================================
omega_period_DFLT = np.radians(np.r_[-180., 180.])

paramMP = None
nCPUs_DFLT = multiprocessing.cpu_count()
logger = logging.getLogger(__name__)


# =============================================================================
# Methods
# =============================================================================
def paintGrid(quats, etaOmeMaps,
              threshold=None, bMat=None,
              omegaRange=None, etaRange=None,
              omeTol=constants.d2r, etaTol=constants.d2r,
              omePeriod=omega_period_DFLT,
              doMultiProc=False,
              nCPUs=None, debug=False):
    r"""
    Spherical map-based indexing algorithm, i.e. paintGrid.

    Given a list of trial orientations `quats` and an eta-omega intensity map
    object `etaOmeMaps`, this method executes a test to produce a completeness
    ratio for each orientation across the spherical inensity maps.

    Parameters
    ----------
    quats : (4, N) ndarray
        hstacked array of trial orientations in the form of unit quaternions.
    etaOmeMaps : object
        an spherical map object of type `hexrd.instrument.GenerateEtaOmeMaps`.
    threshold : float, optional
        threshold value on the etaOmeMaps.
    bMat : (3, 3) ndarray, optional
        the COB matrix from the reciprocal lattice to the reference crystal
        frame.  In not provided, the B in the planeData class in the etaOmeMaps
        is used.
    omegaRange : array_like, optional
        list of valid omega ranges in radians,
        e.g. np.radians([(-60, 60), (120, 240)])
    etaRange : array_like, optional
        list of valid eta ranges in radians,
        e.g. np.radians([(-85, 85), (95, 265)])
    omeTol : float, optional
        the tolerance to use in the omega dimension in radians.  Default is
        1 degree (0.017453292519943295)
    etaTol : float, optional
        the tolerance to use in the eta dimension in radians.  Default is
        1 degree (0.017453292519943295)
    omePeriod : (2, ) array_like, optional
        the period to use for omega angles in radians,
        e.g. np.radians([-180, 180])
    doMultiProc : bool, optional
        flag for enabling multiprocessing
    nCPUs : int, optional
        number of processes to use in case doMultiProc = True
    debug : bool, optional
        debugging mode flag

    Raises
    ------
    RuntimeError
        DESCRIPTION.

    Returns
    -------
    retval : (N, ) list
        completeness score list for `quats`.


    Notes
    -----
    Notes about the implementation algorithm (if needed).

    This can have multiple paragraphs.

    You may include some math:

    .. math:: X(e^{j\omega } ) = x(n)e^{ - j\omega n}

    And even use a Greek symbol like :math:`\omega` inline.

    References
    ----------
    Cite the relevant literature, e.g. [1]_.  You may also cite these
    references in the notes section above.

    .. [1] O. McNoleg, "The integration of GIS, remote sensing,
       expert systems and adaptive co-kriging for environmental habitat
       modelling of the Highland Haggis using object-oriented, fuzzy-logic
       and neural-network techniques," Computers & Geosciences, vol. 22,
       pp. 585-588, 1996.

    Examples
    --------
    These are written in doctest format, and should illustrate how to
    use the function.

    >>> a = [1, 2, 3]
    >>> print([x + 3 for x in a])
    [4, 5, 6]
    >>> print("a\nb")
    a
    b
    """
    quats = np.atleast_2d(quats)
    if quats.size == 4:
        quats = quats.reshape(4, 1)

    planeData = etaOmeMaps.planeData

    # !!! these are master hklIDs
    hklIDs = np.asarray(etaOmeMaps.iHKLList)
    hklList = planeData.getHKLs(*hklIDs).tolist()
    hkl_idx = planeData.getHKLID(
        planeData.getHKLs(*hklIDs).T,
        master=False
    )
    nHKLS = len(hklIDs)

    numEtas = len(etaOmeMaps.etaEdges) - 1
    numOmes = len(etaOmeMaps.omeEdges) - 1

    if threshold is None:
        threshold = np.zeros(nHKLS)
        for i in range(nHKLS):
            threshold[i] = np.mean(
                np.r_[np.mean(etaOmeMaps.dataStore[i]),
                      np.median(etaOmeMaps.dataStore[i])]
            )
    elif threshold is not None and not hasattr(threshold, '__len__'):
        threshold = threshold * np.ones(nHKLS)
    elif hasattr(threshold, '__len__'):
        if len(threshold) != nHKLS:
            raise RuntimeError("threshold list is wrong length!")
        else:
            print("INFO: using list of threshold values")
    else:
        raise RuntimeError(
            "unknown threshold option. should be a list of numbers or None"
            )
    if bMat is None:
        bMat = planeData.latVecOps['B']

    # ???
    # not positive why these are needed
    etaIndices = np.arange(numEtas)
    omeIndices = np.arange(numOmes)

    omeMin = None
    omeMax = None
    if omegaRange is None:  # FIXME
        omeMin = [np.min(etaOmeMaps.omeEdges), ]
        omeMax = [np.max(etaOmeMaps.omeEdges), ]
    else:
        omeMin = [omegaRange[i][0] for i in range(len(omegaRange))]
        omeMax = [omegaRange[i][1] for i in range(len(omegaRange))]
    if omeMin is None:
        omeMin = [-np.pi, ]
        omeMax = [np.pi, ]
    omeMin = np.asarray(omeMin)
    omeMax = np.asarray(omeMax)

    etaMin = None
    etaMax = None
    if etaRange is not None:
        etaMin = [etaRange[i][0] for i in range(len(etaRange))]
        etaMax = [etaRange[i][1] for i in range(len(etaRange))]
    if etaMin is None:
        etaMin = [-np.pi, ]
        etaMax = [np.pi, ]
    etaMin = np.asarray(etaMin)
    etaMax = np.asarray(etaMax)

    multiProcMode = nCPUs_DFLT > 1 and doMultiProc

    if multiProcMode:
        nCPUs = nCPUs or nCPUs_DFLT
        chunksize = min(quats.shape[1] // nCPUs, 10)
        logger.info(
            "using multiprocessing with %d processes and a chunk size of %d",
            nCPUs, chunksize
            )
    else:
        logger.info("running in serial mode")
        nCPUs = 1

    # Get the symHKLs for the selected hklIDs
    symHKLs = planeData.getSymHKLs()
    symHKLs = [symHKLs[id] for id in hkl_idx]
    # Restructure symHKLs into a flat NumPy HKL array with
    # each HKL stored contiguously (C-order instead of F-order)
    # symHKLs_ix provides the start/end index for each subarray
    # of symHKLs.
    symHKLs_ix = np.add.accumulate([0] + [s.shape[1] for s in symHKLs])
    symHKLs = np.vstack([s.T for s in symHKLs])

    # Pack together the common parameters for processing
    params = {
        'symHKLs': symHKLs,
        'symHKLs_ix': symHKLs_ix,
        'wavelength': planeData.wavelength,
        'hklList': hklList,
        'omeMin': omeMin,
        'omeMax': omeMax,
        'omeTol': omeTol,
        'omeIndices': omeIndices,
        'omePeriod': omePeriod,
        'omeEdges': etaOmeMaps.omeEdges,
        'etaMin': etaMin,
        'etaMax': etaMax,
        'etaTol': etaTol,
        'etaIndices': etaIndices,
        'etaEdges': etaOmeMaps.etaEdges,
        'etaOmeMaps': np.stack(etaOmeMaps.dataStore),
        'bMat': bMat,
        'threshold': np.asarray(threshold)
        }

    # do the mapping
    start = timeit.default_timer()
    retval = None
    if multiProcMode:
        # multiple process version
        pool = multiprocessing.Pool(nCPUs, paintgrid_init, (params, ))
        retval = pool.map(paintGridThis, quats.T, chunksize=chunksize)
        pool.close()
    else:
        # single process version.
        global paramMP
        paintgrid_init(params)    # sets paramMP
        retval = list(map(paintGridThis, quats.T))
        paramMP = None    # clear paramMP
    elapsed = (timeit.default_timer() - start)
    logger.info("paintGrid took %.3f seconds", elapsed)

    return retval


def _meshgrid2d(x, y):
    """
    Special-cased implementation of np.meshgrid.

    For just two arguments, (x, y). Found to be about 3x faster on some simple
    test arguments.

    Parameters
    ----------
    x : TYPE
        DESCRIPTION.
    y : TYPE
        DESCRIPTION.

    Returns
    -------
    r1 : TYPE
        DESCRIPTION.
    r2 : TYPE
        DESCRIPTION.

    """
    x, y = (np.asarray(x), np.asarray(y))
    shape = (len(y), len(x))
    dt = np.result_type(x, y)
    r1, r2 = (np.empty(shape, dt), np.empty(shape, dt))
    r1[...] = x[np.newaxis, :]
    r2[...] = y[:, np.newaxis]
    return (r1, r2)


def _normalize_ranges(starts, stops, offset, ccw=False):
    """
    Range normalization.

    Normalize in the range [offset, 2*pi+offset[ the ranges defined
    by starts and stops.

    Checking if an angle lies inside a range can be done in a way that
    is more efficient than using validateAngleRanges.

    Note this function assumes that ranges don't overlap.

    Parameters
    ----------
    starts : TYPE
        DESCRIPTION.
    stops : TYPE
        DESCRIPTION.
    offset : TYPE
        DESCRIPTION.
    ccw : TYPE, optional
        DESCRIPTION. The default is False.

    Raises
    ------
    ValueError
        DESCRIPTION.

    Returns
    -------
    TYPE
        DESCRIPTION.
    """
    if ccw:
        starts, stops = stops, starts

    # results are in the range of [0, 2*np.pi]
    if not np.all(starts < stops):
        raise ValueError('Invalid angle ranges')

    # If there is a range that spans more than 2*pi,
    # return the full range
    two_pi = 2 * np.pi
    if np.any((starts + two_pi) < stops + 1e-8):
        return np.array([offset, two_pi+offset])

    starts = np.mod(starts - offset, two_pi) + offset
    stops = np.mod(stops - offset, two_pi) + offset

    order = np.argsort(starts)
    result = np.hstack((starts[order, np.newaxis],
                        stops[order, np.newaxis])).ravel()
    # at this point, result is in its final form unless there
    # is wrap-around in the last segment. Handle this case:
    if result[-1] < result[-2]:
        new_result = np.empty((len(result)+2,), dtype=result.dtype)
        new_result[0] = offset
        new_result[1] = result[-1]
        new_result[2:-1] = result[0:-1]
        new_result[-1] = offset + two_pi
        result = new_result

    if not np.all(starts[1:] > stops[0:-2]):
        raise ValueError('Angle ranges overlap')

    return result


def paintgrid_init(params):
    """
    Initialize global variables for paintGrid.

    Parameters
    ----------
    params : dict
        multiprocessing parameter dictionary.

    Returns
    -------
    None.
    """
    global paramMP
    paramMP = params

    # create valid_eta_spans, valid_ome_spans from etaMin/Max and omeMin/Max
    # this allows using faster checks in the code.
    # TODO: build valid_eta_spans and valid_ome_spans directly in paintGrid
    #       instead of building etaMin/etaMax and omeMin/omeMax. It may also
    #       be worth handling range overlap and maybe "optimize" ranges if
    #       there happens to be contiguous spans.
    paramMP['valid_eta_spans'] = _normalize_ranges(paramMP['etaMin'],
                                                   paramMP['etaMax'],
                                                   -np.pi)

    paramMP['valid_ome_spans'] = _normalize_ranges(paramMP['omeMin'],
                                                   paramMP['omeMax'],
                                                   min(paramMP['omePeriod']))
    return


###############################################################################
#
# paintGridThis contains the bulk of the process to perform for paintGrid for a
# given quaternion. This is also used as the basis for multiprocessing, as the
# work is split in a per-quaternion basis among different processes.
# The remainding arguments are marshalled into the module variable "paramMP".
#
# There is a version of PaintGridThis using numba, and another version used
# when numba is not available. The numba version should be noticeably faster.
###############################################################################
def _check_dilated(eta, ome, dpix_eta, dpix_ome, etaOmeMap, threshold):
    """Part of paintGridThis.

    check if there exists a sample over the given threshold in the etaOmeMap
    at (eta, ome), with a tolerance of (dpix_eta, dpix_ome) samples.

    Note this function is "numba friendly" and will be jitted when using numba.

    TODO: currently behaves like "np.any" call for values above threshold.
    There is some ambigutiy if there are NaNs in the dilation range, but it
    hits a value above threshold first.  Is that ok???

    FIXME: works in non-numba implementation of paintGridThis only
    <JVB 2017-04-27>
    """
    i_max, j_max = etaOmeMap.shape
    ome_start, ome_stop = (
        max(ome - dpix_ome, 0),
        min(ome + dpix_ome + 1, i_max)
    )
    eta_start, eta_stop = (
        max(eta - dpix_eta, 0),
        min(eta + dpix_eta + 1, j_max)
    )

    for i in range(ome_start, ome_stop):
        for j in range(eta_start, eta_stop):
            if etaOmeMap[i, j] > threshold:
                return 1
            if np.isnan(etaOmeMap[i, j]):
                return -1
    return 0


if USE_NUMBA:
    def paintGridThis(quat):
        """Single instance paintGrid call.

        Note that this version does not use omeMin/omeMax to specify the valid
        angles. It uses "valid_eta_spans" and "valid_ome_spans". These are
        precomputed and make for a faster check of ranges than
        "validateAngleRanges"
        """
        symHKLs = paramMP['symHKLs']  # the HKLs
        symHKLs_ix = paramMP['symHKLs_ix']  # index partitioning of symHKLs
        bMat = paramMP['bMat']
        wavelength = paramMP['wavelength']
        omeEdges = paramMP['omeEdges']
        omeTol = paramMP['omeTol']
        omePeriod = paramMP['omePeriod']
        valid_eta_spans = paramMP['valid_eta_spans']
        valid_ome_spans = paramMP['valid_ome_spans']
        omeIndices = paramMP['omeIndices']
        etaEdges = paramMP['etaEdges']
        etaTol = paramMP['etaTol']
        etaIndices = paramMP['etaIndices']
        etaOmeMaps = paramMP['etaOmeMaps']
        threshold = paramMP['threshold']

        # dpix_ome and dpix_eta are the number of pixels for the tolerance in
        # ome/eta. Maybe we should compute this per run instead of per
        # quaternion
        del_ome = abs(omeEdges[1] - omeEdges[0])
        del_eta = abs(etaEdges[1] - etaEdges[0])
        dpix_ome = int(round(omeTol / del_ome))
        dpix_eta = int(round(etaTol / del_eta))

        # FIXME
        debug = False
        if debug:
            print(
                "using ome, eta dilitations of (%d, %d) pixels"
                % (dpix_ome, dpix_eta)
            )

        # get the equivalent rotation of the quaternion in matrix form (as
        # expected by oscillAnglesOfHKLs

        rMat = xfcapi.makeRotMatOfQuat(quat)

        # Compute the oscillation angles of all the symHKLs at once
        oangs_pair = xfcapi.oscillAnglesOfHKLs(symHKLs, 0., rMat, bMat,
                                               wavelength)
        # pdb.set_trace()
        return _filter_and_count_hits(oangs_pair[0], oangs_pair[1], symHKLs_ix,
                                      etaEdges, valid_eta_spans,
                                      valid_ome_spans, omeEdges, omePeriod,
                                      etaOmeMaps, etaIndices, omeIndices,
                                      dpix_eta, dpix_ome, threshold)

    @numba.njit(nogil=True, cache=True)
    def _find_in_range(value, spans):
        """
        Find the index in spans where value >= spans[i] and value < spans[i].

        spans is an ordered array where spans[i] <= spans[i+1]
        (most often < will hold).

        If value is not in the range [spans[0], spans[-1]], then
        -2 is returned.

        This is equivalent to "bisect_right" in the bisect package, in which
        code it is based, and it is somewhat similar to NumPy's searchsorted,
        but non-vectorized
        """
        if value < spans[0] or value >= spans[-1]:
            return -2

        # from the previous check, we know 0 is not a possible result
        li = 0
        ri = len(spans)

        while li < ri:
            mi = (li + ri) // 2
            if value < spans[mi]:
                ri = mi
            else:
                li = mi+1

        return li

    @numba.njit(nogil=True, cache=True)
    def _angle_is_hit(ang, eta_offset, ome_offset, hkl, valid_eta_spans,
                      valid_ome_spans, etaEdges, omeEdges, etaOmeMaps,
                      etaIndices, omeIndices, dpix_eta, dpix_ome, threshold):
        """Perform work on one of the angles.

        This includes:

        - filtering nan values

        - filtering out angles not in the specified spans

        - checking that the discretized angle fits into the sensor range (maybe
          this could be merged with the previous test somehow, for extra speed)

        - actual check for a hit, using dilation for the tolerance.

        Note the function returns both, if it was a hit and if it passed the
        filtering, as we'll want to discard the filtered values when computing
        the hit percentage.

        CAVEAT: added map-based nan filtering to _check_dilated; this may not
        be the best option.  Perhaps filter here? <JVB 2017-04-27>

        """
        tth, eta, ome = ang

        if np.isnan(tth):
            return 0, 0

        eta = _map_angle(eta, eta_offset)
        if _find_in_range(eta, valid_eta_spans) & 1 == 0:
            # index is even: out of valid eta spans
            return 0, 0

        ome = _map_angle(ome, ome_offset)
        if _find_in_range(ome, valid_ome_spans) & 1 == 0:
            # index is even: out of valid ome spans
            return 0, 0

        # discretize the angles
        eta_idx = _find_in_range(eta, etaEdges) - 1
        if eta_idx < 0:
            # out of range
            return 0, 0

        ome_idx = _find_in_range(ome, omeEdges) - 1
        if ome_idx < 0:
            # out of range
            return 0, 0

        eta = etaIndices[eta_idx]
        ome = omeIndices[ome_idx]
        isHit = _check_dilated(eta, ome, dpix_eta, dpix_ome,
                               etaOmeMaps[hkl], threshold[hkl])
        if isHit == -1:
            return 0, 0
        else:
            return isHit, 1

    @numba.njit(nogil=True, cache=True)
    def _filter_and_count_hits(angs_0, angs_1, symHKLs_ix, etaEdges,
                               valid_eta_spans, valid_ome_spans, omeEdges,
                               omePeriod, etaOmeMaps, etaIndices, omeIndices,
                               dpix_eta, dpix_ome, threshold):
        """Accumulate completeness scores.

        assumes:
        we want etas in -pi -> pi range
        we want omes in ome_offset -> ome_offset + 2*pi range

        Instead of creating an array with the angles of angs_0 and angs_1
        interleaved, in this numba version calls for both arrays are performed
        getting the angles from angs_0 and angs_1. this is done in this way to
        reuse hkl computation. This may not be that important, though.

        """
        eta_offset = -np.pi
        ome_offset = np.min(omePeriod)
        hits = 0
        total = 0
        curr_hkl_idx = 0
        end_curr = symHKLs_ix[1]
        count = len(angs_0)

        for i in range(count):
            if i >= end_curr:
                curr_hkl_idx += 1
                end_curr = symHKLs_ix[curr_hkl_idx+1]

            # first solution
            hit, not_filtered = _angle_is_hit(
                angs_0[i], eta_offset, ome_offset,
                curr_hkl_idx, valid_eta_spans,
                valid_ome_spans, etaEdges,
                omeEdges, etaOmeMaps, etaIndices,
                omeIndices, dpix_eta, dpix_ome,
                threshold)
            hits += hit
            total += not_filtered

            # second solution
            hit, not_filtered = _angle_is_hit(
                angs_1[i], eta_offset, ome_offset,
                curr_hkl_idx, valid_eta_spans,
                valid_ome_spans, etaEdges,
                omeEdges, etaOmeMaps, etaIndices,
                omeIndices, dpix_eta, dpix_ome,
                threshold)
            hits += hit
            total += not_filtered

        return float(hits)/float(total) if total != 0 else 0.0

    @numba.njit(nogil=True, cache=True)
    def _map_angle(angle, offset):
        """Numba-firendly equivalent to xf.mapAngle."""
        return np.mod(angle-offset, 2*np.pi)+offset

    # use a jitted version of _check_dilated
    _check_dilated = numba.njit(nogil=True, cache=True)(_check_dilated)
else:
    def paintGridThis(quat):
        """
        Single instance completeness test.

        Parameters
        ----------
        quat : (4,) array_like
            DESCRIPTION.

        Returns
        -------
        retval : float
            DESCRIPTION.

        """
        # unmarshall parameters into local variables
        symHKLs = paramMP['symHKLs']  # the HKLs
        symHKLs_ix = paramMP['symHKLs_ix']  # index partitioning of symHKLs
        bMat = paramMP['bMat']
        wavelength = paramMP['wavelength']
        omeEdges = paramMP['omeEdges']
        omeTol = paramMP['omeTol']
        omePeriod = paramMP['omePeriod']
        valid_eta_spans = paramMP['valid_eta_spans']
        valid_ome_spans = paramMP['valid_ome_spans']
        omeIndices = paramMP['omeIndices']
        etaEdges = paramMP['etaEdges']
        etaTol = paramMP['etaTol']
        etaIndices = paramMP['etaIndices']
        etaOmeMaps = paramMP['etaOmeMaps']
        threshold = paramMP['threshold']

        # dpix_ome and dpix_eta are the number of pixels for the tolerance in
        # ome/eta. Maybe we should compute this per run instead of
        # per-quaternion
        del_ome = abs(omeEdges[1] - omeEdges[0])
        del_eta = abs(etaEdges[1] - etaEdges[0])
        dpix_ome = int(round(omeTol / del_ome))
        dpix_eta = int(round(etaTol / del_eta))

        debug = False
        if debug:
            print("using ome, eta dilitations of (%d, %d) pixels"
                  % (dpix_ome, dpix_eta))

        # get the equivalent rotation of the quaternion in matrix form (as
        # expected by oscillAnglesOfHKLs

        rMat = xfcapi.makeRotMatOfQuat(quat)

        # Compute the oscillation angles of all the symHKLs at once
        oangs_pair = xfcapi.oscillAnglesOfHKLs(symHKLs, 0., rMat, bMat,
                                               wavelength)
        hkl_idx, eta_idx, ome_idx = _filter_angs(oangs_pair[0], oangs_pair[1],
                                                 symHKLs_ix, etaEdges,
                                                 valid_eta_spans, omeEdges,
                                                 valid_ome_spans, omePeriod)

        if len(hkl_idx > 0):
            hits, predicted = _count_hits(
                eta_idx, ome_idx, hkl_idx, etaOmeMaps,
                etaIndices, omeIndices, dpix_eta, dpix_ome,
                threshold)
            retval = float(hits) / float(predicted)
            if retval > 1:
                import pdb
                pdb.set_trace()
        return retval

    def _normalize_angs_hkls(angs_0, angs_1, omePeriod, symHKLs_ix):
        # Interleave the two produced oang solutions to simplify later
        # processing
        oangs = np.empty((len(angs_0)*2, 3), dtype=angs_0.dtype)
        oangs[0::2] = angs_0
        oangs[1::2] = angs_1

        # Map all of the angles at once
        oangs[:, 1] = xfcapi.mapAngle(oangs[:, 1])
        oangs[:, 2] = xfcapi.mapAngle(oangs[:, 2], omePeriod)

        # generate array of symHKLs indices
        symHKLs_ix = symHKLs_ix*2
        hkl_idx = np.empty((symHKLs_ix[-1],), dtype=int)
        start = symHKLs_ix[0]
        idx = 0
        for end in symHKLs_ix[1:]:
            hkl_idx[start:end] = idx
            start = end
            idx += 1

        return oangs, hkl_idx

    def _filter_angs(angs_0, angs_1, symHKLs_ix, etaEdges, valid_eta_spans,
                     omeEdges, valid_ome_spans, omePeriod):
        """Part of paintGridThis.

        Bakes data in a way that invalid (nan or out-of-bound) is discarded.

        Parameters
        ----------
        angs_0 : TYPE
            DESCRIPTION.
        angs_1 : TYPE
            DESCRIPTION.
        symHKLs_ix : TYPE
            DESCRIPTION.
        etaEdges : TYPE
            DESCRIPTION.
        valid_eta_spans : TYPE
            DESCRIPTION.
        omeEdges : TYPE
            DESCRIPTION.
        valid_ome_spans : TYPE
            DESCRIPTION.
        omePeriod : TYPE
            DESCRIPTION.

        Returns
        -------
        hkl_idx : ndarray
            associate hkl indices.
        eta_idx : ndarray
            associated eta indices of predicted.
        ome_idx : ndarray
            associated ome indices of predicted.

        """
        oangs, hkl_idx = _normalize_angs_hkls(
            angs_0, angs_1, omePeriod, symHKLs_ix)
        # using "right" side to make sure we always get an index *past* the
        # value if it happens to be equal; i.e. we search the index of the
        # first value that is "greater than" rather than "greater or equal"
        culled_eta_indices = np.searchsorted(etaEdges, oangs[:, 1],
                                             side='right')
        culled_ome_indices = np.searchsorted(omeEdges, oangs[:, 2],
                                             side='right')
        # this check is equivalent to validateAngleRanges:
        #
        # The spans contains an ordered sucession of start and end angles which
        # form the valid angle spans. So knowing if an angle is valid is
        # equivalent to finding the insertion point in the spans array and
        # checking if the resulting insertion index is odd or even. An odd
        # value means that it falls between a start and a end point of the
        # "valid span", meaning it is a hit. An even value will result in
        # either being out of the range (0 or the last index, as length is even
        # by construction) or that it falls between a "end" point from one span
        # and the "start" point of the next one.
        valid_eta = np.searchsorted(valid_eta_spans, oangs[:, 1], side='right')
        valid_ome = np.searchsorted(valid_ome_spans, oangs[:, 2], side='right')
        # fast odd/even check
        valid_eta = valid_eta & 1
        valid_ome = valid_ome & 1
        # Create a mask of the good ones
        valid = ~np.isnan(oangs[:, 0])  # tth not NaN
        valid = np.logical_and(valid, valid_eta)
        valid = np.logical_and(valid, valid_ome)
        valid = np.logical_and(valid, culled_eta_indices > 0)
        valid = np.logical_and(valid, culled_eta_indices < len(etaEdges))
        valid = np.logical_and(valid, culled_ome_indices > 0)
        valid = np.logical_and(valid, culled_ome_indices < len(omeEdges))

        hkl_idx = hkl_idx[valid]
        eta_idx = culled_eta_indices[valid] - 1
        ome_idx = culled_ome_indices[valid] - 1

        return hkl_idx, eta_idx, ome_idx

    def _count_hits(eta_idx, ome_idx, hkl_idx, etaOmeMaps,
                    etaIndices, omeIndices, dpix_eta, dpix_ome, threshold):
        """
        Part of paintGridThis.

        for every eta, ome, hkl check if there is a sample that surpasses the
        threshold in the eta ome map.
        """
        predicted = len(hkl_idx)
        hits = 0

        for curr_ang in range(predicted):
            culledEtaIdx = eta_idx[curr_ang]
            culledOmeIdx = ome_idx[curr_ang]
            iHKL = hkl_idx[curr_ang]
            # got a result
            eta = etaIndices[culledEtaIdx]
            ome = omeIndices[culledOmeIdx]
            isHit = _check_dilated(eta, ome, dpix_eta, dpix_ome,
                                   etaOmeMaps[iHKL], threshold[iHKL])

            if isHit > 0:
                hits += 1
            if isHit == -1:
                predicted -= 1

        return hits, predicted


# hexrd/material.py

# -*- coding: utf-8 -*-
# =============================================================================
# Copyright (c) 2012, Lawrence Livermore National Security, LLC.
# Produced at the Lawrence Livermore National Laboratory.
# Written by Joel Bernier <bernier2@llnl.gov> and others.
# LLNL-CODE-529294.
# All rights reserved.
#
# This file is part of HEXRD. For details on dowloading the source,
# see the file COPYING.
#
# Please also see the file LICENSE.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License (as published by the Free
# Software Foundation) version 2.1 dated February 1999.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY OF MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the terms and conditions of the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this program (see file LICENSE); if not, write to
# the Free Software Foundation, Inc., 59 Temple Place, Suite 330,
# Boston, MA 02111-1307 USA or visit <http://www.gnu.org/licenses/>.
# =============================================================================
"""
Module for XRD material class

Use the Material class directly for new materials.  Known
materials are defined by name in materialDict.
"""
from configparser import SafeConfigParser as Parser
import numpy

from hexrd.crystallography import PlaneData as PData
from hexrd.valunits import valWUnit
from hexrd import unitcell
from hexrd.constants import ptable, ptableinverse, chargestate
from hexrd import symmetry
import copy

from os import path
from pathlib import Path
from CifFile import ReadCif
import h5py
from warnings import warn
from hexrd.mksupport import Write2H5File
from hexrd.symbols import xtal_sys_dict
from hexrd.symbols import Hall_to_sgnum, HM_to_sgnum
from hexrd.utils.compatibility import h5py_read_string
from hexrd.fitting.peakfunctions import _unit_gaussian

__all__ = ['Material', 'loadMaterialList']


#
# ================================================== Module Data
#


def _angstroms(x):
    return valWUnit('lp', 'length',  x, 'angstrom')


def _degrees(x):
    return valWUnit('lp', 'angle',   x, 'degrees')


def _kev(x):
    return valWUnit('xrayenergy', 'energy',  x, 'keV')


def _key(x):
    return x.name


#
# ---------------------------------------------------CLASS:  Material
#


class Material(object):
    """Simple class for holding lattice parameters, accessible by name.

    The class references materials by name and contains lattice and
    space group data.
    default data is for nickel, but name is material
    """
    DFLT_NAME = 'material.xtal'
    DFLT_XTAL = 'Ni'
    DFLT_SGNUM = 225

    DFLT_LPARMS = [_angstroms(3.61), _angstroms(3.61), _angstroms(3.61),
                   _degrees(90.0), _degrees(90.0), _degrees(90.0)]
    DFLT_SSMAX = 100

    DFLT_KEV = valWUnit('wavelength', 'energy', 80.725e0, 'keV')
    DFLT_STR = 0.0025
    DFLT_TTH = numpy.radians(0.25)
    DFLT_TTHMAX = None
    """
    ATOMINFO    Fractional Atom Position of an atom in the
    unit cell followed by the site occupany and debye waller
    (U) factor in A^(-2)
    B is related to U by B = 8 pi^2 U

    ATOMTYPE    atomic number of all the different species in the unitcell
    """
    DFLT_ATOMINFO = numpy.array([[0., 0., 0., 1.]])
    DFLT_U = numpy.array([6.33E-3])
    DFLT_ATOMTYPE = numpy.array([28])
    DFLT_CHARGE = numpy.array(["0"])

    '''
    the dmin parameter is used to figure out the maximum sampling for g-vectors
    this parameter is in angstroms
    '''
    DFLT_DMIN = _angstroms(0.75)

    '''
    default stiffness tensor in voight notation
    '''
    DFLT_STIFFNESS = numpy.eye(6)

    '''
    some materials have more than one space group setting. for ex
    the diamond cubic system has two settings with the origin either
    at (0,0,0) or at (1/4,1/4,1/4) etc. this handle takes care of these
    cases. but the defaiult is always 0

    default space group setting
    '''
    DFLT_SGSETTING = 0

    def __init__(self, name=None,
                 material_file=None,
                 dmin=None,
                 kev=None,
                 sgsetting=DFLT_SGSETTING):
        """Constructor for Material

        name -- (str) name of crystal
        material_file -- (str) name of the material file
        which contains the crystal. this could be either cif
        or hdf5
        """
        if name is None:
            self._name = Material.DFLT_XTAL
        else:
            assert isinstance(name, str), "name must be a str"
            self._name = name

        self.description = ''

        self.sgsetting = sgsetting

        if material_file:

            # >> @ date 08/20/2020 SS removing dependence on hklmax
            if isinstance(material_file, (Path, str)):
                form = Path(material_file).suffix[1:]
            else:
                form = None

            h5_suffixes = ('h5', 'hdf5', 'xtal')

            if form == 'cif':
                self._readCif(material_file)
            elif isinstance(material_file, h5py.Group) or form in h5_suffixes:
                self._readHDFxtal(fhdf=material_file, xtal=name)
        else:
            # default name
            self._name = Material.DFLT_XTAL
            # Use default values
            self._lparms = Material.DFLT_LPARMS
            # self._hklMax = Material.DFLT_SSMAX
            #
            self.description = ''
            #
            self.sgnum = Material.DFLT_SGNUM
            self._sgsetting = Material.DFLT_SGSETTING
            #
            self._atominfo = Material.DFLT_ATOMINFO
            #
            self._U = Material.DFLT_U
            #
            self._atomtype = Material.DFLT_ATOMTYPE
            self._charge = Material.DFLT_CHARGE
            self._tThWidth = Material.DFLT_TTH
            #
            self._dmin = Material.DFLT_DMIN
            self._beamEnergy = Material.DFLT_KEV

        # If these were specified, they override any other method of
        # obtaining them (including loading them from files).
        if dmin is not None:
            self._dmin = dmin

        if kev is not None:
            self._beamEnergy = kev

        self._newUnitcell()
        self._newPdata()
        self.update_structure_factor()

    def __str__(self):
        """String representation"""
        s = 'Material:  %s\n' % self.name
        if self.description:
            s += '   description:  %s\n' % self.description
            pass
        s += '   plane Data:  %s' % str(self.planeData)
        return s

    def _reset_lparms(self):
        """
        @author Saransh Singh, Lawrence Livermore National Lab
        @date 03/11/2021 SS 1.0 original
        @details correctly initialize lattice parameters based
        on the space group number
        """
        lparms = [x.value for x in self._lparms]
        ltype = symmetry.latticeType(self.sgnum)
        lparms = [lparms[i] for i in unitcell._rqpDict[ltype][0]]
        lparms = unitcell._rqpDict[ltype][1](lparms)
        lparms_vu = []
        for i in range(6):
            if(i < 3):
                lparms_vu.append(_angstroms(lparms[i]))
            else:
                lparms_vu.append(_degrees(lparms[i]))

        self._lparms = lparms_vu

    def _newUnitcell(self):
        """
        @author Saransh Singh, Lawrence Livermore National Lab
        @date 03/11/2021 SS 1.0 original
        @details create a new unitcell class with everything initialized
        correctly
        """
        self._reset_lparms()
        self._unitcell = unitcell.unitcell(
            self._lparms, self.sgnum, self._atomtype, self._charge,
            self._atominfo, self._U,
            self._dmin.getVal('nm'), self._beamEnergy.value,
            self._sgsetting)

        if hasattr(self, 'stiffness'):
            self._unitcell.stiffness = self.stiffness
        else:
            self._unitcell.stiffness = Material.DFLT_STIFFNESS

        if pdata := getattr(self, '_pData', None):
            laue = self.unitcell._laueGroup
            reduced_lparms = self.reduced_lattice_parameters
            if pdata.laueGroup != laue or pdata.lparms != reduced_lparms:
                pdata.set_laue_and_lparms(laue, reduced_lparms)

    def _hkls_changed(self):
        # Call this when something happens that changes the hkls...
        self._newPdata()
        self.update_structure_factor()

    def _newPdata(self):
        """Create a new plane data instance if the hkls have changed"""
        # spaceGroup module calulates forbidden reflections
        '''
        >> @date 08/20/2020 SS removing dependence of planeData
        initialization on the spaceGroup module. everything is
        initialized using the unitcell module now

        >> @DATE 02/09/2021 SS adding initialization of exclusion
        from hdf5 file using a hkl list
        '''
        hkls = self.unitcell.getHKLs(self._dmin.getVal('nm')).T
        if old_pdata := getattr(self, '_pData', None):
            if hkls_match(hkls.T, old_pdata.getHKLs(allHKLs=True)):
                # There's no need to generate a new plane data object...
                return

            # Copy over attributes from the previous PlaneData object
            self._pData = PData(hkls, old_pdata, exclusions=None)

            # Get a mapping to new hkl indices
            old_indices, new_indices = map_hkls_to_new(old_pdata, self._pData)

            # Map the new exclusions to the old ones. New ones default to True.
            exclusions = numpy.ones(hkls.shape[1], dtype=bool)
            exclusions[new_indices] = old_pdata.exclusions[old_indices]

            if numpy.all(exclusions):
                # If they are all excluded, just set the default exclusions
                self.set_default_exclusions()
            else:
                self._pData.exclusions = exclusions
        else:
            # Make the PlaneData object from scratch...
            lprm = self.reduced_lattice_parameters
            laue = self.unitcell._laueGroup
            self._pData = PData(hkls, lprm, laue,
                                self._beamEnergy, Material.DFLT_STR,
                                tThWidth=self._tThWidth,
                                tThMax=Material.DFLT_TTHMAX)

            self.set_default_exclusions()

    def set_default_exclusions(self):
        if hasattr(self, 'hkl_from_file'):
            # If we loaded hkls from the file, use those
            self.enable_hkls_from_file()
        else:
            # Otherwise, enable only the first 5 by default
            self.enable_hkls_below_index(5)

    def enable_hkls_from_file(self):
        # Enable hkls from the file
        # 'hkl_from_file' must be an attribute on `self`
        exclusions = numpy.ones_like(self._pData.exclusions, dtype=bool)
        for i, g in enumerate(self._pData.hklDataList):
            if g['hkl'].tolist() in self.hkl_from_file.tolist():
                exclusions[i] = False

        self._pData.exclusions = exclusions

    def enable_hkls_below_index(self, index=5):
        # Enable hkls with indices less than @index
        exclusions = numpy.ones_like(self._pData.exclusions, dtype=bool)
        exclusions[:index] = False
        self._pData.exclusions = exclusions

    def enable_hkls_below_tth(self, tth_threshold=90.0):
        '''
          enable reflections with two-theta less than @tth_threshold degrees
        '''
        tth_threshold = numpy.radians(tth_threshold)

        tth = numpy.array([hkldata['tTheta']
                           for hkldata in self._pData.hklDataList])
        dflt_excl = numpy.ones(tth.shape, dtype=numpy.bool)
        dflt_excl2 = numpy.ones(tth.shape, dtype=numpy.bool)

        if(hasattr(self, 'hkl_from_file')):
            """
            hkls were read from the file so the exclusions will be set
            based on what hkls are present
            """
            for i, g in enumerate(self._pData.hklDataList):
                if(g['hkl'].tolist() in self.hkl_from_file.tolist()):
                    dflt_excl[i] = False

            dflt_excl2[~numpy.isnan(tth)] = \
                ~((tth[~numpy.isnan(tth)] >= 0.0) &
                  (tth[~numpy.isnan(tth)] <= tth_threshold))

            dflt_excl = numpy.logical_or(dflt_excl, dflt_excl2)

        else:
            dflt_excl[~numpy.isnan(tth)] = \
                ~((tth[~numpy.isnan(tth)] >= 0.0) &
                  (tth[~numpy.isnan(tth)] <= tth_threshold))
            dflt_excl[0] = False

        self._pData.exclusions = dflt_excl

    def update_structure_factor(self):
        hkls = self.planeData.getHKLs(allHKLs=True)
        sf = self.unitcell.CalcXRSF(hkls)
        self.planeData.set_structFact(sf)

    def compute_powder_overlay(self,
                               ttharray=numpy.linspace(0, 80, 2000),
                               fwhm=0.25,
                               scale=1.0):
        """
        this function computes a simulated spectra
        for using in place of lines for the powder
        overlay. inputs are simplified as compared
        to the typical LeBail/Rietveld computation.
        only a fwhm (in degrees) and scale are passed

        requested feature from Amy Jenei
        """
        tth = numpy.degrees(self.planeData.getTTh())  # convert to degrees
        Ip = self.planeData.powder_intensity
        self.powder_overlay = numpy.zeros_like(ttharray)
        for t, I in zip(tth, Ip):
            p = [t, fwhm]
            self.powder_overlay += scale*I*_unit_gaussian(p, ttharray)

    def remove_duplicate_atoms(self):
        """
        this function calls the same function in the
        unitcell class and updates planedata structure
        factors etc.
        """
        self.unitcell.remove_duplicate_atoms()
        self.atominfo = self.unitcell.atom_pos
        self.atomtype = self.unitcell.atom_type
        self.charge = self.unitcell.chargestates
        self._hkls_changed()

    def _readCif(self, fcif=DFLT_NAME+'.cif'):

        """
        >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab,
                        saransh1@llnl.gov
        >> @DATE:       10/16/2019 SS 1.0 original
        >> @DETAILS:    hexrd3 will have real structure factors and will
                        require the overhaul of the crystallography.
                        In this effort, we will have a cif reader and
                        also the HDF5 format reader in the material
                        class. We will be using pycifrw for i/o
        """

        try:
            cif = ReadCif(fcif)
        except RuntimeError:
            print("file not found")

        # read the file
        for k in cif.keys():
            if('_cell_length_a' in cif[k]):
                m = k
                break
        cifdata = cif[m]
        # cifdata = cif[cif.keys()[0]]

        # make sure the space group is present in the cif file, either as
        # international table number, hermann-maguain or hall symbol
        sgkey = ['_space_group_IT_number',
                 '_symmetry_space_group_name_h-m',
                 '_symmetry_space_group_name_hall',
                 '_symmetry_Int_Tables_number']

        sgdata = False
        for key in sgkey:
            sgdata = sgdata or (key in cifdata)
            if(sgdata):
                skey = key
                break

        if(not(sgdata)):
            raise RuntimeError(' No space group information in CIF file! ')

        sgnum = 0
        if skey is sgkey[0]:
            sgnum = int(cifdata[sgkey[0]])
        elif (skey is sgkey[1]):
            HM = cifdata[sgkey[1]]
            HM = HM.replace(" ", "")
            HM = HM.replace("_", "")
            sgnum = HM_to_sgnum[HM]
        elif (skey is sgkey[2]):
            hall = cifdata[sgkey[2]]
            hall = hall.replace(" ", "")
            sgnum = Hall_to_sgnum[HM]
        elif(skey is sgkey[3]):
            sgnum = int(cifdata[sgkey[3]])

        # lattice parameters
        lparms = []
        lpkey = ['_cell_length_a', '_cell_length_b',
                 '_cell_length_c', '_cell_angle_alpha',
                 '_cell_angle_beta', '_cell_angle_gamma']

        for key in lpkey:
            n = cifdata[key].find('(')
            if(n != -1):
                lparms.append(float(cifdata[key][:n]))
            else:
                lparms.append(float(cifdata[key]))

        for i in range(6):
            if(i < 3):
                lparms[i] = _angstroms(lparms[i])
            else:
                lparms[i] = _degrees(lparms[i])

        self._lparms = lparms
        self.sgnum = sgnum

        # fractional atomic site, occ and vibration amplitude
        fracsitekey = ['_atom_site_fract_x', '_atom_site_fract_y',
                       '_atom_site_fract_z', ]

        occ_U = ['_atom_site_occupancy',
                 '_atom_site_u_iso_or_equiv', '_atom_site_U_iso_or_equiv']

        sitedata = True
        for key in fracsitekey:
            sitedata = sitedata and (key in cifdata)

        if(not(sitedata)):
            raise RuntimeError(
                ' fractional site position is not present \
                or incomplete in the CIF file! ')

        atompos = []
        for key in fracsitekey:
            slist = cifdata[key]
            pos = []

            for p in slist:
                n = p.find('(')

                if(n != -1):
                    pos.append(p[:n])
                else:
                    pos.append(p)

            '''
            sometimes cif files have negative values so need to
            bring them back to fractional coordinates between 0-1
            '''
            pos = numpy.asarray(pos).astype(numpy.float64)
            pos, _ = numpy.modf(pos+100.0)
            atompos.append(pos)

        """note that the vibration amplitude, U is just the amplitude (in A)
            to convert to the typical B which occurs in the debye-waller
            factor, we will use the following formula
            B = 8 * pi ^2 * < U_av^2 >
            this will be done here so we dont have to worry about it later
        """

        pocc = (occ_U[0] in cifdata.keys())
        pU = (occ_U[1] in cifdata.keys()) or (occ_U[2] in cifdata.keys())

        if(not pocc):
            warn('occupation fraction not present. setting it to 1')
            occ = numpy.ones(atompos[0].shape)
            atompos.append(occ)
        else:
            slist = cifdata[occ_U[0]]
            occ = []
            for p in slist:
                n = p.find('(')

                if(n != -1):
                    occ.append(p[:n])
                else:
                    occ.append(p)

            chkstr = numpy.asarray([isinstance(x, str) for x in occ])
            occstr = numpy.array(occ)
            occstr[chkstr] = 1.0

            atompos.append(numpy.asarray(occstr).astype(numpy.float64))

        if(not pU):
            msg = (f"'Debye-Waller factors not present. "
                   f"setting to same values for all atoms.'")
            warn(msg)
            U = self.DFLT_U[0] * numpy.ones(atompos[0].shape)
            self._U = U
        else:
            if(occ_U[1] in cifdata.keys()):
                k = occ_U[1]
            else:
                k = occ_U[2]

            slist = cifdata[k]
            U = []
            for p in slist:
                n = p.find('(')

                if(n != -1):
                    U.append(p[:n])
                else:
                    U.append(p)

            chkstr = numpy.asarray([isinstance(x, str) for x in U])

            for ii, x in enumerate(chkstr):
                if x:
                    try:
                        U[ii] = float(U[ii])
                    except ValueError:
                        U[ii] = self.DFLT_U[0]

            self._U = numpy.asarray(U)
        '''
        format everything in the right shape etc.
        '''
        self._atominfo = numpy.asarray(atompos).T

        '''
        get atome types here i.e. the atomic number of atoms at each site
        '''
        atype = '_atom_site_type_symbol'
        patype = (atype in cifdata)
        if(not patype):
            raise RuntimeError('atom types not defined in cif file.')

        satype = cifdata[atype]
        atomtype = []
        charge = []
        for s in satype:
            if "+" in s:
                ss = s[:-2]
                c = s[-2:]
                if c[0] == '+':
                    c = c[::-1]
            elif "-" in s:
                ss = s[:-2]
                c = s[-2:]
                if c[0] == '-':
                    c = c[::-1]
            else:
                ss = s
                c = "0"

            atomtype.append(ptable[ss])
            charge.append(c)

        self._atomtype = numpy.asarray(atomtype).astype(numpy.int32)
        self._charge = charge
        self._sgsetting = 0

        self._dmin = Material.DFLT_DMIN
        self._beamEnergy = Material.DFLT_KEV
        self._tThWidth = Material.DFLT_TTH

    def _readHDFxtal(self, fhdf=DFLT_NAME, xtal=DFLT_NAME):
        """
        >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab,
                        saransh1@llnl.gov
        >> @DATE:       10/17/2019 SS 1.0 original
                        01/07/2021 SS 1.1 check for optional
                        stiffness in material file.
                        read and initialize unitcell stiffness field if present
        >> @DETAILS:    hexrd3 will have real structure factors and will
                        require the overhaul of the crystallography.
                        In this effort, we will have a HDF file reader
                        the file will be the same as the EMsoft xtal
                        file. h5py will be used for i/o
        """

        if isinstance(fhdf, (Path, str)):
            if not path.exists(fhdf):
                raise IOError('material file does not exist.')

            root_gid = h5py.File(fhdf, 'r')
            xtal = f'/{xtal}'
        elif isinstance(fhdf, h5py.Group):
            root_gid = fhdf
        else:
            raise Exception(f'Unknown type for fhdf: {fhdf}')

        if xtal not in root_gid:
            raise IOError('crystal doesn''t exist in material file.')

        gid = root_gid.get(xtal)

        sgnum = numpy.array(gid.get('SpaceGroupNumber'),
                            dtype=numpy.int32).item()
        """
            IMPORTANT NOTE:
            note that the latice parameters is nm by default
            hexrd on the other hand uses A as the default units, so we
            need to be careful and convert it right here, so there is no
            confusion later on
        """
        lparms = list(gid.get('LatticeParameters'))

        for i in range(6):
            if(i < 3):
                lparms[i] = _angstroms(lparms[i]*10.0)
            else:
                lparms[i] = _degrees(lparms[i])

        self._lparms = lparms

        # fill space group and lattice parameters
        self.sgnum = sgnum

        # the U factors are related to B by the relation B = 8pi^2 U
        self._atominfo = numpy.transpose(numpy.array(
            gid.get('AtomData'), dtype=numpy.float64))
        self._U = numpy.transpose(numpy.array(
            gid.get('U'), dtype=numpy.float64))

        # read atom types (by atomic number, Z)
        self._atomtype = numpy.array(gid.get('Atomtypes'), dtype=numpy.int32)
        if 'ChargeStates' in gid:
            self._charge = h5py_read_string(gid['ChargeStates'])
        else:
            self._charge = ['0']*self._atomtype.shape[0]
        self._atom_ntype = self._atomtype.shape[0]

        self._sgsetting = numpy.array(gid.get('SpaceGroupSetting'),
                                      dtype=numpy.int32).item()

        if('stiffness' in gid):
            # we're assuming the stiffness is in units of GPa
            self.stiffness = numpy.array(gid.get('stiffness'))
        elif('compliance' in gid):
            # we're assuming the compliance is in units of TPa^-1
            self.stiffness = numpy.linalg.inv(
                numpy.array(gid.get('compliance'))) * 1.e3
        else:
            self.stiffness = numpy.zeros([6, 6])

        # if dmin is present in file:
        if('dmin' in gid):
            # if dmin is present in the HDF5 file, then use that
            dmin = numpy.array(gid.get('dmin'),
                               dtype=numpy.float64).item()
            self._dmin = _angstroms(dmin*10.)
        else:
            self._dmin = Material.DFLT_DMIN

        # if kev is present in file
        if('kev' in gid):
            kev = numpy.array(gid.get('kev'),
                              dtype=numpy.float64).item()
            self._beamEnergy = _kev(kev)
        else:
            self._beamEnergy = Material.DFLT_KEV

        if 'tThWidth' in gid:
            tThWidth = numpy.array(gid.get('tThWidth'),
                                   dtype=numpy.float64).item()
            tThWidth = numpy.radians(tThWidth)
        else:
            tThWidth = Material.DFLT_TTH

        self._tThWidth = tThWidth

        if('hkls' in gid):
            self.hkl_from_file = numpy.array(gid.get('hkls'),
                                             dtype=numpy.int32)

        if isinstance(fhdf, (Path, str)):
            # Close the file...
            root_gid.close()

    def dump_material(self, file, path=None):
        '''
        get the atominfo dictionaary aand the lattice parameters
        '''
        AtomInfo = {}

        AtomInfo['file'] = file
        AtomInfo['xtalname'] = self.name
        AtomInfo['xtal_sys'] = xtal_sys_dict[self.unitcell.latticeType.lower()]
        AtomInfo['Z'] = self.unitcell.atom_type
        AtomInfo['charge'] = self.unitcell.chargestates
        AtomInfo['SG'] = self.unitcell.sgnum
        AtomInfo['SGsetting'] = self.unitcell.sgsetting
        AtomInfo['APOS'] = self.unitcell.atom_pos
        AtomInfo['U'] = self.unitcell.U
        AtomInfo['stiffness'] = self.unitcell.stiffness
        AtomInfo['hkls'] = self.planeData.getHKLs()
        AtomInfo['dmin'] = self.unitcell.dmin
        AtomInfo['kev'] = self.beamEnergy.getVal("keV")
        if self.planeData.tThWidth is None:
            AtomInfo['tThWidth'] = numpy.degrees(Material.DFLT_TTH)
        else:
            AtomInfo['tThWidth'] = numpy.degrees(self.planeData.tThWidth)
        '''
        lattice parameters
        '''
        lat_param = {'a': self.unitcell.a,
                     'b': self.unitcell.b,
                     'c': self.unitcell.c,
                     'alpha': self.unitcell.alpha,
                     'beta': self.unitcell.beta,
                     'gamma': self.unitcell.gamma}

        Write2H5File(AtomInfo, lat_param, path)

    # ============================== API
    #
    #  ========== Properties
    #

    # property:  spaceGroup

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, mat_name):
        assert isinstance(mat_name, str), "must set name to a str"
        self._name = mat_name

    @property
    def spaceGroup(self):
        """(read only) Space group"""
        return self._spaceGroup

    @property
    def vol(self):
        return self.unitcell.vol*1e3

    @property
    def lparms(self):
        return numpy.array([x.getVal("nm") if x.isLength() else
                            x.getVal("degrees") for x in self._lparms])

    @property
    def latticeType(self):
        return self.unitcell.latticeType

    @property
    def vol_per_atom(self):
        return self.unitcell.vol_per_atom

    @property
    def atom_pos(self):
        return self.unitcell.atom_pos

    @property
    def atom_type(self):
        return self.unitcell.atom_type

    @property
    def aniU(self):
        return self.unitcell.aniU

    @property
    def U(self):
        return self.unitcell.U

    @U.setter
    def U(self, Uarr):
        Uarr = numpy.array(Uarr)
        self._U = Uarr

    # property:  sgnum
    def _get_sgnum(self):
        """Get method for sgnum"""
        return self._sgnum

    def _set_sgnum(self, v):
        """Set method for sgnum
        >> @date 08/20/2020 SS removed planedata initialization
            everytime sgnum is updated singe everything is initialized
            using unitcell now
        """
        self._sgnum = v

        # Update the unit cell if there is one
        if hasattr(self, 'unitcell'):
            self._newUnitcell()
            self._hkls_changed()

    sgnum = property(_get_sgnum, _set_sgnum, None,
                     "Space group number")
    # property:  beamEnergy

    def _get_beamEnergy(self):
        """Get method for beamEnergy"""
        return self._beamEnergy

    def _set_beamEnergy(self, keV):
        """
        Set method for beamEnergy

        * note that units are assumed to be keV for
          float arguments.  Also can take a valWUnit
          instance
        """
        if(isinstance(keV, valWUnit)):
            self._beamEnergy = keV
        else:
            self._beamEnergy = valWUnit('kev', 'energy', keV, 'keV')

        '''
        acceleration potential is set in volts therefore the factor of 1e3
        @TODO make voltage valWUnit instance so this that we dont have to
        track this manually inn the future
        '''
        self.unitcell.voltage = self.beamEnergy.value*1e3
        self.planeData.wavelength = keV
        self._hkls_changed()

    beamEnergy = property(_get_beamEnergy, _set_beamEnergy, None,
                          "Beam energy in keV")

    """
    03/11/2021 SS 1.0 original
    """
    @property
    def unitcell(self):
        return self._unitcell

    @property
    def planeData(self):
        """(read only) Return the planeData attribute (lattice parameters)"""
        return self._pData

    # property:  latticeParameters
    @property
    def latticeParameters(self):
        return self._lparms

    @latticeParameters.setter
    def latticeParameters(self, v):
        """Set method for latticeParameters"""
        if(len(v) != 6):
            v = unitcell._rqpDict[self.unitcell.latticeType][1](v)
        lp = [_angstroms(v[i]) for i in range(3)]
        for i in range(3, 6):
            lp.append(_degrees(v[i]))
        self._lparms = lp
        self._newUnitcell()
        self._hkls_changed()

    @property
    def reduced_lattice_parameters(self):
        ltype = self.unitcell.latticeType
        return [self._lparms[i] for i in unitcell._rqpDict[ltype][0]]

    def _get_name(self):
        """Set method for name"""
        return self._name

    def _set_name(self, v):
        """Set method for name"""
        self._name = v

        return

    name = property(_get_name, _set_name, None,
                    "Name of material")

    @property
    def dmin(self):
        return self._dmin

    @dmin.setter
    def dmin(self, v):
        if self._dmin.getVal('angstrom') == v.getVal('angstrom'):
            return

        self._dmin = v

        # Update the unit cell
        self.unitcell.dmin = v.getVal('nm')

        self._hkls_changed()

    @property
    def charge(self):
        return self._charge

    @charge.setter
    def charge(self, vals):
        """
        first make sure the lengths are correct
        """
        if len(vals) != len(self.atomtype):
            msg = (f"incorrect size of charge. "
                   f"must be same size as number of aom types.")
            raise ValueError(msg)
        """
        now check if charge states are actually allowed
        """
        for ii, z in enumerate(self.atomtype):
            elem = ptableinverse[z]
            cs = chargestate[elem]
            if not vals[ii] in cs:
                msg = (f"element {elem} does not allow "
                       f"charge state of {vals[ii]}. "
                       f"allowed charge states are {cs}.")
                raise ValueError(msg)

        self._charge = vals
        # self._newUnitcell()
        # self.update_structure_factor()

    @property
    def absorption_length(self):
        return self.unitcell.absorption_length

    @property
    def natoms(self):
        return self.atominfo.shape[0]

    # property: "atominfo"

    def _get_atominfo(self):
        """get method for name"""
        return self._atominfo

    def _set_atominfo(self, v):
        """set method for name"""
        if v.ndim != 2:
            raise ValueError("input must be 2-d.")
        if v.shape[1] != 4:
            raise ValueError("enter x, y, z, occ as nx4 array")

        self._atominfo = v

    atominfo = property(
        _get_atominfo, _set_atominfo, None,
        "Information about atomic positions and electron number")

    # property: "atomtype"
    def _get_atomtype(self):
        """get method for name"""
        return self._atomtype

    def _set_atomtype(self, v):
        """set method for atomtype"""
        self._atomtype = v

    atomtype = property(
        _get_atomtype, _set_atomtype, None,
        "Information about atomic types")

    def _set_atomdata(self, atomtype, atominfo, U, charge):
        """
        sometimes the number of atom types and their
        positions are changed when creating a material.
        this was leading to error in updating the material
        since the atominfo anf atomtype were separately updated
        with the unitcell updated for each of those calls.
        the error resulted when there was a size mismatch.
        this routine allows for simulataneous update of the two
        so there is no discrepancy and any discrepancy detected
        here is real

        the first entry is the atomtype array and the second is
        the atominfo array and the final is the U data.
        @todo pass charge state as the fourth input
        for now all charge set to zero
        """

        # check for consistency of sizes here
        atomtype = numpy.array(atomtype)
        atominfo = numpy.array(atominfo)
        U = numpy.array(U)

        if atomtype.shape[0] != atominfo.shape[0]:
            msg = (f"inconsistent shapes: number of atoms "
                   f"types passed = {atomtype.shape[0]} \n"
                   f" number of atom positions passed = {atominfo.shape[0]}")
            raise ValueError(msg)

        if atomtype.shape[0] != U.shape[0]:
            msg = (f"inconsistent shapes: number of atoms "
                   f"types passed = {atomtype.shape[0]} \n"
                   f" U passed for {U.shape[0]} atoms.")
            raise ValueError(msg)

        if atominfo.shape[0] != U.shape[0]:
            msg = (f"inconsistent shapes: number of atom "
                   f"positions passed = {atominfo.shape[0]} \n"
                   f"U passed for {U.shape[0]} atoms.")
            raise ValueError(msg)

        if len(charge) != atomtype.shape[0]:
            msg = (f"inconsistent shapes: number of atoms "
                   f"types passed = {atomtype.shape[0]} \n"
                   f"charge value passed for {len(charge)} atoms.")
            raise ValueError(msg)

        self.atomtype = atomtype
        self.atominfo = atominfo
        self.U = U
        self.charge = charge

        self._newUnitcell()
        self.update_structure_factor()
    #
    #  ========== Methods
    #
    #
    pass  # end class


#
#  -----------------------------------------------END CLASS:  Material
#
#  Utility Functions
#


def loadMaterialList(cfgFile):
    """Load a list of materials from a file

    The file uses the config file format.  See ConfigParser module.
"""
    p = Parser()
    p.read(cfgFile)
    #
    #  Each section defines a material
    #
    names = p.sections()
    matList = [Material(n, p) for n in names]
    # Sort the list
    matList = sorted(matList, key=_key)

    return matList


def load_materials_hdf5(f, dmin=None, kev=None,
                        sgsetting=Material.DFLT_SGSETTING):
    """Load materials from an HDF5 file

    The file uses the HDF5 file format.
    """
    if isinstance(f, (Path, str)):
        with h5py.File(f, 'r') as rf:
            names = list(rf)
    elif isinstance(f, h5py.Group):
        names = list(f)
    else:
        raise Exception(f'Unknown type for materials file: {f}')

    if isinstance(dmin, float):
        dmin = _angstroms(dmin)

    if isinstance(kev, float):
        kev = _kev(kev)

    kwargs = {
        'material_file': f,
        'dmin': dmin,
        'kev': kev,
        'sgsetting': sgsetting,
    }
    return {
        name: Material(name, **kwargs)
        for name in names
    }


def save_materials_hdf5(f, materials, path=None):
    """Save a dict of materials into an HDF5 file"""
    for material in materials.values():
        material.dump_material(f, path)


def hkls_match(a, b):
    # Check if hkls match. Expects inputs to have shape (x, 3).
    def sorted_hkls(x):
        return x[numpy.lexsort((x[:, 2], x[:, 1], x[:, 0]))]
    return numpy.array_equal(sorted_hkls(a), sorted_hkls(b))


def map_hkls_to_new(old_pdata, new_pdata):
    # Creates a mapping of old hkl indices to new ones.
    # Expects inputs to be PlaneData objects.
    def get_hkl_strings(pdata):
        return pdata.getHKLs(allHKLs=True, asStr=True)

    kwargs = {
        'ar1': get_hkl_strings(old_pdata),
        'ar2': get_hkl_strings(new_pdata),
        'return_indices': True,
    }
    return numpy.intersect1d(**kwargs)[1:]

#
#  ============================== Executable section for testing
#


if __name__ == '__main__':
    #
    #  For testing
    #
    import sys

    if len(sys.argv) == 1:
        print("need argument:  materials.cfg")
        sys.exit()
        pass

    ml = loadMaterialList(sys.argv[1])

    print('MATERIAL LIST\n')
    print(('   from file:  ', sys.argv[1]))
    for m in ml:
        print(m)
        pass
    pass


# hexrd/matrixutil.py

# -*- coding: utf-8 -*-
# =============================================================================
# Copyright (c) 2012, Lawrence Livermore National Security, LLC.
# Produced at the Lawrence Livermore National Laboratory.
# Written by Joel Bernier <bernier2@llnl.gov> and others.
# LLNL-CODE-529294.
# All rights reserved.
#
# This file is part of HEXRD. For details on dowloading the source,
# see the file COPYING.
#
# Please also see the file LICENSE.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License (as published by the Free
# Software Foundation) version 2.1 dated February 1999.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY OF MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the terms and conditions of the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this program (see file LICENSE); if not, write to
# the Free Software Foundation, Inc., 59 Temple Place, Suite 330,
# Boston, MA 02111-1307 USA or visit <http://www.gnu.org/licenses/>.
# =============================================================================

import numpy as np

from numpy.linalg import svd

from scipy import sparse

from hexrd import constants
from hexrd.constants import USE_NUMBA
if USE_NUMBA:
    import numba


# module variables
sqr6i = 1./np.sqrt(6.)
sqr3i = 1./np.sqrt(3.)
sqr2i = 1./np.sqrt(2.)
sqr2 = np.sqrt(2.)
sqr3 = np.sqrt(3.)
sqr2b3 = np.sqrt(2./3.)

fpTol = constants.epsf  # 2.220446049250313e-16
vTol = 100*fpTol


def columnNorm(a):
    """
    normalize array of column vectors (hstacked, axis = 0)
    """
    if len(a.shape) > 2:
        raise RuntimeError(
            "incorrect shape: arg must be 1-d or 2-d, yours is %d"
            % (len(a.shape))
        )

    cnrma = np.sqrt(np.sum(np.asarray(a)**2, axis=0))

    return cnrma


def rowNorm(a):
    """
    normalize array of row vectors (vstacked, axis = 1)
    """
    if len(a.shape) > 2:
        raise RuntimeError(
            "incorrect shape: arg must be 1-d or 2-d, yours is %d"
            % (len(a.shape))
        )

    cnrma = np.sqrt(np.sum(np.asarray(a)**2, axis=1))

    return cnrma


def unitVector(a):
    """
    normalize array of column vectors (hstacked, axis = 0)
    """
    assert a.ndim in [1, 2], \
        "incorrect arg shape; must be 1-d or 2-d, yours is %d-d" % (a.ndim)

    ztol = constants.ten_epsf

    m = a.shape[0]
    n = 1

    nrm = np.tile(np.sqrt(np.sum(np.asarray(a)**2, axis=0)), (m, n))

    # prevent divide by zero
    zchk = nrm <= ztol
    nrm[zchk] = 1.0

    nrma = a/nrm

    return nrma


def nullSpace(A, tol=vTol):
    """
    computes the null space of the real matrix A
    """
    assert A.ndim == 2, \
        'input must be 2-d; yours is %d-d' % (A.ndim)

    n, m = A.shape

    if n > m:
        return nullSpace(A.T, tol).T

    U, S, V = svd(A)

    S = np.hstack([S, np.zeros(m - n)])

    null_mask = (S <= tol)
    null_space = V[null_mask, :]

    return null_space


def blockSparseOfMatArray(matArray):
    """
    blockSparseOfMatArray

    Constructs a block diagonal sparse matrix (csc format) from a
    (p, m, n) ndarray of p (m, n) arrays

    ...maybe optional args to pick format type?
    """

    # if isinstance(args[0], str):
    #    a = args[0]
    # if a == 'csc': ...

    if len(matArray.shape) != 3:
        raise RuntimeError("input array is not the correct shape!")

    p = matArray.shape[0]
    m = matArray.shape[1]
    n = matArray.shape[2]

    mn = m*n
    jmax = p*n
    imax = p*m
    ntot = p*m*n

    rl = np.asarray(list(range(p)), 'int')
    rm = np.asarray(list(range(m)), 'int')
    rjmax = np.asarray(list(range(jmax)), 'int')

    sij = matArray.transpose(0, 2, 1).reshape(1, ntot).squeeze()
    j = np.reshape(np.tile(rjmax, (m, 1)).T, (1, ntot))
    i = np.reshape(np.tile(rm, (1, jmax)), (1, ntot)) + \
        np.reshape(np.tile(m*rl, (mn, 1)).T, (1, ntot))

    ij = np.concatenate((i, j), axis=0)

    # syntax as of scipy-0.7.0
    # csc_matrix((data, indices, indptr), shape=(M, N))
    smat = sparse.csc_matrix((sij, ij), shape=(imax, jmax))

    return smat


def symmToVecMV(A, scale=True):
    """
    convert from symmetric matrix to Mandel-Voigt vector
    representation (JVB)
    """
    if scale:
        fac = sqr2
    else:
        fac = 1.
    mvvec = np.zeros(6, dtype='float64')
    mvvec[0] = A[0, 0]
    mvvec[1] = A[1, 1]
    mvvec[2] = A[2, 2]
    mvvec[3] = fac * A[1, 2]
    mvvec[4] = fac * A[0, 2]
    mvvec[5] = fac * A[0, 1]
    return mvvec


def vecMVToSymm(A, scale=True):
    """
    convert from Mandel-Voigt vector to symmetric matrix
    representation (JVB)
    """
    if scale:
        fac = sqr2
    else:
        fac = 1.
    symm = np.zeros((3, 3), dtype='float64')
    symm[0, 0] = A[0]
    symm[1, 1] = A[1]
    symm[2, 2] = A[2]
    symm[1, 2] = A[3] / fac
    symm[0, 2] = A[4] / fac
    symm[0, 1] = A[5] / fac
    symm[2, 1] = A[3] / fac
    symm[2, 0] = A[4] / fac
    symm[1, 0] = A[5] / fac
    return symm


def vecMVCOBMatrix(R):
    """
    GenerateS array of 6 x 6 basis transformation matrices for the
    Mandel-Voigt tensor representation in 3-D given by:

    [A] = [[A_11, A_12, A_13],
           [A_12, A_22, A_23],
           [A_13, A_23, A_33]]

    {A} = [A_11, A_22, A_33, sqrt(2)*A_23, sqrt(2)*A_13, sqrt(2)*A_12]

    where the operation :math:`R*A*R.T` (in tensor notation) is obtained by
    the matrix-vector product [T]*{A}.

    USAGE

        T = vecMVCOBMatrix(R)

    INPUTS

        1) R is (3, 3) an ndarray representing a change of basis matrix

    OUTPUTS

        1) T is (6, 6), an ndarray of transformation matrices as
           described above

    NOTES

        1) Compoments of symmetric 4th-rank tensors transform in a
           manner analogous to symmetric 2nd-rank tensors in full
           matrix notation.

    SEE ALSO

    symmToVecMV, vecMVToSymm, quatToMat
    """
    rdim = len(R.shape)
    if rdim == 2:
        nrot = 1
        R = np.tile(R, (1, 1, 1))
    elif rdim == 3:
        nrot = R.shape[0]
    else:
        raise RuntimeError(
            "R array must be (3, 3) or (n, 3, 3); input has dimension %d"
            % (rdim)
        )

    T = np.zeros((nrot, 6, 6), dtype='float64')

    T[:, 0, 0] = R[:, 0, 0]**2
    T[:, 0, 1] = R[:, 0, 1]**2
    T[:, 0, 2] = R[:, 0, 2]**2
    T[:, 0, 3] = sqr2 * R[:, 0, 1] * R[:, 0, 2]
    T[:, 0, 4] = sqr2 * R[:, 0, 0] * R[:, 0, 2]
    T[:, 0, 5] = sqr2 * R[:, 0, 0] * R[:, 0, 1]
    T[:, 1, 0] = R[:, 1, 0]**2
    T[:, 1, 1] = R[:, 1, 1]**2
    T[:, 1, 2] = R[:, 1, 2]**2
    T[:, 1, 3] = sqr2 * R[:, 1, 1] * R[:, 1, 2]
    T[:, 1, 4] = sqr2 * R[:, 1, 0] * R[:, 1, 2]
    T[:, 1, 5] = sqr2 * R[:, 1, 0] * R[:, 1, 1]
    T[:, 2, 0] = R[:, 2, 0]**2
    T[:, 2, 1] = R[:, 2, 1]**2
    T[:, 2, 2] = R[:, 2, 2]**2
    T[:, 2, 3] = sqr2 * R[:, 2, 1] * R[:, 2, 2]
    T[:, 2, 4] = sqr2 * R[:, 2, 0] * R[:, 2, 2]
    T[:, 2, 5] = sqr2 * R[:, 2, 0] * R[:, 2, 1]
    T[:, 3, 0] = sqr2 * R[:, 1, 0] * R[:, 2, 0]
    T[:, 3, 1] = sqr2 * R[:, 1, 1] * R[:, 2, 1]
    T[:, 3, 2] = sqr2 * R[:, 1, 2] * R[:, 2, 2]
    T[:, 3, 3] = R[:, 1, 2] * R[:, 2, 1] + R[:, 1, 1] * R[:, 2, 2]
    T[:, 3, 4] = R[:, 1, 2] * R[:, 2, 0] + R[:, 1, 0] * R[:, 2, 2]
    T[:, 3, 5] = R[:, 1, 1] * R[:, 2, 0] + R[:, 1, 0] * R[:, 2, 1]
    T[:, 4, 0] = sqr2 * R[:, 0, 0] * R[:, 2, 0]
    T[:, 4, 1] = sqr2 * R[:, 0, 1] * R[:, 2, 1]
    T[:, 4, 2] = sqr2 * R[:, 0, 2] * R[:, 2, 2]
    T[:, 4, 3] = R[:, 0, 2] * R[:, 2, 1] + R[:, 0, 1] * R[:, 2, 2]
    T[:, 4, 4] = R[:, 0, 2] * R[:, 2, 0] + R[:, 0, 0] * R[:, 2, 2]
    T[:, 4, 5] = R[:, 0, 1] * R[:, 2, 0] + R[:, 0, 0] * R[:, 2, 1]
    T[:, 5, 0] = sqr2 * R[:, 0, 0] * R[:, 1, 0]
    T[:, 5, 1] = sqr2 * R[:, 0, 1] * R[:, 1, 1]
    T[:, 5, 2] = sqr2 * R[:, 0, 2] * R[:, 1, 2]
    T[:, 5, 3] = R[:, 0, 2] * R[:, 1, 1] + R[:, 0, 1] * R[:, 1, 2]
    T[:, 5, 4] = R[:, 0, 0] * R[:, 1, 2] + R[:, 0, 2] * R[:, 1, 0]
    T[:, 5, 5] = R[:, 0, 1] * R[:, 1, 0] + R[:, 0, 0] * R[:, 1, 1]

    if nrot == 1:
        T = T.squeeze()

    return T


def nrmlProjOfVecMV(vec):
    """
    Gives vstacked p x 6 array to To perform n' * A * n as [N]*{A} for
    p hstacked input 3-vectors using the Mandel-Voigt convention.

    Nvec = normalProjectionOfMV(vec)

    *) the input vector array need not be normalized; it is performed in place

    """
    # normalize in place... col vectors!
    n = unitVector(vec)

    nmat = np.array(
        [n[0, :]**2,
         n[1, :]**2,
         n[2, :]**2,
         sqr2 * n[1, :] * n[2, :],
         sqr2 * n[0, :] * n[2, :],
         sqr2 * n[0, :] * n[1, :]],
        dtype='float64'
    )

    return nmat.T


def rankOneMatrix(vec1, *args):
    """
    Create rank one matrices (dyadics) from vectors.

      r1mat = rankOneMatrix(vec1)
      r1mat = rankOneMatrix(vec1, vec2)

      vec1 is m1 x n, an array of n hstacked m1 vectors
      vec2 is m2 x n, (optional) another array of n hstacked m2 vectors

      r1mat is n x m1 x m2, an array of n rank one matrices
                   formed as c1*c2' from columns c1 and c2

      With one argument, the second vector is taken to
      the same as the first.

      Notes:

      *)  This routine loops on the dimension m, assuming this
          is much smaller than the number of points, n.
    """
    if len(vec1.shape) > 2:
        raise RuntimeError("input vec1 is the wrong shape")

    if (len(args) == 0):
        vec2 = vec1.copy()
    else:
        vec2 = args[0]
        if len(vec1.shape) > 2:
            raise RuntimeError("input vec2 is the wrong shape")

    m1, n1 = np.asmatrix(vec1).shape
    m2, n2 = np.asmatrix(vec2).shape

    if (n1 != n2):
        raise RuntimeError("Number of vectors differ in arguments.")

    m1m2 = m1 * m2

    r1mat = np.zeros((m1m2, n1), dtype='float64')

    mrange = np.asarray(list(range(m1)), dtype='int')

    for i in range(m2):
        r1mat[mrange, :] = vec1 * np.tile(vec2[i, :], (m1, 1))
        mrange = mrange + m1

    r1mat = np.reshape(r1mat.T, (n1, m2, m1)).transpose(0, 2, 1)
    return r1mat.squeeze()


def skew(A):
    """
    skew-symmetric decomposition of n square (m, m) ndarrays.  Result
    is a (squeezed) (n, m, m) ndarray
    """
    A = np.asarray(A)

    if A.ndim == 2:
        m = A.shape[0]
        n = A.shape[1]
        if m != n:
            raise RuntimeError(
                "this function only works for square arrays; yours is (%d, %d)"
                % (m, n)
            )
        A.resize(1, m, n)
    elif A.ndim == 3:
        m = A.shape[1]
        n = A.shape[2]
        if m != n:
            raise RuntimeError("this function only works for square arrays")
    else:
        raise RuntimeError("this function only works for square arrays")

    return np.squeeze(0.5*(A - A.transpose(0, 2, 1)))


def symm(A):
    """
    symmetric decomposition of n square (m, m) ndarrays.  Result
    is a (squeezed) (n, m, m) ndarray.
    """
    A = np.asarray(A)

    if A.ndim == 2:
        m = A.shape[0]
        n = A.shape[1]
        if m != n:
            raise RuntimeError(
                "this function only works for square arrays; yours is (%d, %d)"
                % (m, n)
            )
        A.resize(1, m, n)
    elif A.ndim == 3:
        m = A.shape[1]
        n = A.shape[2]
        if m != n:
            raise RuntimeError("this function only works for square arrays")
    else:
        raise RuntimeError("this function only works for square arrays")

    return np.squeeze(0.5*(A + A.transpose(0, 2, 1)))


def skewMatrixOfVector(w):
    """
    skewMatrixOfVector(w)

    given a (3, n) ndarray, w,  of n hstacked axial vectors, computes
    the associated skew matrices and stores them in an (n, 3, 3)
    ndarray.  Result is (3, 3) for w.shape = (3, 1) or (3, ).

    See also: vectorOfSkewMatrix
    """
    dims = w.ndim
    stackdim = 0
    if dims == 1:
        if len(w) != 3:
            raise RuntimeError('input is not a 3-d vector')
        else:
            w = np.vstack(w)
            stackdim = 1
    elif dims == 2:
        if w.shape[0] != 3:
            raise RuntimeError(
                'input is of incorrect shape; expecting shape[0] = 3'
            )
        else:
            stackdim = w.shape[1]
    else:
        raise RuntimeError(
            'input is incorrect shape; expecting ndim = 1 or 2'
        )

    zs = np.zeros((1, stackdim), dtype='float64')
    W = np.vstack(
        [zs,
         -w[2, :],
         w[1, :],
         w[2, :],
         zs,
         -w[0, :],
         -w[1, :],
         w[0, :],
         zs]
    )

    return np.squeeze(np.reshape(W.T, (stackdim, 3, 3)))


def vectorOfSkewMatrix(W):
    """
    vectorOfSkewMatrix(W)

    given an (n, 3, 3) or (3, 3) ndarray, W, of n stacked 3x3 skew
    matrices, computes the associated axial vector(s) and stores them
    in an (3, n) ndarray.  Result always has ndim = 2.

    See also: skewMatrixOfVector
    """
    stackdim = 0
    if W.ndim == 2:
        if W.shape[0] != 3 or W.shape[0] != 3:
            raise RuntimeError('input is not (3, 3)')
        stackdim = 1
        W.resize(1, 3, 3)
    elif W.ndim == 3:
        if W.shape[1] != 3 or W.shape[2] != 3:
            raise RuntimeError('input is not (3, 3)')
        stackdim = W.shape[0]
    else:
        raise RuntimeError('input is incorrect shape; expecting (n, 3, 3)')

    w = np.zeros((3, stackdim), dtype='float64')
    for i in range(stackdim):
        w[:, i] = np.r_[-W[i, 1, 2], W[i, 0, 2], -W[i, 0, 1]]

    return w


def multMatArray(ma1, ma2):
    """
    multiply two 3-d arrays of 2-d matrices
    """
    shp1 = ma1.shape
    shp2 = ma2.shape

    if len(shp1) != 3 or len(shp2) != 3:
        raise RuntimeError(
            'input is incorrect shape; '
            + 'expecting len(ma1).shape = len(ma2).shape = 3'
        )

    if shp1[0] != shp2[0]:
        raise RuntimeError('mismatch on number of matrices')

    if shp1[2] != shp2[1]:
        raise RuntimeError('mismatch on internal matrix dimensions')

    prod = np.zeros((shp1[0], shp1[1], shp2[2]))
    for j in range(shp1[0]):
        prod[j, :, :] = np.dot(ma1[j, :, :], ma2[j, :, :])

    return prod


def uniqueVectors(v, tol=1.0e-12):
    """
    Sort vectors and discard duplicates.

      USAGE:

          uvec = uniqueVectors(vec, tol=1.0e-12)

    v   --
    tol -- (optional) comparison tolerance

    D. E. Boyce 2010-03-18
    """

    vdims = v.shape

    iv = np.zeros(vdims)
    for row in range(vdims[0]):
        tmpord = np.argsort(v[row, :]).tolist()
        tmpsrt = v[np.ix_([row], tmpord)].squeeze()
        tmpcmp = abs(tmpsrt[1:] - tmpsrt[0:-1])
        indep = np.hstack([True, tmpcmp > tol])  # independent values
        rowint = indep.cumsum()
        iv[np.ix_([row], tmpord)] = rowint
        pass
    #
    #  Dictionary sort from bottom up
    #
    iNum = np.lexsort(iv)
    ivSrt = iv[:, iNum]
    vSrt = v[:, iNum]

    ivInd = np.zeros(vdims[1], dtype='int')
    nUniq = 1
    ivInd[0] = 0
    for col in range(1, vdims[1]):
        if any(ivSrt[:, col] != ivSrt[:, col - 1]):
            ivInd[nUniq] = col
            nUniq += 1
            pass
        pass

    return vSrt[:, ivInd[0:nUniq]]


def findDuplicateVectors(vec, tol=vTol, equivPM=False):
    """
    Find vectors in an array that are equivalent to within
    a specified tolerance

      USAGE:

          eqv = DuplicateVectors(vec, *tol)

      INPUT:

          1) vec is n x m, a double array of m horizontally concatenated
                           n-dimensional vectors.
         *2) tol is 1 x 1, a scalar tolerance.  If not specified, the default
                           tolerance is 1e-14.
         *3) set equivPM to True if vec and -vec
             are to be treated as equivalent

      OUTPUT:

          1) eqv is 1 x p, a list of p equivalence relationships.

      NOTES:

          Each equivalence relationship is a 1 x q vector of indices that
          represent the locations of duplicate columns/entries in the array
          vec.  For example:

                | 1     2     2     2     1     2     7 |
          vec = |                                       |
                | 2     3     5     3     2     3     3 |

          eqv = [[1x2 double]    [1x3 double]], where

          eqv[0] = [0  4]
          eqv[1] = [1  3  5]
    """

    vlen = vec.shape[1]
    vlen0 = vlen
    orid = np.asarray(list(range(vlen)), dtype="int")

    torid = orid.copy()
    tvec = vec.copy()

    eqv = []
    eqvTot = 0
    uid = 0

    ii = 1
    while vlen > 1 and ii < vlen0:
        dupl = np.tile(tvec[:, 0], (vlen, 1))

        if not equivPM:
            diff = abs(tvec - dupl.T).sum(0)
            match = abs(diff[1:]) <= tol    # logical to find duplicates
        else:
            diffn = abs(tvec - dupl.T).sum(0)
            matchn = abs(diffn[1:]) <= tol
            diffp = abs(tvec + dupl.T).sum(0)
            matchp = abs(diffp[1:]) <= tol
            match = matchn + matchp

        kick = np.hstack([True, match])    # pick self too

        if kick.sum() > 1:
            eqv += [torid[kick].tolist()]
            eqvTot = np.hstack([eqvTot, torid[kick]])
            uid = np.hstack([uid, torid[kick][0]])

        cmask = np.ones((vlen,))
        cmask[kick] = 0
        cmask = cmask != 0

        tvec = tvec[:, cmask]

        torid = torid[cmask]

        vlen = tvec.shape[1]

        ii += 1

    if len(eqv) == 0:
        eqvTot = []
        uid = []
    else:
        eqvTot = eqvTot[1:].tolist()
        uid = uid[1:].tolist()

    # find all single-instance vectors
    singles = np.sort(np.setxor1d(eqvTot, list(range(vlen0))))

    # now construct list of unique vector column indices
    uid = np.int_(np.sort(np.union1d(uid, singles))).tolist()
    # make sure is a 1D list
    if not hasattr(uid, '__len__'):
        uid = [uid]

    return eqv, uid


def normvec(v):
    mag = np.linalg.norm(v)
    return mag


def normvec3(v):
    """
    ??? deprecated
    """
    mag = np.sqrt(v[0]*v[0] + v[1]*v[1] + v[2]*v[2])
    return mag


def normalized(v):
    mag = normvec(v)  # normvec3(v)
    n = v / mag
    return n


def cross(v1, v2):
    # return the cross product of v1 with another vector
    # return a vector
    newv3 = np.zeros(3, dtype='float64')
    newv3[0] = v1[1]*v2[2] - v1[2]*v2[1]
    newv3[1] = v1[2]*v2[0] - v1[0]*v2[2]
    newv3[2] = v1[0]*v2[1] - v1[1]*v2[0]
    return newv3


def determinant3(mat):
    v = np.cross(mat[0, :], mat[1, :])
    det = np.sum(mat[2, :] * v[:])
    return det


def strainTenToVec(strainTen):
    strainVec = np.zeros(6, dtype='float64')
    strainVec[0] = strainTen[0, 0]
    strainVec[1] = strainTen[1, 1]
    strainVec[2] = strainTen[2, 2]
    strainVec[3] = 2*strainTen[1, 2]
    strainVec[4] = 2*strainTen[0, 2]
    strainVec[5] = 2*strainTen[0, 1]
    strainVec = np.atleast_2d(strainVec).T
    return strainVec


def strainVecToTen(strainVec):
    strainTen = np.zeros((3, 3), dtype='float64')
    strainTen[0, 0] = strainVec[0]
    strainTen[1, 1] = strainVec[1]
    strainTen[2, 2] = strainVec[2]
    strainTen[1, 2] = strainVec[3] / 2.
    strainTen[0, 2] = strainVec[4] / 2.
    strainTen[0, 1] = strainVec[5] / 2.
    strainTen[2, 1] = strainVec[3] / 2.
    strainTen[2, 0] = strainVec[4] / 2.
    strainTen[1, 0] = strainVec[5] / 2.
    return strainTen


def stressTenToVec(stressTen):
    stressVec = np.zeros(6, dtype='float64')
    stressVec[0] = stressTen[0, 0]
    stressVec[1] = stressTen[1, 1]
    stressVec[2] = stressTen[2, 2]
    stressVec[3] = stressTen[1, 2]
    stressVec[4] = stressTen[0, 2]
    stressVec[5] = stressTen[0, 1]
    stressVec = np.atleast_2d(stressVec).T
    return stressVec


def stressVecToTen(stressVec):

    stressTen = np.zeros((3, 3), dtype='float64')
    stressTen[0, 0] = stressVec[0]
    stressTen[1, 1] = stressVec[1]
    stressTen[2, 2] = stressVec[2]
    stressTen[1, 2] = stressVec[3]
    stressTen[0, 2] = stressVec[4]
    stressTen[0, 1] = stressVec[5]
    stressTen[2, 1] = stressVec[3]
    stressTen[2, 0] = stressVec[4]
    stressTen[1, 0] = stressVec[5]

    return stressTen


def ale3dStrainOutToV(vecds):
    """
    convert from vecds representation to symmetry matrix
    takes 5 components of evecd and the 6th component is lndetv
    """
    eps = np.zeros([3, 3], dtype='float64')
    # Akk_by_3 = sqr3i * vecds[5]  # -p
    a = np.exp(vecds[5])**(1./3.)  # -p
    t1 = sqr2i*vecds[0]
    t2 = sqr6i*vecds[1]

    eps[0, 0] = t1 - t2
    eps[1, 1] = -t1 - t2
    eps[2, 2] = sqr2b3*vecds[1]
    eps[1, 0] = vecds[2] * sqr2i
    eps[2, 0] = vecds[3] * sqr2i
    eps[2, 1] = vecds[4] * sqr2i

    eps[0, 1] = eps[1, 0]
    eps[0, 2] = eps[2, 0]
    eps[1, 2] = eps[2, 1]

    epstar = eps/a

    V = (constants.identity_3x3 + epstar)*a
    Vinv = (constants.identity_3x3 - epstar)/a

    return V, Vinv


def vecdsToSymm(vecds):
    """convert from vecds representation to symmetry matrix"""
    A = np.zeros([3, 3], dtype='float64')
    Akk_by_3 = sqr3i * vecds[5]  # -p
    t1 = sqr2i*vecds[0]
    t2 = sqr6i*vecds[1]

    A[0, 0] = t1 - t2 + Akk_by_3
    A[1, 1] = -t1 - t2 + Akk_by_3
    A[2, 2] = sqr2b3*vecds[1] + Akk_by_3
    A[1, 0] = vecds[2] * sqr2i
    A[2, 0] = vecds[3] * sqr2i
    A[2, 1] = vecds[4] * sqr2i

    A[0, 1] = A[1, 0]
    A[0, 2] = A[2, 0]
    A[1, 2] = A[2, 1]
    return A


def traceToVecdsS(Akk):
    return sqr3i * Akk


def vecdsSToTrace(vecdsS):
    return vecdsS * sqr3


def trace3(A):
    return A[0, 0] + A[1, 1] + A[2, 2]


def symmToVecds(A):
    """convert from symmetry matrix to vecds representation"""
    vecds = np.zeros(6, dtype='float64')
    vecds[0] = sqr2i * (A[0, 0] - A[1, 1])
    vecds[1] = sqr6i * (2. * A[2, 2] - A[0, 0] - A[1, 1])
    vecds[2] = sqr2 * A[1, 0]
    vecds[3] = sqr2 * A[2, 0]
    vecds[4] = sqr2 * A[2, 1]
    vecds[5] = traceToVecdsS(trace3(A))
    return vecds


def solve_wahba(v, w, weights=None):
    """
    take unique vectors 3-vectors v = [[v0], [v1], ..., [vn]] in frame 1 that
    are aligned with vectors w = [[w0], [w1], ..., [wn]] in frame 2 and solve
    for the rotation that takes components in frame 1 to frame 2

    minimizes the cost function:

      J(R) = 0.5 * sum_{k=1}^{N} a_k * || w_k - R*v_k ||^2

    INPUTS:
      v is list-like, where each entry is a length 3 vector
      w is list-like, where each entry is a length 3 vector

      len(v) == len(w)

      weights are optional, and must have the same length as v, w

    OUTPUT:
      (3, 3) orthognal matrix that takes components in frame 1 to frame 2
    """
    n_vecs = len(v)

    assert len(w) == n_vecs

    if weights is not None:
        assert len(weights) == n_vecs
    else:
        weights = np.ones(n_vecs)

    # cast v, w, as arrays if not
    v = np.atleast_2d(v)
    w = np.atleast_2d(w)

    # compute weighted outer product sum
    B = np.zeros((3, 3))
    for i in range(n_vecs):
        B += weights[i]*np.dot(w[i].reshape(3, 1), v[i].reshape(1, 3))

    # compute svd
    Us, Ss, VsT = svd(B)

    # form diagonal matrix for solution
    M = np.diag([1., 1., np.linalg.det(Us)*np.linalg.det(VsT)])
    return np.dot(Us, np.dot(M, VsT))

# =============================================================================
# Numba-fied frame cache writer
# =============================================================================


if USE_NUMBA:
    @numba.njit(cache=True, nogil=True)
    def extract_ijv(in_array, threshold, out_i, out_j, out_v):
        n = 0
        w, h = in_array.shape
        for i in range(w):
            for j in range(h):
                v = in_array[i, j]
                if v > threshold:
                    out_i[n] = i
                    out_j[n] = j
                    out_v[n] = v
                    n += 1
        return n
else:    # not USE_NUMBA
    def extract_ijv(in_array, threshold, out_i, out_j, out_v):
        mask = in_array > threshold
        n = np.sum(mask)
        tmp_i, tmp_j = mask.nonzero()
        out_i[:n] = tmp_i
        out_j[:n] = tmp_j
        out_v[:n] = in_array[mask]
        return n


# hexrd/mksupport.py

from hexrd.symbols import pstr_Elements, sitesym, \
    tworig, PrintPossibleSG, TRIG, pstr_spacegroup,\
    pstr_mkxtal
import h5py
import os
import numpy as np
import datetime
import getpass
from hexrd.unitcell import _StiffnessDict, _pgDict

def mk(filename, xtalname):

    # print some initial information for the user
    print(pstr_mkxtal)

    # get the crystal system. legend is printed above
    xtal_sys, bool_trigonal, bool_hexset = GetXtalSystem()

    lat_param = GetLatticeParameters(xtal_sys, bool_trigonal)

    # get the space group number. legend will be printed above
    space_group, iset = GetSpaceGroup(xtal_sys, bool_trigonal, bool_hexset)

    AtomInfo = GetAtomInfo()
    AtomInfo.update({'file': filename, 'xtalname': xtalname,
                     'xtal_sys': xtal_sys, 'SG': space_group,\
                     'SGsetting': iset})

    Write2H5File(AtomInfo, lat_param)


def GetXtalSystem():

    xtal_sys = input("Crystal System (1-7 use the legend above): ")
    if(not xtal_sys.isdigit()):
        raise ValueError(
            "Invalid value. \
            Please enter valid number between 1-7 using the legend above.")
    else:
        xtal_sys = int(xtal_sys)
        if(xtal_sys < 1 or xtal_sys > 7):
            raise ValueError(
                "Value outside range. \
                Please enter numbers between 1 and 7 using legend above")

    btrigonal = False
    bhexset = False

    if(xtal_sys == 5):
        print(" 1. Hexagonal setting \n 2. Rhombohedral setting")
        hexset = input("(1/2)? :    ")

        if(not hexset.isdigit()):
            raise ValueError("Invalid value.")
        else:
            hexset = int(hexset)
            if(not hexset in [1, 2]):
                raise ValueError(
                    "Invalid value of integer. Only 1 or 2 is acceptable.")

        btrigonal = True

        if(hexset == 1):
            bhexset = True
            xtal_sys = 4
            # only temporarily set to 4 so that the correct
            # lattice parameter can be queried next

        elif(hexset == 2):
            bhexset = False

    return xtal_sys, btrigonal, bhexset


def GetLatticeParameters(xtal_sys, bool_trigonal):

    a = input("a [nm] : ")
    if(not a.replace('.', '', 1).isdigit()):
        raise ValueError("Invalid floating point value.")
    else:
        a = float(a)

    b = a
    c = a
    alpha = 90.0
    beta = 90.0
    gamma = 90.0
    lat_param = {'a': a, 'b': b, 'c': c,
                 'alpha': alpha, 'beta': beta, 'gamma': gamma}

    # cubic symmetry
    if (xtal_sys == 1):
        pass

    # tetragonal symmetry
    elif(xtal_sys == 2):

        c = input("c [nm] : ")
        if(not c.replace('.', '', 1).isdigit()):
            raise ValueError("Invalid floating point value.")
        else:
            c = float(c)

        lat_param['c'] = c

    # orthorhombic symmetry
    elif(xtal_sys == 3):
        b = input("b [nm] : ")
        if(not b.replace('.', '', 1).isdigit()):
            raise ValueError("Invalid floating point value.")
        else:
            b = float(b)

        c = input("c [nm] : ")
        if(not c.replace('.', '', 1).isdigit()):
            raise ValueError("Invalid floating point value.")
        else:
            c = float(c)

        lat_param['b'] = c
        lat_param['c'] = c

    # hexagonal system
    elif(xtal_sys == 4):
        c = input("c [nm] : ")
        if(not c.replace('.', '', 1).isdigit()):
            raise ValueError("Invalid floating point value.")
        else:
            c = float(c)

        lat_param['c'] = c
        lat_param['gamma'] = 120.0

        if(bool_trigonal):
            xtal_sys = 5

    # rhombohedral system
    elif(xtal_sys == 5):
        alpha = input("alpha [deg] :    ")
        if(not alpha.replace('.', '', 1).isdigit()):
            raise ValueError("Invalid floating point value.")
        else:
            alpha = float(alpha)

        lat_param['alpha'] = alpha
        lat_param['beta'] = alpha
        lat_param['gamma'] = alpha

    # monoclinic system
    elif(xtal_sys == 6):
        b = input("b [nm] : ")
        if(not b.replace('.', '', 1).isdigit()):
            raise ValueError("Invalid floating point value.")
        else:
            b = float(b)

        c = input("c [nm] : ")
        if(not c.replace('.', '', 1).isdigit()):
            raise ValueError("Invalid floating point value.")
        else:
            c = float(c)

        beta = input("beta [deg] :  ")
        if(not beta.replace('.', '', 1).isdigit()):
            raise ValueError("Invalid floating point value.")
        else:
            beta = float(beta)

        lat_param['b'] = b
        lat_param['c'] = c
        lat_param['beta'] = beta

    # triclinic system
    elif(xtal_sys == 7):
        b = input("b [nm] : ")
        if(not b.replace('.', '', 1).isdigit()):
            raise ValueError("Invalid floating point value.")
        else:
            b = float(b)

        c = input("c [nm] : ")
        if(not c.replace('.', '', 1).isdigit()):
            raise ValueError("Invalid floating point value.")
        else:
            c = float(c)

        alpha = input("alpha [deg] :    ")
        if(not alpha.replace('.', '', 1).isdigit()):
            raise ValueError("Invalid floating point value.")
        else:
            alpha = float(alpha)

        beta = input("beta [deg] :  ")
        if(not beta.replace('.', '', 1).isdigit()):
            raise ValueError("Invalid floating point value.")
        else:
            beta = float(beta)

        gamma = input("gamma [deg] :    ")
        if(not gamma.replace('.', '', 1).isdigit()):
            raise ValueError("Invalid floating point value.")
        else:
            gamma = float(gamma)

        lat_param['b'] = b
        lat_param['c'] = c
        lat_param['alpha'] = alpha
        lat_param['beta'] = beta
        lat_param['gamma'] = gamma

    print("\n")
    return lat_param


def GetSpaceGroup(xtal_sys, btrigonal, bhexset):

    if(btrigonal):
        xtal_sys = 5

    if(btrigonal and (not bhexset)):
        print("\n The space groups below correspond to the ")
        print("second (rhombohedral) setting.")
        print(" Please select one of these space groups.\n")

        for i in range(0, 7):
            pstr = str(TRIG[i]) + ":" + pstr_spacegroup[TRIG[i]]
            if ((i + 1) % 4 == 0 or i == 6):
                print(pstr)
            else:
                print(pstr, end='')

        print(50*"-"+"\n")

    else:
        sgmin, sgmax = PrintPossibleSG(xtal_sys)

    sg = input("Space group number (use legend above): ")
    if(not sg.isdigit()):
        raise ValueError(
            "Invalid value. Please enter valid number between \
            1 and 230 using the legend above.")
    else:
        sg = int(sg)

    if(btrigonal and (not bhexset)):
        if(not sg in TRIG):
            raise ValueError(
                "Invalid space group entered. \
                Please use one of the space groups from the legend above")
        if (sg == 146):
            sg = 231
        if (sg == 148):
            sg = 232
        if (sg == 155):
            sg = 233
        if (sg == 160):
            sg = 234
        if (sg == 161):
            sg = 235
        if (sg == 166):
            sg = 236
        if (sg == 167):
            sg = 237
    else:
        if(sg < sgmin or sg > sgmax):
            raise ValueError(
                "Value outside range. Please enter numbers between \
                 {} and {} using legend above".format(sgmin, sgmax))

    iset = SpaceGroupSetting(sg)

    return sg, iset


def SpaceGroupSetting(sgnum):

    iset = 1
    if(sgnum in tworig):
        idx = tworig.index(sgnum)
        print(' ---------------------------------------------')
        print(' This space group has two origin settings.')
        print(' The first setting has site symmetry    : ' +
              sitesym[2*idx - 2])
        print(' the second setting has site symmetry   : ' +
              sitesym[2*idx - 1])
        iset = input(' Which setting do you wish to use (1/2) : ')
        if(not iset.isdigit()):
            raise ValueError("Invalid integer value for atomic number.")
        else:
            iset = int(iset)
            print(iset)
            if(not iset in [1, 2]):
                raise ValueError(" Value entered for setting must be 1 or 2 !")

    return iset-1


def GetAtomInfo():

    print(pstr_Elements)
    ctr = 0
    Z = []
    APOS = []
    DW = []
    ani = 0
    stiffness = np.zeros([6, 6])

    ques = 'y'
    while(ques.strip().lower() == 'y' or ques.strip().lower() == 'yes'):
        tmp = input("Enter atomic number of species :   ")

        if(not tmp.isdigit()):
            raise ValueError("Invalid integer value for atomic number.")
        else:
            tmp = int(tmp)

        Z.append(tmp)
        asym, U, ani = GetAsymmetricPositions(ani)
        APOS.append(asym)
        DW.append(U)
        ques = input("Another atom? (y/n) : ")

    return {'Z': Z, 'APOS': APOS, 'U': DW, 'stiffness': stiffness}


def GetAsymmetricPositions(aniU):

    asym = input(
        "Enter asymmetric position of atom in unit cell \
         separated by comma (fractional coordinates) :  ")
    asym = [x.strip() for x in asym.split(',')]

    for i, x in enumerate(asym):
        tmp = x.split('/')
        if(len(tmp) == 2):
            if(tmp[1].strip() != '0'):
                asym[i] = str(float(tmp[0])/float(tmp[1]))
            else:
                raise ValueError("Division by zero in fractional coordinates.")
        else:
            pass

    if(len(asym) != 3):
        raise ValueError("Need 3 coordinates in x,y,z fractional coordinates.")

    for i, x in enumerate(asym):
        if(not x.replace('.', '', 1).isdigit()):
            raise ValueError(
                "Invalid floating point value in fractional coordinates.")
        else:
            asym[i] = float(x)
            if(asym[i] < 0.0 or asym[i] >= 1.0):
                raise ValueError(
                    " fractional coordinates only in the \
                    range [0,1) i.e. 1 excluded")

    occ, dw = GetOccDW(aniU=aniU)
    if isinstance(dw, float):
        aniU = 1
    else:
        aniU = 2

    asym.extend([occ])
    return asym, dw, aniU


def GetOccDW(aniU=0):

    occ = input("Enter site occupation :    ")
    if(not occ.replace('.', '', 1).isdigit()):
        raise ValueError(
            "Invalid floating point value in fractional coordinates.")
    else:
        occ = float(occ)
        if(occ > 1.0 or occ <= 0.0):
            raise ValueError(
                "site occupation can only in range (0,1.0] i.e. 0 excluded")

    if(aniU != 0):
        ani = aniU
    else:
        ani = input(
            "Isotropic or anisotropic Debye-Waller factor? \n \
             1 for isotropic, 2 for anisotropic : ")

        if(not ani.isdigit()):
            raise ValueError("Invalid integer value for atomic number.")
        else:
            ani = int(ani)

    if(ani == 1):

        dw = input("Enter isotropic Debye-Waller factor [nm^(-2)] : ")
        if(not dw.replace('.', '', 1).isdigit()):
            raise ValueError(
                "Invalid floating point value in fractional coordinates.")
        else:
            dw = float(dw)

        return [occ, dw]
    elif(ani == 2):

        U = np.zeros([6, ])

        U11 = input("Enter U11 [nm^2] : ")
        if(not U11.replace('.', '', 1).isdigit()):
            raise ValueError("Invalid floating point value in U11.")
        else:
            U[0] = float(U11)

        U22 = input("Enter U22 [nm^2] : ")
        if(not U22.replace('.', '', 1).isdigit()):
            raise ValueError("Invalid floating point value in U22.")
        else:
            U[1] = float(U22)

        U33 = input("Enter U33 [nm^2] : ")
        if(not U33.replace('.', '', 1).isdigit()):
            raise ValueError("Invalid floating point value in U33.")
        else:
            U[2] = float(U33)

        U12 = input("Enter U12 [nm^2] : ")
        if(not U12.replace('.', '', 1).isdigit()):
            raise ValueError("Invalid floating point value in U12.")
        else:
            U[3] = float(U12)

        U13 = input("Enter U13 [nm^2] : ")
        if(not U13.replace('.', '', 1).isdigit()):
            raise ValueError("Invalid floating point value in U13.")
        else:
            U[4] = float(U13)

        U23 = input("Enter U23 [nm^2] : ")
        if(not U23.replace('.', '', 1).isdigit()):
            raise ValueError("Invalid floating point value in U23.")
        else:
            U[5] = float(U23)

        return [occ, U]

    else:
        raise ValueError("Invalid input. Only 1 or 2 acceptable.")

# write to H5 file


def Write2H5File(AtomInfo, lat_param, path=None):
    # Should we close the file? Only do so if we opened it
    close = False

    if isinstance(AtomInfo['file'], str):
        # first check if file exists
        fexist = os.path.isfile(AtomInfo['file'])

        if(fexist):
            fid = h5py.File(AtomInfo['file'], 'r+')
        else:
            Warning('File doesn''t exist. creating it')
            fid = h5py.File(AtomInfo['file'], 'x')

        close = True
    # Have we been past a h5py.File?
    elif isinstance(AtomInfo['file'], h5py.File):
        fid = AtomInfo['file']
    else:
        raise TypeError('Unexpected file type.')

    WriteH5Data(fid, AtomInfo, lat_param, path)

    if close:
        fid.close()


def WriteH5Data(fid, AtomInfo, lat_param, path=None):
    """
    @DATE 02/09/2021 SS added hkls, exclusions and dmin to
    material.h5 file output
    @Date 01/14/2022 SS added tThWidth to materials file
    """

    # Add the path prefix if we have been given one
    if path is not None:
        path = f"{path}/{AtomInfo['xtalname']}"
    else:
        path = AtomInfo['xtalname']

    node = f'/{path}'
    if node in fid:
        print("crystal already exists. overwriting...\n")
        del fid[node]

    gid = fid.create_group(path)

    did = gid.create_dataset(
        "Atomtypes", (len(AtomInfo['Z']), ), dtype=np.int32)
    did.write_direct(np.array(AtomInfo['Z'], dtype=np.int32))

    did = gid.create_dataset("CrystalSystem", (1,), dtype=np.int32)
    did.write_direct(np.array(AtomInfo['xtal_sys'], dtype=np.int32))

    did = gid.create_dataset("Natomtypes", (1,), dtype=np.int32)
    did.write_direct(np.array([len(AtomInfo['Z'])], dtype=np.int32))

    did = gid.create_dataset("SpaceGroupNumber", (1,), dtype=np.int32)
    did.write_direct(np.array([AtomInfo['SG']], dtype=np.int32))

    did = gid.create_dataset("SpaceGroupSetting", (1,), dtype=np.int32)
    did.write_direct(np.array([AtomInfo['SGsetting']], dtype=np.int32))

    did = gid.create_dataset("LatticeParameters", (6,), dtype=np.float64)
    did.write_direct(np.array(list(lat_param.values()), dtype=np.float64))

    did = gid.create_dataset("stiffness", (6, 6), dtype=np.float64)
    did.write_direct(np.array(AtomInfo['stiffness'], dtype=np.float64))


    if "tThWidth" in AtomInfo: 
        did = gid.create_dataset("tThWidth", (1,), dtype=np.float64)
        did.write_direct(np.array([AtomInfo['tThWidth']], dtype=np.float64))

    if 'hkls' in AtomInfo:
        if AtomInfo['hkls'].shape != (0,):
            did = gid.create_dataset("hkls",
                                     AtomInfo['hkls'].shape,
                                     dtype=np.int32)
            did.write_direct(AtomInfo['hkls'])

    if 'dmin' in AtomInfo:
        did = gid.create_dataset("dmin", (1,), dtype=np.float64)
        did.write_direct(np.array(AtomInfo['dmin'], dtype=np.float64))

    if 'kev' in AtomInfo:
        did = gid.create_dataset("kev", (1,), dtype=np.float64)
        did.write_direct(np.array(AtomInfo['kev'], dtype=np.float64))

    did = gid.create_dataset(
        "AtomData", (4, len(AtomInfo['Z'])), dtype=np.float64)
    # this is done for contiguous c-allocation
    arr = np.array(AtomInfo['APOS'], dtype=np.float32).transpose()
    arr2 = arr.copy()
    did.write_direct(arr2)

    if 'charge' in AtomInfo:
        data = np.array(AtomInfo['charge'], dtype=object)
        dt = h5py.special_dtype(vlen=str)
        did = gid.create_dataset("ChargeStates",
                                 (len(AtomInfo['Z']),),
                                 dtype=dt)
        did.write_direct(data)

    '''
        handling of isotropic vs
        anisotropic debye waller factors
    '''

    if not isinstance(AtomInfo['U'][0], np.floating):
        did = gid.create_dataset(
            "U", (6, len(AtomInfo['Z'])), dtype=np.float64)
        arr = np.array(AtomInfo['U'], dtype=np.float32).transpose()
        arr2 = arr.copy()
        did.write_direct(arr2)
    else:
        did = gid.create_dataset("U", (len(AtomInfo['Z']),), dtype=np.float32)
        did.write_direct(np.array(AtomInfo['U'], dtype=np.float64))

    # variable length string type
    dt = h5py.special_dtype(vlen=str)

    date = datetime.date.today().strftime("%B %d, %Y")
    did = gid.create_dataset("CreationDate", data=date, dtype=dt)

    time = datetime.datetime.now().strftime("%I:%M%p on %B %d, %Y")
    did = gid.create_dataset("CreationTime", data=time, dtype=dt)

    creator = getpass.getuser()
    did = gid.create_dataset("Creator", data=creator, dtype=dt)

    pname = "ProgramName"
    did = gid.create_dataset(pname, data="heXRD", dtype=dt)


# hexrd/rotations.py

# -*- coding: utf-8 -*-
# =============================================================================
# Copyright (c) 2012, Lawrence Livermore National Security, LLC.
# Produced at the Lawrence Livermore National Laboratory.
# Written by Joel Bernier <bernier2@llnl.gov> and others.
# LLNL-CODE-529294.
# All rights reserved.
#
# This file is part of HEXRD. For details on dowloading the source,
# see the file COPYING.
#
# Please also see the file LICENSE.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License (as published by the Free
# Software Foundation) version 2.1 dated February 1999.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY OF MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the terms and conditions of the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this program (see file LICENSE); if not, write to
# the Free Software Foundation, Inc., 59 Temple Place, Suite 330,
# Boston, MA 02111-1307 USA or visit <http://www.gnu.org/licenses/>.
# =============================================================================
#
# Module containing functions relevant to rotations
#
import sys
import timeit

import numpy as np
from numpy import (
    arange, arctan2, array, argmax, asarray, atleast_1d, average,
    ndarray, diag, zeros,
    cross, dot, pi, arccos, arcsin, cos, sin, sqrt,
    sort, tile, vstack, hstack, c_, ix_,
    abs, mod, sign,
    finfo, isscalar
)
from numpy import float_ as nFloat
from numpy import int_ as nInt
from scipy.optimize import leastsq

from hexrd import constants as cnst
from hexrd.matrixutil import \
    columnNorm, unitVector, \
    skewMatrixOfVector, findDuplicateVectors, \
    multMatArray, nullSpace
from hexrd import symmetry
from hexrd.utils.decorators import numba_njit_if_available

# =============================================================================
# Module Data
# =============================================================================

angularUnits = 'radians'  # module-level angle units
periodDict = {'degrees': 360.0, 'radians': 2*np.pi}
conversion_to_dict = {'degrees': cnst.r2d, 'radians': cnst.d2r}

I3 = cnst.identity_3x3    # (3, 3) identity matrix

# axes orders, all permutations
axes_orders = [
    'xyz', 'zyx',
    'zxy', 'yxz',
    'yzx', 'xzy',
    'xyx', 'xzx',
    'yxy', 'yzy',
    'zxz', 'zyz',
]

sq3by2 = sqrt(3.)/2.
piby2 = pi/2.
piby3 = pi/3.
piby4 = pi/4.
piby6 = pi/6.

# =============================================================================
# Functions
# =============================================================================


def arccosSafe(temp):
    """
    Protect against numbers slightly larger than 1 in magnitude
    due to round-off
    """
    temp = atleast_1d(temp)
    if (abs(temp) > 1.00001).any():
        print("attempt to take arccos of %s" % temp, file=sys.stderr)
        raise RuntimeError("unrecoverable error")

    gte1 = temp >= 1.
    lte1 = temp <= -1.

    temp[gte1] = 1
    temp[lte1] = -1

    ang = arccos(temp)

    return ang


#
#  ==================== Quaternions
#


def fixQuat(q):
    """
    flip to positive q0 and normalize
    """
    qdims = q.ndim
    if qdims == 3:
        l, m, n = q.shape
        assert m == 4, 'your 3-d quaternion array isn\'t the right shape'
        q = q.transpose(0, 2, 1).reshape(l*n, 4).T

    qfix = unitVector(q)

    q0negative = qfix[0, ] < 0
    qfix[:, q0negative] = -1*qfix[:, q0negative]

    if qdims == 3:
        qfix = qfix.T.reshape(l, n, 4).transpose(0, 2, 1)

    return qfix


def invertQuat(q):
    """
    silly little routine for inverting a quaternion
    """
    numq = q.shape[1]

    imat = tile(vstack([-1, 1, 1, 1]), (1, numq))

    qinv = imat * q

    return fixQuat(qinv)


def misorientation(q1, q2, *args):
    """
    sym is a tuple (crystal_symmetry, *sample_symmetry)
    generally coded.

    !!! may split up special cases for no symmetry or crystal/sample only...
    """
    if not isinstance(q1, ndarray) or not isinstance(q2, ndarray):
        raise RuntimeError("quaternion args are not of type `numpy ndarray'")

    if q1.ndim != 2 or q2.ndim != 2:
        raise RuntimeError(
            "quaternion args are the wrong shape; must be 2-d (columns)"
        )

    if q1.shape[1] != 1:
        raise RuntimeError(
            "first argument should be a single quaternion, got shape %s"
            % (q1.shape,)
        )

    if len(args) == 0:
        # no symmetries; use identity
        sym = (c_[1., 0, 0, 0].T, c_[1., 0, 0, 0].T)
    else:
        sym = args[0]
        if len(sym) == 1:
            if not isinstance(sym[0], ndarray):
                raise RuntimeError("symmetry argument is not an numpy array")
            else:
                # add triclinic sample symmetry (identity)
                sym += (c_[1., 0, 0, 0].T,)
        elif len(sym) == 2:
            if not isinstance(sym[0], ndarray) \
                    or not isinstance(sym[1], ndarray):
                raise RuntimeError(
                    "symmetry arguments are not an numpy arrays"
                )
        elif len(sym) > 2:
            raise RuntimeError(
                "symmetry argument has %d entries; should be 1 or 2"
                % (len(sym))
            )

    # set some lengths
    n = q2.shape[1]             # length of misorientation list
    m = sym[0].shape[1]         # crystal (right)
    p = sym[1].shape[1]         # sample  (left)

    # tile q1 inverse
    q1i = quatProductMatrix(invertQuat(q1), mult='right').squeeze()

    # convert symmetries to (4, 4) qprod matrices
    rsym = quatProductMatrix(sym[0], mult='right')
    lsym = quatProductMatrix(sym[1], mult='left')

    # Do R * Gc, store as
    # [q2[:, 0] * Gc[:, 0:m], ..., q2[:, n-1] * Gc[:, 0:m]]
    q2 = dot(rsym, q2).transpose(2, 0, 1).reshape(m*n, 4).T

    # Do Gs * (R * Gc), store as
    # [Gs[:, 0:p]*q[:,   0]*Gc[:, 0], ..., Gs[:, 0:p]*q[:,   0]*Gc[:, m-1], ...
    #  Gs[:, 0:p]*q[:, n-1]*Gc[:, 0], ..., Gs[:, 0:p]*q[:, n-1]*Gc[:, m-1]]
    q2 = dot(lsym, q2).transpose(2, 0, 1).reshape(p*m*n, 4).T

    # Calculate the class misorientations for full symmetrically equivalent
    # classes for q1 and q2.  Note the use of the fact that the application
    # of the symmetry groups is an isometry.
    eqvMis = fixQuat(dot(q1i, q2))

    # Reshape scalar comp columnwise by point in q2 (and q1, if applicable)
    sclEqvMis = eqvMis[0, :].reshape(n, p*m).T

    # Find misorientation closest to origin for each n equivalence classes
    #   - fixed quats so garaunteed that sclEqvMis is nonnegative
    qmax = sclEqvMis.max(0)

    # remap indices to use in eqvMis
    qmaxInd = (sclEqvMis == qmax).nonzero()
    qmaxInd = c_[qmaxInd[0], qmaxInd[1]]

    eqvMisColInd = sort(qmaxInd[:, 0] + qmaxInd[:, 1]*p*m)

    # store Rmin in q
    mis = eqvMis[ix_(list(range(4)), eqvMisColInd)]

    angle = 2 * arccosSafe(qmax)

    return angle, mis


def quatProduct(q1, q2):
    """
    Product of two unit quaternions.

    qp = quatProduct(q2, q1)

    q2, q1 are 4 x n, arrays whose columns are
           quaternion parameters

    qp is 4 x n, an array whose columns are the
       quaternion parameters of the product; the
       first component of qp is nonnegative

    If R(q) is the rotation corresponding to the
    quaternion parameters q, then

    R(qp) = R(q2) R(q1)
    """
    n1 = q1.shape[1]
    n2 = q2.shape[1]

    nq = 1
    if n1 == 1 or n2 == 1:
        if n2 > n1:
            q1 = tile(q1, (1, n2))
            nq = n2
        else:
            q2 = tile(q2, (1, n1))
            nq = n1

    a = q2[0, ]
    a3 = tile(a, (3, 1))
    b = q1[0, ]
    b3 = tile(b, (3, 1))

    avec = q2[1:, ]
    bvec = q1[1:, ]

    axb = zeros((3, nq))
    for i in range(nq):
        axb[:, i] = cross(avec[:, i], bvec[:, i])

    qp = vstack([a*b - diag(dot(avec.T, bvec)),
                 a3*bvec + b3*avec + axb])

    return fixQuat(qp)


def quatProductMatrix(quats, mult='right'):
    """
    Form 4 x 4 arrays to perform the quaternion product

    USAGE
        qmats = quatProductMatrix(quats, mult='right')

    INPUTS
        1) quats is (4, n), a numpy ndarray array of n quaternions
           horizontally concatenated
        2) mult is a keyword arg, either 'left' or 'right', denoting
           the sense of the multiplication:

                       | quatProductMatrix(h, mult='right') * q
           q * h  --> <
                       | quatProductMatrix(q, mult='left') * h

    OUTPUTS
        1) qmats is (n, 4, 4), the left or right quaternion product
           operator

    NOTES
       *) This function is intended to replace a cross-product based
          routine for products of quaternions with large arrays of
          quaternions (e.g. applying symmetries to a large set of
          orientations).
    """

    if quats.shape[0] != 4:
        raise RuntimeError("input is the wrong size along the 0-axis")

    nq = quats.shape[1]

    q0 = quats[0, :].copy()
    q1 = quats[1, :].copy()
    q2 = quats[2, :].copy()
    q3 = quats[3, :].copy()

    if mult == 'right':
        qmats = array([[q0], [q1], [q2], [q3],
                       [-q1], [q0], [-q3], [q2],
                       [-q2], [q3], [q0], [-q1],
                       [-q3], [-q2], [q1], [q0]])
    elif mult == 'left':
        qmats = array([[q0], [q1], [q2], [q3],
                       [-q1], [q0], [q3], [-q2],
                       [-q2], [-q3], [q0], [q1],
                       [-q3], [q2], [-q1], [q0]])

    # some fancy reshuffling...
    qmats = qmats.T.reshape(nq, 4, 4).transpose(0, 2, 1)

    return qmats


def quatOfAngleAxis(angle, rotaxis):
    """
    make an hstacked array of quaternions from arrays of angle/axis pairs
    """
    if isinstance(angle, list):
        n = len(angle)
        angle = asarray(angle)
    elif isinstance(angle, float) or isinstance(angle, int):
        n = 1
    elif isinstance(angle, ndarray):
        n = angle.shape[0]
    else:
        raise RuntimeError(
            "angle argument is of incorrect type; "
            + "must be a list, int, float, or ndarray."
        )

    if rotaxis.shape[1] == 1:
        rotaxis = tile(rotaxis, (1, n))
    else:
        if rotaxis.shape[1] != n:
            raise RuntimeError("rotation axes argument has incompatible shape")

    halfangle = 0.5*angle
    cphiby2 = cos(halfangle)
    sphiby2 = sin(halfangle)

    quat = vstack([cphiby2, tile(sphiby2, (3, 1)) * unitVector(rotaxis)])

    return fixQuat(quat)


def quatOfExpMap(expMaps):
    """
    Returns the unit quaternions associated with exponential map parameters.

    Parameters
    ----------
    expMaps : array_like
        The (3,) or (3, n) list of hstacked exponential map parameters to
        convert.

    Returns
    -------
    quats : array_like
        The (4,) or (4, n) array of unit quaternions.

    Notes
    -----
    1) be aware that the output will always have non-negative q0; recall the
       antipodal symmetry of unit quaternions

    """
    cdim = 3  # critical dimension of input
    expMaps = np.atleast_2d(expMaps)
    if len(expMaps) == 1:
        assert expMaps.shape[1] == cdim, \
            "your input quaternion must have %d elements" % cdim
        expMaps = np.reshape(expMaps, (cdim, 1))
    else:
        assert len(expMaps) == cdim, \
            "your input quaternions must have shape (%d, n) for n > 1" % cdim
    angles = columnNorm(expMaps)
    axes = unitVector(expMaps)

    quats = quatOfAngleAxis(angles, axes)
    return quats.squeeze()


def quatOfRotMat(R):
    """
    """
    angs, axxs = angleAxisOfRotMat(R)
    quats = vstack(
        [cos(0.5 * angs),
         tile(sin(0.5 * angs), (3, 1)) * axxs]
    )
    return quats


def quatAverageCluster(q_in, qsym):
    """
    """
    assert q_in.ndim == 2, 'input must be 2-s hstacked quats'

    # renormalize
    q_in = unitVector(q_in)

    # check to see num of quats is > 1
    if q_in.shape[1] < 3:
        if q_in.shape[1] == 1:
            q_bar = q_in
        else:
            ma, mq = misorientation(q_in[:, 0].reshape(4, 1),
                                    q_in[:, 1].reshape(4, 1), (qsym,))

            q_bar = quatProduct(
                q_in[:, 0].reshape(4, 1),
                quatOfExpMap(0.5*ma*unitVector(mq[1:])).reshape(4, 1)
            )
    else:
        # first drag to origin using first quat (arb!)
        q0 = q_in[:, 0].reshape(4, 1)
        qrot = dot(
            quatProductMatrix(invertQuat(q0), mult='left'),
            q_in)

        # second, re-cast to FR
        qrot = symmetry.toFundamentalRegion(qrot.squeeze(), crysSym=qsym)

        # compute arithmetic average
        q_bar = unitVector(average(qrot, axis=1).reshape(4, 1))

        # unrotate!
        q_bar = dot(
            quatProductMatrix(q0, mult='left'),
            q_bar)

        # re-map
        q_bar = symmetry.toFundamentalRegion(q_bar, crysSym=qsym)
    return q_bar


def quatAverage(q_in, qsym):
    """
    """
    assert q_in.ndim == 2, 'input must be 2-s hstacked quats'

    # renormalize
    q_in = unitVector(q_in)

    # check to see num of quats is > 1
    if q_in.shape[1] < 3:
        if q_in.shape[1] == 1:
            q_bar = q_in
        else:
            ma, mq = misorientation(q_in[:, 0].reshape(4, 1),
                                    q_in[:, 1].reshape(4, 1), (qsym,))
            q_bar = quatProduct(
                q_in[:, 0].reshape(4, 1),
                quatOfExpMap(0.5*ma*unitVector(mq[1:].reshape(3, 1)))
            )
    else:
        # use first quat as initial guess
        phi = 2. * arccos(q_in[0, 0])
        if phi <= finfo(float).eps:
            x0 = zeros(3)
        else:
            n = unitVector(q_in[1:, 0].reshape(3, 1))
            x0 = phi*n.flatten()
        results = leastsq(quatAverage_obj, x0, args=(q_in, qsym))
        phi = sqrt(sum(results[0]*results[0]))
        if phi <= finfo(float).eps:
            q_bar = c_[1., 0., 0., 0.].T
        else:
            n = results[0] / phi
            q_bar = hstack([cos(0.5*phi), sin(0.5*phi)*n]).reshape(4, 1)
    return q_bar


def quatAverage_obj(xi_in, quats, qsym):
    phi = sqrt(sum(xi_in.flatten()*xi_in.flatten()))
    if phi <= finfo(float).eps:
        q0 = c_[1., 0., 0., 0.].T
    else:
        n = xi_in.flatten() / phi
        q0 = hstack([cos(0.5*phi), sin(0.5*phi)*n])
    resd = misorientation(q0.reshape(4, 1), quats, (qsym, ))[0]
    return resd


def expMapOfQuat(quats):
    """
    Return the exponential map parameters for an array of unit quaternions

    Parameters
    ----------
    quats : array_like
        The (4, ) or (4, n) array of hstacked unit quaternions.  The convention
        is [q0, q] where q0 is the scalar part and q is the vector part.

    Returns
    -------
    expmaps : array_like
        The (3, ) or (3, n) array of exponential map parameters associated
        with the input quaternions.

    """
    cdim = 4  # critical dimension of input
    quats = np.atleast_2d(quats)
    if len(quats) == 1:
        assert quats.shape[1] == cdim, \
            "your input quaternion must have %d elements" % cdim
        quats = np.reshape(quats, (cdim, 1))
    else:
        assert len(quats) == cdim, \
            "your input quaternions must have shape (%d, n) for n > 1" % cdim

    # ok, we have hstacked quats; get angle
    phis = 2.*arccosSafe(quats[0, :])

    # now axis
    ns = unitVector(quats[1:, :])

    # reassemble
    expmaps = phis*ns
    return expmaps.squeeze()


def rotMatOfExpMap_opt(expMap):
    """Optimized version of rotMatOfExpMap
    """
    if expMap.ndim == 1:
        expMap = expMap.reshape(3, 1)

    # angles of rotation from exponential maps
    phi = atleast_1d(columnNorm(expMap))

    # skew matrices of exponential maps
    W = skewMatrixOfVector(expMap)

    # Find tiny angles to avoid divide-by-zero and apply limits in expressions
    zeroIndex = phi < cnst.epsf
    phi[zeroIndex] = 1

    # first term
    C1 = sin(phi) / phi
    C1[zeroIndex] = 1  # is this right?  might be OK since C1 multiplies W

    # second term
    C2 = (1 - cos(phi)) / phi**2
    C2[zeroIndex] = 0.5  # won't matter because W^2 is small

    numObjs = expMap.shape[1]
    if numObjs == 1:  # case of single point
        W = np.reshape(W, [1, 3, 3])
        pass

    C1 = np.tile(
        np.reshape(C1, [numObjs, 1]),
        [1, 9]).reshape([numObjs, 3, 3])
    C2 = np.tile(
        np.reshape(C2, [numObjs, 1]),
        [1, 9]).reshape([numObjs, 3, 3])

    W2 = np.zeros([numObjs, 3, 3])

    for i in range(3):
        for j in range(3):
            W2[:, i, j] = np.sum(W[:, i, :]*W[:, :, j], 1)
            pass
        pass

    rmat = C1*W + C2 * W2
    rmat[:, 0, 0] += 1.
    rmat[:, 1, 1] += 1.
    rmat[:, 2, 2] += 1.

    return rmat.squeeze()


def rotMatOfExpMap_orig(expMap):
    """
    Original rotMatOfExpMap, used for comparison to optimized version
    """
    if isinstance(expMap, ndarray):
        if expMap.ndim != 2:
            if expMap.ndim == 1 and len(expMap) == 3:
                numObjs = 1
                expMap = expMap.reshape(3, 1)
            else:
                raise RuntimeError("input is the wrong dimensionality")
        elif expMap.shape[0] != 3:
            raise RuntimeError(
                "input is the wrong shape along the 0-axis; "
                + "Yours is %d when is should be 3"
                % (expMap.shape[0])
            )
        else:
            numObjs = expMap.shape[1]
    elif isinstance(expMap, list) or isinstance(expMap, tuple):
        if len(expMap) != 3:
            raise RuntimeError(
                "for list/tuple input only one exponential map "
                + "vector is allowed"
            )
        else:
            if not isscalar(expMap[0]) or not isscalar(expMap[1]) \
                    or not isscalar(expMap[2]):
                raise RuntimeError(
                    "for list/tuple input only one exponential map "
                    + "vector is allowed"
                )
            else:
                numObjs = 1
                expMap = asarray(expMap).reshape(3, 1)

    phi = columnNorm(expMap)  # angles of rotation from exponential maps
    W = skewMatrixOfVector(expMap)  # skew matrices of exponential maps

    # Find tiny angles to avoid divide-by-zero and apply limits in expressions
    zeroIndex = phi < cnst.epsf
    phi[zeroIndex] = 1

    # first term
    C1 = sin(phi) / phi
    C1[zeroIndex] = 1

    # second term
    C2 = (1 - cos(phi)) / phi**2
    C2[zeroIndex] = 1

    if numObjs == 1:
        rmat = I3 + C1 * W + C2 * dot(W, W)
    else:
        rmat = zeros((numObjs, 3, 3))
        for i in range(numObjs):
            rmat[i, :, :] = \
                I3 + C1[i] * W[i, :, :] + C2[i] * dot(W[i, :, :], W[i, :, :])

    return rmat


# Donald Boyce's
rotMatOfExpMap = rotMatOfExpMap_opt


@numba_njit_if_available(cache=True, nogil=True)
def _rotmatofquat(quat):
    n = quat.shape[1]
    # FIXME: maybe preallocate for speed?
    # R = zeros(n*3*3, dtype='float64')

    a = np.ascontiguousarray(quat[0, :]).reshape(n, 1)
    b = np.ascontiguousarray(quat[1, :]).reshape(n, 1)
    c = np.ascontiguousarray(quat[2, :]).reshape(n, 1)
    d = np.ascontiguousarray(quat[3, :]).reshape(n, 1)

    R = np.hstack((a**2 + b**2 - c**2 - d**2,
                   2*b*c - 2*a*d,
                   2*a*c + 2*b*d,
                   2*a*d + 2*b*c,
                   a**2 - b**2 + c**2 - d**2,
                   2*c*d - 2*a*b,
                   2*b*d - 2*a*c,
                   2*a*b + 2*c*d,
                   a**2 - b**2 - c**2 + d**2))

    return R.reshape(n, 3, 3)


def rotMatOfQuat(quat):
    """
    Convert quaternions to rotation matrices.

    Take an array of n quats (numpy ndarray, 4 x n) and generate an
    array of rotation matrices (n x 3 x 3)

    Parameters
    ----------
    quat : TYPE
        DESCRIPTION.

    Raises
    ------
    RuntimeError
        DESCRIPTION.

    Returns
    -------
    rmat : TYPE
        DESCRIPTION.

    Notes
    -----
    Uses the truncated series expansion for the exponential map;
    didvide-by-zero is checked using the global 'cnst.epsf'
    """
    if quat.ndim == 1:
        if len(quat) != 4:
            raise RuntimeError("input is the wrong shape")
        else:
            quat = quat.reshape(4, 1)
    else:
        if quat.shape[0] != 4:
            raise RuntimeError("input is the wrong shape")

    rmat = _rotmatofquat(quat)

    return np.squeeze(rmat)


def angleAxisOfRotMat(R):
    """
    Extracts angle and axis invariants from rotation matrices.

    Parameters
    ----------
    R : numpy.ndarray
        The (3, 3) or (n, 3, 3) array of rotation matrices.
        Note that these are assumed to be proper orthogonal.

    Raises
    ------
    RuntimeError
        If `R` is not an shape is not (3, 3) or (n, 3, 3).

    Returns
    -------
    phi : numpy.ndarray
        The (n, ) array of rotation angles for the n input
        rotation matrices.
    n : numpy.ndarray
        The (3, n) array of unit rotation axes for the n
        input rotation matrices.

    """
    if not isinstance(R, ndarray):
        raise RuntimeError('Input must be a 2 or 3-d ndarray')
    else:
        rdim = R.ndim
        if rdim == 2:
            R = tile(R, (1, 1, 1))
        elif rdim == 3:
            pass
        else:
            raise RuntimeError(
                "R array must be (3, 3) or (n, 3, 3); input has dimension %d"
                % (rdim)
            )

    #
    #  Find angle of rotation.
    #
    ca = 0.5*(R[:, 0, 0] + R[:, 1, 1] + R[:, 2, 2] - 1)

    angle = arccosSafe(ca)  # !!! result in (0, pi)

    #
    #  Three cases for the angle:
    #
    #  *   near zero -- matrix is effectively the identity
    #  *   near pi   -- binary rotation; need to find axis
    #  *   neither   -- general case; can use skew part
    #
    tol = 1e-6  # !!! ~1e-12 in cosine(angle); cnst.sqrt_epsf too tight

    anear0 = abs(angle) < tol

    angle[anear0] = 0.

    raxis = vstack(
        [R[:, 2, 1] - R[:, 1, 2],
         R[:, 0, 2] - R[:, 2, 0],
         R[:, 1, 0] - R[:, 0, 1]]
    )
    raxis[:, anear0] = 1.

    special = abs(angle - pi) < tol  # !!! see above

    nspec = special.sum()
    if nspec > 0:

        tmp = R[special, :, :] + tile(I3, (nspec, 1, 1))
        tmpr = tmp.transpose(0, 2, 1).reshape(nspec*3, 3).T

        tmpnrm = (tmpr*tmpr).sum(0).reshape(3, nspec)
        mx = tmpnrm.max(0)

        # remap indices
        maxInd = (tmpnrm == mx).nonzero()
        maxInd = c_[maxInd[0], maxInd[1]]

        tmprColInd = sort(maxInd[:, 0] + maxInd[:, 1]*nspec)

        saxis = tmpr[:, tmprColInd]

        raxis[:, special] = saxis

    return angle, unitVector(raxis)


def _check_axes_order(x):
    if not isinstance(x, str):
        raise RuntimeError("argument must be str")
    axo = x.lower()
    if axo not in axes_orders:
        raise RuntimeError(
            "order '%s' is not a valid choice"
            % x
        )
    return axo


def _check_is_rmat(x):
    x = np.asarray(x)
    if x.shape != (3, 3):
        raise RuntimeError("shape of input must be (3, 3)")
    chk1 = np.linalg.det(x)
    chk2 = np.sum(abs(np.eye(3) - np.dot(x, x.T)))
    if 1. - abs(chk1) < cnst.sqrt_epsf and chk2 < cnst.sqrt_epsf:
        return x
    else:
        raise RuntimeError("input is not an orthogonal matrix")


def make_rmat_euler(tilt_angles, axes_order, extrinsic=True):
    """
    Generate rotation matrix from Euler angles.

    Parameters
    ----------
    tilt_angles : array_like
        The (3, ) list of Euler angles in RADIANS.
    axes_order : str
        The axes order specification (case-insensitive).  This must be one
        of the following: 'xyz', 'zyx'
                          'zxy', 'yxz'
                          'yzx', 'xzy'
                          'xyx', 'xzx'
                          'yxy', 'yzy'
                          'zxz', 'zyz'
    extrinsic : bool, optional
        Flag denoting the convention.  If True, the convention is
        extrinsic (passive); if False, the convention is
        instrinsic (active). The default is True.

    Returns
    -------
    numpy.ndarray
        The (3, 3) rotation matrix corresponding to the input specification.

    TODO: add kwarg for unit selection for `tilt_angles`
    TODO: input checks
    """
    axes = np.eye(3)
    axes_dict = dict(x=0, y=1, z=2)

    axo = _check_axes_order(axes_order)

    if extrinsic:
        rmats = np.zeros((3, 3, 3))
        for i, ax in enumerate(axo):
            rmats[i] = rotMatOfExpMap(
                tilt_angles[i]*axes[axes_dict[ax]]
            )
        return np.dot(rmats[2], np.dot(rmats[1], rmats[0]))
    else:
        rm0 = rotMatOfExpMap(
            tilt_angles[0]*axes[axes_dict[axo[0]]]
        )
        rm1 = rotMatOfExpMap(
            tilt_angles[1]*rm0[:, axes_dict[axo[1]]]
        )
        rm2 = rotMatOfExpMap(
            tilt_angles[2]*np.dot(rm1, rm0[:, axes_dict[axo[2]]])
        )
        return np.dot(rm2, np.dot(rm1, rm0))


def angles_from_rmat_xyz(rmat):
    """
    Calculate passive x-y-z Euler angles from a rotation matrix.

    Parameters
    ----------
    rmat : TYPE
        DESCRIPTION.

    Returns
    -------
    rx : TYPE
        DESCRIPTION.
    ry : TYPE
        DESCRIPTION.
    rz : TYPE
        DESCRIPTION.

    """
    rmat = _check_is_rmat(rmat)

    eps = sqrt(finfo('float').eps)
    ry = -arcsin(rmat[2, 0])
    sgny = sign(ry)
    if abs(ry) < 0.5*pi - eps:
        cosy = cos(ry)
        rz = arctan2(rmat[1, 0]/cosy, rmat[0, 0]/cosy)
        rx = arctan2(rmat[2, 1]/cosy, rmat[2, 2]/cosy)
    else:
        rz = 0.5*arctan2(sgny*rmat[1, 2], sgny*rmat[0, 2])
        if sgny > 0:
            rx = -rz
        else:
            rx = rz
    return rx, ry, rz


def angles_from_rmat_zxz(rmat):
    """
    Calculate active z-x-z Euler angles from a rotation matrix.

    Parameters
    ----------
    rmat : TYPE
        DESCRIPTION.

    Returns
    -------
    alpha : TYPE
        DESCRIPTION.
    beta : TYPE
        DESCRIPTION.
    gamma : TYPE
        DESCRIPTION.

    """
    rmat = _check_is_rmat(rmat)

    if abs(rmat[2, 2]) > 1. - sqrt(finfo('float').eps):
        beta = 0.
        alpha = arctan2(rmat[1, 0], rmat[0, 0])
        gamma = 0.
    else:
        xnew = rmat[:, 0]
        znew = rmat[:, 2]
        alpha = arctan2(znew[0], -znew[1])
        rma = rotMatOfExpMap(alpha*c_[0., 0., 1.].T)
        znew1 = dot(rma.T, znew)
        beta = arctan2(-znew1[1], znew1[2])
        rmb = rotMatOfExpMap(beta*c_[cos(alpha), sin(alpha), 0.].T)
        xnew2 = dot(rma.T, dot(rmb.T, xnew))
        gamma = arctan2(xnew2[1], xnew2[0])
    return alpha, beta, gamma


class RotMatEuler(object):
    def __init__(self, angles, axes_order, extrinsic=True, units=angularUnits):
        """
        Abstraction of a rotation matrix defined by Euler angles.

        Parameters
        ----------
        angles : array_like
            The (3, ) list of Euler angles in RADIANS.
        axes_order : str
            The axes order specification (case-insensitive).  This must be one
            of the following:

                'xyz', 'zyx'
                'zxy', 'yxz'
                'yzx', 'xzy'
                'xyx', 'xzx'
                'yxy', 'yzy'
                'zxz', 'zyz'

        extrinsic : bool, optional
            Flag denoting the convention.  If True, the convention is
            extrinsic (passive); if False, the convention is
            instrinsic (active). The default is True.

        Returns
        -------
        None.

        TODO: add check that angle input is array-like, len() = 3?
        TODO: add check on extrinsic as bool
        """
        self._axes = np.eye(3)
        self._axes_dict = dict(x=0, y=1, z=2)

        # these will be properties
        self._angles = angles
        self._axes_order = _check_axes_order(axes_order)
        self._extrinsic = extrinsic
        if units.lower() not in periodDict.keys():
            raise RuntimeError("angular units '%s' not understood" % units)
        self._units = units

    @property
    def angles(self):
        return self._angles

    @angles.setter
    def angles(self, x):
        x = np.atleast_1d(x).flatten()
        if len(x) == 3:
            self._angles = x
        else:
            raise RuntimeError("input must be array-like with __len__ = 3")

    @property
    def axes_order(self):
        return self._axes_order

    @axes_order.setter
    def axes_order(self, x):
        axo = _check_axes_order(x)
        self._axes_order = axo

    @property
    def extrinsic(self):
        return self._extrinsic

    @extrinsic.setter
    def extrinsic(self, x):
        if isinstance(x, bool):
            self._extrinsic = x
        else:
            raise RuntimeError("input must be a bool")

    @property
    def units(self):
        return self._units

    @units.setter
    def units(self, x):
        if isinstance(x, str) and x in periodDict.keys():
            if self._units != x:
                # !!! we are changing units; update self.angles
                self.angles = conversion_to_dict[x]*np.asarray(self.angles)
            self._units = x
        else:
            raise RuntimeError("input must be 'degrees' or 'radians'")

    @property
    def rmat(self):
        """
        Return the rotation matrix.

        As calculated from angles, axes_order, and convention.

        Returns
        -------
        numpy.ndarray
            The (3, 3) proper orthogonal matrix according to the specification.

        """
        angs_in = self.angles
        if self.units == 'degrees':
            angs_in = conversion_to_dict['radians']*angs_in
        self._rmat = make_rmat_euler(
            angs_in, self.axes_order, self.extrinsic)
        return self._rmat

    @rmat.setter
    def rmat(self, x):
        """
        Update class via input rotation matrix.

        Parameters
        ----------
        x : array_like
            A (3, 3) array to be interpreted as a rotation matrix.

        Raises
        ------
        NotImplementedError
            Currently only works for the cases:
                axes_order     extrinsic
                ----------     ---------
                  'xyz'          True
                  'zxz'          False

        Returns
        -------
        None.

        Notes
        -----
        1) This requires case-by-case implementations for all 24 possible
           combinations of axes order and convention.
        2) May be able to use SciPy to fill in some additional conventions.  As
           for now, the api for a function that yields angles from simply takes
           in a rotation matrix and yields the angles in radians.
        """
        rmat = _check_is_rmat(x)
        self._rmat = rmat
        if self.axes_order == 'xyz':
            if self.extrinsic:
                angles = angles_from_rmat_xyz(rmat)
            else:
                raise NotImplementedError
        elif self.axes_order == 'zxz':
            if self.extrinsic:
                raise NotImplementedError
            else:
                angles = angles_from_rmat_zxz(rmat)
        else:
            raise NotImplementedError

        # set self.angles according to self.units
        # !!! at this point angles are in radians
        if self.units == 'degrees':
            self._angles = conversion_to_dict['degrees']*np.asarray(angles)
        else:
            self._angles = angles

    @property
    def exponential_map(self):
        """
        The matrix invariants of self.rmat as exponential map parameters

        Returns
        -------
        np.ndarray
            The (3, ) array representing the exponential map parameters of
            the encoded rotation (self.rmat).

        """
        phi, n = angleAxisOfRotMat(self.rmat)
        return phi*n.flatten()

    @exponential_map.setter
    def exponential_map(self, x):
        """
        Updates encoded rotation via exponential map parameters

        Parameters
        ----------
        x : array_like
            The (3, ) vector representing exponential map parameters of a
            rotation.

        Returns
        -------
        None.

        Notes
        -----
        Updates the encoded rotation from expoential map parameters via
        self.rmat property
        """
        x = np.atleast_1d(x).flatten()
        assert len(x) == 3, "input must have exactly 3 elements"
        self.rmat = rotMatOfExpMap(x.reshape(3, 1))  # use local func


#
#  ==================== Fiber
#


def distanceToFiber(c, s, q, qsym, **kwargs):
    """
    Calculate symmetrically reduced distance to orientation fiber.

    Parameters
    ----------
    c : TYPE
        DESCRIPTION.
    s : TYPE
        DESCRIPTION.
    q : TYPE
        DESCRIPTION.
    qsym : TYPE
        DESCRIPTION.
    **kwargs : TYPE
        DESCRIPTION.

    Raises
    ------
    RuntimeError
        DESCRIPTION.

    Returns
    -------
    d : TYPE
        DESCRIPTION.

    """
    csymFlag = False
    B = I3

    arglen = len(kwargs)

    if len(c) != 3 or len(s) != 3:
        raise RuntimeError('c and/or s are not 3-vectors')

    # argument handling
    if arglen > 0:
        argkeys = list(kwargs.keys())
        for i in range(arglen):
            if argkeys[i] == 'centrosymmetry':
                csymFlag = kwargs[argkeys[i]]
            elif argkeys[i] == 'bmatrix':
                B = kwargs[argkeys[i]]
            else:
                raise RuntimeError("keyword arg \'%s\' is not recognized"
                                   % (argkeys[i]))

    c = unitVector(dot(B, asarray(c)))
    s = unitVector(asarray(s).reshape(3, 1))

    nq = q.shape[1]  # number of quaternions
    rmats = rotMatOfQuat(q)  # (nq, 3, 3)

    csym = applySym(c, qsym, csymFlag)  # (3, m)
    m = csym.shape[1]  # multiplicity

    if nq == 1:
        rc = dot(rmats, csym)  # apply q's to c's

        sdotrc = dot(s.T, rc).max()
    else:
        rc = multMatArray(
            rmats, tile(csym, (nq, 1, 1))
        )  # apply q's to c's

        sdotrc = dot(
            s.T,
            rc.swapaxes(1, 2).reshape(nq*m, 3).T
        ).reshape(nq, m).max(1)

    d = arccosSafe(array(sdotrc))

    return d


def discreteFiber(c, s, B=I3, ndiv=120, invert=False, csym=None, ssym=None):
    """
    Generate symmetrically reduced discrete orientation fiber.

    Parameters
    ----------
    c : TYPE
        DESCRIPTION.
    s : TYPE
        DESCRIPTION.
    B : TYPE, optional
        DESCRIPTION. The default is I3.
    ndiv : TYPE, optional
        DESCRIPTION. The default is 120.
    invert : TYPE, optional
        DESCRIPTION. The default is False.
    csym : TYPE, optional
        DESCRIPTION. The default is None.
    ssym : TYPE, optional
        DESCRIPTION. The default is None.

    Raises
    ------
    RuntimeError
        DESCRIPTION.

    Returns
    -------
    retval : TYPE
        DESCRIPTION.

    """

    ztol = cnst.sqrt_epsf

    # arg handling for c
    if hasattr(c, '__len__'):
        if hasattr(c, 'shape'):
            assert c.shape[0] == 3, \
                'scattering vector must be 3-d; yours is %d-d' \
                % (c.shape[0])
            if len(c.shape) == 1:
                c = c.reshape(3, 1)
            elif len(c.shape) > 2:
                raise RuntimeError(
                    'incorrect arg shape; must be 1-d or 2-d, yours is %d-d'
                    % (len(c.shape))
                )
        else:
            # convert list input to array and transpose
            if len(c) == 3 and isscalar(c[0]):
                c = asarray(c).reshape(3, 1)
            else:
                c = asarray(c).T
    else:
        raise RuntimeError('input must be array-like')

    # arg handling for s
    if hasattr(s, '__len__'):
        if hasattr(s, 'shape'):
            assert s.shape[0] == 3, \
                'scattering vector must be 3-d; yours is %d-d' \
                % (s.shape[0])
            if len(s.shape) == 1:
                s = s.reshape(3, 1)
            elif len(s.shape) > 2:
                raise RuntimeError(
                    'incorrect arg shape; must be 1-d or 2-d, yours is %d-d'
                    % (len(s.shape)))
        else:
            # convert list input to array and transpose
            if len(s) == 3 and isscalar(s[0]):
                s = asarray(s).reshape(3, 1)
            else:
                s = asarray(s).T
    else:
        raise RuntimeError('input must be array-like')

    nptc = c.shape[1]
    npts = s.shape[1]

    c = unitVector(dot(B, c))  # turn c hkls into unit vector in crys frame
    s = unitVector(s)  # convert s to unit vector in samp frame

    retval = []
    for i_c in range(nptc):
        dupl_c = tile(c[:, i_c], (npts, 1)).T

        ax = s + dupl_c
        anrm = columnNorm(ax).squeeze()  # should be 1-d

        okay = anrm > ztol
        nokay = okay.sum()
        if nokay == npts:
            ax = ax / tile(anrm, (3, 1))
        else:
            nspace = nullSpace(c[:, i_c].reshape(3, 1))
            hperp = nspace[:, 0].reshape(3, 1)
            if nokay == 0:
                ax = tile(hperp, (1, npts))
            else:
                ax[:,     okay] = ax[:, okay] / tile(anrm[okay], (3, 1))
                ax[:, not okay] = tile(hperp, (1, npts - nokay))

        q0 = vstack([zeros(npts), ax])

        # find rotations
        # note: the following line fixes bug with use of arange
        # with float increments
        phi = arange(0, ndiv) * (2*pi/float(ndiv))
        qh = quatOfAngleAxis(phi, tile(c[:, i_c], (ndiv, 1)).T)

        # the fibers, arraged as (npts, 4, ndiv)
        qfib = dot(
            quatProductMatrix(qh, mult='right'),
            q0
        ).transpose(2, 1, 0)
        if csym is not None:
            retval.append(
                toFundamentalRegion(
                    qfib.squeeze(),
                    crysSym=csym,
                    sampSym=ssym
                )
            )
        else:
            retval.append(fixQuat(qfib).squeeze())
    return retval


#
#  ==================== Utility Functions
#


def mapAngle(ang, *args, **kwargs):
    """
    Utility routine to map an angle into a specified period
    """
    period = 2.*pi        # radians
    units = angularUnits  # usually

    kwargKeys = list(kwargs.keys())
    for iArg in range(len(kwargKeys)):
        if kwargKeys[iArg] == 'units':
            units = kwargs[kwargKeys[iArg]]
        else:
            raise RuntimeError(
                "Unknown keyword argument: "
                + str(kwargKeys[iArg])
            )

    if units.lower() == 'degrees':
        period = 360.
    elif units.lower() != 'radians':
        raise RuntimeError(
            "unknown angular units: "
            + str(kwargs[kwargKeys[iArg]])
        )

    ang = atleast_1d(nFloat(ang))

    # if we have a specified angular range, use that
    if len(args) > 0:
        angRange = atleast_1d(nFloat(args[0]))

        # divide of multiples of period
        ang = ang - nInt(ang / period) * period

        lb = angRange.min()
        ub = angRange.max()

        if abs(ub - lb) != period:
            raise RuntimeError('range is incomplete!')

        lbi = ang < lb
        while lbi.sum() > 0:
            ang[lbi] = ang[lbi] + period
            lbi = ang < lb
            pass
        ubi = ang > ub
        while ubi.sum() > 0:
            ang[ubi] = ang[ubi] - period
            ubi = ang > ub
            pass
        retval = ang
    else:
        retval = mod(ang + 0.5*period, period) - 0.5*period
    return retval


def angularDifference_orig(angList0, angList1, units=angularUnits):
    """
    Do the proper (acute) angular difference in the context of a branch cut.

    *) Default angular range in the code is [-pi, pi]
    *) ... maybe more efficient not to vectorize?
    """
    if units == 'radians':
        period = 2*pi
    elif units == 'degrees':
        period = 360.
    else:
        raise RuntimeError(
            "'%s' is an unrecognized option for angular units!"
            % (units)
        )

    # take difference as arrays
    diffAngles = asarray(angList0) - asarray(angList1)

    return abs(mod(diffAngles + 0.5*period, period) - 0.5*period)


def angularDifference_opt(angList0, angList1, units=angularUnits):
    """
    Do the proper (acute) angular difference in the context of a branch cut.

    *) Default angular range in the code is [-pi, pi]
    """
    period = periodDict[units]
    d = abs(angList1 - angList0)
    return np.minimum(d, period - d)


angularDifference = angularDifference_opt


def applySym(vec, qsym, csFlag=False, cullPM=False, tol=cnst.sqrt_epsf):
    """
    Apply symmetry group to a single 3-vector (columnar) argument.

    csFlag : centrosymmetry flag
    cullPM : cull +/- flag
    """
    nsym = qsym.shape[1]
    Rsym = rotMatOfQuat(qsym)
    if nsym == 1:
        Rsym = array([Rsym, ])
    allhkl = multMatArray(
        Rsym, tile(vec, (nsym, 1, 1))
    ).swapaxes(1, 2).reshape(nsym, 3).T

    if csFlag:
        allhkl = hstack([allhkl, -1*allhkl])
    eqv, uid = findDuplicateVectors(allhkl, tol=tol, equivPM=cullPM)

    return allhkl[ix_(list(range(3)), uid)]


# =============================================================================
# Symmetry functions
# =============================================================================


def toFundamentalRegion(q, crysSym='Oh', sampSym=None):
    """
    Map quaternions to fundamental region.

    Parameters
    ----------
    q : TYPE
        DESCRIPTION.
    crysSym : TYPE, optional
        DESCRIPTION. The default is 'Oh'.
    sampSym : TYPE, optional
        DESCRIPTION. The default is None.

    Raises
    ------
    NotImplementedError
        DESCRIPTION.

    Returns
    -------
    qr : TYPE
        DESCRIPTION.
    """
    qdims = q.ndim
    if qdims == 3:
        l3, m3, n3 = q.shape
        assert m3 == 4, 'your 3-d quaternion array isn\'t the right shape'
        q = q.transpose(0, 2, 1).reshape(l3*n3, 4).T
    if isinstance(crysSym, str):
        qsym_c = quatProductMatrix(
            quatOfLaueGroup(crysSym), 'right'
        )  # crystal symmetry operator
    else:
        qsym_c = quatProductMatrix(crysSym, 'right')

    n = q.shape[1]              # total number of quats
    m = qsym_c.shape[0]         # number of symmetry operations

    #
    # MAKE EQUIVALENCE CLASS
    #
    # Do R * Gc, store as
    # [q[:, 0] * Gc[:, 0:m], ..., 2[:, n-1] * Gc[:, 0:m]]
    qeqv = dot(qsym_c, q).transpose(2, 0, 1).reshape(m*n, 4).T

    if sampSym is None:
        # need to fix quats to sort
        qeqv = fixQuat(qeqv)

        # Reshape scalar comp columnwise by point in qeqv
        q0 = qeqv[0, :].reshape(n, m).T

        # Find q0 closest to origin for each n equivalence classes
        q0maxColInd = argmax(q0, 0) + [x*m for x in range(n)]

        # store representatives in qr
        qr = qeqv[:, q0maxColInd]
    else:
        if isinstance(sampSym, str):
            qsym_s = quatProductMatrix(
                quatOfLaueGroup(sampSym), 'left'
            )  # sample symmetry operator
        else:
            qsym_s = quatProductMatrix(sampSym, 'left')

        p = qsym_s.shape[0]         # number of sample symmetry operations

        # Do Gs * (R * Gc), store as
        # [Gs[:, 0:p]*q[:,   0]*Gc[:, 0], ..., Gs[:, 0:p]*q[:,   0]*Gc[:, m-1],
        #  ...,
        #  Gs[:, 0:p]*q[:, n-1]*Gc[:, 0], ..., Gs[:, 0:p]*q[:, n-1]*Gc[:, m-1]]
        qeqv = fixQuat(
            dot(qsym_s, qeqv).transpose(2, 0, 1).reshape(p*m*n, 4).T
        )

        raise NotImplementedError

    # debug
    assert qr.shape[1] == n, 'oops, something wrong here with your reshaping'

    if qdims == 3:
        qr = qr.T.reshape(l3, n3, 4).transpose(0, 2, 1)

    return qr


def ltypeOfLaueGroup(tag):
    """
    Yield lattice type of input tag.

    Parameters
    ----------
    tag : TYPE
        DESCRIPTION.

    Raises
    ------
    RuntimeError
        DESCRIPTION.

    Returns
    -------
    ltype : TYPE
        DESCRIPTION.

    """
    if not isinstance(tag, str):
        raise RuntimeError("entered flag is not a string!")

    if tag.lower() == 'ci' or tag.lower() == 's2':
        ltype = 'triclinic'
    elif tag.lower() == 'c2h':
        ltype = 'monoclinic'
    elif tag.lower() == 'd2h' or tag.lower() == 'vh':
        ltype = 'orthorhombic'
    elif tag.lower() == 'c4h' or tag.lower() == 'd4h':
        ltype = 'tetragonal'
    elif tag.lower() == 'c3i' or tag.lower() == 's6' or tag.lower() == 'd3d':
        ltype = 'trigonal'
    elif tag.lower() == 'c6h' or tag.lower() == 'd6h':
        ltype = 'hexagonal'
    elif tag.lower() == 'th' or tag.lower() == 'oh':
        ltype = 'cubic'
    else:
        raise RuntimeError(
            "unrecognized symmetry group.  "
            + "See ''help(quatOfLaueGroup)'' for a list of valid options.  "
            + "Oh, and have a great day ;-)"
        )

    return ltype


def quatOfLaueGroup(tag):
    """
    Return quaternion representation of requested symmetry group.

    Parameters
    ----------
    tag : str
        A case-insensitive string representing the Schoenflies symbol for the
        desired Laue group.  The 14 available choices are:

              Class           Symbol      N
             -------------------------------
              Triclinic       Ci (S2)     1
              Monoclinic      C2h         2
              Orthorhombic    D2h (Vh)    4
              Tetragonal      C4h         4
                              D4h         8
              Trigonal        C3i (S6)    3
                              D3d         6
              Hexagonal       C6h         6
                              D6h         12
              Cubic           Th          12
                              Oh          24

    Raises
    ------
    RuntimeError
        For invalid symmetry group tag.

    Returns
    -------
    qsym : (4, N) ndarray
        the quaterions associated with each element of the chosen symmetry
        group having n elements (dep. on group -- see INPUTS list above).

    Notes
    -----
    The conventions used for assigning a RHON basis, {x1, x2, x3}, to each
    point group are consistent with those published in Appendix B of [1]_.

    References
    ----------
    [1] Nye, J. F., ``Physical Properties of Crystals: Their
    Representation by Tensors and Matrices'', Oxford University Press,
    1985. ISBN 0198511655
    """
    if not isinstance(tag, str):
        raise RuntimeError("entered flag is not a string!")

    if tag.lower() == 'ci' or tag.lower() == 's2':
        # TRICLINIC
        angleAxis = vstack([0., 1., 0., 0.])  # identity
    elif tag.lower() == 'c2h':
        # MONOCLINIC
        angleAxis = c_[
            [0.,   1,   0,   0],  # identity
            [pi,   0,   1,   0],  # twofold about 010 (x2)
        ]
    elif tag.lower() == 'd2h' or tag.lower() == 'vh':
        # ORTHORHOMBIC
        angleAxis = c_[
            [0.,   1,   0,   0],  # identity
            [pi,   1,   0,   0],  # twofold about 100
            [pi,   0,   1,   0],  # twofold about 010
            [pi,   0,   0,   1],  # twofold about 001
        ]
    elif tag.lower() == 'c4h':
        # TETRAGONAL (LOW)
        angleAxis = c_[
            [0.0,       1,    0,    0],  # identity
            [piby2,     0,    0,    1],  # fourfold about 001 (x3)
            [pi,        0,    0,    1],  #
            [piby2*3,   0,    0,    1],  #
        ]
    elif tag.lower() == 'd4h':
        # TETRAGONAL (HIGH)
        angleAxis = c_[
            [0.0,       1,    0,    0],  # identity
            [piby2,     0,    0,    1],  # fourfold about 0  0  1 (x3)
            [pi,        0,    0,    1],  #
            [piby2*3,   0,    0,    1],  #
            [pi,        1,    0,    0],  # twofold about  1  0  0 (x1)
            [pi,        0,    1,    0],  # twofold about  0  1  0 (x2)
            [pi,        1,    1,    0],  # twofold about  1  1  0
            [pi,       -1,    1,    0],  # twofold about -1  1  0
        ]
    elif tag.lower() == 'c3i' or tag.lower() == 's6':
        # TRIGONAL (LOW)
        angleAxis = c_[
            [0.0,       1,    0,    0],  # identity
            [piby3*2,   0,    0,    1],  # threefold about 0001 (x3,c)
            [piby3*4,   0,    0,    1],  #
        ]
    elif tag.lower() == 'd3d':
        # TRIGONAL (HIGH)
        angleAxis = c_[
            [0.0,       1,     0,    0],  # identity
            [piby3*2,   0,     0,    1],  # threefold about 0001 (x3,c)
            [piby3*4,   0,     0,    1],  #
            [pi,        1,     0,    0],  # twofold about  2 -1 -1  0 (x1,a1)
            [pi,       -0.5,   sq3by2,  0],  # twofold about -1  2 -1  0 (a2)
            [pi,       -0.5,  -sq3by2,  0],  # twofold about -1 -1  2  0 (a3)
        ]
    elif tag.lower() == 'c6h':
        # HEXAGONAL (LOW)
        angleAxis = c_[
            [0.0,       1,     0,    0],  # identity
            [piby3,     0,     0,    1],  # sixfold about 0001 (x3,c)
            [piby3*2,   0,     0,    1],  #
            [pi,        0,     0,    1],  #
            [piby3*4,   0,     0,    1],  #
            [piby3*5,   0,     0,    1],  #
        ]
    elif tag.lower() == 'd6h':
        # HEXAGONAL (HIGH)
        angleAxis = c_[
            [0.0,       1,       0,       0],  # identity
            [piby3,     0,       0,       1],  # sixfold about  0  0  1 (x3,c)
            [piby3*2,   0,       0,       1],  #
            [pi,        0,       0,       1],  #
            [piby3*4,   0,       0,       1],  #
            [piby3*5,   0,       0,       1],  #
            [pi,        1,       0,       0],  # twofold about  2 -1  0 (x1,a1)
            [pi,       -0.5,     sq3by2,  0],  # twofold about -1  2  0 (a2)
            [pi,       -0.5,    -sq3by2,  0],  # twofold about -1 -1  0 (a3)
            [pi,        sq3by2,  0.5,     0],  # twofold about  1  0  0
            [pi,        0,       1,       0],  # twofold about -1  1  0 (x2)
            [pi,       -sq3by2,  0.5,     0],  # twofold about  0 -1  0
        ]
    elif tag.lower() == 'th':
        # CUBIC (LOW)
        angleAxis = c_[
            [0.0,       1,    0,    0],  # identity
            [pi,        1,    0,    0],  # twofold about    1  0  0 (x1)
            [pi,        0,    1,    0],  # twofold about    0  1  0 (x2)
            [pi,        0,    0,    1],  # twofold about    0  0  1 (x3)
            [piby3*2,   1,    1,    1],  # threefold about  1  1  1
            [piby3*4,   1,    1,    1],  #
            [piby3*2,  -1,    1,    1],  # threefold about -1  1  1
            [piby3*4,  -1,    1,    1],  #
            [piby3*2,  -1,   -1,    1],  # threefold about -1 -1  1
            [piby3*4,  -1,   -1,    1],  #
            [piby3*2,   1,   -1,    1],  # threefold about  1 -1  1
            [piby3*4,   1,   -1,    1],  #
        ]
    elif tag.lower() == 'oh':
        # CUBIC (HIGH)
        angleAxis = c_[
            [0.0,       1,    0,    0],  # identity
            [piby2,     1,    0,    0],  # fourfold about   1  0  0 (x1)
            [pi,        1,    0,    0],  #
            [piby2*3,   1,    0,    0],  #
            [piby2,     0,    1,    0],  # fourfold about   0  1  0 (x2)
            [pi,        0,    1,    0],  #
            [piby2*3,   0,    1,    0],  #
            [piby2,     0,    0,    1],  # fourfold about   0  0  1 (x3)
            [pi,        0,    0,    1],  #
            [piby2*3,   0,    0,    1],  #
            [piby3*2,   1,    1,    1],  # threefold about  1  1  1
            [piby3*4,   1,    1,    1],  #
            [piby3*2,  -1,    1,    1],  # threefold about -1  1  1
            [piby3*4,  -1,    1,    1],  #
            [piby3*2,  -1,   -1,    1],  # threefold about -1 -1  1
            [piby3*4,  -1,   -1,    1],  #
            [piby3*2,   1,   -1,    1],  # threefold about  1 -1  1
            [piby3*4,   1,   -1,    1],  #
            [pi,        1,    1,    0],  # twofold about    1  1  0
            [pi,       -1,    1,    0],  # twofold about   -1  1  0
            [pi,        1,    0,    1],  # twofold about    1  0  1
            [pi,        0,    1,    1],  # twofold about    0  1  1
            [pi,       -1,    0,    1],  # twofold about   -1  0  1
            [pi,        0,   -1,    1],  # twofold about    0 -1  1
        ]
    else:
        raise RuntimeError(
            "unrecognized symmetry group.  "
            + "See ``help(quatOfLaueGroup)'' for a list of valid options.  "
            + "Oh, and have a great day ;-)"
        )

    angle = angleAxis[0, ]
    axis = angleAxis[1:, ]

    #  Note: Axis does not need to be normalized in call to quatOfAngleAxis
    #  05/01/2014 JVB -- made output a contiguous C-ordered array
    qsym = array(quatOfAngleAxis(angle, axis).T, order='C').T

    return qsym


# =============================================================================
# Tests
# =============================================================================


def printTestName(num, name):
    print('==================== Test %d:  %s' % (num, name))


def testRotMatOfExpMap(numpts):
    """Test rotation matrix from axial vector."""
    print('* checking case of 1D vector input')
    map = np.zeros(3)
    rmat_1 = rotMatOfExpMap_orig(map)
    rmat_2 = rotMatOfExpMap_opt(map)
    print('resulting shapes:  ', rmat_1.shape, rmat_2.shape)
    #
    #
    map = np.random.rand(3, numPts)
    map = np.zeros([3, numPts])
    map[0, :] = np.linspace(0, np.pi, numPts)
    #
    print('* testing rotMatOfExpMap with %d random points' % numPts)
    #
    t0 = timeit.default_timer()
    rmat_1 = rotMatOfExpMap_orig(map)
    et1 = timeit.default_timer() - t0
    #
    t0 = timeit.default_timer()
    rmat_2 = rotMatOfExpMap_opt(map)
    et2 = timeit.default_timer() - t0
    #
    print('   timings:\n   ... original ', et1)
    print('   ... optimized', et2)
    #
    drmat = np.absolute(rmat_2 - rmat_1)
    print('maximum difference between results')
    print(np.amax(drmat, 0))

    return


if __name__ == '__main__':
    #
    #  Simple tests.
    #
    #  1. Exponential map.
    #
    printTestName(1, 'rotMatOfExpMap')
    numPts = 10000
    testRotMatOfExpMap(numPts)
    #
    #  2.  Angular difference
    #
    num = 2
    name = 'angularDifference'
    printTestName(num, name)
    units = 'radians'
    numPts = 1000000
    a1 = 2*np.pi * np.random.rand(3, numPts) - np.pi
    a2 = 2*np.pi * np.random.rand(3, numPts) - np.pi
    print('* testing %s with %d random points' % (name, numPts))
    #
    t0 = timeit.default_timer()
    d1 = angularDifference_orig(a1, a2)
    et1 = timeit.default_timer() - t0
    #
    t0 = timeit.default_timer()
    d2 = angularDifference_opt(a1, a2)
    et2 = timeit.default_timer() - t0
    #
    print('   timings:\n   ... original ', et1)
    print('   ... optimized', et2)
    #
    dd = np.absolute(d2 - d1)
    print('maximum difference between results')
    print(np.max(dd, 0).max())

    pass


# hexrd/spacegroup.py

#! /usr/bin/env python
# ============================================================
# Copyright (c) 2012, Lawrence Livermore National Security, LLC.
# Produced at the Lawrence Livermore National Laboratory.
# Written by Joel Bernier <bernier2@llnl.gov> and others.
# LLNL-CODE-529294.
# All rights reserved.
#
# This file is part of HEXRD. For details on dowloading the source,
# see the file COPYING.
#
# Please also see the file LICENSE.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License (as published
# by the Free Software
# Foundation) version 2.1 dated February 1999.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY OF MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the terms and conditions of the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this program (see file LICENSE); if not, write to
# the Free Software Foundation, Inc., 59 Temple Place, Suite 330,
# Boston, MA 02111-1307 USA or visit <http://www.gnu.org/licenses/>.
# ============================================================
#
"""This module contains mappings from space group number to either
Hall or Hermann-Mauguin notation, as well as the inverse notations.

Space groups can be mapped to crystal class (one of 32 point groups)
and then to crystal system .

NOTES:

* Laue group is the cyrstal class if you add a center of symmetry.
  There are 11 Laue groups, determined directly from the point group.
* This module avoids the use of numpy and uses math module instead.
  That means the hkl lists are not numpy arrays, but simple lists of
  tuples.
* Rhombohedral lattices:

REFERENCES

1.  Mappings among space group number, Hall symbols and Hermann-Mauguin symbols.
http://cci.lbl.gov/sginfo/hall_symbols.html

2.  For mapping space group number to point group (crystal class in Schonflies
    notation)
http://en.wikipedia.org/wiki/Space_group

3.  Crystallography and crystal defects By Anthony Kelly, G. W. Groves, P. Kidd

4.  Contains point group from sgnum.
http://en.wikipedia.org/wiki/Space_group#Classification_systems_for_space_groups

5.  Point group to laue group
http://www.ruppweb.org/Xray/tutorial/32point.htm

6.  For discussion of rhombohedral lattice and "obverse"
and "reverse" settings for lattice parameters.
Crystal structure determination (book) By Werner Massa

TESTING

Run this module as main to generate all space groups and test
the HKL evaluation.
"""

from collections import OrderedDict
from math import sqrt, floor

from hexrd import symbols, constants, symmetry
import numpy as np
#
__all__ = ['SpaceGroup']


#
# ---------------------------------------------------CLASS:  SpaceGroup
#


class SpaceGroup:

    def __init__(self, sgnum):
        """Constructor for SpaceGroup


        INPUTS
        sgnum -- (int) space group number (between 1 and 230)
        """
        self.sgnum = sgnum

    def __str__(self):
        """Print information about the space group"""
        s = 'space group number:  %d\n' % self.sgnum
        s += '       Hall Symbol:  %s\n' % self.HallSymbol
        s += '   Hermann-Mauguin:  %s\n' % self.hermannMauguin
        s += '       Point Group:  %s\n' % self.pointGroup
        s += '        Laue Group:  %s\n' % self.laueGroup
        s += '      Lattice Type:  %s\n' % self.latticeType

        return s

    #
    # ============================== API
    #
    #                     ========== Properties
    #
    # property:  sgnum

    def _get_sgnum(self):
        """Get method for sgnum"""
        return self._sgnum

    def _set_sgnum(self, v):
        """Set method for sgnum"""
        self._sgnum = v
        self._Hall = lookupHall[v]
        #
        # Set point group and laue group
        #
        # * Point group dictionary maps a range of space group numbers
        #   to a Schoenflies string for the point group
        #
        for k in list(_pgDict.keys()):
            if v in k:
                pglg = _pgDict[k]
                self._pointGroup = pglg[0]
                self._laueGroup = pglg[1]
                pass
            pass

        return

    sgnum = property(_get_sgnum, _set_sgnum, None,
                     "Space group number")

    @property
    def laueGroup(self):
        """Schonflies symbol for Laue group (read only)"""
        return self._laueGroup

    @property
    def laueGroup_international(self):
        """Internationl symbol for Laue group (read only)"""
        return _laue_international[self._laueGroup]

    @property
    def pointGroup(self):
        """Schonflies symbol for point group (read only)"""
        return self._pointGroup

    @property
    def latticeType(self):
        """Lattice type

        Possible values are 'cubic', 'hexagonal', 'trigonal',
        'tetragonal', 'orthorhombic', 'monoclinic' and 'triclinic'

        Rhombohedral lattices are treated as trigonal using the
        "obverse" setting.
"""
        return _ltDict[self.laueGroup]

    @property
    def reqParams(self):
        """(read only) Zero-based indices of required lattice parameters"""
        return _rqpDict[self.latticeType][0]

    @property
    def hermannMauguin(self):
        """(read only) Hermann-Mauguin symbol"""
        return lookupHM[self.sgnum]

    @property
    def HallSymbol(self):
        """(read only) Hall symbol"""
        return self._Hall

    #                     ========== Public Methods
    #
    def sixLatticeParams(self, lparams):
        """
        Return the complete set of six lattice parameters from
        the abbreviated set


        INPUTS
        lparams -- (tuple) the abbreviated set of lattice parameters

        OUTPUTS
        sparams -- (tuple) the complete set of lattice parameters;
                           (a, b, c, alpha, beta, gamma)

        DESCRIPTION
        * Output angles are in degrees
        """
        return _rqpDict[self.latticeType][1](lparams)


#
# -----------------------------------------------END CLASS:  SpaceGroup
#


#
# Hall Symbols copied from:
#
_hallStr = r"""
     1         P 1
     2        -P 1
     3:b       P 2y
     3:c       P 2
     3:a       P 2x
     4:b       P 2yb
     4:c       P 2c
     4:a       P 2xa
     5:b1      C 2y
     5:b2      A 2y
     5:b3      I 2y
     5:c1      A 2
     5:c2      B 2
     5:c3      I 2
     5:a1      B 2x
     5:a2      C 2x
     5:a3      I 2x
     6:b       P -2y
     6:c       P -2
     6:a       P -2x
     7:b1      P -2yc
     7:b2      P -2yac
     7:b3      P -2ya
     7:c1      P -2a
     7:c2      P -2ab
     7:c3      P -2b
     7:a1      P -2xb
     7:a2      P -2xbc
     7:a3      P -2xc
     8:b1      C -2y
     8:b2      A -2y
     8:b3      I -2y
     8:c1      A -2
     8:c2      B -2
     8:c3      I -2
     8:a1      B -2x
     8:a2      C -2x
     8:a3      I -2x
     9:b1      C -2yc
     9:b2      A -2yac
     9:b3      I -2ya
     9:-b1     A -2ya
     9:-b2     C -2ybc
     9:-b3     I -2yc
     9:c1      A -2a
     9:c2      B -2bc
     9:c3      I -2b
     9:-c1     B -2b
     9:-c2     A -2ac
     9:-c3     I -2a
     9:a1      B -2xb
     9:a2      C -2xbc
     9:a3      I -2xc
     9:-a1     C -2xc
     9:-a2     B -2xbc
     9:-a3     I -2xb
    10:b      -P 2y
    10:c      -P 2
    10:a      -P 2x
    11:b      -P 2yb
    11:c      -P 2c
    11:a      -P 2xa
    12:b1     -C 2y
    12:b2     -A 2y
    12:b3     -I 2y
    12:c1     -A 2
    12:c2     -B 2
    12:c3     -I 2
    12:a1     -B 2x
    12:a2     -C 2x
    12:a3     -I 2x
    13:b1     -P 2yc
    13:b2     -P 2yac
    13:b3     -P 2ya
    13:c1     -P 2a
    13:c2     -P 2ab
    13:c3     -P 2b
    13:a1     -P 2xb
    13:a2     -P 2xbc
    13:a3     -P 2xc
    14:b1     -P 2ybc
    14:b2     -P 2yn
    14:b3     -P 2yab
    14:c1     -P 2ac
    14:c2     -P 2n
    14:c3     -P 2bc
    14:a1     -P 2xab
    14:a2     -P 2xn
    14:a3     -P 2xac
    15:b1     -C 2yc
    15:b2     -A 2yac
    15:b3     -I 2ya
    15:-b1    -A 2ya
    15:-b2    -C 2ybc
    15:-b3    -I 2yc
    15:c1     -A 2a
    15:c2     -B 2bc
    15:c3     -I 2b
    15:-c1    -B 2b
    15:-c2    -A 2ac
    15:-c3    -I 2a
    15:a1     -B 2xb
    15:a2     -C 2xbc
    15:a3     -I 2xc
    15:-a1    -C 2xc
    15:-a2    -B 2xbc
    15:-a3    -I 2xb
    16         P 2 2
    17         P 2c 2
    17:cab     P 2a 2a
    17:bca     P 2 2b
    18         P 2 2ab
    18:cab     P 2bc 2
    18:bca     P 2ac 2ac
    19         P 2ac 2ab
    20         C 2c 2
    20:cab     A 2a 2a
    20:bca     B 2 2b
    21         C 2 2
    21:cab     A 2 2
    21:bca     B 2 2
    22         F 2 2
    23         I 2 2
    24         I 2b 2c
    25         P 2 -2
    25:cab     P -2 2
    25:bca     P -2 -2
    26         P 2c -2
    26:ba-c    P 2c -2c
    26:cab     P -2a 2a
    26:-cba    P -2 2a
    26:bca     P -2 -2b
    26:a-cb    P -2b -2
    27         P 2 -2c
    27:cab     P -2a 2
    27:bca     P -2b -2b
    28         P 2 -2a
    28:ba-c    P 2 -2b
    28:cab     P -2b 2
    28:-cba    P -2c 2
    28:bca     P -2c -2c
    28:a-cb    P -2a -2a
    29         P 2c -2ac
    29:ba-c    P 2c -2b
    29:cab     P -2b 2a
    29:-cba    P -2ac 2a
    29:bca     P -2bc -2c
    29:a-cb    P -2a -2ab
    30         P 2 -2bc
    30:ba-c    P 2 -2ac
    30:cab     P -2ac 2
    30:-cba    P -2ab 2
    30:bca     P -2ab -2ab
    30:a-cb    P -2bc -2bc
    31         P 2ac -2
    31:ba-c    P 2bc -2bc
    31:cab     P -2ab 2ab
    31:-cba    P -2 2ac
    31:bca     P -2 -2bc
    31:a-cb    P -2ab -2
    32         P 2 -2ab
    32:cab     P -2bc 2
    32:bca     P -2ac -2ac
    33         P 2c -2n
    33:ba-c    P 2c -2ab
    33:cab     P -2bc 2a
    33:-cba    P -2n 2a
    33:bca     P -2n -2ac
    33:a-cb    P -2ac -2n
    34         P 2 -2n
    34:cab     P -2n 2
    34:bca     P -2n -2n
    35         C 2 -2
    35:cab     A -2 2
    35:bca     B -2 -2
    36         C 2c -2
    36:ba-c    C 2c -2c
    36:cab     A -2a 2a
    36:-cba    A -2 2a
    36:bca     B -2 -2b
    36:a-cb    B -2b -2
    37         C 2 -2c
    37:cab     A -2a 2
    37:bca     B -2b -2b
    38         A 2 -2
    38:ba-c    B 2 -2
    38:cab     B -2 2
    38:-cba    C -2 2
    38:bca     C -2 -2
    38:a-cb    A -2 -2
    39         A 2 -2c
    39:ba-c    B 2 -2c
    39:cab     B -2c 2
    39:-cba    C -2b 2
    39:bca     C -2b -2b
    39:a-cb    A -2c -2c
    40         A 2 -2a
    40:ba-c    B 2 -2b
    40:cab     B -2b 2
    40:-cba    C -2c 2
    40:bca     C -2c -2c
    40:a-cb    A -2a -2a
    41         A 2 -2ac
    41:ba-c    B 2 -2bc
    41:cab     B -2bc 2
    41:-cba    C -2bc 2
    41:bca     C -2bc -2bc
    41:a-cb    A -2ac -2ac
    42         F 2 -2
    42:cab     F -2 2
    42:bca     F -2 -2
    43         F 2 -2d
    43:cab     F -2d 2
    43:bca     F -2d -2d
    44         I 2 -2
    44:cab     I -2 2
    44:bca     I -2 -2
    45         I 2 -2c
    45:cab     I -2a 2
    45:bca     I -2b -2b
    46         I 2 -2a
    46:ba-c    I 2 -2b
    46:cab     I -2b 2
    46:-cba    I -2c 2
    46:bca     I -2c -2c
    46:a-cb    I -2a -2a
    47        -P 2 2
    48:1       P 2 2 -1n
    48:2      -P 2ab 2bc
    49        -P 2 2c
    49:cab    -P 2a 2
    49:bca    -P 2b 2b
    50:1       P 2 2 -1ab
    50:2      -P 2ab 2b
    50:1cab    P 2 2 -1bc
    50:2cab   -P 2b 2bc
    50:1bca    P 2 2 -1ac
    50:2bca   -P 2a 2c
    51        -P 2a 2a
    51:ba-c   -P 2b 2
    51:cab    -P 2 2b
    51:-cba   -P 2c 2c
    51:bca    -P 2c 2
    51:a-cb   -P 2 2a
    52        -P 2a 2bc
    52:ba-c   -P 2b 2n
    52:cab    -P 2n 2b
    52:-cba   -P 2ab 2c
    52:bca    -P 2ab 2n
    52:a-cb   -P 2n 2bc
    53        -P 2ac 2
    53:ba-c   -P 2bc 2bc
    53:cab    -P 2ab 2ab
    53:-cba   -P 2 2ac
    53:bca    -P 2 2bc
    53:a-cb   -P 2ab 2
    54        -P 2a 2ac
    54:ba-c   -P 2b 2c
    54:cab    -P 2a 2b
    54:-cba   -P 2ac 2c
    54:bca    -P 2bc 2b
    54:a-cb   -P 2b 2ab
    55        -P 2 2ab
    55:cab    -P 2bc 2
    55:bca    -P 2ac 2ac
    56        -P 2ab 2ac
    56:cab    -P 2ac 2bc
    56:bca    -P 2bc 2ab
    57        -P 2c 2b
    57:ba-c   -P 2c 2ac
    57:cab    -P 2ac 2a
    57:-cba   -P 2b 2a
    57:bca    -P 2a 2ab
    57:a-cb   -P 2bc 2c
    58        -P 2 2n
    58:cab    -P 2n 2
    58:bca    -P 2n 2n
    59:1       P 2 2ab -1ab
    59:2      -P 2ab 2a
    59:1cab    P 2bc 2 -1bc
    59:2cab   -P 2c 2bc
    59:1bca    P 2ac 2ac -1ac
    59:2bca   -P 2c 2a
    60        -P 2n 2ab
    60:ba-c   -P 2n 2c
    60:cab    -P 2a 2n
    60:-cba   -P 2bc 2n
    60:bca    -P 2ac 2b
    60:a-cb   -P 2b 2ac
    61        -P 2ac 2ab
    61:ba-c   -P 2bc 2ac
    62        -P 2ac 2n
    62:ba-c   -P 2bc 2a
    62:cab    -P 2c 2ab
    62:-cba   -P 2n 2ac
    62:bca    -P 2n 2a
    62:a-cb   -P 2c 2n
    63        -C 2c 2
    63:ba-c   -C 2c 2c
    63:cab    -A 2a 2a
    63:-cba   -A 2 2a
    63:bca    -B 2 2b
    63:a-cb   -B 2b 2
    64        -C 2bc 2
    64:ba-c   -C 2bc 2bc
    64:cab    -A 2ac 2ac
    64:-cba   -A 2 2ac
    64:bca    -B 2 2bc
    64:a-cb   -B 2bc 2
    65        -C 2 2
    65:cab    -A 2 2
    65:bca    -B 2 2
    66        -C 2 2c
    66:cab    -A 2a 2
    66:bca    -B 2b 2b
    67        -C 2b 2
    67:ba-c   -C 2b 2b
    67:cab    -A 2c 2c
    67:-cba   -A 2 2c
    67:bca    -B 2 2c
    67:a-cb   -B 2c 2
    68:1       C 2 2 -1bc
    68:2      -C 2b 2bc
    68:1ba-c   C 2 2 -1bc
    68:2ba-c  -C 2b 2c
    68:1cab    A 2 2 -1ac
    68:2cab   -A 2a 2c
    68:1-cba   A 2 2 -1ac
    68:2-cba  -A 2ac 2c
    68:1bca    B 2 2 -1bc
    68:2bca   -B 2bc 2b
    68:1a-cb   B 2 2 -1bc
    68:2a-cb  -B 2b 2bc
    69        -F 2 2
    70:1       F 2 2 -1d
    70:2      -F 2uv 2vw
    71        -I 2 2
    72        -I 2 2c
    72:cab    -I 2a 2
    72:bca    -I 2b 2b
    73        -I 2b 2c
    73:ba-c   -I 2a 2b
    74        -I 2b 2
    74:ba-c   -I 2a 2a
    74:cab    -I 2c 2c
    74:-cba   -I 2 2b
    74:bca    -I 2 2a
    74:a-cb   -I 2c 2
    75         P 4
    76         P 4w
    77         P 4c
    78         P 4cw
    79         I 4
    80         I 4bw
    81         P -4
    82         I -4
    83        -P 4
    84        -P 4c
    85:1       P 4ab -1ab
    85:2      -P 4a
    86:1       P 4n -1n
    86:2      -P 4bc
    87        -I 4
    88:1       I 4bw -1bw
    88:2      -I 4ad
    89         P 4 2
    90         P 4ab 2ab
    91         P 4w 2c
    92         P 4abw 2nw
    93         P 4c 2
    94         P 4n 2n
    95         P 4cw 2c
    96         P 4nw 2abw
    97         I 4 2
    98         I 4bw 2bw
    99         P 4 -2
   100         P 4 -2ab
   101         P 4c -2c
   102         P 4n -2n
   103         P 4 -2c
   104         P 4 -2n
   105         P 4c -2
   106         P 4c -2ab
   107         I 4 -2
   108         I 4 -2c
   109         I 4bw -2
   110         I 4bw -2c
   111         P -4 2
   112         P -4 2c
   113         P -4 2ab
   114         P -4 2n
   115         P -4 -2
   116         P -4 -2c
   117         P -4 -2ab
   118         P -4 -2n
   119         I -4 -2
   120         I -4 -2c
   121         I -4 2
   122         I -4 2bw
   123        -P 4 2
   124        -P 4 2c
   125:1       P 4 2 -1ab
   125:2      -P 4a 2b
   126:1       P 4 2 -1n
   126:2      -P 4a 2bc
   127        -P 4 2ab
   128        -P 4 2n
   129:1       P 4ab 2ab -1ab
   129:2      -P 4a 2a
   130:1       P 4ab 2n -1ab
   130:2      -P 4a 2ac
   131        -P 4c 2
   132        -P 4c 2c
   133:1       P 4n 2c -1n
   133:2      -P 4ac 2b
   134:1       P 4n 2 -1n
   134:2      -P 4ac 2bc
   135        -P 4c 2ab
   136        -P 4n 2n
   137:1       P 4n 2n -1n
   137:2      -P 4ac 2a
   138:1       P 4n 2ab -1n
   138:2      -P 4ac 2ac
   139        -I 4 2
   140        -I 4 2c
   141:1       I 4bw 2bw -1bw
   141:2      -I 4bd 2
   142:1       I 4bw 2aw -1bw
   142:2      -I 4bd 2c
   143         P 3
   144         P 31
   145         P 32
   146:H       R 3
   146:R       P 3*
   147        -P 3
   148:H      -R 3
   148:R      -P 3*
   149         P 3 2
   150         P 3 2"
   151         P 31 2c (0 0 1)
   152         P 31 2"
   153         P 32 2c (0 0 -1)
   154         P 32 2"
   155:H       R 3 2"
   155:R       P 3* 2
   156         P 3 -2"
   157         P 3 -2
   158         P 3 -2"c
   159         P 3 -2c
   160:H       R 3 -2"
   160:R       P 3* -2
   161:H       R 3 -2"c
   161:R       P 3* -2n
   162        -P 3 2
   163        -P 3 2c
   164        -P 3 2"
   165        -P 3 2"c
   166:H      -R 3 2"
   166:R      -P 3* 2
   167:H      -R 3 2"c
   167:R      -P 3* 2n
   168         P 6
   169         P 61
   170         P 65
   171         P 62
   172         P 64
   173         P 6c
   174         P -6
   175        -P 6
   176        -P 6c
   177         P 6 2
   178         P 61 2 (0 0 -1)
   179         P 65 2 (0 0 1)
   180         P 62 2c (0 0 1)
   181         P 64 2c (0 0 -1)
   182         P 6c 2c
   183         P 6 -2
   184         P 6 -2c
   185         P 6c -2
   186         P 6c -2c
   187         P -6 2
   188         P -6c 2
   189         P -6 -2
   190         P -6c -2c
   191        -P 6 2
   192        -P 6 2c
   193        -P 6c 2
   194        -P 6c 2c
   195         P 2 2 3
   196         F 2 2 3
   197         I 2 2 3
   198         P 2ac 2ab 3
   199         I 2b 2c 3
   200        -P 2 2 3
   201:1       P 2 2 3 -1n
   201:2      -P 2ab 2bc 3
   202        -F 2 2 3
   203:1       F 2 2 3 -1d
   203:2      -F 2uv 2vw 3
   204        -I 2 2 3
   205        -P 2ac 2ab 3
   206        -I 2b 2c 3
   207         P 4 2 3
   208         P 4n 2 3
   209         F 4 2 3
   210         F 4d 2 3
   211         I 4 2 3
   212         P 4acd 2ab 3
   213         P 4bd 2ab 3
   214         I 4bd 2c 3
   215         P -4 2 3
   216         F -4 2 3
   217         I -4 2 3
   218         P -4n 2 3
   219         F -4c 2 3
   220         I -4bd 2c 3
   221        -P 4 2 3
   222:1       P 4 2 3 -1n
   222:2      -P 4a 2bc 3
   223        -P 4n 2 3
   224:1       P 4n 2 3 -1n
   224:2      -P 4bc 2bc 3
   225        -F 4 2 3
   226        -F 4c 2 3
   227:1       F 4d 2 3 -1d
   227:2      -F 4vw 2vw 3
   228:1       F 4d 2 3 -1cd
   228:2      -F 4cvw 2vw 3
   229        -I 4 2 3
   230        -I 4bd 2c 3
"""
# Hermann-Mauguin notation
_hmStr = r"""
     1        P 1
     2        P -1
     3:b      P 1 2 1
     3:c      P 1 1 2
     3:a      P 2 1 1
     4:b      P 1 21 1
     4:c      P 1 1 21
     4:a      P 21 1 1
     5:b1     C 1 2 1
     5:b2     A 1 2 1
     5:b3     I 1 2 1
     5:c1     A 1 1 2
     5:c2     B 1 1 2
     5:c3     I 1 1 2
     5:a1     B 2 1 1
     5:a2     C 2 1 1
     5:a3     I 2 1 1
     6:b      P 1 m 1
     6:c      P 1 1 m
     6:a      P m 1 1
     7:b1     P 1 c 1
     7:b2     P 1 n 1
     7:b3     P 1 a 1
     7:c1     P 1 1 a
     7:c2     P 1 1 n
     7:c3     P 1 1 b
     7:a1     P b 1 1
     7:a2     P n 1 1
     7:a3     P c 1 1
     8:b1     C 1 m 1
     8:b2     A 1 m 1
     8:b3     I 1 m 1
     8:c1     A 1 1 m
     8:c2     B 1 1 m
     8:c3     I 1 1 m
     8:a1     B m 1 1
     8:a2     C m 1 1
     8:a3     I m 1 1
     9:b1     C 1 c 1
     9:b2     A 1 n 1
     9:b3     I 1 a 1
     9:-b1    A 1 a 1
     9:-b2    C 1 n 1
     9:-b3    I 1 c 1
     9:c1     A 1 1 a
     9:c2     B 1 1 n
     9:c3     I 1 1 b
     9:-c1    B 1 1 b
     9:-c2    A 1 1 n
     9:-c3    I 1 1 a
     9:a1     B b 1 1
     9:a2     C n 1 1
     9:a3     I c 1 1
     9:-a1    C c 1 1
     9:-a2    B n 1 1
     9:-a3    I b 1 1
    10:b      P 1 2/m 1
    10:c      P 1 1 2/m
    10:a      P 2/m 1 1
    11:b      P 1 21/m 1
    11:c      P 1 1 21/m
    11:a      P 21/m 1 1
    12:b1     C 1 2/m 1
    12:b2     A 1 2/m 1
    12:b3     I 1 2/m 1
    12:c1     A 1 1 2/m
    12:c2     B 1 1 2/m
    12:c3     I 1 1 2/m
    12:a1     B 2/m 1 1
    12:a2     C 2/m 1 1
    12:a3     I 2/m 1 1
    13:b1     P 1 2/c 1
    13:b2     P 1 2/n 1
    13:b3     P 1 2/a 1
    13:c1     P 1 1 2/a
    13:c2     P 1 1 2/n
    13:c3     P 1 1 2/b
    13:a1     P 2/b 1 1
    13:a2     P 2/n 1 1
    13:a3     P 2/c 1 1
    14:b1     P 1 21/c 1
    14:b2     P 1 21/n 1
    14:b3     P 1 21/a 1
    14:c1     P 1 1 21/a
    14:c2     P 1 1 21/n
    14:c3     P 1 1 21/b
    14:a1     P 21/b 1 1
    14:a2     P 21/n 1 1
    14:a3     P 21/c 1 1
    15:b1     C 1 2/c 1
    15:b2     A 1 2/n 1
    15:b3     I 1 2/a 1
    15:-b1    A 1 2/a 1
    15:-b2    C 1 2/n 1
    15:-b3    I 1 2/c 1
    15:c1     A 1 1 2/a
    15:c2     B 1 1 2/n
    15:c3     I 1 1 2/b
    15:-c1    B 1 1 2/b
    15:-c2    A 1 1 2/n
    15:-c3    I 1 1 2/a
    15:a1     B 2/b 1 1
    15:a2     C 2/n 1 1
    15:a3     I 2/c 1 1
    15:-a1    C 2/c 1 1
    15:-a2    B 2/n 1 1
    15:-a3    I 2/b 1 1
    16        P 2 2 2
    17        P 2 2 21
    17:cab    P 21 2 2
    17:bca    P 2 21 2
    18        P 21 21 2
    18:cab    P 2 21 21
    18:bca    P 21 2 21
    19        P 21 21 21
    20        C 2 2 21
    20:cab    A 21 2 2
    20:bca    B 2 21 2
    21        C 2 2 2
    21:cab    A 2 2 2
    21:bca    B 2 2 2
    22        F 2 2 2
    23        I 2 2 2
    24        I 21 21 21
    25        P m m 2
    25:cab    P 2 m m
    25:bca    P m 2 m
    26        P m c 21
    26:ba-c   P c m 21
    26:cab    P 21 m a
    26:-cba   P 21 a m
    26:bca    P b 21 m
    26:a-cb   P m 21 b
    27        P c c 2
    27:cab    P 2 a a
    27:bca    P b 2 b
    28        P m a 2
    28:ba-c   P b m 2
    28:cab    P 2 m b
    28:-cba   P 2 c m
    28:bca    P c 2 m
    28:a-cb   P m 2 a
    29        P c a 21
    29:ba-c   P b c 21
    29:cab    P 21 a b
    29:-cba   P 21 c a
    29:bca    P c 21 b
    29:a-cb   P b 21 a
    30        P n c 2
    30:ba-c   P c n 2
    30:cab    P 2 n a
    30:-cba   P 2 a n
    30:bca    P b 2 n
    30:a-cb   P n 2 b
    31        P m n 21
    31:ba-c   P n m 21
    31:cab    P 21 m n
    31:-cba   P 21 n m
    31:bca    P n 21 m
    31:a-cb   P m 21 n
    32        P b a 2
    32:cab    P 2 c b
    32:bca    P c 2 a
    33        P n a 21
    33:ba-c   P b n 21
    33:cab    P 21 n b
    33:-cba   P 21 c n
    33:bca    P c 21 n
    33:a-cb   P n 21 a
    34        P n n 2
    34:cab    P 2 n n
    34:bca    P n 2 n
    35        C m m 2
    35:cab    A 2 m m
    35:bca    B m 2 m
    36        C m c 21
    36:ba-c   C c m 21
    36:cab    A 21 m a
    36:-cba   A 21 a m
    36:bca    B b 21 m
    36:a-cb   B m 21 b
    37        C c c 2
    37:cab    A 2 a a
    37:bca    B b 2 b
    38        A m m 2
    38:ba-c   B m m 2
    38:cab    B 2 m m
    38:-cba   C 2 m m
    38:bca    C m 2 m
    38:a-cb   A m 2 m
    39        A b m 2
    39:ba-c   B m a 2
    39:cab    B 2 c m
    39:-cba   C 2 m b
    39:bca    C m 2 a
    39:a-cb   A c 2 m
    40        A m a 2
    40:ba-c   B b m 2
    40:cab    B 2 m b
    40:-cba   C 2 c m
    40:bca    C c 2 m
    40:a-cb   A m 2 a
    41        A b a 2
    41:ba-c   B b a 2
    41:cab    B 2 c b
    41:-cba   C 2 c b
    41:bca    C c 2 a
    41:a-cb   A c 2 a
    42        F m m 2
    42:cab    F 2 m m
    42:bca    F m 2 m
    43        F d d 2
    43:cab    F 2 d d
    43:bca    F d 2 d
    44        I m m 2
    44:cab    I 2 m m
    44:bca    I m 2 m
    45        I b a 2
    45:cab    I 2 c b
    45:bca    I c 2 a
    46        I m a 2
    46:ba-c   I b m 2
    46:cab    I 2 m b
    46:-cba   I 2 c m
    46:bca    I c 2 m
    46:a-cb   I m 2 a
    47        P m m m
    48:1      P n n n:1
    48:2      P n n n:2
    49        P c c m
    49:cab    P m a a
    49:bca    P b m b
    50:1      P b a n:1
    50:2      P b a n:2
    50:1cab   P n c b:1
    50:2cab   P n c b:2
    50:1bca   P c n a:1
    50:2bca   P c n a:2
    51        P m m a
    51:ba-c   P m m b
    51:cab    P b m m
    51:-cba   P c m m
    51:bca    P m c m
    51:a-cb   P m a m
    52        P n n a
    52:ba-c   P n n b
    52:cab    P b n n
    52:-cba   P c n n
    52:bca    P n c n
    52:a-cb   P n a n
    53        P m n a
    53:ba-c   P n m b
    53:cab    P b m n
    53:-cba   P c n m
    53:bca    P n c m
    53:a-cb   P m a n
    54        P c c a
    54:ba-c   P c c b
    54:cab    P b a a
    54:-cba   P c a a
    54:bca    P b c b
    54:a-cb   P b a b
    55        P b a m
    55:cab    P m c b
    55:bca    P c m a
    56        P c c n
    56:cab    P n a a
    56:bca    P b n b
    57        P b c m
    57:ba-c   P c a m
    57:cab    P m c a
    57:-cba   P m a b
    57:bca    P b m a
    57:a-cb   P c m b
    58        P n n m
    58:cab    P m n n
    58:bca    P n m n
    59:1      P m m n:1
    59:2      P m m n:2
    59:1cab   P n m m:1
    59:2cab   P n m m:2
    59:1bca   P m n m:1
    59:2bca   P m n m:2
    60        P b c n
    60:ba-c   P c a n
    60:cab    P n c a
    60:-cba   P n a b
    60:bca    P b n a
    60:a-cb   P c n b
    61        P b c a
    61:ba-c   P c a b
    62        P n m a
    62:ba-c   P m n b
    62:cab    P b n m
    62:-cba   P c m n
    62:bca    P m c n
    62:a-cb   P n a m
    63        C m c m
    63:ba-c   C c m m
    63:cab    A m m a
    63:-cba   A m a m
    63:bca    B b m m
    63:a-cb   B m m b
    64        C m c a
    64:ba-c   C c m b
    64:cab    A b m a
    64:-cba   A c a m
    64:bca    B b c m
    64:a-cb   B m a b
    65        C m m m
    65:cab    A m m m
    65:bca    B m m m
    66        C c c m
    66:cab    A m a a
    66:bca    B b m b
    67        C m m a
    67:ba-c   C m m b
    67:cab    A b m m
    67:-cba   A c m m
    67:bca    B m c m
    67:a-cb   B m a m
    68:1      C c c a:1
    68:2      C c c a:2
    68:1ba-c  C c c b:1
    68:2ba-c  C c c b:2
    68:1cab   A b a a:1
    68:2cab   A b a a:2
    68:1-cba  A c a a:1
    68:2-cba  A c a a:2
    68:1bca   B b c b:1
    68:2bca   B b c b:2
    68:1a-cb  B b a b:1
    68:2a-cb  B b a b:2
    69        F m m m
    70:1      F d d d:1
    70:2      F d d d:2
    71        I m m m
    72        I b a m
    72:cab    I m c b
    72:bca    I c m a
    73        I b c a
    73:ba-c   I c a b
    74        I m m a
    74:ba-c   I m m b
    74:cab    I b m m
    74:-cba   I c m m
    74:bca    I m c m
    74:a-cb   I m a m
    75        P 4
    76        P 41
    77        P 42
    78        P 43
    79        I 4
    80        I 41
    81        P -4
    82        I -4
    83        P 4/m
    84        P 42/m
    85:1      P 4/n:1
    85:2      P 4/n:2
    86:1      P 42/n:1
    86:2      P 42/n:2
    87        I 4/m
    88:1      I 41/a:1
    88:2      I 41/a:2
    89        P 4 2 2
    90        P 42 1 2
    91        P 41 2 2
    92        P 41 21 2
    93        P 42 2 2
    94        P 42 21 2
    95        P 43 2 2
    96        P 43 21 2
    97        I 4 2 2
    98        I 41 2 2
    99        P 4 m m
   100        P 4 b m
   101        P 42 c m
   102        P 42 n m
   103        P 4 c c
   104        P 4 n c
   105        P 42 m c
   106        P 42 b c
   107        I 4 m m
   108        I 4 c m
   109        I 41 m d
   110        I 41 c d
   111        P -4 2 m
   112        P -4 2 c
   113        P -4 21 m
   114        P -4 21 c
   115        P -4 m 2
   116        P -4 c 2
   117        P -4 b 2
   118        P -4 n 2
   119        I -4 m 2
   120        I -4 c 2
   121        I -4 2 m
   122        I -4 2 d
   123        P 4/m m m
   124        P 4/m c c
   125:1      P 4/n b m:1
   125:2      P 4/n b m:2
   126:1      P 4/n n c:1
   126:2      P 4/n n c:2
   127        P 4/m b m
   128        P 4/m n c
   129:1      P 4/n m m:1
   129:2      P 4/n m m:2
   130:1      P 4/n c c:1
   130:2      P 4/n c c:2
   131        P 42/m m c
   132        P 42/m c m
   133:1      P 42/n b c:1
   133:2      P 42/n b c:2
   134:1      P 42/n n m:1
   134:2      P 42/n n m:2
   135        P 42/m b c
   136        P 42/m n m
   137:1      P 42/n m c:1
   137:2      P 42/n m c:2
   138:1      P 42/n c m:1
   138:2      P 42/n c m:2
   139        I 4/m m m
   140        I 4/m c m
   141:1      I 41/a m d:1
   141:2      I 41/a m d:2
   142:1      I 41/a c d:1
   142:2      I 41/a c d:2
   143        P 3
   144        P 31
   145        P 32
   146:H      R 3
   146:R      R 3
   147        P -3
   148:H      R -3
   148:R      R -3
   149        P 3 1 2
   150        P 3 2 1
   151        P 31 1 2
   152        P 31 2 1
   153        P 32 1 2
   154        P 32 2 1
   155:H      R 32
   155:R      R 32
   156        P 3 m 1
   157        P 3 1 m
   158        P 3 c 1
   159        P 3 1 c
   160:H      R 3 m
   160:R      R 3 m
   161:H      R 3 c
   161:R      R 3 c
   162        P -3 1 m
   163        P -3 1 c
   164        P -3 m 1
   165        P -3 c 1
   166:H      R -3 m
   166:R      R -3 m
   167:H      R -3 c
   167:R      R -3 c
   168        P 6
   169        P 61
   170        P 65
   171        P 62
   172        P 64
   173        P 63
   174        P -6
   175        P 6/m
   176        P 63/m
   177        P 6 2 2
   178        P 61 2 2
   179        P 65 2 2
   180        P 62 2 2
   181        P 64 2 2
   182        P 63 2 2
   183        P 6 m m
   184        P 6 c c
   185        P 63 c m
   186        P 63 m c
   187        P -6 m 2
   188        P -6 c 2
   189        P -6 2 m
   190        P -6 2 c
   191        P 6/m m m
   192        P 6/m c c
   193        P 63/m c m
   194        P 63/m m c
   195        P 2 3
   196        F 2 3
   197        I 2 3
   198        P 21 3
   199        I 21 3
   200        P m -3
   201:1      P n -3:1
   201:2      P n -3:2
   202        F m -3
   203:1      F d -3:1
   203:2      F d -3:2
   204        I m -3
   205        P a -3
   206        I a -3
   207        P 4 3 2
   208        P 42 3 2
   209        F 4 3 2
   210        F 41 3 2
   211        I 4 3 2
   212        P 43 3 2
   213        P 41 3 2
   214        I 41 3 2
   215        P -4 3 m
   216        F -4 3 m
   217        I -4 3 m
   218        P -4 3 n
   219        F -4 3 c
   220        I -4 3 d
   221        P m -3 m
   222:1      P n -3 n:1
   222:2      P n -3 n:2
   223        P m -3 n
   224:1      P n -3 m:1
   224:2      P n -3 m:2
   225        F m -3 m
   226        F m -3 c
   227:1      F d -3 m:1
   227:2      F d -3 m:2
   228:1      F d -3 c:1
   228:2      F d -3 c:2
   229        I m -3 m
   230        I a -3 d
"""


def _buildDict(hstr):
    """build the dictionaries from the notation string

    Returns two dictionaries:  one taking sg number to string
    and the inverse

    Takes the first Hall symbol it finds.  This is desired
    for the rhombohedral lattices so that they use the hexagonal
    convention.
    """
    d = dict()
    di = dict()
    hs = hstr.split('\n')
    for l in hs:
        li = l.strip()
        if li:
            nstr, hstr = li.split(None, 1)
            nstr = nstr.split(':', 1)[0]
            n = int(nstr)
            if n not in d:
                d[n] = hstr
                pass
            di[hstr] = n
            pass
        pass

    return d, di


lookupHall, Hall_to_sgnum = _buildDict(_hallStr)
lookupHM,   HM_to_sgnum = _buildDict(_hmStr)


def _map_sg_info(hstr):
    """build the dictionaries from the notation string

    Returns two dictionaries:  one taking sg id to string
    and the inverse.
    """
    d = OrderedDict()
    di = OrderedDict()
    hs = hstr.split('\n')
    for l in hs:
        li = l.strip()
        if li:
            sgid, altid = li.split(None, 1)
            d[sgid] = altid
            di[altid] = sgid

    return d, di


sgid_to_hall, hall_to_sgid = _map_sg_info(_hallStr)
sgid_to_hm, hm_to_sgid = _map_sg_info(_hmStr)


# ==================== Point Groups/Laue Groups

# TODO: make sane mappings

laue_1 = 'ci'
laue_2 = 'c2h'
laue_3 = 'd2h'
laue_4 = 'c4h'
laue_5 = 'd4h'
laue_6 = 's6'
laue_7 = 'd3d'
laue_8 = 'c6h'
laue_9 = 'd6h'
laue_10 = 'th'
laue_11 = 'oh'

_laue_international = {laue_1:"-1",
laue_2:"2/m",
laue_3:"mmm",
laue_4:"4/m",
laue_5:"4/mmm",
laue_6:"-3",
laue_7:"-3m",
laue_8:"6/m",
laue_9:"6/mmm",
laue_10:"m3",
laue_11:"m3m"}

def _sgrange(min, max): return tuple(range(min, max + 1))  # inclusive range


_pgDict = {
    _sgrange(1,   1): ('c1', laue_1),  # Triclinic
    _sgrange(2,   2): ('ci', laue_1),  # laue 1
    _sgrange(3,   5): ('c2', laue_2),  # Monoclinic
    _sgrange(6,   9): ('cs', laue_2),
    _sgrange(10,  15): ('c2h', laue_2),  # laue 2
    _sgrange(16,  24): ('d2', laue_3),  # Orthorhombic
    _sgrange(25,  46): ('c2v', laue_3),
    _sgrange(47,  74): ('d2h', laue_3),  # laue 3
    _sgrange(75,  80): ('c4', laue_4),  # Tetragonal
    _sgrange(81,  82): ('s4', laue_4),
    _sgrange(83,  88): ('c4h', laue_4),  # laue 4
    _sgrange(89,  98): ('d4', laue_5),
    _sgrange(99, 110): ('c4v', laue_5),
    _sgrange(111, 122): ('d2d', laue_5),
    _sgrange(123, 142): ('d4h', laue_5),  # laue 5
    _sgrange(143, 146): ('c3', laue_6),  # Trigonal
    _sgrange(147, 148): ('s6', laue_6),  # laue 6 [also c3i]
    _sgrange(149, 155): ('d3', laue_7),
    _sgrange(156, 161): ('c3v', laue_7),
    _sgrange(162, 167): ('d3d', laue_7),  # laue 7
    _sgrange(168, 173): ('c6', laue_8),  # Hexagonal
    _sgrange(174, 174): ('c3h', laue_8),
    _sgrange(175, 176): ('c6h', laue_8),  # laue 8
    _sgrange(177, 182): ('d6', laue_9),
    _sgrange(183, 186): ('c6v', laue_9),
    _sgrange(187, 190): ('d3h', laue_9),
    _sgrange(191, 194): ('d6h', laue_9),  # laue 9
    _sgrange(195, 199): ('t',  laue_10),  # Cubic
    _sgrange(200, 206): ('th', laue_10),  # laue 10
    _sgrange(207, 214): ('o',  laue_11),
    _sgrange(215, 220): ('td', laue_11),
    _sgrange(221, 230): ('oh', laue_11),  # laue 11
}

#
# Lattice type dictionary on Laue Group
# .  to replace Symmetry.ltypeOfLaueGroup()
# .  see also lparm.latticeVectors()
#
ltype_1 = 'triclinic'
ltype_2 = 'monoclinic'
ltype_3 = 'orthorhombic'
ltype_4 = 'tetragonal'
ltype_5 = 'trigonal'
ltype_6 = 'hexagonal'
ltype_7 = 'cubic'

_ltDict = {
    laue_1: ltype_1,
    laue_2: ltype_2,
    laue_3: ltype_3,
    laue_4: ltype_4,
    laue_5: ltype_4,
    laue_6: ltype_5,
    laue_7: ltype_5,
    laue_8: ltype_6,
    laue_9: ltype_6,
    laue_10: ltype_7,
    laue_11: ltype_7
}


# Required parameters by lattice type
# * dictionary provides list of required indices with
#   a function that takes the reduced set of lattice parameters
#   to the full set
# * consistent with lparm.latticeVectors
#
_rqpDict = {
    ltype_1: (tuple(range(6)), lambda p: p),  # all 6
    # note beta
    ltype_2: ((0, 1, 2, 4), lambda p: (p[0], p[1], p[2], 90, p[3], 90)),
    ltype_3: ((0, 1, 2), lambda p: (p[0], p[1], p[2], 90, 90,   90)),
    ltype_4: ((0, 2), lambda p: (p[0], p[0], p[1], 90, 90,   90)),
    ltype_5: ((0, 2), lambda p: (p[0], p[0], p[1], 90, 90,  120)),
    ltype_6: ((0, 2), lambda p: (p[0], p[0], p[1], 90, 90,  120)),
    ltype_7: ((0,), lambda p: (p[0], p[0], p[0], 90, 90,   90)),
}


def Allowed_HKLs(sgnum, hkllist):
    """
    this function checks if a particular g vector is allowed
    by lattice centering, screw axis or glide plane
    """
    sg_hmsymbol = symbols.pstr_spacegroup[sgnum-1].strip()
    symmorphic = False
    if(sgnum in constants.sgnum_symmorphic):
        symmorphic = True

    hkllist = np.atleast_2d(hkllist)

    centering = sg_hmsymbol[0]
    if(centering == 'P'):
        # all reflections are allowed
        mask = np.ones([hkllist.shape[0], ], dtype=np.bool)
    elif(centering == 'F'):
        # same parity
        seo = np.sum(np.mod(hkllist+100, 2), axis=1)
        mask = np.logical_not(np.logical_or(seo == 1, seo == 2))
    elif(centering == 'I'):
        # sum is even
        seo = np.mod(np.sum(hkllist, axis=1)+100, 2)
        mask = (seo == 0)
    elif(centering == 'A'):
        # k+l is even
        seo = np.mod(np.sum(hkllist[:, 1:3], axis=1)+100, 2)
        mask = seo == 0
    elif(centering == 'B'):
        # h+l is even
        seo = np.mod(hkllist[:, 0]+hkllist[:, 2]+100, 2)
        mask = seo == 0
    elif(centering == 'C'):
        # h+k is even
        seo = np.mod(hkllist[:, 0]+hkllist[:, 1]+100, 2)
        mask = seo == 0
    elif(centering == 'R'):
        # -h+k+l is divisible by 3
        seo = np.mod(-hkllist[:, 0]+hkllist[:, 1]+hkllist[:, 2]+90, 3)
        mask = seo == 0
    else:
        raise RuntimeError(
            'IsGAllowed: unknown lattice centering encountered.')

    hkls = hkllist[mask, :]
    if(not symmorphic):
        hkls = NonSymmorphicAbsences(sgnum, hkls)
    return hkls.astype(np.int32)


def omitscrewaxisabsences(sgnum, hkllist, ax, iax):
    """
    this function encodes the table on pg 48 of
    international table of crystallography vol A
    the systematic absences due to different screw
    axis is encoded here.
    iax encodes the primary, secondary or tertiary axis
    iax == 0 : primary
    iax == 1 : secondary
    iax == 2 : tertiary
    @NOTE: only unique b axis in monoclinic systems
    implemented as thats the standard setting
    """
    latticeType = symmetry.latticeType(sgnum)

    if(latticeType == 'triclinic'):
        """
            no systematic absences for the triclinic crystals
        """
        pass

    elif(latticeType == 'monoclinic'):
        if(ax != '2_1'):
            raise RuntimeError(
                'omitscrewaxisabsences: monoclinic systems\
                 can only have 2_1 screw axis')
        """
            only unique b-axis will be encoded
            it is the users responsibility to input
            lattice parameters in the standard setting
            with b-axis having the 2-fold symmetry
        """
        if(iax == 1):
            mask1 = np.logical_and(hkllist[:, 0] == 0, hkllist[:, 2] == 0)
            mask2 = np.mod(hkllist[:, 1]+100, 2) != 0
            mask = np.logical_not(np.logical_and(mask1, mask2))
            hkllist = hkllist[mask, :]
        else:
            raise RuntimeError(
                'omitscrewaxisabsences: only b-axis\
                 can have 2_1 screw axis')

    elif(latticeType == 'orthorhombic'):
        if(ax != '2_1'):
            raise RuntimeError(
                'omitscrewaxisabsences: orthorhombic systems\
                 can only have 2_1 screw axis')
        """
        2_1 screw on primary axis
        h00 ; h = 2n
        """
        if(iax == 0):
            mask1 = np.logical_and(hkllist[:, 1] == 0, hkllist[:, 2] == 0)
            mask2 = np.mod(hkllist[:, 0]+100, 2) != 0
            mask = np.logical_not(np.logical_and(mask1, mask2))
            hkllist = hkllist[mask, :]
        elif(iax == 1):
            mask1 = np.logical_and(hkllist[:, 0] == 0, hkllist[:, 2] == 0)
            mask2 = np.mod(hkllist[:, 1]+100, 2) != 0
            mask = np.logical_not(np.logical_and(mask1, mask2))
            hkllist = hkllist[mask, :]
        elif(iax == 2):
            mask1 = np.logical_and(hkllist[:, 0] == 0, hkllist[:, 1] == 0)
            mask2 = np.mod(hkllist[:, 2]+100, 2) != 0
            mask = np.logical_not(np.logical_and(mask1, mask2))
            hkllist = hkllist[mask, :]

    elif(latticeType == 'tetragonal'):
        if(iax == 0):
            mask1 = np.logical_and(hkllist[:, 0] == 0, hkllist[:, 1] == 0)
            if(ax == '4_2'):
                mask2 = np.mod(hkllist[:, 2]+100, 2) != 0
            elif(ax in ['4_1', '4_3']):
                mask2 = np.mod(hkllist[:, 2]+100, 4) != 0
            mask = np.logical_not(np.logical_and(mask1, mask2))
            hkllist = hkllist[mask, :]
        elif(iax == 1):
            mask1 = np.logical_and(hkllist[:, 1] == 0, hkllist[:, 2] == 0)
            mask2 = np.logical_and(hkllist[:, 0] == 0, hkllist[:, 2] == 0)
            if(ax == '2_1'):
                mask3 = np.mod(hkllist[:, 0]+100, 2) != 0
                mask4 = np.mod(hkllist[:, 1]+100, 2) != 0
            mask1 = np.logical_not(np.logical_and(mask1, mask3))
            mask2 = np.logical_not(np.logical_and(mask2, mask4))
            mask = ~np.logical_or(~mask1, ~mask2)
            hkllist = hkllist[mask, :]

    elif(latticeType == 'trigonal'):
        mask1 = np.logical_and(hkllist[:, 0] == 0, hkllist[:, 1] == 0)
        if(iax == 0):
            if(ax in ['3_1', '3_2']):
                mask2 = np.mod(hkllist[:, 2]+90, 3) != 0
        else:
            raise RuntimeError(
                'omitscrewaxisabsences: trigonal \
                systems can only have screw axis')
        mask = np.logical_not(np.logical_and(mask1, mask2))
        hkllist = hkllist[mask, :]

    elif(latticeType == 'hexagonal'):
        mask1 = np.logical_and(hkllist[:, 0] == 0, hkllist[:, 1] == 0)
        if(iax == 0):
            if(ax == '6_3'):
                mask2 = np.mod(hkllist[:, 2]+100, 2) != 0
            elif(ax in ['3_1', '3_2', '6_2', '6_4']):
                mask2 = np.mod(hkllist[:, 2]+90, 3) != 0
            elif(ax in ['6_1', '6_5']):
                mask2 = np.mod(hkllist[:, 2]+120, 6) != 0
        else:
            raise RuntimeError(
                'omitscrewaxisabsences: hexagonal \
                systems can only have screw axis')
        mask = np.logical_not(np.logical_and(mask1, mask2))
        hkllist = hkllist[mask, :]

    elif(latticeType == 'cubic'):
        mask1 = np.logical_and(hkllist[:, 0] == 0, hkllist[:, 1] == 0)
        mask2 = np.logical_and(hkllist[:, 0] == 0, hkllist[:, 2] == 0)
        mask3 = np.logical_and(hkllist[:, 1] == 0, hkllist[:, 2] == 0)
        if(ax in ['2_1', '4_2']):
            mask4 = np.mod(hkllist[:, 2]+100, 2) != 0
            mask5 = np.mod(hkllist[:, 1]+100, 2) != 0
            mask6 = np.mod(hkllist[:, 0]+100, 2) != 0
        elif(ax in ['4_1', '4_3']):
            mask4 = np.mod(hkllist[:, 2]+100, 4) != 0
            mask5 = np.mod(hkllist[:, 1]+100, 4) != 0
            mask6 = np.mod(hkllist[:, 0]+100, 4) != 0
        mask1 = np.logical_not(np.logical_and(mask1, mask4))
        mask2 = np.logical_not(np.logical_and(mask2, mask5))
        mask3 = np.logical_not(np.logical_and(mask3, mask6))
        mask = ~np.logical_or(~mask1, np.logical_or(~mask2, ~mask3))
        hkllist = hkllist[mask, :]
    return hkllist.astype(np.int32)


def omitglideplaneabsences(sgnum, hkllist, plane, ip):
    """
    this function encodes the table on pg 47 of
    international table of crystallography vol A
    the systematic absences due to different glide
    planes is encoded here.
    ip encodes the primary, secondary or tertiary plane normal
    ip == 0 : primary
    ip == 1 : secondary
    ip == 2 : tertiary
    @NOTE: only unique b axis in monoclinic systems
    implemented as thats the standard setting
    """
    latticeType = symmetry.latticeType(sgnum)

    if(latticeType == 'triclinic'):
        pass

    elif(latticeType == 'monoclinic'):
        if(ip == 1):
            mask1 = hkllist[:, 1] == 0
            if(plane == 'c'):
                mask2 = np.mod(hkllist[:, 2]+100, 2) != 0
            elif(plane == 'a'):
                mask2 = np.mod(hkllist[:, 0]+100, 2) != 0
            elif(plane == 'n'):
                mask2 = np.mod(hkllist[:, 0]+hkllist[:, 2]+100, 2) != 0
            mask = np.logical_not(np.logical_and(mask1, mask2))
            hkllist = hkllist[mask, :]

    elif(latticeType == 'orthorhombic'):
        if(ip == 0):
            mask1 = hkllist[:, 0] == 0
            if(plane == 'b'):
                mask2 = np.mod(hkllist[:, 1]+100, 2) != 0
            elif(plane == 'c'):
                mask2 = np.mod(hkllist[:, 2]+100, 2) != 0
            elif(plane == 'n'):
                mask2 = np.mod(hkllist[:, 1]+hkllist[:, 2]+100, 2) != 0
            elif(plane == 'd'):
                mask2 = np.mod(hkllist[:, 1]+hkllist[:, 2]+100, 4) != 0
            mask = np.logical_not(np.logical_and(mask1, mask2))
            hkllist = hkllist[mask, :]

        elif(ip == 1):
            mask1 = hkllist[:, 1] == 0
            if(plane == 'c'):
                mask2 = np.mod(hkllist[:, 2]+100, 2) != 0
            elif(plane == 'a'):
                mask2 = np.mod(hkllist[:, 0]+100, 2) != 0
            elif(plane == 'n'):
                mask2 = np.mod(hkllist[:, 0]+hkllist[:, 2]+100, 2) != 0
            elif(plane == 'd'):
                mask2 = np.mod(hkllist[:, 0]+hkllist[:, 2]+100, 4) != 0
            mask = np.logical_not(np.logical_and(mask1, mask2))
            hkllist = hkllist[mask, :]

        elif(ip == 2):
            mask1 = hkllist[:, 2] == 0
            if(plane == 'a'):
                mask2 = np.mod(hkllist[:, 0]+100, 2) != 0
            elif(plane == 'b'):
                mask2 = np.mod(hkllist[:, 1]+100, 2) != 0
            elif(plane == 'n'):
                mask2 = np.mod(hkllist[:, 0]+hkllist[:, 1]+100, 2) != 0
            elif(plane == 'd'):
                mask2 = np.mod(hkllist[:, 0]+hkllist[:, 1]+100, 4) != 0
            mask = np.logical_not(np.logical_and(mask1, mask2))
            hkllist = hkllist[mask, :]

    elif(latticeType == 'tetragonal'):
        if(ip == 0):
            mask1 = hkllist[:, 2] == 0
            if(plane == 'a'):
                mask2 = np.mod(hkllist[:, 0]+100, 2) != 0
            elif(plane == 'b'):
                mask2 = np.mod(hkllist[:, 1]+100, 2) != 0
            elif(plane == 'n'):
                mask2 = np.mod(hkllist[:, 0]+hkllist[:, 1]+100, 2) != 0
            elif(plane == 'd'):
                mask2 = np.mod(hkllist[:, 0]+hkllist[:, 1]+100, 4) != 0
            mask = np.logical_not(np.logical_and(mask1, mask2))
            hkllist = hkllist[mask, :]

        elif(ip == 1):
            mask1 = hkllist[:, 0] == 0
            mask2 = hkllist[:, 1] == 0
            if(plane in ['a', 'b']):
                mask3 = np.mod(hkllist[:, 1]+100, 2) != 0
                mask4 = np.mod(hkllist[:, 0]+100, 2) != 0
            elif(plane == 'c'):
                mask3 = np.mod(hkllist[:, 2]+100, 2) != 0
                mask4 = mask3
            elif(plane == 'n'):
                mask3 = np.mod(hkllist[:, 1]+hkllist[:, 2]+100, 2) != 0
                mask4 = np.mod(hkllist[:, 0]+hkllist[:, 2]+100, 2) != 0
            elif(plane == 'd'):
                mask3 = np.mod(hkllist[:, 1]+hkllist[:, 2]+100, 4) != 0
                mask4 = np.mod(hkllist[:, 0]+hkllist[:, 2]+100, 4) != 0
            mask1 = np.logical_not(np.logical_and(mask1, mask3))
            mask2 = np.logical_not(np.logical_and(mask2, mask4))
            mask = ~np.logical_or(~mask1, ~mask2)
            hkllist = hkllist[mask, :]

        elif(ip == 2):
            mask1 = np.abs(hkllist[:, 0]) == np.abs(hkllist[:, 1])
            if(plane in ['c', 'n']):
                mask2 = np.mod(hkllist[:, 2]+100, 2) != 0
            elif(plane == 'd'):
                mask2 = np.mod(2*hkllist[:, 0]+hkllist[:, 2]+100, 4) != 0
            mask = np.logical_not(np.logical_and(mask1, mask2))
            hkllist = hkllist[mask, :]

    elif(latticeType == 'trigonal'):
        if(plane != 'c'):
            raise RuntimeError(
                'omitglideplaneabsences: only c-glide \
                allowed for trigonal systems')
        if(ip == 1):
            mask1 = hkllist[:, 0] == 0
            mask2 = hkllist[:, 1] == 0
            mask3 = hkllist[:, 0] == -hkllist[:, 1]
            if(plane == 'c'):
                mask4 = np.mod(hkllist[:, 2]+100, 2) != 0
            else:
                raise RuntimeError(
                    'omitglideplaneabsences: only c-glide \
                    allowed for trigonal systems')

        elif(ip == 2):
            mask1 = hkllist[:, 1] == hkllist[:, 0]
            mask2 = hkllist[:, 0] == -2*hkllist[:, 1]
            mask3 = -2*hkllist[:, 0] == hkllist[:, 1]
            if(plane == 'c'):
                mask4 = np.mod(hkllist[:, 2]+100, 2) != 0
            else:
                raise RuntimeError(
                    'omitglideplaneabsences: only c-glide \
                    allowed for trigonal systems')
        mask1 = np.logical_and(mask1, mask4)
        mask2 = np.logical_and(mask2, mask4)
        mask3 = np.logical_and(mask3, mask4)
        mask = np.logical_not(np.logical_or(
            mask1, np.logical_or(mask2, mask3)))
        hkllist = hkllist[mask, :]

    elif(latticeType == 'hexagonal'):
        if(plane != 'c'):
            raise RuntimeError(
                'omitglideplaneabsences: only c-glide \
                allowed for hexagonal systems')
        if(ip == 2):
            mask1 = hkllist[:, 0] == hkllist[:, 1]
            mask2 = hkllist[:, 0] == -2*hkllist[:, 1]
            mask3 = -2*hkllist[:, 0] == hkllist[:, 1]
            mask4 = np.mod(hkllist[:, 2]+100, 2) != 0
            mask1 = np.logical_and(mask1, mask4)
            mask2 = np.logical_and(mask2, mask4)
            mask3 = np.logical_and(mask3, mask4)
            mask = np.logical_not(np.logical_or(
                mask1, np.logical_or(mask2, mask3)))

        elif(ip == 1):
            mask1 = hkllist[:, 1] == 0
            mask2 = hkllist[:, 0] == 0
            mask3 = hkllist[:, 1] == -hkllist[:, 0]
            mask4 = np.mod(hkllist[:, 2]+100, 2) != 0
        mask1 = np.logical_and(mask1, mask4)
        mask2 = np.logical_and(mask2, mask4)
        mask3 = np.logical_and(mask3, mask4)
        mask = np.logical_not(np.logical_or(
            mask1, np.logical_or(mask2, mask3)))
        hkllist = hkllist[mask, :]

    elif(latticeType == 'cubic'):
        if(ip == 0):
            mask1 = hkllist[:, 0] == 0
            mask2 = hkllist[:, 1] == 0
            mask3 = hkllist[:, 2] == 0
            mask4 = np.mod(hkllist[:, 0]+100, 2) != 0
            mask5 = np.mod(hkllist[:, 1]+100, 2) != 0
            mask6 = np.mod(hkllist[:, 2]+100, 2) != 0
            if(plane == 'a'):
                mask1 = np.logical_or(np.logical_and(
                    mask1, mask5), np.logical_and(mask1, mask6))
                mask2 = np.logical_or(np.logical_and(
                    mask2, mask4), np.logical_and(mask2, mask6))
                mask3 = np.logical_and(mask3, mask4)
                mask = np.logical_not(np.logical_or(
                    mask1, np.logical_or(mask2, mask3)))
            elif(plane == 'b'):
                mask1 = np.logical_and(mask1, mask5)
                mask3 = np.logical_and(mask3, mask5)
                mask = np.logical_not(np.logical_or(mask1, mask3))
            elif(plane == 'c'):
                mask1 = np.logical_and(mask1, mask6)
                mask2 = np.logical_and(mask2, mask6)
                mask = np.logical_not(np.logical_or(mask1, mask2))
            elif(plane == 'n'):
                mask4 = np.mod(hkllist[:, 1]+hkllist[:, 2]+100, 2) != 0
                mask5 = np.mod(hkllist[:, 0]+hkllist[:, 2]+100, 2) != 0
                mask6 = np.mod(hkllist[:, 0]+hkllist[:, 1]+100, 2) != 0
                mask1 = np.logical_not(np.logical_and(mask1, mask4))
                mask2 = np.logical_not(np.logical_and(mask2, mask5))
                mask3 = np.logical_not(np.logical_and(mask3, mask6))
                mask = ~np.logical_or(
                    ~mask1, np.logical_or(~mask2, ~mask3))
            elif(plane == 'd'):
                mask4 = np.mod(hkllist[:, 1]+hkllist[:, 2]+100, 4) != 0
                mask5 = np.mod(hkllist[:, 0]+hkllist[:, 2]+100, 4) != 0
                mask6 = np.mod(hkllist[:, 0]+hkllist[:, 1]+100, 4) != 0
                mask1 = np.logical_not(np.logical_and(mask1, mask4))
                mask2 = np.logical_not(np.logical_and(mask2, mask5))
                mask3 = np.logical_not(np.logical_and(mask3, mask6))
                mask = ~np.logical_or(
                    ~mask1, np.logical_or(~mask2, ~mask3))
            else:
                raise RuntimeError(
                    'omitglideplaneabsences: unknown glide \
                    plane encountered.')
            hkllist = hkllist[mask, :]

        if(ip == 2):
            mask1 = np.abs(hkllist[:, 0]) == np.abs(hkllist[:, 1])
            mask2 = np.abs(hkllist[:, 1]) == np.abs(hkllist[:, 2])
            mask3 = np.abs(hkllist[:, 0]) == np.abs(hkllist[:, 2])
            if(plane in ['a', 'b', 'c', 'n']):
                mask4 = np.mod(hkllist[:, 2]+100, 2) != 0
                mask5 = np.mod(hkllist[:, 0]+100, 2) != 0
                mask6 = np.mod(hkllist[:, 1]+100, 2) != 0
            elif(plane == 'd'):
                mask4 = np.mod(2*hkllist[:, 0]+hkllist[:, 2]+100, 4) != 0
                mask5 = np.mod(hkllist[:, 0]+2*hkllist[:, 1]+100, 4) != 0
                mask6 = np.mod(2*hkllist[:, 0]+hkllist[:, 1]+100, 4) != 0
            else:
                raise RuntimeError(
                    'omitglideplaneabsences: unknown glide \
                    plane encountered.')
            mask1 = np.logical_not(np.logical_and(mask1, mask4))
            mask2 = np.logical_not(np.logical_and(mask2, mask5))
            mask3 = np.logical_not(np.logical_and(mask3, mask6))
            mask = ~np.logical_or(~mask1, np.logical_or(~mask2, ~mask3))
            hkllist = hkllist[mask, :]

    return hkllist


def NonSymmorphicAbsences(sgnum, hkllist):
    """
    this function prunes hkl list for the screw axis and glide
    plane absences
    """
    planes = constants.SYS_AB[sgnum][0]
    for ip, p in enumerate(planes):
        if(p != ''):
            hkllist = omitglideplaneabsences(sgnum, hkllist, p, ip)
    axes = constants.SYS_AB[sgnum][1]
    for iax, ax in enumerate(axes):
        if(ax != ''):
            hkllist = omitscrewaxisabsences(sgnum, hkllist, ax, iax)
    return hkllist

#
# ================================================== HKL Enumeration
#


def _getHKLsBySS(ss):
    """Return a list of HKLs with a given sum of squares'

    ss - (int) sum of squares

"""
    #
    #  NOTE:  the loop below could be speeded up by requiring
    #         h >= k > = l, and then applying all permutations
    #         and sign changes.  Could possibly save up to
    #         a factor of 48.
    #
    def pmrange(n): return list(range(n, -(n+1), -1))  # plus/minus range
    def iroot(n): return int(floor(sqrt(n)))  # integer square root

    hkls = []
    hmax = iroot(ss)
    for h in pmrange(hmax):
        ss2 = ss - h*h
        kmax = iroot(ss2)
        for k in pmrange(kmax):
            rem = ss2 - k*k
            if rem == 0:
                hkls.append((h, k, 0))
            else:
                l = iroot(rem)
                if l*l == rem:
                    hkls += [(h, k, l), (h, k, -l)]
                    pass
                pass
            pass
        pass

    return hkls


#
# ================================================== Test Functions
#


def testHKLs():
    #
    #  Check reduced HKLs
    #
    #  1. Titanium (sg 194)
    #
    sg = SpaceGroup(194)
    print('==================== Titanium (194)')
    ssmax = 20
    myHKLs = sg.getHKLs(ssmax)
    print('Number of HKLs with sum of square %d or less:  %d'
          % (ssmax, len(myHKLs)))
    for hkl in myHKLs:
        ss = hkl[0]**2 + hkl[1]**2 + hkl[2]**2
        print((hkl, ss))
        pass

    #
    #  2. Ruby (sg 167)
    #
    sg = SpaceGroup(167)
    print('==================== Ruby (167)')
    ssmax = 10
    myHKLs = sg.getHKLs(ssmax)
    print('Number of HKLs with sum of square %d or less:  %d'
          % (ssmax, len(myHKLs)))
    for hkl in myHKLs:
        ss = hkl[0]**2 + hkl[1]**2 + hkl[2]**2
        print((hkl, ss))
        pass
    #
    #  Test Generic HKLs
    #
    for ss in range(1, 10):
        print('==================== ss = %d' % ss)
        hkls = _getHKLsBySS(ss)
        print('                     number of hkls:  ', len(hkls))
        print(hkls)
        pass

    return


if __name__ == '__main__':
    #
    import sys
    #
    if 'testHKLs' in sys.argv:
        testHKLs()
        sys.exit()
    #
    #  Test Space groups:
    #
    for n in range(1, 231):
        try:
            sg = SpaceGroup(n)
            sg.getHKLs(10)
            print(sg)
            print('\n')
        except:
            print(('failed for space group number: ', n))
            print(('Hall symbol:  ', lookupHall[n]))
            pass
        pass
    #
    pass


# hexrd/symmetry.py

# -*- coding: utf-8 -*-
# =============================================================================
# Copyright (c) 2012, Lawrence Livermore National Security, LLC.
# Produced at the Lawrence Livermore National Laboratory.
# Written by Joel Bernier <bernier2@llnl.gov> and others.
# LLNL-CODE-529294.
# All rights reserved.
#
# This file is part of HEXRD. For details on dowloading the source,
# see the file COPYING.
#
# Please also see the file LICENSE.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License (as published by the Free
# Software Foundation) version 2.1 dated February 1999.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY OF MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the terms and conditions of the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this program (see file LICENSE); if not, write to
# the Free Software Foundation, Inc., 59 Temple Place, Suite 330,
# Boston, MA 02111-1307 USA or visit <http://www.gnu.org/licenses/>.
# =============================================================================
# -*-python-*-
#
# Module containing functions relevant to symmetries

import numpy as np
from numpy import array, sqrt, pi, \
     vstack, c_, dot, \
     argmax

# from hexrd.rotations import quatOfAngleAxis, quatProductMatrix, fixQuat
from hexrd import rotations as rot
from hexrd import constants
from hexrd.utils.decorators import numba_njit_if_available


# =============================================================================
# Module vars
# =============================================================================

eps = constants.sqrt_epsf
sq3by2 = sqrt(3.)/2.
piby2 = pi/2.
piby3 = pi/3.
piby4 = pi/4.
piby6 = pi/6.


# =============================================================================
# Functions
# =============================================================================


def toFundamentalRegion(q, crysSym='Oh', sampSym=None):
    """
    """
    qdims = q.ndim
    if qdims == 3:
        l3, m3, n3 = q.shape
        assert m3 == 4, 'your 3-d quaternion array isn\'t the right shape'
        q = q.transpose(0, 2, 1).reshape(l3*n3, 4).T
    if isinstance(crysSym, str):
        qsym_c = rot.quatProductMatrix(
            quatOfLaueGroup(crysSym), 'right'
        )  # crystal symmetry operator
    else:
        qsym_c = rot.quatProductMatrix(crysSym, 'right')

    n = q.shape[1]              # total number of quats
    m = qsym_c.shape[0]         # number of symmetry operations

    #
    # MAKE EQUIVALENCE CLASS
    #
    # Do R * Gc, store as
    # [q[:, 0] * Gc[:, 0:m], ..., 2[:, n-1] * Gc[:, 0:m]]
    qeqv = dot(qsym_c, q).transpose(2, 0, 1).reshape(m*n, 4).T

    if sampSym is None:
        # need to fix quats to sort
        qeqv = rot.fixQuat(qeqv)

        # Reshape scalar comp columnwise by point in qeqv
        q0 = qeqv[0, :].reshape(n, m).T

        # Find q0 closest to origin for each n equivalence classes
        q0maxColInd = argmax(q0, 0) + [x*m for x in range(n)]

        # store representatives in qr
        qr = qeqv[:, q0maxColInd]
    else:
        if isinstance(sampSym, str):
            qsym_s = rot.quatProductMatrix(
                quatOfLaueGroup(sampSym), 'left'
            )  # sample symmetry operator
        else:
            qsym_s = rot.quatProductMatrix(sampSym, 'left')

        p = qsym_s.shape[0]         # number of sample symmetry operations

        # Do Gs * (R * Gc), store as
        # [Gs[:, 0:p] * q[:,   0] * Gc[:, 0], ... , Gs[:, 0:p] * q[:,   0] * Gc[:, m-1], ...
        #  Gs[:, 0:p] * q[:, n-1] * Gc[:, 0], ... , Gs[:, 0:p] * q[:, n-1] * Gc[:, m-1]]
        qeqv = rot.fixQuat(
            dot(qsym_s, qeqv).transpose(2, 0, 1).reshape(p*m*n, 4).T
        )

        raise NotImplementedError

    # debug
    assert qr.shape[1] == n, 'oops, something wrong here with your reshaping'

    if qdims == 3:
        qr = qr.T.reshape(l3, n3, 4).transpose(0, 2, 1)

    return qr


def ltypeOfLaueGroup(tag):
    """
    See quatOfLaueGroup
    """

    if not isinstance(tag, str):
        raise RuntimeError("entered flag is not a string!")

    if tag.lower() == 'ci' or tag.lower() == 's2':
        ltype = 'triclinic'
    elif tag.lower() == 'c2h':
        ltype = 'monoclinic'
    elif tag.lower() == 'd2h' or tag.lower() == 'vh':
        ltype = 'orthorhombic'
    elif tag.lower() == 'c4h' or tag.lower() == 'd4h':
        ltype = 'tetragonal'
    elif tag.lower() == 'c3i' or tag.lower() == 's6' or tag.lower() == 'd3d':
        ltype = 'trigonal'
    elif tag.lower() == 'c6h' or tag.lower() == 'd6h':
        ltype = 'hexagonal'
    elif tag.lower() == 'th' or tag.lower() == 'oh':
        ltype = 'cubic'
    else:
        raise RuntimeError(
            "unrecognized symmetry group.  "
            + "See ''help(quatOfLaueGroup)'' for a list of valid options.  "
            + "Oh, and have a great day ;-)"
        )

    return ltype


def quatOfLaueGroup(tag):
    """
    Generate quaternion representation for the specified Laue group.

    USAGE:

         qsym = quatOfLaueGroup(schoenfliesTag)

    INPUTS:

         1) schoenfliesTag 1 x 1, a case-insensitive string representing the
         Schoenflies symbol for the desired Laue group.  The 14 available
         choices are:

              Class           Symbol      n
             -------------------------------
              Triclinic       Ci (S2)     1
              Monoclinic      C2h         2
              Orthorhombic    D2h (Vh)    4
              Tetragonal      C4h         4
                              D4h         8
              Trigonal        C3i (S6)    3
                              D3d         6
              Hexagonal       C6h         6
                              D6h         12
              Cubic           Th          12
                              Oh          24

    OUTPUTS:

         1) qsym is (4, n) the quaterions associated with each element of the
         chosen symmetry group having n elements (dep. on group -- see INPUTS
         list above).

    NOTES:

         *) The conventions used for assigning a RHON basis, {x1, x2, x3}, to
         each point group are consistent with those published in Appendix B
         of [1].

    REFERENCES:

         [1] Nye, J. F., ``Physical Properties of Crystals: Their
         Representation by Tensors and Matrices'', Oxford University Press,
         1985. ISBN 0198511655
    """

    if not isinstance(tag, str):
        raise RuntimeError("entered flag is not a string!")

    if tag.lower() == 'ci' or tag.lower() == 's2':
        # TRICLINIC
        angleAxis = vstack([0., 1., 0., 0.])  # identity
    elif tag.lower() == 'c2h':
        # MONOCLINIC
        angleAxis = c_[
            [0.,   1,   0,   0],  # identity
            [pi,   0,   1,   0],  # twofold about 010 (x2)
            ]
    elif tag.lower() == 'd2h' or tag.lower() == 'vh':
        # ORTHORHOMBIC
        angleAxis = c_[
            [0.,   1,   0,   0],  # identity
            [pi,   1,   0,   0],  # twofold about 100
            [pi,   0,   1,   0],  # twofold about 010
            [pi,   0,   0,   1],  # twofold about 001
            ]
    elif tag.lower() == 'c4h':
        # TETRAGONAL (LOW)
        angleAxis = c_[
            [0.0,       1,    0,    0],  # identity
            [piby2,     0,    0,    1],  # fourfold about 001 (x3)
            [pi,        0,    0,    1],  #
            [piby2*3,   0,    0,    1],  #
            ]
    elif tag.lower() == 'd4h':
        # TETRAGONAL (HIGH)
        angleAxis = c_[
            [0.0,       1,    0,    0],  # identity
            [piby2,     0,    0,    1],  # fourfold about 0  0  1 (x3)
            [pi,        0,    0,    1],  #
            [piby2*3,   0,    0,    1],  #
            [pi,        1,    0,    0],  # twofold about  1  0  0 (x1)
            [pi,        0,    1,    0],  # twofold about  0  1  0 (x2)
            [pi,        1,    1,    0],  # twofold about  1  1  0
            [pi,       -1,    1,    0],  # twofold about -1  1  0
            ]
    elif tag.lower() == 'c3i' or tag.lower() == 's6':
        # TRIGONAL (LOW)
        angleAxis = c_[
            [0.0,       1,    0,    0],  # identity
            [piby3*2,   0,    0,    1],  # threefold about 0001 (x3,c)
            [piby3*4,   0,    0,    1],  #
            ]
    elif tag.lower() == 'd3d':
        # TRIGONAL (HIGH)
        angleAxis = c_[
            [0.0,       1,     0,    0],  # identity
            [piby3*2,   0,     0,    1],  # threefold about 0001 (x3,c)
            [piby3*4,   0,     0,    1],  #
            [pi,        1,     0,    0],  # twofold about  2 -1 -1  0 (x1,a1)
            [pi,       -0.5,   sq3by2,  0],  # twofold about -1  2 -1  0 (a2)
            [pi,       -0.5,  -sq3by2,  0],  # twofold about -1 -1  2  0 (a3)
            ]
    elif tag.lower() == 'c6h':
        # HEXAGONAL (LOW)
        angleAxis = c_[
            [0.0,       1,     0,    0],  # identity
            [piby3,     0,     0,    1],  # sixfold about 0001 (x3,c)
            [piby3*2,   0,     0,    1],  #
            [pi,        0,     0,    1],  #
            [piby3*4,   0,     0,    1],  #
            [piby3*5,   0,     0,    1],  #
            ]
    elif tag.lower() == 'd6h':
        # HEXAGONAL (HIGH)
        angleAxis = c_[
            [0.0,       1,       0,       0],  # identity
            [piby3,     0,       0,       1],  # sixfold about  0  0  1 (x3,c)
            [piby3*2,   0,       0,       1],  #
            [pi,        0,       0,       1],  #
            [piby3*4,   0,       0,       1],  #
            [piby3*5,   0,       0,       1],  #
            [pi,        1,       0,       0],  # twofold about  2 -1  0 (x1,a1)
            [pi,       -0.5,     sq3by2,  0],  # twofold about -1  2  0 (a2)
            [pi,       -0.5,    -sq3by2,  0],  # twofold about -1 -1  0 (a3)
            [pi,        sq3by2,  0.5,     0],  # twofold about  1  0  0
            [pi,        0,       1,       0],  # twofold about -1  1  0 (x2)
            [pi,       -sq3by2,  0.5,     0],  # twofold about  0 -1  0
            ]
    elif tag.lower() == 'th':
        # CUBIC (LOW)
        angleAxis = c_[
            [0.0,       1,    0,    0],  # identity
            [pi,        1,    0,    0],  # twofold about    1  0  0 (x1)
            [pi,        0,    1,    0],  # twofold about    0  1  0 (x2)
            [pi,        0,    0,    1],  # twofold about    0  0  1 (x3)
            [piby3*2,   1,    1,    1],  # threefold about  1  1  1
            [piby3*4,   1,    1,    1],  #
            [piby3*2,  -1,    1,    1],  # threefold about -1  1  1
            [piby3*4,  -1,    1,    1],  #
            [piby3*2,  -1,   -1,    1],  # threefold about -1 -1  1
            [piby3*4,  -1,   -1,    1],  #
            [piby3*2,   1,   -1,    1],  # threefold about  1 -1  1
            [piby3*4,   1,   -1,    1],  #
            ]
    elif tag.lower() == 'oh':
        # CUBIC (HIGH)
        angleAxis = c_[
            [0.0,       1,    0,    0],  # identity
            [piby2,     1,    0,    0],  # fourfold about   1  0  0 (x1)
            [pi,        1,    0,    0],  #
            [piby2*3,   1,    0,    0],  #
            [piby2,     0,    1,    0],  # fourfold about   0  1  0 (x2)
            [pi,        0,    1,    0],  #
            [piby2*3,   0,    1,    0],  #
            [piby2,     0,    0,    1],  # fourfold about   0  0  1 (x3)
            [pi,        0,    0,    1],  #
            [piby2*3,   0,    0,    1],  #
            [piby3*2,   1,    1,    1],  # threefold about  1  1  1
            [piby3*4,   1,    1,    1],  #
            [piby3*2,  -1,    1,    1],  # threefold about -1  1  1
            [piby3*4,  -1,    1,    1],  #
            [piby3*2,  -1,   -1,    1],  # threefold about -1 -1  1
            [piby3*4,  -1,   -1,    1],  #
            [piby3*2,   1,   -1,    1],  # threefold about  1 -1  1
            [piby3*4,   1,   -1,    1],  #
            [pi,        1,    1,    0],  # twofold about    1  1  0
            [pi,       -1,    1,    0],  # twofold about   -1  1  0
            [pi,        1,    0,    1],  # twofold about    1  0  1
            [pi,        0,    1,    1],  # twofold about    0  1  1
            [pi,       -1,    0,    1],  # twofold about   -1  0  1
            [pi,        0,   -1,    1],  # twofold about    0 -1  1
            ]
    else:
        raise RuntimeError(
            "unrecognized symmetry group.  "
            + "See ``help(quatOfLaueGroup)'' for a list of valid options.  "
            + "Oh, and have a great day ;-)"
        )

    angle = angleAxis[0, ]
    axis = angleAxis[1:, ]

    #  Note: Axis does not need to be normalized in call to quatOfAngleAxis
    #  05/01/2014 JVB -- made output a contiguous C-ordered array
    qsym = array(rot.quatOfAngleAxis(angle, axis).T, order='C').T

    return qsym

def GeneratorString(sgnum):
    '''
    these rhombohedral space groups have a hexagonal setting
    with different symmetry matrices and generator strings
    146: 231
    148: 232
    ...
    and so on
    '''
    sg = sgnum-1
    # sgdict = {146:231, 148:232, 155:233, 160:234, 161:235, 166:236, 167:237}
    # if(sgnum in sgdict):
    #     sg = sgdict[sgnum]-1

    return constants.SYM_GL[sg]

def MakeGenerators(genstr, setting):

    t = 'aOOO'
    mat = SYM_fillgen(t)
    genmat = mat

    # genmat[0,:,:] = constants.SYM_GENERATORS['a']
    centrosymmetric = False

    # check if space group has inversion symmetry
    if(genstr[0] == '1'):
        t = 'hOOO'
        mat = SYM_fillgen(t)
        genmat = np.concatenate((genmat, mat))
        centrosymmetric = True

    n = int(genstr[1])
    if(n > 0):
        for i in range(n):
            istart = 2 + i * 4
            istop  = 2 + (i+1) * 4

            t = genstr[istart:istop]

            mat = SYM_fillgen(t)
            genmat = np.concatenate((genmat, mat))
    else:
        istop = 2
    '''
    if there is an alternate setting for this space group
    check if the alternate setting needs to be used
    '''
    if(genstr[istop] != '0'):
        if(setting != 0):
            t = genstr[istop+1:istop+4]
            trans = np.array([constants.SYM_GENERATORS[t[0]],\
                              constants.SYM_GENERATORS[t[1]],\
                              constants.SYM_GENERATORS[t[2]]
                              ])
            for i in range(genmat.shape[0]):
                genmat[i,0:3,3] -= trans

    return genmat, centrosymmetric

def SYM_fillgen(t):
    mat = np.zeros([4,4])
    mat[3,3] = 1.

    mat[0:3,0:3] = constants.SYM_GENERATORS[t[0]]
    mat[0:3,3] = np.array([constants.SYM_GENERATORS[t[1]],\
                           constants.SYM_GENERATORS[t[2]],\
                           constants.SYM_GENERATORS[t[3]]
                           ])

    mat = np.broadcast_to(mat, [1,4,4])
    return mat

def GenerateSGSym(sgnum, setting=0):

    '''
    get the generators for a space group using the
    generator string
    '''
    genstr = GeneratorString(sgnum)
    genmat, centrosymmetric = MakeGenerators(genstr, setting)
    symmorphic = False
    if(sgnum in constants.sgnum_symmorphic):
        symmorphic = True
    '''
    use the generator string to get the rest of the
    factor group

    genmat has shape ngenerators x 4 x 4
    '''
    nsym = genmat.shape[0]

    SYM_SG = genmat

    '''
    generate the factor group
    '''

    k1 = 0
    while k1 < nsym:

        g1 = np.squeeze(SYM_SG[k1,:,:])
        k2 = k1

        while k2 < nsym:
            g2 = np.squeeze(SYM_SG[k2,:,:])
            gnew = np.dot(g1, g2)

            # only fractional parts
            frac = np.modf(gnew[0:3,3])[0]
            frac[frac < 0.] += 1.
            frac[np.abs(frac) < 1E-5] = 0.0
            frac[np.abs(frac-1.0) < 1E-5] = 0.0
            gnew[0:3,3] = frac

            if(isnew(gnew, SYM_SG)):
                gnew = np.broadcast_to(gnew, [1,4,4])
                SYM_SG = np.concatenate((SYM_SG, gnew))
                nsym += 1

                if (nsym >= 192):
                    k2 = nsym
                    k1 = nsym

            k2 += 1
        k1 += 1

    SYM_PG_d = GeneratePGSym(SYM_SG)
    SYM_PG_d_laue = GeneratePGSym_Laue(SYM_PG_d)

    for s in SYM_PG_d:
        if(np.allclose(-np.eye(3),s)):
            centrosymmetric = True


    return SYM_SG, SYM_PG_d, SYM_PG_d_laue, centrosymmetric, symmorphic

def GeneratePGSym(SYM_SG):
    '''
    calculate the direct space point group symmetries
    from the space group symmetry. the direct point
    group symmetries are merely the space group
    symmetries with zero translation part. The reciprocal
    ones are calculated from the direct symmetries by
    using the metric tensors, but that is done in the unitcell
    class
    '''
    nsgsym = SYM_SG.shape[0]

    # first fill the identity rotation
    SYM_PG_d = SYM_SG[0,0:3,0:3]
    SYM_PG_d = np.broadcast_to(SYM_PG_d,[1,3,3])

    for i in range(1,nsgsym):
        g = SYM_SG[i,:,:]
        t = g[0:3,3]
        g = g[0:3,0:3]
        if(isnew(g,SYM_PG_d)):
            g = np.broadcast_to(g,[1,3,3])
            SYM_PG_d = np.concatenate((SYM_PG_d, g))

    return SYM_PG_d.astype(np.int32)

def GeneratePGSym_Laue(SYM_PG_d):
    '''
    generate the laue group symmetry for the given set of
    point group symmetry matrices. this function just adds
    the inversion symmetry and goes through the group action
    to generate the entire laue group for the direct point
    point group matrices
    '''

    '''
    first check if the group already has the inversion symmetry
    '''
    for s in SYM_PG_d:
        if(np.allclose(s,-np.eye(3))):
            return SYM_PG_d

    '''
    if we get here, then the inversion symmetry is not present
    add the inversion symmetry
    '''
    SYM_PG_d_laue = SYM_PG_d
    g = np.broadcast_to(-np.eye(3).astype(np.int32),[1,3,3])
    SYM_PG_d_laue = np.concatenate((SYM_PG_d_laue, g))

    '''
    now go through the group actions and see if its a new matrix
    if it is then add it to the group
    '''
    nsym = SYM_PG_d_laue.shape[0]
    k1 = 0
    while k1 < nsym:
        g1 = np.squeeze(SYM_PG_d_laue[k1,:,:])
        k2 = k1
        while k2 < nsym:
            g2 = np.squeeze(SYM_PG_d_laue[k2,:,:])
            gnew = np.dot(g1, g2)

            if(isnew(gnew, SYM_PG_d_laue)):
                gnew = np.broadcast_to(gnew, [1,3,3])
                SYM_PG_d_laue = np.concatenate((SYM_PG_d_laue, gnew))
                nsym += 1

                if (nsym >= 48):
                    k2 = nsym
                    k1 = nsym

            k2 += 1
        k1 += 1

    return SYM_PG_d_laue


@numba_njit_if_available(cache=True, nogil=True)
def isnew(mat, sym_mats):
    for g in sym_mats:
        diff = np.sum(np.abs(mat - g))
        if diff < 1e-5:
            return False
    return True

def latticeType(sgnum):

    if(sgnum <= 2):
        return 'triclinic'
    elif(sgnum > 2 and sgnum <= 15):
        return 'monoclinic'
    elif(sgnum > 15 and sgnum <= 74):
        return 'orthorhombic'
    elif(sgnum > 74 and sgnum <= 142):
        return 'tetragonal'
    elif(sgnum > 142 and sgnum <= 167):
        return 'trigonal'
    elif(sgnum > 167 and sgnum <= 194):
        return 'hexagonal'
    elif(sgnum > 194 and sgnum <=230):
        return 'cubic'
    else:
        raise RuntimeError('symmetry.latticeType: unknown space group number')


def MakeGenerators_PGSYM(pggenstr):
    '''
    @AUTHOR  Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
    @DATE    11/23/2020 SS 1.0 original
    @DETAIL. these are the supporting routine to generate the ppint group symmetry
             for any point group. this is needed for the coloring routines
    '''
    ngen = int(pggenstr[0])
    SYM_GEN_PG = np.zeros([ngen,3,3])

    for i in range(ngen):
        s = pggenstr[i+1]
        SYM_GEN_PG[i,:,:] = constants.SYM_GENERATORS[s]

    return SYM_GEN_PG

def GeneratePGSYM(pgsym):
    '''
    @AUTHOR  Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
    @DATE    11/23/2020 SS 1.0 original
    @DETAIL. generate the point group symmetry given the point group symbol
    '''
    pggenstr = constants.SYM_GL_PG[pgsym]

    SYM_GEN_PG = MakeGenerators_PGSYM(pggenstr)

    '''
    generate the powers of the group
    '''


    '''
    now go through the group actions and see if its a new matrix
    if it is then add it to the group
    '''
    nsym = SYM_GEN_PG.shape[0]
    k1 = 0
    while k1 < nsym:
        g1 = np.squeeze(SYM_GEN_PG[k1,:,:])
        k2 = k1
        while k2 < nsym:
            g2 = np.squeeze(SYM_GEN_PG[k2,:,:])
            gnew = np.dot(g1, g2)

            if(isnew(gnew, SYM_GEN_PG)):
                gnew = np.broadcast_to(gnew, [1,3,3])
                SYM_GEN_PG = np.concatenate((SYM_GEN_PG, gnew))
                nsym += 1

                if (nsym >= 48):
                    k2 = nsym
                    k1 = nsym

            k2 += 1
        k1 += 1

    SYM_GEN_PG[np.abs(SYM_GEN_PG) < eps] = 0.

    return SYM_GEN_PG


# hexrd/unitcell.py

import importlib.resources
import numpy as np
from hexrd import constants
from hexrd import symmetry, symbols
from hexrd.spacegroup import Allowed_HKLs
from hexrd.ipfcolor import sphere_sector, colorspace
from hexrd.valunits import valWUnit
import hexrd.resources
import warnings
import h5py
from pathlib import Path
from scipy.interpolate import interp1d
import time

from hexrd.utils.decorators import numba_njit_if_available

eps = constants.sqrt_epsf
ENERGY_ID = 0
REAL_F1_ID = 1
IMAG_F2_ID = 2
MU_ID = 3
COH_INCOH_ID = 4
MU_K_ID = 6
WAV_ID = 7

''' calculate dot product of two vectors in any space 'd' 'r' or 'c' '''


@numba_njit_if_available(cache=True, nogil=True)
def _calclength(u, mat):
    return np.sqrt(np.dot(u, np.dot(mat, u)))


@numba_njit_if_available(cache=True, nogil=True)
def _calcstar(v, sym, mat):
    vsym = np.atleast_2d(v)
    for s in sym:
        vp = np.dot(np.ascontiguousarray(s), v)
        # check if this is new
        isnew = True
        for vec in vsym:
            vv = vp - vec
            dist = _calclength(vv, mat)
            if dist < 1E-3:
                isnew = False
                break
        if(isnew):
            vp = np.atleast_2d(vp)
            vsym = np.vstack((vsym, vp))

    return vsym


class unitcell:

    '''
    >> @AUTHOR:     Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
    >> @DATE:       10/09/2018 SS 1.0 original
       @DATE:       10/15/2018 SS 1.1 added space group handling
    >> @DETAILS:    this is the unitcell class

    '''

    # initialize the unitcell class
    # need lattice parameters and space group data from HDF5 file
    def __init__(self, lp, sgnum,
                 atomtypes, charge,
                 atominfo,
                 U, dmin, beamenergy,
                 sgsetting=0):

        self._tstart = time.time()
        self.pref = 0.4178214

        self.atom_type = atomtypes
        self.chargestates = charge
        self.atom_pos = atominfo

        self._dmin = dmin

        self.lparms = lp

        self.U = U
        '''
        initialize interpolation from table for anomalous scattering
        '''
        self.InitializeInterpTable()

        '''
        sets x-ray energy
        calculate wavelength
        also calculates anomalous form factors for xray scattering
        '''
        self.voltage = beamenergy * 1000.0
        '''
        calculate symmetry
        '''
        self.sgsetting = sgsetting
        self.sgnum = sgnum

        self._tstop = time.time()
        self.tinit = self._tstop - self._tstart

    def GetPgLg(self):
        '''
        simple subroutine to get point and laue groups
        to maintain consistency for planedata initialization
        in the materials class
        '''
        for k in list(_pgDict.keys()):
            if self.sgnum in k:
                pglg = _pgDict[k]
                self._pointGroup = pglg[0]
                self._laueGroup = pglg[1]
                self._supergroup = pglg[2]
                self._supergroup_laue = pglg[3]

    def CalcWavelength(self):
        # wavelength in nm
        self.wavelength = constants.cPlanck * \
            constants.cLight /  \
            constants.cCharge / \
            self.voltage
        self.wavelength *= 1e9
        # self.CalcAnomalous()

    def calcBetaij(self):

        self.betaij = np.zeros([3, 3, self.atom_ntype])
        for i in range(self.U.shape[0]):
            U = self.U[i, :]
            self.betaij[:, :, i] = np.array([[U[0], U[3], U[4]],
                                             [U[3], U[1], U[5]],
                                             [U[4], U[5], U[2]]])

            self.betaij[:, :, i] *= 2. * np.pi**2 * self._aij

    def calcmatrices(self):

        a = self.a
        b = self.b
        c = self.c

        alpha = np.radians(self.alpha)
        beta = np.radians(self.beta)
        gamma = np.radians(self.gamma)

        ca = np.cos(alpha)
        cb = np.cos(beta)
        cg = np.cos(gamma)
        sa = np.sin(alpha)
        sb = np.sin(beta)
        sg = np.sin(gamma)
        tg = np.tan(gamma)

        '''
            direct metric tensor
        '''
        self._dmt = np.array([[a**2, a*b*cg, a*c*cb],
                              [a*b*cg, b**2, b*c*ca],
                              [a*c*cb, b*c*ca, c**2]])
        self._vol = np.sqrt(np.linalg.det(self.dmt))

        if(self.vol < 1e-5):
            warnings.warn('unitcell volume is suspiciously small')

        '''
            reciprocal metric tensor
        '''
        self._rmt = np.linalg.inv(self.dmt)

        '''
            direct structure matrix
        '''
        self._dsm = np.array([[a, b*cg, c*cb],
                              [0., b*sg, -c*(cb*cg - ca)/sg],
                              [0., 0., self.vol/(a*b*sg)]])

        self._dsm[np.abs(self._dsm) < eps] = 0.

        '''
            reciprocal structure matrix
        '''
        self._rsm = np.array([[1./a, 0., 0.],
                              [-1./(a*tg), 1./(b*sg), 0.],
                              [b*c*(cg*ca - cb)/(self.vol*sg),
                               a*c*(cb*cg - ca)/(self.vol*sg),
                               a*b*sg/self.vol]])

        self._rsm[np.abs(self._rsm) < eps] = 0.

        ast = self.CalcLength([1, 0, 0], 'r')
        bst = self.CalcLength([0, 1, 0], 'r')
        cst = self.CalcLength([0, 0, 1], 'r')

        self._aij = np.array([[ast**2, ast*bst, ast*cst],
                              [bst*ast, bst**2, bst*cst],
                              [cst*ast, cst*bst, cst**2]])

    ''' transform between any crystal space to any other space.
        choices are 'd' (direct), 'r' (reciprocal) and 'c' (cartesian)'''

    def TransSpace(self, v_in, inspace, outspace):
        if(inspace == 'd'):
            if(outspace == 'r'):
                v_out = np.dot(v_in, self.dmt)
            elif(outspace == 'c'):
                v_out = np.dot(self.dsm, v_in)
            else:
                raise ValueError(
                    'inspace in ''d'' but outspace can''t be identified')

        elif(inspace == 'r'):
            if(outspace == 'd'):
                v_out = np.dot(v_in, self.rmt)
            elif(outspace == 'c'):
                v_out = np.dot(self.rsm, v_in)
            else:
                raise ValueError(
                    'inspace in ''r'' but outspace can''t be identified')

        elif(inspace == 'c'):
            if(outspace == 'r'):
                v_out = np.dot(v_in, self.rsm)
            elif(outspace == 'd'):
                v_out = np.dot(v_in, self.dsm)
            else:
                raise ValueError(
                    'inspace in ''c'' but outspace can''t be identified')

        else:
            raise ValueError('incorrect inspace argument')

        return v_out

    ''' calculate dot product of two vectors in any space 'd' 'r' or 'c' '''

    def CalcDot(self, u, v, space):

        if(space == 'd'):
            dot = np.dot(u, np.dot(self.dmt, v))
        elif(space == 'r'):
            dot = np.dot(u, np.dot(self.rmt, v))
        elif(space == 'c'):
            dot = np.dot(u, v)
        else:
            raise ValueError('space is unidentified')

        return dot

    def CalcLength(self, u, space):

        if(space == 'd'):
            mat = self.dmt
            # vlen = np.sqrt(np.dot(u, np.dot(self.dmt, u)))
        elif(space == 'r'):
            mat = self.rmt
            # vlen = np.sqrt(np.dot(u, np.dot(self.rmt, u)))
        elif(space == 'c'):
            mat = np.eye(3)
            # vlen = np.linalg.norm(u)
        else:
            raise ValueError('incorrect space argument')

        uu = np.array(u).astype(np.float64)
        return _calclength(uu, mat)

    ''' normalize vector in any space 'd' 'r' or 'c' '''

    def NormVec(self, u, space):
        ulen = self.CalcLength(u, space)
        return u/ulen

    ''' calculate angle between two vectors in any space'''

    def CalcAngle(self, u, v, space):

        ulen = self.CalcLength(u, space)
        vlen = self.CalcLength(v, space)

        dot = self.CalcDot(u, v, space)/ulen/vlen
        angle = np.arccos(dot)

        return angle

    ''' calculate cross product between two vectors in any space.

    cross product of two vectors in direct space is a vector in
    reciprocal space

    cross product of two vectors in reciprocal space is a vector in
    direct space

    the outspace specifies if a conversion needs to be made

     @NOTE: iv is the switch (0/1) which will either turn division
     by volume of the unit cell on or off.'''

    def CalcCross(self, p, q, inspace, outspace, vol_divide=False):
        iv = 0
        if(vol_divide):
            vol = self.vol
        else:
            vol = 1.0

        pxq = np.array([p[1]*q[2]-p[2]*q[1],
                        p[2]*q[0]-p[0]*q[2],
                        p[0]*q[1]-p[1]*q[0]])

        if(inspace == 'd'):
            '''
            cross product vector is in reciprocal space
            and can be converted to direct or cartesian space
            '''
            pxq *= vol

            if(outspace == 'r'):
                pass
            elif(outspace == 'd'):
                pxq = self.TransSpace(pxq, 'r', 'd')
            elif(outspace == 'c'):
                pxq = self.TransSpace(pxq, 'r', 'c')
            else:
                raise ValueError(
                    'inspace is ''d'' but outspace is unidentified')

        elif(inspace == 'r'):
            '''
            cross product vector is in direct space and
            can be converted to any other space
            '''
            pxq /= vol
            if(outspace == 'r'):
                pxq = self.TransSpace(pxq, 'd', 'r')
            elif(outspace == 'd'):
                pass
            elif(outspace == 'c'):
                pxq = self.TransSpace(pxq, 'd', 'c')
            else:
                raise ValueError(
                    'inspace is ''r'' but outspace is unidentified')

        elif(inspace == 'c'):
            '''
            cross product is already in cartesian space so no
            volume factor is involved. can be converted to any
            other space too
            '''
            if(outspace == 'r'):
                pxq = self.TransSpace(pxq, 'c', 'r')
            elif(outspace == 'd'):
                pxq = self.TransSpace(pxq, 'c', 'd')
            elif(outspace == 'c'):
                pass
            else:
                raise ValueError(
                    'inspace is ''c'' but outspace is unidentified')

        else:
            raise ValueError('inspace is unidentified')

        return pxq

    def GenerateRecipPGSym(self):

        self.SYM_PG_r = self.SYM_PG_d[0, :, :]
        self.SYM_PG_r = np.broadcast_to(self.SYM_PG_r, [1, 3, 3])

        self.SYM_PG_r_laue = self.SYM_PG_d[0, :, :]
        self.SYM_PG_r_laue = np.broadcast_to(self.SYM_PG_r_laue, [1, 3, 3])

        for i in range(1, self.npgsym):
            g = self.SYM_PG_d[i, :, :]
            g = np.dot(self.dmt, np.dot(g, self.rmt))
            g = np.round(np.broadcast_to(g, [1, 3, 3]))
            self.SYM_PG_r = np.concatenate((self.SYM_PG_r, g))

        for i in range(1, self.SYM_PG_d_laue.shape[0]):
            g = self.SYM_PG_d_laue[i, :, :]
            g = np.dot(self.dmt, np.dot(g, self.rmt))
            g = np.round(np.broadcast_to(g, [1, 3, 3]))
            self.SYM_PG_r_laue = np.concatenate((self.SYM_PG_r_laue, g))

        self.SYM_PG_r = self.SYM_PG_r.astype(np.int32)
        self.SYM_PG_r_laue = self.SYM_PG_r_laue.astype(np.int32)

    def GenerateCartesianPGSym(self):
        '''
        use the direct point group symmetries to generate the 
        symmetry operations in the cartesian frame. this is used
        to reduce directions to the standard stereographi tringle
        '''
        self.SYM_PG_c = []
        self.SYM_PG_c_laue = []

        for sop in self.SYM_PG_d:
            self.SYM_PG_c.append(np.dot(self.dsm, np.dot(sop, self.rsm.T)))

        self.SYM_PG_c = np.array(self.SYM_PG_c)
        self.SYM_PG_c[np.abs(self.SYM_PG_c) < eps] = 0.

        if(self._pointGroup == self._laueGroup):
            self.SYM_PG_c_laue = self.SYM_PG_c
        else:
            for sop in self.SYM_PG_d_laue:
                self.SYM_PG_c_laue.append(
                    np.dot(self.dsm, np.dot(sop, self.rsm.T)))
            self.SYM_PG_c_laue = np.array(self.SYM_PG_c_laue)
            self.SYM_PG_c_laue[np.abs(self.SYM_PG_c_laue) < eps] = 0.

        '''
        use the point group symmetry of the supergroup
        to generate the equivalent operations in the 
        cartesian reference frame

        SS 11/23/2020 added supergroup symmetry operations
        SS 11/24/2020 fix monoclinic groups separately since
        the supergroup for monoclinic is orthorhombic
        '''
        supergroup = self._supergroup
        sym_supergroup = symmetry.GeneratePGSYM(supergroup)

        supergroup_laue = self._supergroup_laue
        sym_supergroup_laue = symmetry.GeneratePGSYM(supergroup_laue)

        if((self.latticeType == 'monoclinic' or
                self.latticeType == 'triclinic')):
            '''
            for monoclinic groups c2 and c2h, the supergroups are 
            orthorhombic, so no need to convert from direct to 
            cartesian as they are identical
            '''
            self.SYM_PG_supergroup = sym_supergroup
            self.SYM_PG_supergroup_laue = sym_supergroup_laue

        else:

            self.SYM_PG_supergroup = []
            self.SYM_PG_supergroup_laue = []

            for sop in sym_supergroup:
                self.SYM_PG_supergroup.append(
                    np.dot(self.dsm, np.dot(sop, self.rsm.T)))

            self.SYM_PG_supergroup = np.array(self.SYM_PG_supergroup)
            self.SYM_PG_supergroup[np.abs(self.SYM_PG_supergroup) < eps] = 0.

            for sop in sym_supergroup_laue:
                self.SYM_PG_supergroup_laue.append(
                    np.dot(self.dsm, np.dot(sop, self.rsm.T)))

        self.SYM_PG_supergroup_laue = np.array(self.SYM_PG_supergroup_laue)
        self.SYM_PG_supergroup_laue[np.abs(
            self.SYM_PG_supergroup_laue) < eps] = 0.

        '''
        the standard setting for the monoclinic system has the b-axis aligned
        with the 2-fold axis. this needs to be accounted for when reduction to
        the standard stereographic triangle is performed. the siplest way is to
        rotate all symmetry elements by 90 about the x-axis

        the supergroups for the monoclinic groups are orthorhombic so they need
        not be rotated as they have the c* axis already aligned with the z-axis
        SS 12/10/2020
        '''
        if(self.latticeType == 'monoclinic'):

            om = np.array([[1., 0., 0.], [0., 0., 1.], [0., -1., 0.]])

            for i, s in enumerate(self.SYM_PG_c):
                ss = np.dot(om, np.dot(s, om.T))
                self.SYM_PG_c[i, :, :] = ss

            for i, s in enumerate(self.SYM_PG_c_laue):
                ss = np.dot(om, np.dot(s, om.T))
                self.SYM_PG_c_laue[i, :, :] = ss
        '''
        for the triclinic group c1, the supergroups are the monoclinic group m
        therefore we need to rotate the mirror to be perpendicular to the z-axis
        same shouldn't be done for the group ci, since the supergroup is just the
        triclinic group c1!!
        SS 12/10/2020 
        '''
        if(self._pointGroup == 'c1'):
            om = np.array([[1., 0., 0.], [0., 0., 1.], [0., -1., 0.]])

            for i, s in enumerate(self.SYM_PG_supergroup):
                ss = np.dot(om, np.dot(s, om.T))
                self.SYM_PG_supergroup[i, :, :] = ss

            for i, s in enumerate(self.SYM_PG_supergroup_laue):
                ss = np.dot(om, np.dot(s, om.T))
                self.SYM_PG_supergroup_laue[i, :, :] = ss

    def CalcOrbit(self, v, reduceToUC=True):
        """
        @date 03/04/2021 SS 1.0 original 

        @details calculate the equivalent position for the 
        space group symmetry. this function will replace the
        code in the CalcPositions subroutine.

        @params v is the factional coordinates in direct space
                reduceToUC reduces the position to the
                fundamental fractional unit cell (0-1)
        """

        asym_pos = []
        n = 1
        if v.shape[0] != 3:
            raise RuntimeError("fractional coordinate in not 3-d")
        r = v
        # using wigner-sietz notation
        r = np.hstack((r, 1.))

        asym_pos = np.broadcast_to(r[0:3], [1, 3])

        for symmat in self.SYM_SG:
            # get new position
            rnew = np.dot(symmat, r)
            rr = rnew[0:3]

            if reduceToUC:
                # reduce to fundamental unitcell with fractional
                # coordinates between 0-1
                rr = np.modf(rr)[0]
                rr[rr < 0.] += 1.
                rr[np.abs(rr) < 1.0E-6] = 0.

            # check if this is new
            isnew = True
            for j in range(n):
                v = rr - asym_pos[j]
                dist = self.CalcLength(v, 'd')
                if dist < 1E-3:
                    isnew = False
                    break

            # if its new add this to the list
            if(isnew):
                asym_pos = np.vstack((asym_pos, rr))
                n += 1

        numat = n

        return asym_pos, numat

    def CalcStar(self, v, space, applyLaue=False):
        '''
        this function calculates the symmetrically equivalent hkls (or uvws)
        for the reciprocal (or direct) point group symmetry.
        '''
        if(space == 'd'):
            mat = self.dmt.astype(np.float64)
            if(applyLaue):
                sym = self.SYM_PG_d_laue.astype(np.float64)
            else:
                sym = self.SYM_PG_d.astype(np.float64)
        elif(space == 'r'):
            mat = self.rmt.astype(np.float64)
            if(applyLaue):
                sym = self.SYM_PG_r_laue.astype(np.float64)
            else:
                sym = self.SYM_PG_r.astype(np.float64)
        elif(space == 'c'):
            mat = np.eye(3)
            if(applyLaue):
                sym = self.SYM_PG_c_laue.astype(np.float64)
            else:
                sym = self.SYM_PG_c.astype(np.float64)
        else:
            raise ValueError('CalcStar: unrecognized space.')

        vv = np.array(v).astype(np.float64)
        return _calcstar(vv, sym, mat)

    def CalcPositions(self):
        '''
        calculate the asymmetric positions in the fundamental unitcell
        used for structure factor calculations
        '''
        numat = []
        asym_pos = []

        for i in range(self.atom_ntype):

            v = self.atom_pos[i, 0:3]
            apos, n = self.CalcOrbit(v)

            asym_pos.append(apos)
            numat.append(n)

        self.numat = np.array(numat)
        self.asym_pos = asym_pos

    def remove_duplicate_atoms(self,
                               atom_pos=None,
                               tol=1e-3):
        """
        @date 03/04/2021 SS 1.0 original

        @details it was requested that a functionality be 
        added which can remove duplicate atoms from the 
        atom_pos field such that no two atoms are closer that
        the distance specified by "tol" (lets assume its in A)
        steps involved are as follows:
        1. get the star (or orbit) oe each point in atom_pos
        2. if any points in the orbits are within tol, then
        remove the second point (the first point will be 
        preserved by convention)
        3. update the densities, interptables for structure factors 
        etc.

        @params tol tolerance of distance between points specified
        in A
        """

        if atom_pos is None:
            atom_pos = self.atom_pos

        atom_pos_fixed = []
        idx = []
        """
        go through the atom_pos and remove the atoms that are duplicate
        """
        for i in range(atom_pos.shape[0]):
            pos = atom_pos[i, 0:3]
            occ = atom_pos[i, 3]
            if i == 0:
                atom_pos_fixed.append(np.hstack([pos, occ]))
                idx.append(i)
                v1, n1 = self.CalcOrbit(pos)

                for j in range(i+1, atom_pos.shape[0]):
                    isclose = False
                    # atom_pos_fixed.append(np.hstack([pos, occ]))
                    pos = atom_pos[j, 0:3]
                    occ = atom_pos[j, 3]
                    v2, n2 = self.CalcOrbit(pos)

                    for v in v2:
                        vv = np.tile(v, [v1.shape[0], 1])
                        vv = vv - v1

                        for vvv in vv:

                            # check if distance less than tol
                            # the factor of 10 is for A --> nm
                            if self.CalcLength(vvv, 'd') < tol/10.:
                                # if true then its a repeated atom
                                isclose = True
                                break

                        if isclose:
                            break

                    if isclose:
                        break
                    else:
                        atom_pos_fixed.append(np.hstack([pos, occ]))
                        idx.append(i)

        idx = np.array(idx)
        atom_pos_fixed = np.array(atom_pos_fixed)
        atom_type = self.atom_type[idx]
        chargestates = [self.chargestates[i] for i in idx]

        if self.aniU:
            U = self.U[idx, :]
        else:
            U = self.U[idx]

        self.atom_type = atom_type
        self.chargestates = chargestates
        self.atom_pos = atom_pos_fixed

        self.U = U
        '''
        initialize interpolation from table for anomalous scattering
        '''
        self.InitializeInterpTable()
        # self.CalcAnomalous()
        self.CalcPositions()
        self.CalcDensity()
        self.calc_absorption_length()

    def CalcDensity(self):
        '''
        calculate density, average atomic weight (avA)
        and average atomic number(avZ)
        '''
        self.avA = 0.0
        self.avZ = 0.0

        for i in range(self.atom_ntype):
            '''
            atype is atom type i.e. atomic number
            numat is the number of atoms of atype
            atom_pos(i,3) has the occupation factor
            '''
            atype = self.atom_type[i]
            numat = self.numat[i]
            occ = self.atom_pos[i, 3]

            # -1 due to 0 indexing in python
            self.avA += numat * constants.atom_weights[atype-1] * occ

            self.avZ += numat * atype

        self.density = self.avA / (self.vol * 1.0E-21 * constants.cAvogadro)

        av_natom = np.dot(self.numat, self.atom_pos[:, 3])

        self.avA /= av_natom
        self.avZ /= np.sum(self.numat)

    ''' calculate the maximum index of diffraction vector along
        each of the three reciprocal
        basis vectors '''

    def init_max_g_index(self):
        """
        added 03/17/2021 SS
        """
        self.ih = 1
        self.ik = 1
        self.il = 1

    def CalcMaxGIndex(self):
        self.init_max_g_index()

        while (1.0 / self.CalcLength(
            np.array([self.ih, 0, 0],
                     dtype=np.float64), 'r') > self.dmin):
            self.ih = self.ih + 1

        while (1.0 / self.CalcLength(
            np.array([0, self.ik, 0],
                     dtype=np.float64), 'r') > self.dmin):
            self.ik = self.ik + 1

        while (1.0 / self.CalcLength(
            np.array([0, 0, self.il],
                     dtype=np.float64), 'r') > self.dmin):
            self.il = self.il + 1

    def InitializeInterpTable(self):

        f_anomalous_data = []
        self.pe_cs = {}
        data = importlib.resources.open_binary(hexrd.resources, 'Anomalous.h5')
        with h5py.File(data, 'r') as fid:
            for i in range(0, self.atom_ntype):

                Z = self.atom_type[i]
                elem = constants.ptableinverse[Z]

                if Z <= 92:
                    gid = fid.get('/'+elem)
                    data = np.array(gid.get('data'))
                    self.pe_cs[elem] = interp1d(data[:, WAV_ID],
                        data[:, MU_ID]+data[:,COH_INCOH_ID])
                    data = data[:, [WAV_ID, REAL_F1_ID, IMAG_F2_ID]]
                    f_anomalous_data.append(data)
                else:
                    wav = np.linspace(1.16E2, 2.86399992e-03, 189)
                    zs = np.ones_like(wav)*Z
                    zrs = np.zeros_like(wav)
                    data_zs = np.vstack((wav, zs, zrs)).T
                    self.pe_cs[elem] = interp1d(wav, zrs)
                    f_anomalous_data.append(data_zs)

        n = max([x.shape[0] for x in f_anomalous_data])
        self.f_anomalous_data = np.zeros([self.atom_ntype, n, 3])
        self.f_anomalous_data_sizes = np.zeros(
            [self.atom_ntype, ], dtype=np.int32)

        for i in range(self.atom_ntype):
            nd = f_anomalous_data[i].shape[0]
            self.f_anomalous_data_sizes[i] = nd
            self.f_anomalous_data[i, :nd, :] = f_anomalous_data[i]

    def CalcAnomalous(self):

        self.f_anam = {}
        for i in range(self.atom_ntype):

            Z = self.atom_type[i]
            elem = constants.ptableinverse[Z]
            f1 = self.f1[elem](self.wavelength)
            f2 = self.f2[elem](self.wavelength)
            frel = constants.frel[elem]
            Z = constants.ptable[elem]
            self.f_anam[elem] = complex(f1+frel-Z, f2)

    def CalcXRFormFactor(self, Z, charge, s):
        '''
        we are using the following form factors for x-aray scattering:
        1. coherent x-ray scattering, f0 tabulated in Acta Cryst. (1995). A51,416-431
        2. Anomalous x-ray scattering (complex (f'+if")) tabulated in J. Phys. Chem. Ref. Data, 24, 71 (1995)
        and J. Phys. Chem. Ref. Data, 29, 597 (2000).
        3. Thompson nuclear scattering, fNT tabulated in Phys. Lett. B, 69, 281 (1977).

        the anomalous scattering is a complex number (f' + if"), where the two terms are given by
        f' = f1 + frel - Z
        f" = f2

        f1 and f2 have been tabulated as a function of energy in Anomalous.h5 in hexrd folder

        overall f = (f0 + f' + if" +fNT)
        '''
        elem = constants.ptableinverse[Z]
        if charge == '0':
            sfact = constants.scatfac[elem]
        else:
            cs = f"{elem}{charge}"
            if cs in constants.scatfac:
                sfact = constants.scatfac[f"{elem}{charge}"]
            else:
                sfact = constants.scatfac[elem]
        fe = sfact[5]
        fNT = constants.fNT[elem]
        frel = constants.frel[elem]
        f_anomalous = self.f_anam[elem]

        for i in range(5):
            fe += sfact[i] * np.exp(-sfact[i+6]*s)

        return (fe+fNT+f_anomalous)

    def CalcXRSF(self, hkl):
        from hexrd.wppf.xtal import _calcxrsf
        '''
        the 1E-2 is to convert to A^-2
        since the fitting is done in those units
        '''
        fNT = np.zeros([self.atom_ntype, ])
        frel = np.zeros([self.atom_ntype, ])
        scatfac = np.zeros([self.atom_ntype, 11])
        f_anomalous_data = self.f_anomalous_data

        hkl2d = np.atleast_2d(hkl).astype(np.float64)
        nref = hkl2d.shape[0]

        multiplicity = np.ones([nref, ])
        w_int = 1.0

        occ = self.atom_pos[:, 3]
        aniU = self.aniU
        if aniU:
            betaij = self.betaij
        else:
            betaij = self.U

        self.asym_pos_arr = np.zeros([self.numat.max(), self.atom_ntype, 3])
        for i in range(0, self.atom_ntype):
            nn = self.numat[i]
            self.asym_pos_arr[:nn, i, :] = self.asym_pos[i]

        self.numat = np.zeros(self.atom_ntype, dtype=np.int32)
        for i in range(0, self.atom_ntype):
            self.numat[i] = self.asym_pos[i].shape[0]
            Z = self.atom_type[i]
            elem = constants.ptableinverse[Z]
            scatfac[i, :] = constants.scatfac[elem]
            if Z <= 92:
                frel[i] = constants.frel[elem]
                fNT[i] = constants.fNT[elem]

        sf, sf_raw = _calcxrsf(hkl2d,
                               nref,
                               multiplicity,
                               w_int,
                               self.wavelength,
                               self.rmt.astype(np.float64),
                               self.atom_type,
                               self.atom_ntype,
                               betaij,
                               occ,
                               self.asym_pos_arr,
                               self.numat,
                               scatfac,
                               fNT,
                               frel,
                               f_anomalous_data,
                               self.f_anomalous_data_sizes)

        return sf_raw

    """
    molecular mass calculates the molar weight of the unit cell
    since the unitcell can have multiple formular units, this 
    might be greater than the molecular weight
    """

    def calc_unitcell_mass(self):
        a_mass = constants.atom_weights[self.atom_type-1]
        return np.sum(a_mass*self.numat)

    """
    calculate the number density in 1/micron^3
    number density = density * Avogadro / unitcell mass
    the 1e-12 factor converts from 1/cm^3 to 1/micron^3
    """

    def calc_number_density(self):
        M = self.calc_unitcell_mass()
        Na = constants.cAvogadro

        return 1e-12 * self.density * Na / M

    def calc_absorption_cross_sec(self):

        abs_cs_total = 0.
        for i in range(self.atom_ntype):
            Z = self.atom_type[i]
            elem = constants.ptableinverse[Z]
            abs_cs_total += self.pe_cs[elem](self.wavelength) *\
                self.numat[i]/np.sum(self.numat)
        return abs_cs_total

    """
    calculate the absorption coefficient which is 
    calculated using the sum of photoeffect, compton and 
    rayleigh cross ections. the pair and triplet production
    cross sections etc are not applicable in the energy range
    of interest and therefore neglected. 

    attenuation coeff = sigma_total * density

    attenuation_length = 1/attenuation_coeff

    NOTE: units will be microns!!

    """

    def calc_absorption_length(self):
        # re = 2.8179403e-9 # in microns
        # N  = self.calc_number_density()
        abs_cs_total = self.calc_absorption_cross_sec()

        # the 1e4 factor converts wavelength from cm -> micron
        self.absorption_length = 1e4/(abs_cs_total*self.density)

    """
    calculate bragg angle for a reflection. returns Nan if
    the reflections is not possible for the voltage/wavelength
    """

    def CalcBraggAngle(self, hkl):
        glen = self.CalcLength(hkl, 'r')
        sth = self.wavelength * glen * 0.5
        return np.arcsin(sth)

    def ChooseSymmetric(self, hkllist, InversionSymmetry=True):
        '''
        this function takes a list of hkl vectors and
        picks out a subset of the list picking only one
        of the symmetrically equivalent one. The convention
        is to choose the hkl with the most positive components.
        '''
        mask = np.ones(hkllist.shape[0], dtype=np.bool)
        laue = InversionSymmetry

        for i, g in enumerate(hkllist):
            if(mask[i]):

                geqv = self.CalcStar(g, 'r', applyLaue=laue)

                for r in geqv[1:, ]:
                    rid = np.where(np.all(r == hkllist, axis=1))
                    mask[rid] = False

        hkl = hkllist[mask, :].astype(np.int32)

        hkl_max = []

        for g in hkl:
            geqv = self.CalcStar(g, 'r', applyLaue=laue)
            loc = np.argmax(np.sum(geqv, axis=1))
            gmax = geqv[loc, :]
            hkl_max.append(gmax)

        return np.array(hkl_max).astype(np.int32)

    def SortHKL(self, hkllist):
        '''
        this function sorts the hkllist by increasing |g|
        i.e. decreasing d-spacing. If two vectors are same
        length, then they are ordered with increasing
        priority to l, k and h
        '''
        glen = []
        for g in hkllist:
            glen.append(np.round(self.CalcLength(g, 'r'), 8))

        # glen = np.atleast_2d(np.array(glen,dtype=np.float)).T
        dtype = [('glen', float), ('max', int), ('sum', int),
                 ('h', int), ('k', int), ('l', int)]

        a = []
        for i, gl in enumerate(glen):
            g = hkllist[i, :]
            a.append((gl, np.max(g), np.sum(g), g[0], g[1], g[2]))
        a = np.array(a, dtype=dtype)

        isort = np.argsort(a, order=['glen', 'max', 'sum', 'l', 'k', 'h'])
        return hkllist[isort, :]

    def getHKLs(self, dmin):
        '''
        this function generates the symetrically unique set of
        hkls up to a given dmin.
        dmin is in nm
        '''
        '''
        always have the centrosymmetric condition because of
        Friedels law for xrays so only 4 of the 8 octants
        are sampled for unique hkls. By convention we will
        ignore all l < 0
        '''

        hmin = -self.ih-1
        hmax = self.ih
        kmin = -self.ik-1
        kmax = self.ik
        lmin = -1
        lmax = self.il

        hkllist = np.array([[ih, ik, il] for ih in np.arange(hmax, hmin, -1)
                            for ik in np.arange(kmax, kmin, -1)
                            for il in np.arange(lmax, lmin, -1)])

        hkl_allowed = Allowed_HKLs(self.sgnum, hkllist)

        hkl = []
        dsp = []

        hkl_dsp = []

        for g in hkl_allowed:

            # ignore [0 0 0] as it is the direct beam
            if(np.sum(np.abs(g)) != 0):

                dspace = 1./self.CalcLength(g, 'r')

                if(dspace >= dmin):
                    hkl_dsp.append(g)

        '''
        we now have a list of g vectors which are all within dmin range
        plus the systematic absences due to lattice centering and glide
        planes/screw axis has been taken care of

        the next order of business is to go through the list and only pick
        out one of the symetrically equivalent hkls from the list.
        '''
        hkl_dsp = np.array(hkl_dsp).astype(np.int32)
        '''
        the inversionsymmetry switch enforces the application of the inversion
        symmetry regradless of whether the crystal has the symmetry or not
        this is necessary in the case of xrays due to friedel's law
        '''
        hkl = self.ChooseSymmetric(hkl_dsp, InversionSymmetry=True)

        '''
        finally sort in order of decreasing dspacing
        '''
        self.hkls = self.SortHKL(hkl)

        return self.hkls
    '''
        set some properties for the unitcell class. only the lattice
        parameters, space group and asymmetric positions can change,
        but all the dependent parameters will be automatically updated
    '''

    def Required_lp(self, p):
        return _rqpDict[self.latticeType][1](p)

    def Required_C(self, C):
        return np.array([C[x] for x in _StiffnessDict[self._laueGroup][0]])

    def MakeStiffnessMatrix(self, inp_Cvals):
        if(len(inp_Cvals) != len(_StiffnessDict[self._laueGroup][0])):
            x = len(_StiffnessDict[self._laueGroup][0])
            msg = (f"number of constants entered is not correct."
                   f" need a total of {x} independent constants.")
            raise IOError(msg)

        # initialize all zeros and fill the supplied values
        C = np.zeros([6, 6])
        for i, x in enumerate(_StiffnessDict[self._laueGroup][0]):

            C[x] = inp_Cvals[i]

        # enforce the equality constraints
        C = _StiffnessDict[self._laueGroup][1](C)

        # finally fill the lower triangular matrix
        for i in range(6):
            for j in range(i):
                C[i, j] = C[j, i]

        self.stifness = C
        self.compliance = np.linalg.inv(C)

    def inside_spheretriangle(self, conn, dir3, hemisphere, switch):
        '''
        check if direction is inside a spherical triangle
        the logic used as follows:
        if determinant of [A B x], [A x C] and [x B C] are 
        all same sign, then the sphere is inside the traingle
        formed by A, B and C

        returns a mask with inside as True and outside as False

        11/23/2020 SS switch is now a string specifying which 
        symmetry group to use for reducing directions
        11/23/2020 SS catching cases when vertices are empty
        '''

        '''
        first get vertices of the triangles in the 
        '''
        vertex = self.sphere_sector.vertices[switch]
        # if(switch == 'pg'):
        #     vertex = self.sphere_sector.vertices

        # elif(switch == 'laue'):
        #     vertex = self.sphere_sector.vertices_laue

        # elif(switch == 'super'):
        #     vertex = self.sphere_sector.vertices_supergroup

        # elif(switch == 'superlaue'):
        #     vertex = self.sphere_sector.vertices_supergroup_laue

        A = np.atleast_2d(vertex[:, conn[0]]).T
        B = np.atleast_2d(vertex[:, conn[1]]).T
        C = np.atleast_2d(vertex[:, conn[2]]).T

        mask = []
        for x in dir3:

            x2 = np.atleast_2d(x).T
            d1 = np.linalg.det(np.hstack((A, B, x2)))
            d2 = np.linalg.det(np.hstack((A, x2, C)))
            d3 = np.linalg.det(np.hstack((x2, B, C)))
            '''
            catching cases very close to FZ boundary when the
            determinant can be very small positive or negative
            number
            '''
            if(np.abs(d1) < eps):
                d1 = 0.
            if(np.abs(d2) < eps):
                d2 = 0.
            if(np.abs(d3) < eps):
                d3 = 0.

            ss = np.unique(np.sign([d1, d2, d3]))
            if(hemisphere == 'upper'):
                if(np.all(ss >= 0.)):
                    mask.append(True)
                else:
                    mask.append(False)

            elif(hemisphere == 'both'):
                if(len(ss) == 1):
                    mask.append(True)
                elif(len(ss) == 2):
                    if(0 in ss):
                        mask.append(True)
                    else:
                        mask.append(False)
                elif(len(ss) == 3):
                    mask.append(False)

        mask = np.array(mask)
        return mask

    '''
        @AUTHOR Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov

        @date 10/28/2020 SS 1.0 original
              11/23/2020 SS 1.1 the laueswitch has been changed from a boolean
              variable to a string input with threee possible values
        @params dir3 : n x 3 array of directions to reduce
                switch switch to decide which symmetry group to use. one of four:
                (a) 'pg' use the cartesian point group symmetry
                (b) 'laue' use the laue symmetry
                (c) 'super' use the supergroup symmetry used in coloring
                (d) 'superlaue' use the supergroup of the laue group

        @detail this subroutine takes a direction vector and uses the point group
        symmetry of the unitcell to reduce it to the fundamental stereographic 
        triangle for that point group. this function is used in generating the IPF
        color legend for orientations. for now we are assuming dir3 is a nx3 array
        of directions.
    '''

    def reduce_dirvector(self, dir3, switch='pg'):
        '''
        check if the dimensions of the dir3 array is to spec
        '''
        idx = np.arange(dir3.shape[0], dtype=np.int32)
        dir3 = np.ascontiguousarray(np.atleast_2d(dir3))
        if(dir3.ndim != 2):
            raise RuntimeError("reduce_dirvector: invalid shape of dir3 array")

        '''
        check if the direction vector is a unit vector or not.
        if it is not normalize it to get a unit vector. the dir vector
        is in the sample frame, so by default it is assumed to be in a
        orthonormal cartesian frame. this defines the normalization as 
        just division by the L2 norm
        '''
        eps = constants.sqrt_epsf

        if(np.all(np.abs(np.linalg.norm(dir3, axis=1) - 1.0) < eps)):
            dir3n = dir3
        else:
            if(np.all(np.linalg.norm(dir3) > eps)):
                dir3n = dir3/np.tile(np.linalg.norm(dir3, axis=1), [3, 1]).T
            else:
                raise RuntimeError(
                    "atleast one of the input direction seems \
                    to be a null vector")

        '''
        we need both the symmetry reductions for the point group and laue group
        this will be used later on in the coloring routines to determine if the
        points needs to be moved to the southern hemisphere or not
        '''
        dir3_copy = np.copy(dir3n)
        dir3_reduced = np.array([])
        idx_copy = np.copy(idx)
        idx_red = np.array([], dtype=np.int32)
        '''
        laue switch is used to determine which set of symmetry operations to 
        loop over
        '''
        hemisphere = self.sphere_sector.hemisphere[switch]
        ntriangle = self.sphere_sector.ntriangle[switch]
        connectivity = self.sphere_sector.connectivity[switch]

        if(switch == 'pg'):
            sym = self.SYM_PG_c

        elif(switch == 'super'):
            sym = self.SYM_PG_supergroup

        elif(switch == 'laue'):
            sym = self.SYM_PG_c_laue

        elif(switch == 'superlaue'):
            sym = self.SYM_PG_supergroup_laue

        for sop in sym:

            if(dir3_copy.size != 0):

                dir3_sym = np.dot(sop, dir3_copy.T).T

                mask = np.zeros(dir3_sym.shape[0]).astype(np.bool)

                if(ntriangle == 0):
                    if(hemisphere == 'both'):
                        mask = np.ones(dir3_sym.shape[0], dtype=np.bool)
                    elif(hemisphere == 'upper'):
                        mask = dir3_sym[:, 2] >= 0.
                else:
                    for ii in range(ntriangle):
                        tmpmask = self.inside_spheretriangle(
                            connectivity[:, ii], dir3_sym,
                            hemisphere, switch)
                        mask = np.logical_or(mask, tmpmask)

                if(np.sum(mask) > 0):
                    if(dir3_reduced.size != 0):
                        dir3_reduced = np.vstack(
                            (dir3_reduced, dir3_sym[mask, :]))
                        idx_red = np.hstack((idx_red, idx[mask]))
                    else:
                        dir3_reduced = np.copy(dir3_sym[mask, :])
                        idx_red = np.copy(idx[mask])

                dir3_copy = dir3_copy[np.logical_not(mask), :]
                idx = idx[np.logical_not(mask)]
            else:
                break
        dir3_r = np.zeros(dir3_reduced.shape)
        dir3_r[idx_red, :] = dir3_reduced

        return dir3_r

    def color_directions(self, dir3, laueswitch):
        '''
        @AUTHOR  Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
        @DATE    11/12/2020 SS 1.0 original
        @PARAM   dir3 is crystal direction obtained by multiplying inverse of 
        crystal orientation with reference direction
                 laueswitch perform reducion based on lauegroup or the point group

        @DETAIL  this is the routine which makes the calls to sphere_sector
        class which correctly color the orientations for this crystal class. the 
        logic is as follows:

        1. reduce direction to fundamental zone of point group
        2. reduce to fundamental zone of super group
        3. If both are same, then color (hsl) assigned by polar and azimuth
        4. If different, then barycenter lightness is replaced by 1-L (equivalent to 
           replaceing barycenter to pi-theta)
        '''

        if(laueswitch == True):
            ''' 
            this is the case where we color orientations based on the laue group
            of the crystal. this is always going to be the case with x-ray which
            introduces inversion symmetry. For other probes, this is not the case.
            '''
            dir3_red = self.reduce_dirvector(dir3, switch='laue')
            dir3_red_supergroup = self.reduce_dirvector(
                dir3, switch='superlaue')
            switch = 'superlaue'

        elif(laueswitch == False):
            '''
            follow the logic in the function description
            '''
            dir3_red = self.reduce_dirvector(dir3, switch='pg')
            dir3_red_supergroup = self.reduce_dirvector(dir3, switch='super')
            switch = 'super'

        mask = np.linalg.norm(dir3_red - dir3_red_supergroup, axis=1) < eps
        hsl = self.sphere_sector.get_color(dir3_red_supergroup, mask, switch)

        rgb = colorspace.hsl2rgb(hsl)
        return rgb

    def color_orientations(self,
                           rmats,
                           ref_dir=np.array([0., 0., 1.]),
                           laueswitch=True):
        '''
        @AUTHOR  Saransh Singh, Lawrence Livermore National Lab, saransh1@llnl.gov
        @DATE    11/12/2020 SS 1.0 original
        @PARAM   rmats rotation matrices of size nx3x3
                 ref_dir reference direction of the sample frame along which all crystal
                 directions are colored
                 laueswitch should we use laue group for coloring or not
        @DETAIL  this is a simple routine which takes orientations as rotations matrices
        and a reference sample direction ([0 0 1] by default) and returns the directions in 
        the crystal reference frame. Note that the crystal orientations is defined as the the
        orientation which takes the """SAMPLE""" reference frame TO the """CRYSTAL""" frame. 
        Since we are computing the conversion from crystal to sample, we will need to INVERT
        these matrices. Thanksfully, this is just a transpose

        '''

        '''
        first make sure that the rotation matric is size nx3x3
        '''
        if(rmats.ndim == 2):
            rmats = np.atleast_3d(rmats).T
        else:
            assert rmats.ndim == 3, "rotations matrices need to \
                                    be nx3x3. Please check size."

        '''
        obtain the direction vectors by simple matrix multiplication of transpose
        of rotation matrix with the reference direction
        '''
        dir3 = []
        for r in rmats:
            dir3.append(np.dot(r.T, ref_dir))

        dir3 = np.array(dir3)
        '''
        finally get the rgb colors
        '''
        rgb = self.color_directions(dir3, laueswitch)
        return rgb

        self.stiffness = C

    def is_editable(self, lp_name):
        """
        @author Saransh Singh, Lawrence Livermore National Lab
        @date 03/17/2021 SS 1.0 original
        @details check if a certain field in the lattice parameter 
        is editable. this depends on the space group number or the
        lattice class
        """

        _lpnamelist = list(_lpname)
        index = _lpnamelist.index(lp_name)
        editable_fields = _rqpDict[self.latticeType][0]
        return index in editable_fields

    def convert_lp_to_valunits(self, lp):
        """
        added 03/17/2021 SS
        """
        lp_valunit = []
        for i in range(6):
            if(i < 3):
                lp_valunit.append(
                    valWUnit('lp', 'length',  lp[i], 'nm'))

            else:
                lp_valunit.append(
                    valWUnit('lp', 'angle',  lp[i], 'degrees'))

        return lp_valunit

    def fill_correct_lp_vals(self, lp, val, lp_name):
        """
        added 03/17/2021 SS
        """
        index = list(_lpname).index(lp_name)
        lp[index] = val
        lp_red = [lp[i] for i in
                  _rqpDict[self.latticeType][0]]
        lp = _rqpDict[self.latticeType][1](lp_red)
        lp_valunit = self.convert_lp_to_valunits(lp)
        return lp_valunit

    @property
    def compliance(self):
        # Compliance in TPa⁻¹. Stiffness is in GPa.
        if not hasattr(self, 'stiffness'):
            raise AttributeError('Stiffness not set on unit cell')

        return np.linalg.inv(self.stiffness / 1.e3)

    @compliance.setter
    def compliance(self, v):
        # Compliance in TPa⁻¹. Stiffness is in GPa.
        self.stiffness = np.linalg.inv(v) * 1.e3

    # lattice constants as properties

    @property
    def lparms(self):
        return [self.a, self.b,
                self.c, self.alpha, self.beta,
                self.gamma]

    @lparms.setter
    def lparms(self, lp):
        """
        set the lattice parameters here
        """
        self._a = lp[0].getVal("nm")
        self._b = lp[1].getVal("nm")
        self._c = lp[2].getVal("nm")
        self._alpha = lp[3].getVal("degrees")
        self._beta = lp[4].getVal("degrees")
        self._gamma = lp[5].getVal("degrees")
        self.calcmatrices()
        self.init_max_g_index()
        self.CalcMaxGIndex()
        if(hasattr(self, 'numat')):
            self.CalcDensity()

    @property
    def lparms_reduced(self):
        lp = self.lparms
        lp_red = [lp[i] for i in
                  _rqpDict[self.latticeType][0]]
        return lp_red

    @property
    def a(self):
        return self._a

    @a.setter
    def a(self, val):
        if self.is_editable("a"):
            lp = self.lparms
            lp_valunit = self.fill_correct_lp_vals(
                lp, val, "a")
            self.lparms = lp_valunit
        else:
            msg = (f"not an editable field"
                   f" for this space group")
            raise RuntimeError(msg)

    @property
    def b(self):
        return self._b

    @b.setter
    def b(self, val):
        if self.is_editable("b"):
            lp = self.lparms
            lp_valunit = self.fill_correct_lp_vals(
                lp, val, "b")
            self.lparms = lp_valunit
        else:
            msg = (f"not an editable field"
                   f" for this space group")
            raise RuntimeError(msg)

    @property
    def c(self):
        return self._c

    @c.setter
    def c(self, val):
        if self.is_editable("c"):
            lp = self.lparms
            lp_valunit = self.fill_correct_lp_vals(
                lp, val, "c")
            self.lparms = lp_valunit
        else:
            msg = (f"not an editable field"
                   f" for this space group")
            raise RuntimeError(msg)

    @property
    def alpha(self):
        return self._alpha

    @alpha.setter
    def alpha(self, val):
        if self.is_editable("alpha"):
            lp = self.lparms
            lp_valunit = self.fill_correct_lp_vals(
                lp, val, "alpha")
            self.lparms = lp_valunit
        else:
            msg = (f"not an editable field"
                   f" for this space group")
            raise RuntimeError(msg)

    @property
    def beta(self):
        return self._beta

    @beta.setter
    def beta(self, val):
        if self.is_editable("beta"):
            lp = self.lparms
            lp_valunit = self.fill_correct_lp_vals(
                lp, val, "beta")
            self.lparms = lp_valunit
        else:
            msg = (f"not an editable field"
                   f" for this space group")
            raise RuntimeError(msg)

    @property
    def gamma(self):
        return self._gamma

    @gamma.setter
    def gamma(self, val):
        if self.is_editable("gamma"):
            lp = self.lparms
            lp_valunit = self.fill_correct_lp_vals(
                lp, val, "gamma")
            self.lparms = lp_valunit
        else:
            msg = (f"not an editable field"
                   f" for this space group")
            raise RuntimeError(msg)

    @property
    def dmin(self):
        return self._dmin

    @dmin.setter
    def dmin(self, v):
        if self._dmin == v:
            return
        self._dmin = v
        # Update the Max G Index
        self.CalcMaxGIndex()

    @property
    def U(self):
        return self._U

    @U.setter
    def U(self, Uarr):
        self._U = Uarr
        self.aniU = False
        if(Uarr.ndim > 1):
            self.aniU = True
            self.calcBetaij()

    @property
    def voltage(self):
        return self._voltage

    @voltage.setter
    def voltage(self, v):
        self._voltage = v
        self.CalcWavelength()

    @property
    def wavelength(self):
        return self._mlambda

    @wavelength.setter
    def wavelength(self, mlambda):
        self._mlambda = mlambda

    # space group number
    @property
    def sgnum(self):
        return self._sym_sgnum

    @sgnum.setter
    def sgnum(self, val):
        if(not(isinstance(val, int))):
            raise ValueError('space group should be integer')
        if(not((val >= 1) and (val <= 230))):
            raise ValueError('space group number should be between 1 and 230.')

        self._sym_sgnum = val
        self.sg_hmsymbol = symbols.pstr_spacegroup[val-1].strip()

        self.SYM_SG, self.SYM_PG_d, self.SYM_PG_d_laue, \
            self.centrosymmetric, self.symmorphic = \
            symmetry.GenerateSGSym(self.sgnum, self.sgsetting)

        self.latticeType = symmetry.latticeType(self.sgnum)

        self.nsgsym = self.SYM_SG.shape[0]
        self.npgsym = self.SYM_PG_d.shape[0]

        self.GenerateRecipPGSym()

        '''
        asymmetric positions due to space group symmetry
        used for structure factor calculations
        '''
        self.CalcPositions()
        self.GetPgLg()

        '''
        SS 11/10/2020 added cartesian PG sym for reducing directions
        to standard stereographic triangle
        '''
        self.GenerateCartesianPGSym()

        '''
        SS 11/11/2020 adding the sphere_sector class initialization here
        '''
        self.sphere_sector = sphere_sector.sector(self._pointGroup,
                                                  self._laueGroup,
                                                  self._supergroup,
                                                  self._supergroup_laue)
        self.CalcDensity()
        self.calc_absorption_length()

    @property
    def atom_pos(self):
        return self._atom_pos

    @atom_pos.setter
    def atom_pos(self, val):
        """
        SS 03/08/2021 fixing some issues with
        updating asymmetric positions after 
        updating atominfo
        fixing 
        """
        if hasattr(self, 'atom_type'):
            if self.atom_ntype != val.shape[0]:
                msg = (f"incorrect number of atom positions."
                       f" number of atom type = {self.atom_ntype} "
                       f" and number of"
                       f" atom positions = {val.shape[0]}.")
                raise ValueError(msg)

        self._atom_pos = val
        """
        update only if its not the first time
        """
        if hasattr(self, 'asym_pos'):
            self.CalcPositions()

        if hasattr(self, 'density'):
            self.CalcDensity()
            self.calc_absorption_length()

    @property
    def atom_ntype(self):
        return self.atom_type.shape[0]

    # asymmetric positions in unit cell
    @property
    def asym_pos(self):
        return self._asym_pos

    @asym_pos.setter
    def asym_pos(self, val):
        assert(type(val) == list),\
            'input type to asymmetric positions should be list'
        self._asym_pos = val

    @property
    def numat(self):
        return self._numat

    @numat.setter
    def numat(self, val):
        assert(val.shape[0] ==
               self.atom_ntype), 'shape of numat is not consistent'
        self._numat = val

    # different atom types; read only
    @property
    def Z(self):
        sz = self.atom_ntype
        return self.atom_type[0:atom_ntype]

    # direct metric tensor is read only
    @property
    def dmt(self):
        return self._dmt

    # reciprocal metric tensor is read only
    @property
    def rmt(self):
        return self._rmt

    # direct structure matrix is read only
    @property
    def dsm(self):
        return self._dsm

    # reciprocal structure matrix is read only
    @property
    def rsm(self):
        return self._rsm

    @property
    def num_atom(self):
        return np.sum(self.numat)

    @property
    def vol(self):
        return self._vol

    @property
    def vol_per_atom(self):
        # vol per atom in A^3
        return 1e3*self.vol/self.num_atom


_rqpDict = {
    'triclinic': (tuple(range(6)), lambda p: p),  # all 6
    # note beta
    'monoclinic': ((0, 1, 2, 4), lambda p: (p[0], p[1], p[2], 90, p[3], 90)),
    'orthorhombic': ((0, 1, 2), lambda p: (p[0], p[1], p[2], 90, 90,   90)),
    'tetragonal': ((0, 2), lambda p: (p[0], p[0], p[1], 90, 90,   90)),
    'trigonal': ((0, 2), lambda p: (p[0], p[0], p[1], 90, 90,  120)),
    'hexagonal': ((0, 2), lambda p: (p[0], p[0], p[1], 90, 90,  120)),
    'cubic': ((0,), lambda p: (p[0], p[0], p[0], 90, 90,   90)),
}

_lpname = np.array(['a', 'b', 'c', 'alpha', 'beta', 'gamma'])

laue_1 = 'ci'
laue_2 = 'c2h'
laue_3 = 'd2h'
laue_4 = 'c4h'
laue_5 = 'd4h'
laue_6 = 's6'
laue_7 = 'd3d'
laue_8 = 'c6h'
laue_9 = 'd6h'
laue_10 = 'th'
laue_11 = 'oh'


'''
these supergroups are the three exceptions to the coloring scheme
the point groups are not topological and can't have no discontinuities
in the IPF coloring scheme. they are -1, -3 and -4 point groups.
'''
supergroup_00 = 'c1'
supergroup_01 = 'c4'
supergroup_02 = 'c3'

supergroup_1 = 'cs'
supergroup_2 = 'c2v'
supergroup_3 = 'd2h'
supergroup_4 = 'c4v'
supergroup_5 = 'd4h'
supergroup_6 = 'c3v'
supergroup_7 = 'c6v'
supergroup_8 = 'd3h'
supergroup_9 = 'd6h'
supergroup_10 = 'td'
supergroup_11 = 'oh'


def _sgrange(min, max): return tuple(range(min, max + 1))  # inclusive range

'''
11/20/2020 SS added supergroup to the list which is used
for coloring the fundamental zone IPF
'''
_pgDict = {
    _sgrange(1,   1): ('c1', laue_1,
                       supergroup_1, supergroup_00),  # Triclinic
    _sgrange(2,   2): ('ci', laue_1, \
                       supergroup_00, supergroup_00),  # laue 1
    _sgrange(3,   5): ('c2', laue_2, \
                       supergroup_2, supergroup_3),  # Monoclinic
    _sgrange(6,   9): ('cs', laue_2, \
                       supergroup_1, supergroup_3),
    _sgrange(10,  15): ('c2h', laue_2, \
                        supergroup_3, supergroup_3),  # laue 2
    _sgrange(16,  24): ('d2', laue_3, \
                        supergroup_3, supergroup_3),  # Orthorhombic
    _sgrange(25,  46): ('c2v', laue_3, \
                        supergroup_2, supergroup_3),
    _sgrange(47,  74): ('d2h', laue_3, \
                        supergroup_3, supergroup_3),  # laue 3
    _sgrange(75,  80): ('c4', laue_4, \
                        supergroup_4, supergroup_5),  # Tetragonal
    _sgrange(81,  82): ('s4', laue_4, \
                        supergroup_01, supergroup_5),
    _sgrange(83,  88): ('c4h', laue_4, \
                        supergroup_5, supergroup_5),  # laue 4
    _sgrange(89,  98): ('d4', laue_5, \
                        supergroup_5, supergroup_5),
    _sgrange(99, 110): ('c4v', laue_5, \
                        supergroup_4, supergroup_5),
    _sgrange(111, 122): ('d2d', laue_5, \
                         supergroup_5, supergroup_5),
    _sgrange(123, 142): ('d4h', laue_5, \
                         supergroup_5, supergroup_5),  # laue 5
    # Trigonal # laue 6 [also c3i]
    _sgrange(143, 146): ('c3', laue_6, \
                         supergroup_6, supergroup_02),
    _sgrange(147, 148): ('s6', laue_6, \
                         supergroup_02, supergroup_02),
    _sgrange(149, 155): ('d3', laue_7, \
                         supergroup_7, supergroup_9),
    _sgrange(156, 161): ('c3v', laue_7, \
                         supergroup_6, supergroup_9),
    _sgrange(162, 167): ('d3d', laue_7, \
                         supergroup_9, supergroup_9),  # laue 7
    _sgrange(168, 173): ('c6', laue_8, \
                         supergroup_7, supergroup_9),  # Hexagonal
    _sgrange(174, 174): ('c3h', laue_8, \
                         supergroup_7, supergroup_9),
    _sgrange(175, 176): ('c6h', laue_8, \
                         supergroup_9, supergroup_9),  # laue 8
    _sgrange(177, 182): ('d6', laue_9, \
                         supergroup_9, supergroup_9),
    _sgrange(183, 186): ('c6v', laue_9, \
                         supergroup_7, supergroup_9),
    _sgrange(187, 190): ('d3h', laue_9, \
                         supergroup_9, supergroup_9),
    _sgrange(191, 194): ('d6h', laue_9, \
                         supergroup_9, supergroup_9),  # laue 9
    _sgrange(195, 199): ('t',  laue_10, \
                         supergroup_10, supergroup_11),  # Cubic
    _sgrange(200, 206): ('th', laue_10, \
                         supergroup_11, supergroup_11),  # laue 10
    _sgrange(207, 214): ('o',  laue_11, \
                         supergroup_11, supergroup_11),
    _sgrange(215, 220): ('td', laue_11, \
                         supergroup_10, supergroup_11),
    _sgrange(221, 230): ('oh', laue_11, \
                         supergroup_11, supergroup_11)   # laue 11
}

'''
this dictionary has the mapping from laue group to number of elastic
constants needed in the voight 6x6 stiffness matrix. the compliance
matrix is just the inverse of the stiffness matrix
taken from International Tables for Crystallography Volume H
Powder diffraction
Edited by C. J. Gilmore, J. A. Kaduk and H. Schenk
'''
# independent components for the triclinic laue group
type1 = []
for i in range(6):
    for j in range(i, 6):
        type1.append((i, j))
type1 = tuple(type1)

# independent components for the monoclinic laue group
# C14 = C15 = C24 = C25 = C34 = C35 = C46 = C56 = 0
type2 = list(type1)
type2.remove((0, 3))
type2.remove((0, 4))
type2.remove((1, 3))
type2.remove((1, 4))
type2.remove((2, 3))
type2.remove((2, 4))
type2.remove((3, 5))
type2.remove((4, 5))
type2 = tuple(type2)

# independent components for the orthorhombic laue group
# Above, plus C16 = C26 = C36 = C45 = 0
type3 = list(type2)
type3.remove((0, 5))
type3.remove((1, 5))
type3.remove((2, 5))
type3.remove((3, 4))
type3 = tuple(type3)

# independent components for the cyclic tetragonal laue group
# monoclinic, plus C36 = C45 = 0, C22 = C11, C23 = C13, C26 = −C16, C55 = C44
type4 = list(type2)
type4.remove((2, 5))
type4.remove((3, 4))
type4.remove((1, 1))
type4.remove((1, 2))
type4.remove((1, 5))
type4.remove((4, 4))
type4 = tuple(type4)

# independent components for the dihedral tetragonal laue group
# Above,  plus C16 = 0
type5 = list(type4)
type5.remove((0, 5))
type5 = tuple(type5)

# independent components for the trigonal laue group
# C16 = C26 = C34 = C35 = C36 = C45 = 0, C22 = C11, C23 = C13, C24 = −C14,
# C25 = −C15, C46 = −C15, C55 = C44, C56 = C14, C66 = (C11 − C12)/2
type6 = list(type1)
type6.remove((0, 5))
type6.remove((1, 5))
type6.remove((2, 3))
type6.remove((2, 4))
type6.remove((2, 5))
type6.remove((3, 4))
type6.remove((1, 1))
type6.remove((1, 2))
type6.remove((1, 3))
type6.remove((1, 4))
type6.remove((3, 5))
type6.remove((4, 4))
type6.remove((4, 5))
type6.remove((5, 5))
type6 = tuple(type6)

# independent components for the rhombohedral laue group
# Above, plus C15 = 0
type7 = list(type6)
type7.remove((0, 4))
type7 = tuple(type7)

# independent components for the hexagonal laue group
# Above, plus C14 = 0
type8 = list(type7)
type8.remove((0, 3))
type8 = tuple(type8)

# independent components for the cubic laue group
# As for dihedral tetragonal, plus C13 = C12, C33 = C11, C66 = C44
type9 = list(type5)
type9.remove((0, 2))
type9.remove((2, 2))
type9.remove((5, 5))

'''
these lambda functions take care of the equality constrains in the
matrices. if there are no equality constraints, then the identity
function is used
C22 = C11, C23 = C13, C24 = −C14,
# C25 = −C15, C46 = −C15, C55 = C44, C56 = C14, C66 = (C11 − C12)/2
'''


def identity(x): return x


def C_cyclictet_eq(x):
    x[1, 1] = x[0, 0]
    x[1, 2] = x[0, 2]
    x[1, 5] = -x[0, 5]
    x[4, 4] = x[3, 3]
    return x


def C_trigonal_eq(x):
    x[1, 1] = x[0, 0]
    x[1, 2] = x[0, 2]
    x[1, 3] = -x[0, 3]
    x[1, 4] = -x[0, 4]
    x[3, 5] = -x[0, 4]
    x[4, 4] = x[3, 3]
    x[4, 5] = x[0, 3]
    x[5, 5] = 0.5*(x[0, 0]-x[0, 1])
    return x


def C_cubic_eq(x):
    x[0, 2] = x[0, 1]
    x[2, 2] = x[0, 0]
    x[5, 5] = x[3, 3]
    x[1, 1] = x[0, 0]
    x[1, 2] = x[0, 2]
    x[1, 5] = -x[0, 5]
    x[4, 4] = x[3, 3]
    return x


_StiffnessDict = {
    # triclinic, all 21 components in upper triangular matrix needed
    laue_1: [type1, identity],
    laue_2: [type2, identity],  # monoclinic, 13 components needed
    laue_3: [type3, identity],  # orthorhombic, 9 components needed
    laue_4: [type4, C_cyclictet_eq],  # cyclic tetragonal, 7 components needed
    # dihedral tetragonal, 6 components needed
    laue_5: [type5, C_cyclictet_eq],
    laue_6: [type6, C_trigonal_eq],  # trigonal I, 7 components
    laue_7: [type7, C_trigonal_eq],  # rhombohedral, 6 components
    laue_8: [type8, C_trigonal_eq],  # cyclic hexagonal, 5 components needed
    laue_9: [type8, C_trigonal_eq],  # dihedral hexagonal, 5 components
    laue_10: [type9, C_cubic_eq],  # cubic, 3 components
    laue_11: [type9, C_cubic_eq]  # cubic, 3 components
}


# hexrd/valunits.py

#! /usr/bin/env python
# ============================================================
# Copyright (c) 2007-2012, Lawrence Livermore National Security, LLC.
# Produced at the Lawrence Livermore National Laboratory.
# Written by Joel Bernier <bernier2@llnl.gov> and others.
# LLNL-CODE-529294.
# All rights reserved.
#
# This file is part of HEXRD. For details on dowloading the source,
# see the file COPYING.
#
# Please also see the file LICENSE.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License (as published by the Free
# Software Foundation) version 2.1 dated February 1999.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the IMPLIED WARRANTY OF MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the terms and conditions of the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this program (see file LICENSE); if not, write to
# the Free Software Foundation, Inc., 59 Temple Place, Suite 330,
# Boston, MA 02111-1307 USA or visit <http://www.gnu.org/licenses/>.
# ============================================================
#
"""Module for associating units with scalar quantities

This module has been modified from its original form
by removing the call to the "units" executable and restricting
the units to only those used by the heXRD package.

"""

import doctest
import math

from hexrd.constants import keVToAngstrom


__all__ = ['valWUnit', 'toFloat', 'valWithDflt']

# centralized unit types; chosen to have a match in the units command
energyUN = "ENERGY"
lengthUN = "LENGTH"
angleUN = "ANGLE"
#
# mapping to centralized unit types, for convenience
#
uTDict = dict(
    length=lengthUN,
    angle=angleUN,
    energy=energyUN,
)


class UNames(object):
    """Units used in this module"""
    degrees = 'degrees'
    radians = 'radians'

    m = 'm'
    mm = 'mm'
    nm = 'nm'
    meter = 'meter'
    angstrom = 'angstrom'

    keV = 'keV'
    J = 'J'

    pass


cv_dict = {
    (UNames.degrees, UNames.radians): math.pi/180.0,
    (UNames.radians, UNames.degrees): 180/math.pi,

    (UNames.m, UNames.mm):  1.0e3,
    (UNames.m, UNames.meter):  1.0,
    (UNames.m, UNames.nm): 1.0e9,
    (UNames.m, UNames.angstrom):  1.0e10,

    (UNames.meter, UNames.mm):  1.0e3,
    (UNames.meter, UNames.m):  1.0,
    (UNames.meter, UNames.nm): 1.0e9,
    (UNames.meter, UNames.angstrom):  1.0e10,

    (UNames.mm, UNames.m):  1.0e-3,
    (UNames.mm, UNames.meter):  1.0e-3,
    (UNames.mm, UNames.nm): 1.0e6,
    (UNames.mm, UNames.angstrom):  1.0e7,

    (UNames.angstrom, UNames.m):  1.0e-10,
    (UNames.angstrom, UNames.meter):  1.0e-10,
    (UNames.angstrom, UNames.mm):  1.0e-7,
    (UNames.angstrom, UNames.nm): 1.0e-1,

    (UNames.keV, UNames.J): 1.60217646e-16,
    (UNames.J, UNames.keV): (1/1.60217646e-16)
    }


class valWUnit:
    "Value with units"""

    def __init__(self, name, unitType, value, unit):
        """Initialization

        INPUTS
        name
           (str) name of the item
        unitType
           (str) class of units, e.g. 'length', 'angle'
        value
           (float) numerical value
        unit
           (str) name of unit
        """
        self.name = name
        if unitType in uTDict:
            self.uT = uTDict[unitType]
        else:
            # trust that unitType is correct -- may be a combined type
            self.uT = unitType

        self.value = value
        self.unit = unit
        #
        #  Original checked if unit is of unitType
        #
        pass  # end init

    def __str__(self):
        tmpl = """item named "%s" representing %g %s"""
        return tmpl % (self.name, self.value, self.unit)

    def __repr__(self):
        tmpl = 'valWUnit("%s","%s",%s,"%s")'
        return tmpl % (self.name, self.uT, self.value, self.unit)

    def __mul__(self, other):
        if isinstance(other, float):
            new = valWUnit(self.name, self.uT, self.value*other, self.unit)
            return new
        elif isinstance(other, valWUnit):
            new = valWUnit('%s_times_%s' % (self.name, other.name),
                           '%s %s' % (self.uT, other.uT),
                           self.value*other.value,
                           '(%s)*(%s)' % (self.unit, other.unit)
                           )
            # really need to put in here something to resolve new.uT
            return new
        else:
            raise RuntimeError("mul with unsupported operand")

    def __add__(self, other):
        if isinstance(other, float):
            new = valWUnit(self.name, self.uT,
                           self.value + other, self.unit)
            return new
        elif isinstance(other, valWUnit):
            new = valWUnit(self.name, self.uT,
                           self.value + other.getVal(self.unit), self.unit)
            return new
        else:
            raise RuntimeError("add with unsupported operand")

    def __sub__(self, other):
        if isinstance(other, float):
            new = valWUnit(self.name, self.uT, self.value - other, self.unit)
            return new
        elif isinstance(other, valWUnit):
            new = valWUnit(self.name, self.uT, self.value
                           - other.getVal(self.unit), self.unit)
            return new
        else:
            raise RuntimeError("add with unsupported operand")

    def _convert(self, toUnit):
        """
        Return the value of self in requested units.

        Parameters
        ----------
        toUnit : str
            The identifier for desired unit type.  Current choices are.
                degrees = 'degrees'
                radians = 'radians'

                meters = 'm' | 'meter'
                millimeters = 'mm'
                nanometers = 'nm'
                Ångstroms = 'angstrom'

                kilo electron-Volt = 'keV'
                Joule = 'J'

        Raises
        ------
        RuntimeError
            if `toUnit` is invalid.

        Returns
        -------
        saclar
            The converted unit value of self.

        """
        if self.unit == toUnit:
            return self.value
        #
        #  Needs conversion
        #
        from_to = (self.unit, toUnit)
        try:
            return cv_dict[from_to]*self.value
        except(KeyError):
            special_case = ('keV', 'angstrom')
            if from_to == special_case or from_to == special_case[::-1]:
                return keVToAngstrom(self.value)
            raise RuntimeError(f"Unit conversion '{from_to[0]} --> "
                               + f"{from_to[1]}' not recognized")

    def isLength(self):
        """Return true if quantity is a length"""
        retval = self.uT == uTDict['length']
        return retval

    def isAngle(self):
        """Return true if quantity is an angle"""
        retval = self.uT == uTDict['angle']
        return retval

    def isEnergy(self):
        """Return true if quantity  is an energy"""
        retval = self.uT == uTDict['energy']
        return retval

    def getVal(self, toUnit):
        """
        Returns object value in requested units.

        Parameters
        ----------
        toUnit : str
            The identifier for desired unit type.  Current choices are
            .

        Raises
        ------
        RuntimeError
            Where the requested units are invalid

        Returns
        -------
        scalar
            The value of the object in the requested units.

        """
        return self._convert(toUnit)


def _toFloatScalar(v, u):
    if hasattr(v, 'getVal'):
        return v.getVal(u)
    else:
        return v


def toFloat(val, unitName):
    """Return the raw value of the object

    INPUTS

    val
       (float|valWUnit) object with value
    unitName
       (str) name of unit

    This function returns the raw value of the object, ignoring the
    unit, if it is numeric or converts it to the requested units and
    returns the magnitude if it is a valWUnit instance.

    For example:

    >>> print(toFloat(1.1, 'radians'))
    1.1
    >>> v = valWUnit('vee', 'angle', 1.1, 'radians')
    >>> print(toFloat(v, 'degrees'))
    63.02535746439056

    """

    if hasattr(val, '__len__'):
        retval = [_toFloatScalar(x, unitName) for x in val]
    else:
        retval = _toFloatScalar(val, unitName)
    return retval


def valWithDflt(val, dflt, toUnit=None):
    """Return value or default value"""
    retval = val
    if retval is None:
        retval = dflt
    if toUnit is not None:
        retval = toFloat(retval, toUnit)
    return retval


if __name__ == '__main__':
    #
    #  doc testing
    #
    print("running doctest")
    doctest.testmod()

    #
    #  other tests
    #
    def testConversions():
        print('===== Testing unit conversions ...')
        print('..... angles:')
        v = valWUnit('180d', 'angle', 180.0, 'degrees')
        print(v)
        print(('   in degrees:', v.getVal('degrees')))
        print(('   in radians: ', v.getVal('radians')))

        print('..... lengths:')
        ulist = ['m', 'mm', 'meter', 'angstrom']
        v = valWUnit('one meter', 'length', 1.0, 'meter')
        print(v)
        for u in ulist:
            print(('   in ', u, ': ', v.getVal(u)))
        return
    testConversions()
    pass  # end of tests


# hexrd/instrument/cylindrical_detector.py
import numpy as np

from hexrd import constants as ct
from hexrd import xrdutil
from hexrd.utils.decorators import memoize

from .detector import Detector


class CylindricalDetector(Detector):
    """Base class for 2D cylindrical detector

       A cylindrical detector is a simple rectangular
       row-column detector which has been bent in the
       shape of a cylinder. Inherting the PlanarDetector
       class except for a few changes to account for the
       cylindder ray intersection.
    """

    def __init__(self, **detector_kwargs):
        super().__init__(**detector_kwargs)

    @property
    def detector_type(self):
        return 'cylindrical'

    def _dewarp_from_cylinder(self, uvw):
        """
        routine to convert cylindrical coordinates
        to cartesian coordinates in image frame
        """
        cx = np.atleast_2d(self.caxis).T
        # px = np.atleast_2d(self.paxis).T
        num = uvw.shape[0]

        vx = uvw - np.tile(np.dot(uvw, cx), [1, 3]) * np.tile(cx, [1, num]).T
        vx = vx/np.tile(np.linalg.norm(vx, axis=1), [3, 1]).T
        sgn = np.sign(np.dot(self.paxis, vx.T))
        sgn[sgn == 0] = 1
        ang = np.abs(np.arccos(np.dot(self.tvec, vx.T)/self.radius))
        xcrd = self.radius*ang*sgn
        ycrd = np.dot(self.caxis, uvw.T)
        return np.vstack((xcrd, ycrd)).T

    def _valid_points(self, vecs):
        """
        this rotuine takes a list of vectors
        and checks if it falls inside the
        cylindrical panel

        returns the subset of vectors which fall
        on the panel
        """
        pass

    def _unitvec_to_cylinder(self, uvw):
        """
        get point where unitvector uvw
        intersect the cylindrical detector.
        this will give points which are
        outside the actual panel. the points
        will be clipped to the panel later

        Parameters
        ----------
        uvw : numpy.ndarray
        unit vectors stacked row wise (nx3) shape

        Returns
        -------
        numpy.ndarray
        (x,y,z) vectors point which intersect with
        the cylinder with (nx3) shape
        """
        num = uvw.shape[0]
        cx = np.atleast_2d(self.caxis).T
        dp = np.dot(uvw, cx)
        den = np.squeeze(np.sqrt(1 - dp**2))
        mask = den < 1E-8
        beta = np.zeros([num, ])
        beta[~mask] = self.radius/den[~mask]
        beta[mask] = np.nan

        return np.tile(beta, [3, 1]).T * uvw

    def _clip_to_cylindrical_detector(self, uvw):
        """
        takes in the intersection points uvw
        with the cylindrical detector and
        prunes out points which don't actually
        hit the actual panel

        Parameters
        ----------
        uvw : numpy.ndarray
        unit vectors stacked row wise (nx3) shape

        Returns
        -------
        numpy.ndarray
        (x,y,z) vectors point which fall on panel
        with (mx3) shape
        """
        # first get rid of points which are above
        # or below the detector
        size = self.physical_size
        tvec = np.atleast_2d(self.tvec).T
        cx = np.atleast_2d(self.caxis).T
        num = uvw.shape[0]
        ycomp = uvw - np.tile(self.tvec, [num, 1])
        ylen = np.squeeze(np.dot(ycomp, cx))
        mask = np.abs(ylen) <= size[0] * 0.5
        if not isinstance(mask, np.ndarray):
            mask = np.array([mask])
        res = uvw[mask, :]

        # next get rid of points that fall outside
        # the polar angle range
        num = res.shape[0]
        dp = np.squeeze(np.dot(res, cx))
        v = np.tile(cx, [1, num])*np.tile(dp, [3, 1])
        v = v.T
        xcomp = res - v
        magxcomp = np.linalg.norm(xcomp, axis=1)
        ang = np.squeeze(np.dot(xcomp, tvec)) / self.radius / magxcomp
        ang = np.arccos(ang)
        mask = ang < self.angle_extent
        if not isinstance(mask, np.ndarray):
            mask = np.array([mask])
        res = res[mask, :]

        return res

    def cart_to_angles(self, xy_data,
                       rmat_s=None,
                       tvec_s=None, tvec_c=None,
                       apply_distortion=False):
        if rmat_s is None:
            rmat_s = ct.identity_3x3
        if tvec_s is None:
            tvec_s = ct.zeros_3
        if tvec_c is None:
            tvec_c = ct.zeros_3
        if apply_distortion and self.distortion is not None:
            xy_data = self.distortion.apply(xy_data)

        dvecs = xrdutil.utils._warp_to_cylinder(xy_data,
                                                self.tvec,
                                                self.radius,
                                                self.rmat,
                                                normalize=True)
        tth, eta = xrdutil.utils._dvec_to_angs(dvecs, self.bvec, self.evec)
        tth_eta = np.vstack((tth, eta)).T
        return tth_eta, dvecs

    def angles_to_cart(self, tth_eta,
                       rmat_s=None, tvec_s=None,
                       rmat_c=None, tvec_c=None,
                       apply_distortion=False):
        if rmat_s is None:
            rmat_s = ct.identity_3x3
        if tvec_s is None:
            tvec_s = ct.zeros_3
        if rmat_c is None:
            rmat_c = ct.identity_3x3
        if tvec_c is None:
            tvec_c = ct.zeros_3

        # get chi and ome from rmat_s
        # !!! WARNING: API ambiguity
        # !!! this assumes rmat_s was made from the composition
        # !!! rmat_s = R(Xl, chi) * R(Yl, ome)
        chi = np.arccos(rmat_s[1, 1])
        ome = np.arccos(rmat_s[0, 0])

        angs = np.hstack([tth_eta, np.tile(ome, (len(tth_eta), 1))])
        kwargs = {"beamVec": self.bvec}
        args = (angs, self.rmat, chi, self.tvec,
                self.caxis, self.paxis, self.radius,
                self.physical_size, self.angle_extent,
                self.distortion)

        proj_func = xrdutil.utils._project_on_detector_cylinder
        valid_xy, rMat_ss, valid_mask = proj_func(*args, **kwargs)
        xy_det = np.empty([angs.shape[0], 2])
        xy_det.fill(np.nan)
        xy_det[valid_mask, :] = valid_xy
        return xy_det

    def pixel_angles(self, origin=ct.zeros_3):
        args = (origin, self.pixel_coords, self.distortion, self.tvec,
                self.radius, self.rmat, self.bvec, self.evec, self.rows,
                self.cols)
        return _pixel_angles(*args)

    def pixel_tth_gradient(self, origin=ct.zeros_3):
        return _pixel_tth_gradient(origin, self.pixel_coords, self.distortion,
                                   self.rmat, self.tvec, self.bvec, self.evec,
                                   self.rows, self.cols)

    def pixel_eta_gradient(self, origin=ct.zeros_3):
        return _pixel_eta_gradient(origin, self.pixel_coords, self.distortion,
                                   self.rmat, self.tvec, self.bvec, self.evec,
                                   self.rows, self.cols)

    @property
    def caxis(self):
        # returns the cylinder axis
        return np.dot(self.rmat, ct.lab_y)

    @property
    def paxis(self):
        # returns the cylinder axis
        return np.dot(self.rmat, ct.lab_x)

    @property
    def radius(self):
        # units of mm
        return np.linalg.norm(self.tvec)

    @property
    def physical_size(self):
        # return physical size of detector
        # in mm after dewarped to rectangle
        return np.array([self.rows*self.pixel_size_row,
                         self.cols*self.pixel_size_col])

    @property
    def beam_position(self):
        """
        returns the coordinates of the beam in the cartesian detector
        frame {Xd, Yd, Zd}.  NaNs if no intersection.
        """
        output = np.nan * np.ones(2)
        pt_on_cylinder = self._unitvec_to_cylinder(
                             np.atleast_2d(self.bvec))
        pt_on_cylinder = self._clip_to_cylindrical_detector(pt_on_cylinder)
        output = self._dewarp_from_cylinder(pt_on_cylinder)
        return output

    @property
    def angle_extent(self):
        # extent is from -theta, theta
        sz = self.physical_size[1]
        return sz / self.radius / 2.0

    @staticmethod
    def update_memoization_sizes(all_panels):
        Detector.update_memoization_sizes(all_panels)

        num_matches = sum(x is CylindricalDetector for x in all_panels)
        funcs = [
            _pixel_angles,
            _pixel_tth_gradient,
            _pixel_eta_gradient,
        ]
        Detector.increase_memoization_sizes(funcs, num_matches)


@memoize
def _pixel_angles(origin,
                  pixel_coords,
                  distortion,
                  tVec_d,
                  radius,
                  rMat_d,
                  bvec,
                  evec,
                  rows,
                  cols):
    assert len(origin) == 3, "origin must have 3 elements"

    pix_i, pix_j = pixel_coords
    xy = np.ascontiguousarray(
        np.vstack([
            pix_j.flatten(), pix_i.flatten()
            ]).T
        )

    if distortion is not None:
        xy = distortion.apply(xy)

    dvecs = xrdutil.utils._warp_to_cylinder(xy,
                                            tVec_d-origin,
                                            radius,
                                            rMat_d,
                                            normalize=True)

    angs = xrdutil.utils._dvec_to_angs(dvecs, bvec, evec)

    tth = angs[0].reshape(rows, cols)
    eta = angs[1].reshape(rows, cols)
    return tth, eta


@memoize
def _pixel_tth_gradient(origin, pixel_coords, distortion, rmat, tvec, bvec,
                        evec, rows, cols):
    raise NotImplementedError("Not implemented yet!")


@memoize
def _pixel_eta_gradient(origin, pixel_coords, distortion, rmat, tvec, bvec,
                        evec, rows, cols):
    raise NotImplementedError("Not implemented yet!")
