def __init__(self, lattice):
        self.lattice = lattice
        self.atoms = IndexedAtoms()
        self.neighbor_list = NeighborList(lattice)
        self.bounding_box = BoundingBox()

        self.energies = dict()
        self.feature_vector = None
    def build_from_dictionary_descriptions(self, dictionary):
        lattice_data = dictionary['lattice']
        self.lattice = FCCLattice(lattice_data['width'], lattice_data['length'], lattice_data['height'], lattice_data['lattice_constant'])
        self.neighbor_list = NeighborList(self.lattice)

        self.energies = dictionary['energies']
        self.atoms.add_atoms(zip(dictionary['atoms']['indices'], dictionary['atoms']['symbols']))
        self.neighbor_list.construct(dictionary['atoms']['indices'])
class BaseNanoparticle:
    def __init__(self, lattice):
        self.lattice = lattice
        self.atoms = IndexedAtoms()
        self.neighbor_list = NeighborList(lattice)
        self.bounding_box = BoundingBox()

        self.energies = dict()
        self.feature_vector = None

    def from_particle_data(self, atoms, neighbor_list=None):
        self.atoms = atoms
        if neighbor_list is None:
            self.construct_neighbor_list()
        else:
            self.neighbor_list = neighbor_list()

        self.construct_bounding_box()

    def add_atoms(self, atoms):
        self.atoms.add_atoms(atoms)
        indices, _ = zip(*atoms)
        self.neighbor_list.add_atoms(indices)

    def remove_atoms(self, latticeIndices):
        self.atoms.remove_atoms(latticeIndices)
        self.neighbor_list.remove_atoms(latticeIndices)

    def random_ordering(self, symbols, n_atoms_same_symbol):
        self.atoms.random_ordering(symbols, n_atoms_same_symbol)

    def rectangular_prism(self, w, l, h, symbol='X'):
        anchor_point = self.lattice.get_anchor_index_of_centered_box(w, l, h)
        for x in range(w):
            for y in range(l):
                for z in range(h):
                    cur_position = anchor_point + np.array([x, y, z])

                    if self.lattice.is_valid_lattice_position(cur_position):
                        lattice_index = self.lattice.get_index_from_lattice_position(
                            cur_position)
                        self.atoms.add_atoms([(lattice_index, symbol)])
        self.construct_neighbor_list()

    def convex_shape(self, symbols, n_atoms_same_symbol, w, l, h,
                     cutting_plane_generator):
        self.rectangular_prism(w, l, h)
        self.construct_bounding_box()
        indices_current_atoms = set(self.atoms.get_indices())

        final_n_atoms = sum(n_atoms_same_symbol)
        MAX_CUTTING_ATTEMPTS = 50
        cur_cutting_attempt = 0
        cutting_plane_generator.set_center(self.bounding_box.get_center())

        while len(
                indices_current_atoms
        ) > final_n_atoms and cur_cutting_attempt < MAX_CUTTING_ATTEMPTS:
            # create cut plane
            cutting_plane = cutting_plane_generator.generate_new_cutting_plane(
            )

            # count atoms to be removed, if new Count >= final Number remove
            atoms_to_be_removed, atoms_to_be_kept = cutting_plane.splitAtomIndices(
                self.lattice, indices_current_atoms)
            if len(atoms_to_be_removed
                   ) != 0.0 and len(indices_current_atoms) - len(
                       atoms_to_be_removed) >= final_n_atoms:
                indices_current_atoms = indices_current_atoms.difference(
                    atoms_to_be_removed)
                cur_cutting_attempt = 0
            else:
                cur_cutting_attempt = cur_cutting_attempt + 1

        if cur_cutting_attempt == MAX_CUTTING_ATTEMPTS:
            # place cutting plane parallel to one of the axes and at the anchor point
            cutting_plane = cutting_plane_generator.create_axis_parallel_cutting_plane(
                self.bounding_box.position)

            # shift till too many atoms would get removed
            n_atoms_yet_to_be_removed = len(
                indices_current_atoms) - final_n_atoms
            atoms_to_be_removed = set()
            while len(atoms_to_be_removed) < n_atoms_yet_to_be_removed:
                cutting_plane = CuttingPlane(
                    cutting_plane.anchor +
                    cutting_plane.normal * self.lattice.latticeConstant,
                    cutting_plane.normal)
                atoms_to_be_kept, atoms_to_be_removed = cutting_plane.splitAtomIndices(
                    self.lattice, indices_current_atoms)

            # remove atoms till the final number is reached "from the ground up"

            # TODO implement sorting prioritzing the different directions in random order
            def sort_by_position(atom):
                return self.lattice.get_lattice_position_from_index(atom)[0]

            atoms_to_be_removed = list(atoms_to_be_removed)
            atoms_to_be_removed.sort(key=sort_by_position)
            atoms_to_be_removed = atoms_to_be_removed[:
                                                      n_atoms_yet_to_be_removed]

            atoms_to_be_removed = set(atoms_to_be_removed)
            indices_current_atoms = indices_current_atoms.difference(
                atoms_to_be_removed)

        # redistribute the different elements randomly
        self.atoms.clear()
        self.atoms.add_atoms(
            zip(indices_current_atoms, ['X'] * len(indices_current_atoms)))
        self.atoms.random_ordering(symbols, n_atoms_same_symbol)

        self.construct_neighbor_list()

    def optimize_coordination_numbers(self, steps=15):
        for step in range(steps):
            outer_atoms = self.get_atom_indices_from_coordination_number(
                range(9))
            outer_atoms.sort(key=lambda x: self.get_coordination_number(x))

            start_index = outer_atoms[0]
            symbol = self.atoms.get_symbol(start_index)

            surface_vacancies = list(self.get_surface_vacancies())
            surface_vacancies.sort(
                key=lambda x: self.get_n_atomic_neighbors(x), reverse=True)

            end_index = surface_vacancies[0]
            if self.get_coordination_number(
                    start_index) < self.get_n_atomic_neighbors(end_index):
                self.remove_atoms([start_index])
                self.add_atoms([(end_index, symbol)])
            else:
                break

        self.construct_neighbor_list()

    def construct_neighbor_list(self):
        self.neighbor_list.construct(self.atoms.get_indices())

    def construct_bounding_box(self):
        self.bounding_box.construct(self.lattice, self.atoms.get_indices())

    def get_corner_atom_indices(self, symbol=None):
        corner_coordination_numbers = [1, 2, 3, 4]
        return self.get_atom_indices_from_coordination_number(
            corner_coordination_numbers, symbol)

    def get_edge_indices(self, symbol=None):
        edge_coordination_numbers = [5, 6, 7]
        return self.get_atom_indices_from_coordination_number(
            edge_coordination_numbers, symbol)

    def get_surface_atom_indices(self, symbol=None):
        surface_coordination_numbers = [8, 9]
        return self.get_atom_indices_from_coordination_number(
            surface_coordination_numbers, symbol)

    def get_terrace_atom_indices(self, symbol=None):
        terrace_coordination_numbers = [10, 11]
        return self.get_atom_indices_from_coordination_number(
            terrace_coordination_numbers, symbol)

    def get_inner_atom_indices(self, symbol=None):
        innerCoordinationNumbers = [12]
        return self.get_atom_indices_from_coordination_number(
            innerCoordinationNumbers, symbol)

    def get_n_homo_and_heterogenous_bonds(self):
        n_heteroatomic_bonds = 0
        n_homogenous_bonds = 0

        symbol = self.atoms.get_symbols()[0]

        for lattice_index_with_symbol in self.atoms.get_indices_by_symbol(
                symbol):
            neighbor_list = self.neighbor_list[lattice_index_with_symbol]
            for neighbor in neighbor_list:
                symbol_of_neighbor = self.atoms.get_symbol(neighbor)

                if symbol != symbol_of_neighbor:
                    n_heteroatomic_bonds = n_heteroatomic_bonds + 1
                else:
                    n_homogenous_bonds += 1

        return n_homogenous_bonds, n_heteroatomic_bonds

    def get_atom_indices_from_coordination_number(self,
                                                  coordination_numbers,
                                                  symbol=None):
        if symbol is None:
            return list(
                filter(
                    lambda x: self.get_coordination_number(x) in
                    coordination_numbers, self.atoms.get_indices()))
        else:
            return list(
                filter(
                    lambda x: self.get_coordination_number(
                        x) in coordination_numbers and self.atoms.get_symbol(x)
                    == symbol, self.atoms.get_indices()))

    def get_coordination_number(self, lattice_index):
        return self.neighbor_list.get_coordination_number(lattice_index)

    def get_atoms(self, atomIndices=None):
        return copy.deepcopy(self.atoms.get_atoms(atomIndices))

    def get_neighbor_list(self):
        return self.neighbor_list

    def get_ASE_atoms(self, centered=True):
        atom_positions = list()
        atomic_symbols = list()
        for lattice_index in self.atoms.get_indices():
            atom_positions.append(
                self.lattice.get_cartesian_position_from_index(lattice_index))
            atomic_symbols.append(self.atoms.get_symbol(lattice_index))

        atoms = Atoms(positions=atom_positions, symbols=atomic_symbols)
        if centered:
            COM = atoms.get_center_of_mass()
            return Atoms(
                positions=[position - COM for position in atom_positions],
                symbols=atomic_symbols)
        else:
            return atoms

    def get_stoichiometry(self):
        return self.atoms.get_stoichiometry()

    def get_n_atoms(self):
        return self.atoms.get_n_atoms()

    def get_n_atoms_of_symbol(self, symbol):
        return self.atoms.get_n_atoms_of_symbol(symbol)

    def get_surface_vacancies(self):
        not_fully_coordinated_atoms = self.get_atom_indices_from_coordination_number(
            range(self.lattice.MAX_NEIGHBORS))
        surface_vacancies = set()

        for atom in not_fully_coordinated_atoms:
            neighbor_vacancies = self.lattice.get_nearest_neighbors(
                atom).difference(self.neighbor_list[atom])
            surface_vacancies = surface_vacancies.union(neighbor_vacancies)
        return surface_vacancies

    def get_atomic_neighbors(self, index):
        neighbors = list()
        nearest_neighbors = self.lattice.get_nearest_neighbors(index)
        for lattice_index in nearest_neighbors:
            if lattice_index in self.atoms.get_indices():
                neighbors.append(lattice_index)

        return neighbors

    def get_n_atomic_neighbors(self, index):
        return len(self.get_atomic_neighbors(index))

    def set_energy(self, key, energy):
        self.energies[key] = energy

    def get_energy(self, key):
        return self.energies[key]

    def set_feature_vector(self, feature_vector):
        self.feature_vector = feature_vector

    def get_feature_vector(self):
        return self.feature_vector

    def is_pure(self):
        first_symbol = True
        for symbol in self.atoms.get_symbols():
            if self.atoms.get_n_atoms_of_symbol(symbol) > 0:
                if first_symbol:
                    first_symbol = False
                else:
                    return False
        return True