Hyperfine parameter distributions

Hyperfine parameters can be distributed over a range of magnitudes or angles. In order not to set a new Hyperfine for each point in a distribution manually, Nexus allows to define distributions for hyperfine parameters.

Distributions that act on a single parameter of the hyperfine object are introduced via the Distribution class. Nexus has predefined distributions implemented in the library lib.distribution, like Gaussian or Lorentzian and many more.

The Hyperfine class has two special methods for random angular distributions in 2D or 3D. These distributions require special treatment. One option is to set the Hyperfine.isotropic parameter. It is an analytical implementation of 3D angular distributions where both, the hyperfine field and the quadrupole splitting, are randomly distributed in 3D.

In case you want

  • any kind of 2D angular distribution

  • a 3D distribution acting on only the magnetic field or the quadrupole splitting

use the Hyperfine.SetRandomDistribution() method.

Note

Although a lib.distribution.Random class exists, do not use this to emulate 2D or 3D angular distributions. Two random distributions of two angles do not give a real random distribution of the angles! Only use Hyperfine.SetRandomDistribution() or Hyperfine.isotropic for random angular 2D and 3D angular distributions.

A Distribution object defines an array of delta values and the corresponding weight of the delta value. The delta values serve as an absolute difference to the target parameter. Most of the distributions do not need to know on which parameter they act. They just define the absolute differences to the target parameter they are applied to. This might seem a bit weird at the beginning but actually keeps the interface simple and flexible. A distribution can be applied to any target parameter. Internally, the delta values are just added to the parameter they are applied to.

Assume your target parameter has a value of X. The distribution has values from -Y to Y. Then the distribution will range from X-Y to X+Y. This is how most of the predefined distributions work. They calculate a range and are then applied to any target parameter you set them to.

The delta values can also serve as absolute values when the target parameter is zero. For a distribution with delta values from X to Y and a target parameter of 0, the distribution ranges from X to Y.

Note

An experiment should not have too many distributions. Each distribution point number multiplies with the previously set distribution points in a hyperfine site. For example, having three distributions with 30 points each will result in 27000 distribution points in total for the site. In this case the calculation of the transitions takes quite long. As a rule of thumb try to stay below 1000 distribution points per experiment for reasonable speeds.

Note

For 3D random distributions in both the magnetic field and the quadrupole splitting set Hyperfine.isotropic to True. This will results in considerable faster calculations than setting two 3D distributions via Hyperfine.SetRandomDistribution().

A simple distribution

The following code should serve as the basis for the examples on distributions. Time spectra are calculated here, because effects due to distributions are better visualized in the quantum beat than in a Moessbauer spectrum.

# import packages
import nexus as nx
import numpy as np
import matplotlib.pyplot as plt

mat_Fe = nx.Material.Template(nx.lib.material.Fe)

layer_Fe = nx.Layer(id = "Fe",
                    material = mat_Fe,
                    thickness = 3000,  # in nanometer
                    roughness = 100)  # in nanometer

site = nx.Hyperfine(magnetic_field = 33,
                    magnetic_theta = 0,
                    magnetic_phi = 0)

mat_Fe.hyperfine_sites = [site]

sample = nx.Sample(layers = [layer_Fe])

beam  = nx.Beam()
beam.LinearSigma()

exp = nx.Experiment(beam = beam,
                    objects = [sample],
                    isotope = nx.moessbauer.Fe57)

time_spectrum = nx.TimeSpectrum(experiment = exp,
                                time_length = 200,
                                time_step = 0.2)

time_axis, intensity = time_spectrum.Calculate()

plt.semilogy(time_axis, intensity, label = 'no dist')

Now, a distribution of the magnetic field will be added. There are two different ways to do so. Either you define the distribution in the initializer or you can use a Set method to set it later.

So, either define the distribution before initializing the site

import nexus as nx
import numpy as np
import matplotlib.pyplot as plt

mat_Fe = nx.Material.Template(nx.lib.material.Fe)

layer_Fe = nx.Layer(id = "Fe",
                    material = mat_Fe,
                    thickness = 3000,  # in nanometer
                    roughness = 100)  # in nanometer

# definition of distribution
gauss_dist = nx.lib.distribution.Gaussian(points = 31,
                                          fwhm = 1)

# initializer definition
site = nx.Hyperfine(magnetic_field = 33,
                    magnetic_field_dist = gauss_dist,
                    magnetic_theta = 0,
                    magnetic_phi = 0)

mat_Fe.hyperfine_sites = [site]

sample = nx.Sample(layers = [layer_Fe])

or define it via a Set method on an existing Hyperfine object

# define a distribution from the library
# we have to define the number of points and the full width half maximum for a Gaussian distribution
gauss_dist = nx.lib.distribution.Gaussian(points = 31,
                                          fwhm = 1) # this can also be a fittable Var

# apply the distribution to the hyperfine parameter via set method
site.SetMagneticFieldDistribution(gauss_dist)

time_axis, intensity = time_spectrum.Calculate()

# plot the results
plt.semilogy(time_axis, intensity, label = 'with dist')

plt.xlabel('time (ns)')
plt.ylabel('Intensity ($\Gamma$/ns)')
plt.legend()
plt.show()
../../_images/example_distribution.png

Let’s assume the magnetization has a random distribution in the \(\sigma-\pi\) plane (in the sample plane for forward scattering) in addition to the distribution in its amplitude.

# create a random distribution of the magnetic field angles
# the target can be either the magnetic field "mag" or the "efg"
# the type are either certain 2D distributions or a 3D distribution.
# random distributions can only be applied via a Set method.
site.SetRandomDistribution(target = "mag",
                           type  = "2Dsp", # random in the sigma-pi plane (sp)
                           points = 101)

plt.semilogy(*time_spectrum.Calculate(), label = 'with dist and 2D')

plt.xlabel('time (ns)')
plt.ylabel('Intensity ($\Gamma$/ns)')
plt.legend()
plt.show()
../../_images/example_distribution2D.png

The Hyperfine has the attribute BareHyperfines. It holds all actual hyperfine site implementations generated via all distributions applied to the site. Those implementations are of type BareHyperfine, which only hold the bare hyperfine parameters. In order to see each actual hyperfine parameter due to distributions that belong to a hyperfine site you can use

for elem in site.BareHyperfines:
    print(elem)

Predefined Distributions

The easiest is to call one of the predefined distributions. It should fit most purposes. The lib.distribution library holds a lot of distribution types. For example,

# define an asymmetric Gaussian distribution from the lib.distribution.
asym_gauss_dist = nx.lib.distribution.AsymmetricGaussian(
    points = 101,
    hwhm_low = nx.Var(value = 0.5, min = 0, max = 2, fit = True),
    hwhm_high = nx.Var(value = 1, min = 0, max = 2, fit = True))

# get the full width half maximum from the two set hwhm values
print("FWHM = {}".format(asym_gauss_dist.GetFWHM()))

# define a hyperfine site
site = nx.Hyperfine(magnetic_field = 10)

# define target parameter for the distribution
site.SetMagneticFieldDistribution(asym_gauss_dist)

# get the values and weight of the distribution
values, weight = site.GetMagneticFieldDistribution()

plt.plot(values, weight)
plt.xlabel('delta value')
plt.ylabel('weight')
plt.show()
../../_images/AsymGaussDist_plus_value.png

There are two predefined distributions that must know the target value. Those are PosGaussian and NegGaussian. These distributions must therefore only be applied to the specific target parameter that it takes as an argument.

site = nx.Hyperfine(quadrupole = 0.2)

# define a Gaussian distribution with only positive values from nexus.lib.distribution.
dist = nx.lib.distribution.PosGaussian(points = 101,
                                       fwhm = nx.Var(0.5, min = 0, max = 2, fit = True),
                                       target_var = site.quadrupole)

# only apply this dist to the quadrupole parameter
site.SetQuadrupoleDistribution(dist)

values, weight = site.GetQuadrupoleDistribution()

plt.plot(values, weight)
plt.xlabel('delta value')
plt.ylabel('weight')
plt.show()
../../_images/PosDist.png

Random Distributions

The Hyperfine.SetRandomDistribution() method is used to set special multi-dimensional angular distributions. Those can be applied to either the magnetic hyperfine field or the quadrupole splitting. The distributions itself can be 3D or 2D, the latter lies in either of the planes spanned by the basis vectors given by \(\vec{\sigma}\), \(\vec{\pi}\) and \(\vec{k}\).

site = nx.Hyperfine(magnetic_field = 33)

# define a 2D random distribution of the magnetic field
# in the plane spanned by sigma and pi vectors ("sp")
site.SetRandomDistribution(target = "mag", type = "2Dsp", points = 401)

Note

The GetXXXDistribution() methods do not work for these random distributions.

Note

It is recommended to take at least 200 distribution points for random distributions.

Here are the possible random distributions on the quadrupole splitting.

# import packages
import nexus as nx
import numpy as np
import matplotlib.pyplot as plt

mat_Fe = nx.Material.Template(nx.lib.material.Fe)

layer_Fe = nx.Layer(id = "Fe",
                    material = mat_Fe,
                    thickness = 3000,  # in nanometer
                    roughness = 100)  # in nanometer

site = nx.Hyperfine(quadrupole = 1.0)

mat_Fe.hyperfine_sites = [site]

sample = nx.Sample(layers = [layer_Fe])

beam  = nx.Beam()
beam.LinearSigma()

exp = nx.Experiment(beam = beam,
                    objects = [sample],
                    isotope = nx.moessbauer.Fe57)

time_spectrum = nx.TimeSpectrum(experiment = exp,
                                time_length = 200,
                                time_step = 0.2,
                                max_detuning = 0)

fig, axs = plt.subplots(2,2, sharex=True, sharey=True)
fig.suptitle('Random distributions efg')

site.SetRandomDistribution("efg", "3D", 401)

axs[0,0].semilogy(*time_spectrum.Calculate(), label = '3D')
axs[0,0].legend()
axs[0,0].set_ylabel('Intensity ($\Gamma$/ns)')

site.SetRandomDistribution("efg", "2Dsp", 401)

axs[0,1].semilogy(*time_spectrum.Calculate(), label = '2Dsp')
axs[0,1].legend()


site.SetRandomDistribution("efg", "2Dsk", 401)

axs[1,0].semilogy(*time_spectrum.Calculate(), label = '2Dsk')
axs[1,0].legend()
axs[1,0].set_xlabel('time (ns)')
axs[1,0].set_ylabel('Intensity ($\Gamma$/ns)')

site.SetRandomDistribution("efg", "2Dpk", 401)

axs[1,1].semilogy(*time_spectrum.Calculate(), label = '2Dpk')
axs[1,1].legend()
axs[1,1].set_xlabel('time (ns)')

fig.show()
../../_images/efg_distributions.png

and the ones for the magnetic hyperfine field

# import packages
import nexus as nx
import numpy as np
import matplotlib.pyplot as plt

mat_Fe = nx.Material.Template(nx.lib.material.Fe)

layer_Fe = nx.Layer(id = "Fe",
                    material = mat_Fe,
                    thickness = 3000,  # in nanometer
                    roughness = 100)  # in nanometer

site = nx.Hyperfine(magnetic_field = 33)

mat_Fe.hyperfine_sites = [site]

sample = nx.Sample(layers = [layer_Fe])

beam  = nx.Beam()
beam.LinearSigma()

exp = nx.Experiment(beam = beam,
                    objects = [sample],
                    isotope = nx.moessbauer.Fe57)

time_spectrum = nx.TimeSpectrum(experiment = exp,
                                time_length = 200,
                                time_step = 0.2,
                                max_detuning = 0)

fig, axs = plt.subplots(2,2)
fig.suptitle('Random distributions mag')

site.SetRandomDistribution("mag", "3D", 401)

axs[0,0].semilogy(*time_spectrum.Calculate(), label = '3D')
axs[0,0].legend()
axs[0,0].set_ylabel('Intensity ($\Gamma$/ns)')

site.SetRandomDistribution("mag", "2Dsp", 401)

axs[0,1].semilogy(*time_spectrum.Calculate(), label = '2Dsp')
axs[0,1].legend()

site.SetRandomDistribution("mag", "2Dsk", 401)

axs[1,0].semilogy(*time_spectrum.Calculate(), label = '2Dsk')
axs[1,0].legend()
axs[1,0].set_xlabel('time (ns)')
axs[1,0].set_ylabel('Intensity ($\Gamma$/ns)')

site.SetRandomDistribution("mag", "2Dpk", 401)

axs[1,1].semilogy(*time_spectrum.Calculate(), label = '2Dpk')
axs[1,1].legend()
axs[1,1].set_xlabel('time (ns)')

fig.show()
../../_images/mag_distributions.png

Distribution from arrays

One can pass two arrays directly to a distribution. The first array is either the absolute difference or the absolute value of the hyperfine parameter. The second array is the weight.

With absolute differences

delta = np.linspace(-5, 5, 6)  # absolute differences to the target value
weight = np.linspace(0.1, 1, 6)  # some weight values

array_dist = nx.lib.distribution.Array(delta, weight)

site = nx.Hyperfine(magnetic_field = 30,  # distribution will range from 25 to 35
                    magnetic_theta = 90,
                    magnetic_phi = 30)

site.SetMagneticFieldDistribution(array_dist)

# Print all combinations applied to the site
print(site)

# print each parameter combination due to distributions stored in site.BareHyperfines
for elem in site.BareHyperfines:
    print(elem)

With absolute values

values = np.linspace(1, 30, 6)  # absolute values
weight = np.square(values)  # some dependence on values

array_dist = nx.lib.distribution.Array(values, weight)

array_dist.DistributionFunction()

site = nx.Hyperfine(magnetic_field = 0,  # zero because absolute values are assumed
                    magnetic_theta = 90,
                    magnetic_phi = 30
                    )

site.SetMagneticFieldDistribution(array_dist)

print(site)

for elem in site.BareHyperfines:
    print(elem)

Distribution from file

The distribution can be set with a file containing the distribution points and the weights, see distribution.txt.

31.2    0.3
32.2    0.5
33.2    0.8
34.2    0.3
35.2    0.1

The first column gives absolute differences or absolute values, e.g. the magnetic hyperfine field, and the second column the relative weights. Load the distribution with

# load a Distribution from a file
file_dist = nx.lib.distribution.File("distribution.txt")

site = nx.Hyperfine(magnetic_field = 0,  # zero because absolute field values are given
                    magnetic_theta = 90,
                    magnetic_phi = 30)

site.SetMagneticFieldDistribution(file_dist)

for elem in site.BareHyperfines:
    print(elem)

User defined distributions

User-defined distributions are advanced methods to create arbitrary distributions acting on a hyperfine site. The user must define a new distribution and provide the implementation of the mathematical function and the possible fit parameters. You define a child class inherited from the parent class Distribution. In this example, a Gaussian distribution is implemented by the user. The \(\sigma\) value of the Gaussian should be a fittable value.

class myGaussianDist(nx.Distribution):
    def __init__(self, points):
        # class inheritance
        super().__init__(points, "user defined Gaussian")
        # define a sigma class attribute
        self.sigma = nx.Var(value = 2, min = 0, max = 5, fit = True, id = "sigma")
        # register fit variables
        self.fit_varibales = [self.sigma]

This will define the new derived class myGaussianDist with a class instance attribute sigma of type Var that can be fit.

Next, implement the actual DistributionFunction(). The DistributionFunction() must set the delta values either by SetRange() or SetDelta() and the weight by SetWeight(). Those are the only requirements on this function. The SetRange() method sets the delta values symmetrically around zero over the given range, while the SetDelta() directly sets the values. Use numpy whenever possible for the function implementation for efficient calculations.

# define the distribution function
def DistributionFunction(self):

    # prevent division by zero in fitting
    if self.sigma.value == 0:
        self.sigma.value = 1e-299

    # set the delta values to a reasonable range
    x = self.SetRange(6 * self.sigma.value)

    # to process the x array with operators like +,-,*,/ in numpy, convert to numpy array
    x = np.array(x)

    # calculate the weight
    weight = np.exp(-1/2 * np.square(x) / np.square(self.sigma.value))

    # set the weight values
    self.SetWeight(weight)

The complete code for the distribution is

class myGaussianDist(nx.Distribution):
    def __init__(self, points):
        super().__init__(points, "user defined Gaussian")
        self.sigma = nx.Var(value = 2, min = 0, max = 5, fit = True, id = "sigma")
        self.fit_varibales = [self.sigma]

    def DistributionFunction(self):
        if self.sigma.value == 0:
            self.sigma.value = 1e-299
        x = self.SetRange(6 * self.sigma.value)
        weight = np.exp(-1/2 * np.square(x) / np.square(self.sigma.value))
        self.SetWeight(weight)

my_dist = myGaussianDist(points = 101)

In order to make your class more flexible and to use various instances of it, you can pass the sigma value in the __init__ method and define a class attribute which is given to the __init__ input.

class myGaussianDist(nx.Distribution):
    # put sigma to __init__ method
    def __init__(self, points, sigma):
        super().__init__(points, "user defined Gaussian")
        # set the class attribute to sigma
        self.sigma = sigma
        self.fit_varibales = [sigma]


    def DistributionFunction(self):
        if self.sigma.value == 0:
            self.sigma.value = 1e-299
        x = self.SetRange(6*self.sigma.value)
        weight = np.exp(-1/2*np.square(x)/(np.square(self.sigma.value)))
        self.SetWeight(weight)

my_dist = myGaussianDist(points = 101, sigma = nx.Var(1, min = 0, max = 7, fit = True))

sigma must be a Var object in this case. float assignment will not work. To do so you can change the code to:

class myGaussianDist(nx.Distribution):
    def __init__(self, points, sigma):
        super().__init__(points, "user defined Gaussian")
        self.sigma = sigma
        self.fit_varibales = [sigma]

    def DistributionFunction(self):
        if self.sigma == 0:
            self.sigma = 1e-299
        x = self.SetRange(6*self.sigma)
        weight = np.exp(-1/2*np.square(x)/(np.square(self.sigma)))
        self.SetWeight(weight)

my_dist = myGaussianDist(points = 101, sigma = 1)

But sigma will not be fittable in this case.

The user-defined functions do not need an analytical function to work. The lib.distribution.Array and lib.distribution.File distributions are also based on the Distribution parent class. For more advanced class definitions have a look to lib.distribution implementations.

Notebooks

distributions - nb_distributions.ipynb.