Defining Materials

We talk about how to modify built-in materials and define custom ones.

Defining a custom material

Let's first look at the Material class itself

from granad import MaterialCatalog, Material, Rectangle
print(Material.__doc__)
    Represents a material in a simulation, encapsulating its physical properties and interactions.

    Attributes:
        name (str): The name of the material.
        species (dict): Dictionary mapping species names to their quantum numbers and associated atoms.
                        Each species is defined with properties like spin quantum number (s), and the atom type.
        orbitals (defaultdict[list]): A mapping from species to lists of orbitals. Each orbital is represented
                                      as a dictionary containing the orbital's position and an optional tag
                                      for further identification.
        interactions (defaultdict[dict]): Describes the interactions between orbitals within the material.
                                         Each interaction is categorized by type (e.g., 'hamiltonian', 'Coulomb'),
                                         and includes the participants, parameters like 
                                         [onsite, offsite_nearest_neighbor, offsite_next_to_nearest_neighbor, ...], and                                         
                                         an optional mathematical expression defining the interaction for the coupling beyound 
                                         the len(parameters) - th nearest neighbor.

    Note:
        The `Material` class is used to define a material's structure and properties step-by-step.
        An example is constructing the material graphene, with specific lattice properties,
        orbitals corresponding to carbon's p_z orbitals, and defining hamiltonian and Coulomb interactions
        among these orbitals.

        ```python
        graphene = (
            Material("graphene")
            .lattice_constant(2.46)
            .lattice_basis([
                [1, 0, 0],
                [-0.5, jnp.sqrt(3)/2, 0]
            ])
            .add_orbital_species("pz", atom='C')
            .add_orbital(position=(0, 0), tag="sublattice_1", species="pz")
            .add_orbital(position=(-1/3, -2/3), tag="sublattice_2", species="pz")
            .add_interaction(
                "hamiltonian",
                participants=("pz", "pz"),
                parameters=[0.0, -2.66],
            )
            .add_interaction(
                "coulomb",
                participants=("pz", "pz"),
                parameters=[16.522, 8.64, 5.333],
                expression=lambda r : 1/r + 0j
            )
        )
        ```

The Material class offers all specialized functions to specify a material from a TB model. A preliminary, work-in-progress version of the Hubbard model would look like this

t = 1. # nearest-neighbor hopping
U = 0.1 # onsite coulomb repulsion
hubbard = (
    Material("Hubbard")
    .lattice_constant(1.0)
    .lattice_basis([
        [1, 0, 0],
        [0, 1, 0],
        [0, 0, 1]
    ])
    .add_orbital_species("up", s = -1)
    .add_orbital_species("down", s = 1)
    .add_orbital(position=(0, 0, 0), species = "up")
    .add_orbital(position=(0, 0, 0), species = "down")   
    .add_interaction(
        "hamiltonian",
        participants=("up", "up"),
        parameters=[0.0, t],
    )
    .add_interaction(
        "hamiltonian",
        participants=("down", "down"),
        parameters=[0.0, t],
    )
    .add_interaction(
        "coulomb",
        participants=("up", "down"),
        parameters=[U]
        )
)

To see how cutting finite flakes from this 3D material works, let's inspect

help(hubbard.cut_flake)
Help on method cut_flake in module granad.materials:

cut_flake() method of granad.materials.Material instance
    Finalizes the material construction by defining a method to cut a flake of the material,
    according to the material's dimensions like this

    1D material : materials.cut_flake_1d
    2D material : materials.cut_flake_2d
    3D material and higher : materials.cut_flake_generic

    This method is intended to be called after all material properties (like lattice constants,
    basis, orbitals, and interactions) have been fully defined.

    Note:
    This method does not take any parameters and does not return any value. Its effect is
    internal to the state of the Material object and is meant to prepare the material for
    simulation by implementing necessary final structural adjustments.

So, the cut_flake method is automatically determined. We have specified a 3D material, so let's look at the function that is applied

from granad.materials import cut_flake_generic
help(cut_flake_generic)
Help on function cut_flake_generic in module granad.materials:

cut_flake_generic(material, grid_range)
    Cuts a flake from the material using a specified grid range. This method is generic and can be applied
    to materials of any dimensionality.

    The function calculates the positions of orbitals within the unit cell, projects these onto the full
    lattice based on the provided grid range, and ensures that each position is unique. The result is a list
    of orbitals that are correctly positioned within the defined grid.

    Parameters:
        material (Material): The material instance from which to cut the flake.
        grid_range (list of tuples): Each tuple in the list specifies the range for the grid in that dimension.
                                     For example, [(0, 10), (0, 5)] defines a grid that extends from 0 to 10
                                     in the first dimension and from 0 to 5 in the second dimension.

    Returns:
        list: A list of orbitals within the specified grid range, uniquely positioned.

    Note:
        The grid_range parameter should be aligned with the material's dimensions and lattice structure,
        as mismatches can lead to incorrect or inefficient slicing of the material.

Let's see this in action

flake = hubbard.cut_flake( [(0,3), (0,3), (0,3)] ) # 3*3*3 = 27 unit cells, 3 in every direction, each hosting spin up and spin down atom
flake.show_3d()
No artists with labels found to put in legend.  Note that artists whose label start with an underscore are ignored when legend() is called with no argument.

png

Direction-dependent couplings

Up to now, we have only considered couplings dependent on the scalar distance between two orbitals. They can also be given as direction-dependent functions, like in Slater-Koster orbitals. To this end, replace the neighbor coupling list by another list, where each entry specifies the coupling as [d_x, d_y, d_z, coupling], where d_i are the distance vector components. The example below illustrates this for a preliminary, work-in-progress version of the Haldane model of graphene.

import jax.numpy as jnp

delta, t1, t2 = 0.2, -2.66, 1j + 1    
haldane_graphene =  (
    Material("haldane_graphene")
    .lattice_constant(2.46)
    .lattice_basis([
        [1, 0, 0],
        [-0.5, jnp.sqrt(3)/2, 0]
    ])
    .add_orbital_species("pz1", atom='C')
    .add_orbital_species("pz2", atom='C')
    .add_orbital(position=(0, 0), tag="sublattice_1", species="pz1")
    .add_orbital(position=(-1/3, -2/3), tag="sublattice_2", species="pz2")
    .add_interaction(
        "hamiltonian",
        participants=("pz1", "pz2"),
        parameters= [t1],
    )
    .add_interaction(
        "hamiltonian",
        participants=("pz1", "pz1"),            
        # a bit overcomplicated
        parameters=[                
            [0, 0, 0, delta], # onsite                
            # clockwise hoppings
            [-2.46, 0, 0, t2], 
            [2.46, 0, 0, jnp.conj(t2)],
            [2.46*0.5, 2.46*jnp.sqrt(3)/2, 0, t2],
            [-2.46*0.5, -2.46*jnp.sqrt(3)/2, 0, jnp.conj(t2)],
            [2.46*0.5, -2.46*jnp.sqrt(3)/2, 0, t2],
            [-2.46*0.5, 2.46*jnp.sqrt(3)/2, 0, jnp.conj(t2)]
        ],
    )
    .add_interaction(
        "hamiltonian",
            participants=("pz2", "pz2"),
            parameters=[                
                [0, 0, 0, 0],
                [-2.46, 0, 0, jnp.conj(t2)], 
                [2.46, 0, 0, t2],
                [2.46*0.5, 2.46*jnp.sqrt(3)/2, 0, jnp.conj(t2)],
                [-2.46*0.5, -2.46*jnp.sqrt(3)/2, 0, t2],
                [2.46*0.5, -2.46*jnp.sqrt(3)/2, 0, jnp.conj(t2)],
                [-2.46*0.5, 2.46*jnp.sqrt(3)/2, 0, t2]
            ],
        )
        .add_interaction(                
            "coulomb",
            participants=("pz1", "pz2"),
            parameters=[8.64],
        )
        .add_interaction(
            "coulomb",
            participants=("pz1", "pz1"),
            parameters=[16.522, 5.333],
        )
        .add_interaction(
            "coulomb",
            participants=("pz2", "pz2"),
            parameters=[16.522, 5.333],
        )
    )

We now display the edge state

import jax.numpy as jnp
flake_topological = haldane_graphene.cut_flake(Rectangle(20, 20))
idx = jnp.argwhere(jnp.abs(flake_topological.energies) < 1e-1)[0].item()
flake_topological.show_2d( display = flake_topological.eigenvectors[:, idx], scale = True  )

png

Modifying existing materials

You can copy the material you want to modify and change/overwrite its attributes. As an example, we will turn our ordinary graphene model into a direction-independent variant of the Haldane model by introducing complex nnn hoppings and without the staggered onsite potential

from copy import deepcopy
graphene  = MaterialCatalog.get("graphene")
graphene_haldane_variant = deepcopy(graphene)
graphene_haldane_variant.add_interaction("hamiltonian", participants = ('pz', 'pz'), parameters = [0, 1.0, 1j*0.1])
print(graphene_haldane_variant)
Material: graphene
  Lattice Constant: 2.46 Å
  Lattice Basis: 
[[ 1.         0.         0.       ]
 [-0.5        0.8660254  0.       ]]
  Orbital Species:
    pz characterized by (n,l,m,s, atom name) = (0, 'C')
  Orbitals:
    Position: (0, 0), Tag: sublattice_1, Species: pz
    Position: (-0.3333333333333333, -0.6666666666666666), Tag: sublattice_2, Species: pz
  Interactions:
   Type: hamiltonian, Participants: ('pz', 'pz'):
                    NN Couplings: 0, 1.0, 0.1j
                    Other neighbors: 
    Returns a zero coupling constant as a complex number.

    Args:
        d (float): A parameter (typically representing distance or some other factor) that is ignored by the function, as the output is always zero.

    Returns:
        complex: Returns 0.0 as a complex number (0.0j).

   Type: coulomb, Participants: ('pz', 'pz'):
                    NN Couplings: 16.522, 8.64, 5.333
                    Other neighbors: Coupling with a (regularized) Coulomb-like potential

Handling non-periodic dimensions

Say you want to have a custom ssh chain with 2 atoms in the unit cell, but displace one of the atoms in y-direction. You do this by including a second basis vector in y-direction and give the atom you want to displace a non-vanishing y-coordinate. To not get a 2D lattice, you have to specify explicitly which direction(s) you want to be periodically repeated.

_ssh_modified = (
    Material("ssh")
    .lattice_constant(2.46)
    .lattice_basis([
        [1, 0, 0],
        [0, 1, 0],
    ],
    periodic = [0]) # THIS IS THE LINE    
    .add_orbital_species("pz", atom='C')
    .add_orbital(position=(0,0), tag="sublattice_1", species="pz")
    .add_orbital(position=(0.8,0.1), tag="sublattice_2", species="pz")
    .add_interaction(
        "hamiltonian",
        participants=("pz", "pz"),
        parameters=[0.0, 1 + 0.2, 1 - 0.2],
    )
    .add_interaction(
        "coulomb",
        participants=("pz", "pz"),
        parameters=[16.522, 8.64, 5.333],
        expression=lambda d: 14.399 / d + 0j
    )
)
flake = _ssh_modified.cut_flake( unit_cells = 10)
flake.show_2d()
flake.show_energies()

png

png