Source code for udkm1Dsim.simulations.phonons

#!/usr/bin/env python
# -*- coding: utf-8 -*-

# The MIT License (MIT)
# Copyright (c) 2020 Daniel Schick
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
# OR OTHER DEALINGS IN THE SOFTWARE.

__all__ = ['Phonon', 'PhononNum', 'PhononAna']

__docformat__ = 'restructuredtext'

from .simulation import Simulation
from ..helpers import make_hash_md5, finderb
import numpy as np
from os import path
from time import time
from scipy.integrate import solve_ivp
from tqdm.notebook import tqdm, trange


[docs] class Phonon(Simulation): """Phonon Base class for phonon simulations in a linear chain of masses and springs. Args: S (Structure): sample to do simulations with. force_recalc (boolean): force recalculation of results. Keyword Args: save_data (boolean): true to save simulation results. cache_dir (str): path to cached data. disp_messages (boolean): true to display messages from within the simulations. progress_bar (boolean): enable tqdm progress bar. only_heat (boolean): true when including only thermal expansion without coherent phonon dynamics. Attributes: S (Structure): sample structure to calculate simulations on. force_recalc (boolean): force recalculation of results. save_data (boolean): true to save simulation results. cache_dir (str): path to cached data. disp_messages (boolean): true to display messages from within the simulations. progress_bar (boolean): enable tqdm progress bar. only_heat (boolean): true when including only thermal expansion without coherent phonon dynamics. """ def __init__(self, S, force_recalc, **kwargs): super().__init__(S, force_recalc, **kwargs) self.only_heat = kwargs.get('only_heat', False) def __str__(self, output=[]): """String representation of this class""" output = [['only heat', self.only_heat], ] + output class_str = 'Phonon simulation properties:\n\n' class_str += super().__str__(output) return class_str
[docs] def get_hash(self, delays, temp_map, delta_temp_map, **kwargs): """get_hash Calculates an unique hash given by the ``delays``, and ``temp_map`` and ``delta_temp_map`` as well as the sample structure hash for relevant lattice parameters. Args: delays (ndarray[float]): delay grid for the simulation. temp_map (ndarray[float]): spatio-temporal temperature profile. delta_temp_map (ndarray[float]): spatio-temporal temperature difference profile. **kwargs (float, optional): optional parameters. Returns: hash (str): unique hash. """ param = [delays, self.only_heat] if np.size(temp_map) > 1e6: temp_map = temp_map.flatten()[0:1000000] delta_temp_map = delta_temp_map.flatten()[0:1000000] param.append(temp_map) param.append(delta_temp_map) for value in kwargs.values(): param.append(value) return self.S.get_hash(types='phonon') + '_' + make_hash_md5(param)
[docs] def get_all_strains_per_unique_layer(self, strain_map): """get_all_strains_per_unique_layer Determines all values of the strain per unique layer. Args: strain_map (ndarray[float]): spatio-temporal strain profile. Returns: strains (list[ndarray[float]]: all strains per unique layer. """ # get the position indices of all unique layers in the sample structure positions = self.S.get_all_positions_per_unique_layer() strains = [] for value in positions.values(): strains.append(np.sort(np.unique(strain_map[:, value].flatten()))) return strains
[docs] def get_reduced_strains_per_unique_layer(self, strain_map, N=100): """get_reduced_strains_per_unique_layer Calculates all strains per unique layer that are given by the input ``strain_map``, but with a reduced number. The reduction is done by equally spacing the strains between the ``min`` and ``max`` strain with a given number :math:`N`, which can be also an array of the :math:`len(N) = L`, where :math:`L` is the number of unique layers. Args: strain_map (ndarray[float]): spatio-temporal strain profile. N (int, optional): number of reduced strains. Defaults to 100. Returns: strains (list[ndarray[float]]: reduced strains per unique layer. """ # initialize all_strains = self.get_all_strains_per_unique_layer(strain_map) L = len(all_strains) # Nb. of unique layers strains = [] if np.size(N) == 1: N = N*np.ones([L, 1]) elif np.size(N) != L: raise ValueError('The dimension of N must be either 1 or the number ' 'of unique layers the structure!') for i, value in enumerate(all_strains): min_strain = np.min(value) max_strain = np.max(value) strains.append(np.sort(np.unique( np.r_[0, np.linspace(min_strain, max_strain, int(N[i]))]))) return strains
[docs] def check_temp_maps(self, temp_map, delta_temp_map, delays): """check_temp_maps Check temperature profiles for correct dimensions. Args: temp_map (ndarray[float]): spatio-temporal temperature profile. delta_temp_map (ndarray[float]): spatio-temporal temperature difference profile. delays (ndarray[float]): delay grid for the simulation. Returns: (tuple): - *temp_map (ndarray[float])* - checked spatio-temporal temperature profile. - *delta_temp_map (ndarray[float])* - checked spatio-temporal differential temperature profile. """ M = len(delays) L = self.S.get_number_of_layers() K = self.S.num_sub_systems # check size of delta_temp_map if K == 1: if np.shape(delta_temp_map) == (1, L): temp = delta_temp_map delta_temp_map = np.zeros([M, L]) delta_temp_map[0, :] = temp elif (np.size(delta_temp_map, 0) != M) or (np.size(delta_temp_map, 1) != L): raise ValueError('The given temperature difference map does not have the ' 'dimension M x L, where M is the number of delay steps ' 'and L the number of layers!') else: if np.shape(delta_temp_map) == (1, L, K): temp = delta_temp_map delta_temp_map = np.zeros([K, L, K]) delta_temp_map[0, :, :] = temp elif ((np.size(delta_temp_map, 0) != M) or (np.size(delta_temp_map, 1) != L) or (np.size(delta_temp_map, 2) != K)): raise ValueError('The given temperature difference map does not have the ' 'dimension M x L x K, where M is the number of delay steps ' 'and L the number of layers and K is the number of subsystems!') if np.shape(temp_map) != np.shape(delta_temp_map): raise ValueError('The temperature map does not have the same size as the ' 'temperature difference map!') return temp_map, delta_temp_map
[docs] def calc_sticks_from_temp_map(self, temp_map, delta_temp_map): r"""calc_sticks_from_temp_map Calculates the sticks to insert into the layer springs which model the external force (thermal stress). The length of :math:`l_i` of the :math:`i`-th spacer stick is calculated from the temperature-dependent linear thermal expansion :math:`\alpha(T)` of the layer: .. math:: \alpha(T) = \frac{1}{L} \frac{d L}{d T} which results after integration in .. math:: l = \Delta L = L_1 \exp(A(T_2) - A(T_1)) - L_1 where :math:`A(T)` is the integrated lin. therm. expansion coefficient in respect to the temperature :math:`T`. The indices 1 and 2 indicate the initial and final state. Args: temp_map (ndarray[float]): spatio-temporal temperature profile. delta_temp_map (ndarray[float]): spatio-temporal temperature difference profile. Returns: (tuple): - *sticks (ndarray[float])* - summed spacer sticks. - *sticks_sub_systems (ndarray[float])* - spacer sticks per sub-system. """ M = np.size(temp_map, 0) # nb of delay steps L = self.S.get_number_of_layers() K = self.S.num_sub_systems temp_map = np.reshape(temp_map, [M, L, K]) delta_temp_map = np.reshape(delta_temp_map, [M, L, K]) thicknesses = self.S.get_layer_property_vector('_thickness') int_lin_therm_exps = self.S.get_layer_property_vector('_int_lin_therm_exp') # evaluated initial integrated linear thermal expansion from T1 to T2 int_alpha_T0 = np.zeros([L, K]) # evaluated integrated linear thermal expansion from T1 to T2 int_alpha_T = np.zeros([L, K]) sticks = np.zeros([M, L]) # the sticks inserted in the unit cells sticks_sub_systems = np.zeros([M, L, K]) # the sticks for each thermodynamic subsystem # calculate initial integrated linear thermal expansion from T1 to T2 # traverse subsystems for ii in range(L): for iii in range(K): int_alpha_T0[ii, iii] = int_lin_therm_exps[ii][iii](temp_map[0, ii, iii] - delta_temp_map[0, ii, iii]) # calculate sticks for all subsystems for all delay steps # traverse time for i in range(M): if np.any(delta_temp_map[i, :]): # there is a temperature change # Calculate new sticks from the integrated linear thermal # expansion from initial temperature to current temperature for # each subsystem # traverse subsystems for ii in range(L): for iii in range(K): int_alpha_T[ii, iii] = int_lin_therm_exps[ii][iii](temp_map[i, ii, iii]) # calculate the length of the sticks of each subsystem and sum # them up sticks_sub_systems[i, :, :] = np.tile(thicknesses, (K, 1)).T \ * np.exp(int_alpha_T-int_alpha_T0) - np.tile(thicknesses, (K, 1)).T sticks[i, :] = np.sum(sticks_sub_systems[i, :, :], 1) else: # no temperature change, so keep the current sticks if i > 0: sticks_sub_systems[i, :, :] = sticks_sub_systems[i-1, :, :] sticks[i, :] = sticks[i-1, :] return sticks, sticks_sub_systems
[docs] class PhononNum(Phonon): """PhononNum Numerical model to simulate coherent acoustic phonons. Args: S (Structure): sample to do simulations with. force_recalc (boolean): force recalculation of results. Keyword Args: save_data (boolean): true to save simulation results. cache_dir (str): path to cached data. disp_messages (boolean): true to display messages from within the simulations. progress_bar (boolean): enable tqdm progress bar. only_heat (boolean): true when including only thermal expansion without coherent phonon dynamics. Attributes: S (Structure): sample structure to calculate simulations on. force_recalc (boolean): force recalculation of results. save_data (boolean): true to save simulation results. cache_dir (str): path to cached data. disp_messages (boolean): true to display messages from within the simulations. progress_bar (boolean): enable tqdm progress bar. only_heat (boolean): true when including only thermal expansion without coherent phonon dynamics. ode_options (dict): options for scipy solve_ivp ode solver. References: .. [7] A. Bojahr, M. Herzog, D. Schick, I. Vrejoiu, & M. Bargheer, *Calibrated real-time detection of nonlinearly propagating strain waves*, `Phys. Rev. B, 86(14), 144306 (2012). <http://www.doi.org/10.1103/PhysRevB.86.144306>`_ """ def __init__(self, S, force_recalc, **kwargs): super().__init__(S, force_recalc, **kwargs) self.ode_options = { 'method': 'RK23', 'first_step': None, 'max_step': np.inf, 'rtol': 1e-3, 'atol': 1e-6, } def __str__(self, output=[]): """String representation of this class""" class_str = 'Numerical Phonon simulation properties:\n\n' class_str += super().__str__() return class_str
[docs] def get_strain_map(self, delays, temp_map, delta_temp_map): """get_strain_map Returns a strain profile for the sample structure for given temperature profile. The result can be saved using an unique hash of the sample and the simulation parameters in order to reuse it. Args: delays (ndarray[Quantity]): delays range of simulation [s]. temp_map (ndarray[float]): spatio-temporal temperature map. delta_temp_map (ndarray[float]): spatio-temporal differential temperature map. Returns: strain_map (ndarray[float]): spatio-temporal strain profile. """ filename = 'strain_map_num_' \ + self.get_hash(delays, temp_map, delta_temp_map) \ + '.npz' full_filename = path.abspath(path.join(self.cache_dir, filename)) if path.exists(full_filename) and not self.force_recalc: # found something so load it tmp = np.load(full_filename) strain_map = tmp['strain_map'] self.disp_message('_strain_map_ loaded from file:\n\t' + filename) else: # file does not exist so calculate and save strain_map, _, _ = \ self.calc_strain_map(delays, temp_map, delta_temp_map) self.save(full_filename, {'strain_map': strain_map}, '_strain_map_num_') return strain_map
[docs] def calc_strain_map(self, delays, temp_map, delta_temp_map): r"""calc_strain_map Calculates the ``strain_map`` of the sample structure for a given ``temp_map`` and ``delta_temp_map`` and ``delay`` array. Further details are given in Ref. [7]_. The coupled differential equations are solved for each oscillator in a linear chain of masses and springs: .. math:: m_i\ddot{x}_i = & -k_i(x_i-x_{i-1})-k_{i+1}(x_i-x_{i+1}) \\ & + m_i\gamma_i(\dot{x}_i-\dot{x}_{i-1}) + F_i^{heat}(t) where :math:`x_i(t) = z_{i}(t) - z_i^0` is the shift of each layer. :math:`m_i` is the mass and :math:`k_i = m_i\, v_i^2/c_i^2` is the spring constant of each layer. Furthermore an empirical damping term :math:`F_i^{damp} = \gamma_i(\dot{x}_i-\dot{x}_{i-1})` is introduced and the external force (thermal stress) :math:`F_i^{heat}(t)`. The thermal stresses are modelled as spacer sticks which are calculated from the linear thermal expansion coefficients. The equation of motion can be reformulated as: .. math:: m_i\ddot{x}_i = F_i^{spring} + F_i^{damp} + F_i^{heat}(t) The numerical solution also allows for non-harmonic inter-atomic potentials of up to the order :math:`M`. Accordingly :math:`k_i = (k_i^1 \ldots k_i^{M-1})` can be an array accounting for higher orders of the potential which is in the harmonic case purely quadratic (:math:`k_i = k_i^1`). The resulting force from the displacement of the springs .. math:: F_i^{spring} = -k_i(x_i-x_{i-1})-k_{i+1}(x_i-x_{i+1}) includes: .. math:: k_i(x_i-x_{i-1}) = \sum_{j=1}^{M-1} k_i^j (x_i-x_{i-1})^j Args: delays (ndarray[Quantity]): delays range of simulation [s]. temp_map (ndarray[float]): spatio-temporal temperature map. delta_temp_map (ndarray[float]): spatio-temporal differential temperature map. Returns: (tuple): - *strain_map (ndarray[float])* - spatio-temporal strain profile. - *sticks_sub_systems (ndarray[float])* - spacer sticks per sub-system. - *velocities (ndarray[float])* - spatio-temporal velocity profile. """ t1 = time() # initialize L = self.S.get_number_of_layers() thicknesses = self.S.get_layer_property_vector('_thickness') x0 = np.zeros([2*L]) # initial condition for the shift of the layers try: delays = delays.to('s').magnitude except AttributeError: pass # check temp_maps [temp_map, delta_temp_map] = self.check_temp_maps(temp_map, delta_temp_map, delays) # calculate the sticks due to heat expansion first for all delay steps self.disp_message('Calculating linear thermal expansion ...') sticks, sticks_sub_systems = self.calc_sticks_from_temp_map(temp_map, delta_temp_map) if self.only_heat: # no coherent dynamics so calculate the strain directly strain_map = sticks/np.tile(thicknesses, [np.size(sticks, 0), 1]) velocities = np.zeros_like(strain_map) # this is quasi-static else: # include coherent dynamics self.disp_message('Calculating coherent dynamics with ODE solver ...') L = self.S.get_number_of_layers() masses = self.S.get_layer_property_vector('_mass_unit_area') thicknesses = self.S.get_layer_property_vector('_thickness') spring_consts = self.S.get_layer_property_vector('spring_const') damping = self.S.get_layer_property_vector('_phonon_damping') force_from_heat = PhononNum.calc_force_from_heat(sticks, spring_consts) # apply scipy's ode-solver together if self.progress_bar: # with tqdm progressbar pbar = tqdm() pbar.set_description('Delay = {:.3f} ps'.format(delays[0]*1e12)) state = [delays[0], abs(delays[-1]-delays[0])/100] else: # without progressbar pbar = None state = None sol = solve_ivp( PhononNum.ode_func, [delays[0], delays[-1]], x0, args=(delays, force_from_heat, damping, spring_consts, masses, L, pbar, state), t_eval=delays, **self.ode_options) if pbar is not None: # close tqdm progressbar if used pbar.close() # calculate the strain_map as the second spacial derivative # of the layer shift x(t). The result of the ode solver # contains x(t) = X(:,1:N) and v(t) = X(:,N+1:end) the # positions and velocities of the layers, respectively. temp = np.diff(sol.y[0:L, :].T, 1, 1) strain_map = np.zeros([temp.shape[0], temp.shape[1]+1]) strain_map[:, :-1] = temp strain_map = strain_map/np.tile(thicknesses[:], [np.size(temp, 0), 1]) velocities = sol.y[L:, :].T self.disp_message('Elapsed time for _strain_map_:' ' {:f} s'.format(time()-t1)) return strain_map, sticks_sub_systems, velocities
[docs] @staticmethod def ode_func(t, X, delays, force_from_heat, damping, spring_consts, masses, L, pbar=None, state=None): r"""ode_func Provides the according ode function for the ode solver which has to be solved. The ode function has the input :math:`t` and :math:`X(t)` and calculates the temporal derivative :math:`\dot{X}(t)` where the vector .. math:: X(t) = [x(t) \; \dot{x}(t)] \quad \mbox{and } \quad \dot{X}(t) = [\dot{x}(t) \; \ddot{x}(t)] . :math:`x(t)` is the actual shift of each layer. Args: t (ndarray[float]): internal time steps of the ode solver. X (ndarray[float]): internal variable of the ode solver. delays (ndarray[float]): delays range of simulation [s]. force_from_heat (ndarray[float]): force due to thermal expansion. damping (ndarray[float]): phonon damping. spring_consts (ndarray[float]): spring constants of masses. masses (ndarray[float]): masses of layers. L (int): number of layers. pbar (tqdm): tqdm progressbar. state (list[float]): state variables for progress bar. Returns: X_prime (ndarray[float]): velocities and accelerations of masses. """ if pbar is not None: # set everything for the tqdm progressbar last_t, dt = state n = (t - last_t)/dt if n >= 1: pbar.update(1) pbar.set_description('Delay = {:.3f} ps'.format(t*1e12)) state[0] = t elif n < 0: state[0] = t # start with the actual ode function x = X[0:L] v = X[L:] # the output must be a column vector X_prime = np.zeros([2*L]) # accelerations = derivative of velocities X_prime[L:] = ( PhononNum.calc_force_from_damping(v, damping, masses) + PhononNum.calc_force_from_spring( np.r_[np.diff(x), 0], np.r_[0, np.diff(x)], spring_consts) + force_from_heat[:, finderb(t, delays)].squeeze() )/masses # velocities = derivative of positions X_prime[0:L] = v return X_prime
[docs] @staticmethod def calc_force_from_spring(d_X1, d_X2, spring_consts): r"""calc_force_from_spring Calculates the force :math:`F_i^{spring}` acting on each mass due to the displacement between the left and right site of that mass. .. math:: F_i^{spring} = -k_i(x_i-x_{i-1})-k_{i+1}(x_i-x_{i+1}) We introduce-higher order inter-atomic potentials by .. math:: k_i(x_i-x_{i-1}) = \sum_{j=1}^{M} k_i^j (x_i-x_{i-1})^j where :math:`M` is the order of the spring constants. Args: d_X1 (ndarray[float]): left displacements. d_X2 (ndarray[float]): right displacements. spring_consts (ndarray[float]): spring constants of masses. Returns: F (ndarray[float]): force from springs. """ try: spring_order = np.size(spring_consts, 1) except IndexError: spring_order = 1 spring_consts = np.reshape(spring_consts, [np.size(spring_consts, 0), spring_order]) coeff1 = np.vstack((-spring_consts[0:-1, :], np.zeros([1, spring_order]))) coeff2 = np.vstack((np.zeros([1, spring_order]), -spring_consts[0:-1, :])) temp1 = np.zeros([len(d_X1), spring_order]) temp2 = np.zeros([len(d_X1), spring_order]) for i in range(spring_order): temp1[:, i] = d_X1**(i+1) temp2[:, i] = d_X2**(i+1) F = np.sum(coeff2*temp2, 1) - np.sum(coeff1*temp1, 1) return F
[docs] @staticmethod def calc_force_from_heat(sticks, spring_consts): """calc_force_from_heat Calculates the force acting on each mass due to the heat expansion, which is modeled by spacer sticks. Args: sticks (ndarray[float]): spacer sticks. spring_consts (ndarray[float]): spring constants of masses. Returns: F (ndarray[float]): force from thermal expansion. """ M, L = np.shape(sticks) F = np.zeros([L, M]) # traverse time for i in range(M): F[:, i] = -PhononNum.calc_force_from_spring( np.hstack((sticks[i, 0:L-1], 0)), np.hstack((0, sticks[i, 0:L-1])), spring_consts ) return F
[docs] @staticmethod def calc_force_from_damping(v, damping, masses): r"""calc_force_from_damping Calculates the force acting on each mass in a linear spring due to damping (:math:`\gamma_i`) according to the shift velocity difference :math:`v_{i}-v_{i-1}` with :math:`v_i(t) = \dot{x}_i(t)`: .. math:: F_i^{damp} = \gamma_i(\dot{x}_i-\dot{x}_{i-1}) Args: v (ndarray[float]): velocity of masses. damping (ndarray[float]): phonon damping. masses (ndarray[float]): masses. Returns: F (ndarray[float]): force from damping. """ F = masses*damping*np.diff(v, 0) return F
[docs] class PhononAna(Phonon): """PhononAna Analytical model to simulate coherent acoustic phonons. Args: S (Structure): sample to do simulations with. force_recalc (boolean): force recalculation of results. Keyword Args: save_data (boolean): true to save simulation results. cache_dir (str): path to cached data. disp_messages (boolean): true to display messages from within the simulations. progress_bar (boolean): enable tqdm progress bar. only_heat (boolean): true when including only thermal expansion without coherent phonon dynamics. Attributes: S (Structure): sample structure to calculate simulations on. force_recalc (boolean): force recalculation of results. save_data (boolean): true to save simulation results. cache_dir (str): path to cached data. disp_messages (boolean): true to display messages from within the simulations. progress_bar (boolean): enable tqdm progress bar. only_heat (boolean): true when including only thermal expansion without coherent phonon dynamics. References: .. [8] M. Herzog, D. Schick, P. Gaal, R. Shayduk, C. von Korff Schmising & M. Bargheer, *Analysis of ultrafast X-ray diffraction data in a linear-chain model of the lattice dynamics*, `Applied Physics A, 106(3), 489-499 (2011). <http://www.doi.org/doi:10.1007/s00339-011-6719-z>`_ """ def __init__(self, S, force_recalc, **kwargs): super().__init__(S, force_recalc, **kwargs) def __str__(self, output=[]): """String representation of this class""" class_str = 'Analytical Phonon simulation properties:\n\n' class_str += super().__str__() return class_str
[docs] def get_strain_map(self, delays, temp_map, delta_temp_map): """get_strain_map Returns a strain profile for the sample structure for given temperature profile. The result can be saved using an unique hash of the sample and the simulation parameters in order to reuse it. Args: delays (ndarray[Quantity]): delays range of simulation [s]. temp_map (ndarray[float]): spatio-temporal temperature map. delta_temp_map (ndarray[float]): spatio-temporal differential temperature map. Returns: (tuple): - *strain_map (ndarray[float])* - spatio-temporal strain profile. - *A (ndarray[float])* - coefficient vector A of general solution. - *B (ndarray[float])* - coefficient vector B of general solution. """ filename = 'strain_map_ana_' \ + self.get_hash(delays, temp_map, delta_temp_map) \ + '.npz' full_filename = path.abspath(path.join(self.cache_dir, filename)) if path.exists(full_filename) and not self.force_recalc: # found something so load it tmp = np.load(full_filename) strain_map = tmp['strain_map'] A = tmp['A'] B = tmp['B'] self.disp_message('_strain_map_ loaded from file:\n\t' + filename) else: # file does not exist so calculate and save strain_map, A, B = \ self.calc_strain_map(delays, temp_map, delta_temp_map) self.save(full_filename, {'strain_map': strain_map, 'A': A, 'B': B}, '_strain_map_ana_') return strain_map, A, B
[docs] def calc_strain_map(self, delays, temp_map, delta_temp_map): r"""calc_strain_map Calculates the ``strain_map`` of the sample structure for a given ``temp_map`` and ``delta_temp_map`` and ``delay`` array. Further details are given in Ref. [8]_. Within the linear chain of :math:`N` masses (:math:`m_i`) at position :math:`z_i` coupled with spring constants :math:`k_i` one can formulate the differential equation of motion as follow: .. math:: m_i\ddot{x}_i = -k_i(x_i-x_{i-1})-k_{i+1}(x_i-x_{i+1}) + F_i^{heat}(t) Since we only consider nearest-neighbor interaction one can write: .. math:: \ddot{x}_i = \sum_{n=1}^N \kappa_{i,n} x_n = \Delta_i(t) Here :math:`x_i(t) = z_i(t)-z_i^0` is the shift of each layer, :math:`F_i^{heat}(t)` is the external force (thermal stress) of each layer and :math:`\kappa_{i,i} = -(k_i + k_{i+1})/m_i`, and :math:`\kappa_{i,i+1} = \kappa_{i+1,i} = k_{i+1}/m_i`. :math:`k_i = m_i\, v_i^2/c_i^2` is the spring constant and :math:`c_i` and :math:`v_i` are the thickness and longitudinal sound velocity of each layer respectively. One can rewrite the homogeneous differential equation in matrix form to obtain the general solution .. math:: \frac{d^2}{dt^2} X = K \, X Here :math:`X = (x_1 \ldots x_N)` and :math:`K` is the tri-diagonal matrix of :math:`\kappa` which is real and symmetric. The differential equation can be solved with the ansatz: .. math:: X(t) = \sum_j \Xi_j \, (A_j \cos(\omega_j \, t) + B_j \sin(\omega_j \, t)) where :math:`\Xi_j = (\xi_1^j \ldots \xi_N^j)` are the eigenvectors of the matrix :math:`K`. Thus by solving the Eigenproblem for :math:`K` one gets the eigenvecotrs :math:`\Xi_j` and the eigenfrequencies :math:`\omega_j`. From the initial conditions .. math:: X(0) = \sum_j \Xi_j \, A_j = \Xi \, A \qquad V(0) = \dot{X}(0) = \sum_j \Xi_j \, \omega_j\, B_j = \Xi \, \omega \, B one can determine the real coefficient vectors :math:`A` and :math:`B` in order to calculate :math:`X(t)` and :math:`V(t)` using the ansatz: .. math:: A = \Xi \setminus X(0) \qquad B = (\Xi \setminus V(0)) / \omega The external force is implemented as spacer sticks which are inserted into the springs and hence the layers have a new equilibrium positions :math:`z_i(\infty) = z_i^\infty`. Thus we can do a coordination transformation: .. math:: z_i(t) = z_i^0 + x_i(t) = z_i^\infty + x_i^\infty(t) and .. math:: x_i^\infty(t) = z_i^0 - z_i^\infty + x_i(t) with the initial condition :math:`x_i(0) = 0` it becomes .. math:: x_i^\infty(0) = z_i^0 - z_i^\infty = \sum_{j = i+1}^N l_j :math:`x_i^\infty(0)` is the new initial condition after the excitation where :math:`l_i` is the length of the :math:`i`-th spacer stick. The spacer sticks are calculated from the temperature change and the linear thermal expansion coefficients. The actual strain :math:`\epsilon_i(t)` of each layer is calculates as follows: .. math:: \epsilon_i(t) = [ \Delta x_i(t) + l_i) ] / c_i with :math:`\Delta x_i = x_i - x_{i-1}`. The stick :math:`l_i` have to be added here, because :math:`x_i` has been transformed into the new coordinate system :math:`x_i^\infty`. Args: delays (ndarray[Quantity]): delays range of simulation [s]. temp_map (ndarray[float]): spatio-temporal temperature map. delta_temp_map (ndarray[float]): spatio-temporal differential temperature map. Returns: (tuple): - *strain_map (ndarray[float])* - spatio-temporal strain profile. - *A (ndarray[float])* - coefficient vector A of general solution. - *B (ndarray[float])* - coefficient vector B of general solution. """ t1 = time() # initialize L = self.S.get_number_of_layers() M = len(delays) try: delays = delays.to('s').magnitude except AttributeError: pass delay0 = delays[0] # initial delay thicknesses = self.S.get_layer_property_vector('_thickness') X = np.zeros([M, L]) # shifts of the layers V = np.zeros_like(X) # velocities of the layers A = np.zeros_like(X) # coefficient vector for eigenwert solution B = np.zeros_like(X) # coefficient vector for eigenwert solution strain_map = np.zeros_like(X) # the restulting strain map # check temp_maps [temp_map, delta_temp_map] = self.check_temp_maps(temp_map, delta_temp_map, delays) # calculate the sticks due to heat expansion first for all delay steps self.disp_message('Calculating linear thermal expansion ...') sticks, _ = self.calc_sticks_from_temp_map(temp_map, delta_temp_map) if self.only_heat: # no coherent dynamics so calculate the strain directly strain_map = sticks/np.tile(thicknesses, [np.size(sticks, 0), 1]) else: # solve the eigenproblem for the structure to obtains the # eigenvectors X_i and eigenfreqeuencies omega for the L # coupled differential equations Xi, omega = self.solve_eigenproblem() # calculate the actual strain map with the solution of the # eigenproblem and the external force (sticks, thermal stress) self.disp_message('Calculating _strain_map_ ...') if self.progress_bar: iterator = trange(M, desc='Progress', leave=True) else: iterator = range(M) for i in iterator: dt = delays[i]-delay0 # this is the time step # calculate the current shift X and velocity V of all # layers using the ansatz X[i, :] = np.dot(Xi, (A[i, :].T*np.cos(omega*dt) + B[i, :].T*np.sin(omega*dt))) V[i, :] = np.dot(Xi, (omega*(-A[i, :].T*np.sin(omega*dt) + B[i, :].T*np.cos(omega*dt)))) # remember the velocities and shifts as ic for the next # time step X0 = X[i, :].T V0 = V[i, :].T # the strain can only be calculated for L-1 layers, so # we neglect the last one if i > 0: strain_map[i, 0:-1] = (np.diff(X[i, :]) + sticks[i-1, 0:-1])/thicknesses[0:-1].T else: # initial sticks are zero strain_map[i, 0:-1] = np.diff(X[i, :])/thicknesses[0:-1].T # calculate everything for the next step if i < (M-1): # check, if there is a next step if np.any(delta_temp_map[i, :]): # there is a temperature change delay0 = delays[i] # set new initial delay # determining the shifts due to inserted sticks # as new initial conditions if i > 0: temp = np.flipud(np.cumsum(np.flipud(sticks[i, :].T-sticks[i-1, :].T))) else: # initial sticks are zero temp = np.flipud(np.cumsum(np.flipud(sticks[i, :].T))) X0 = X0 + temp # determining the coefficient vectors A and B of # the general solution of X(t) using the initial # conditions X0 and V0 A[i+1, :] = np.linalg.solve(Xi, X0) B[i+1, :] = (np.linalg.solve(Xi, V0)/omega).T else: # no temperature change, so keep the current As, # Bs, and sticks A[i+1, :] = A[i, :] B[i+1, :] = B[i, :] self.disp_message('Elapsed time for _strain_map_:' ' {:f} s'.format(time()-t1)) return strain_map, A, B
[docs] def solve_eigenproblem(self): r"""solve_eigenproblem Creates the real and symmetric :math:`K` matrix (:math:`L \times L`) of spring constants :math:`k_i` and masses :math:`m_i` and calculates the eigenvectors :math:`\Xi_j` and eigenfrequencies :math:`\omega_j` for the matrix which are used to calculate the ``strain_map`` of the structure. If the result has been save to file, load it from there. Returns: (tuple): - *Xi (ndarray[float])* - eigenvectors. - *omega (ndarray[float])* - eigenfrequencies. """ # create the file name to look for filename = 'eigenvalues_' \ + self.S.get_hash(types='phonon') \ + '.npz' full_filename = path.abspath(path.join(self.cache_dir, filename)) if path.exists(full_filename) and not self.force_recalc: # found something so load it tmp = np.load(full_filename) Xi = tmp['Xi'] omega = tmp['omega'] self.disp_message('_eigen_values_ loaded from file:\n\t' + filename) else: # file does not exist so calculate and save t1 = time() self.disp_message('Calculating _eigen_values_ ...') # initialize L = self.S.get_number_of_layers() K = np.zeros([L, L]) # initializing three-diagonal springs-masses matrix. omega = np.zeros([L, 1], dtype=np.cfloat) # initializing a vector for eigenfrequencies masses = self.S.get_layer_property_vector('_mass_unit_area') spring_consts = self.S.get_layer_property_vector('spring_const') spring_consts = np.hstack((0, spring_consts)) # set the first spring free for i in range(L): # defining main diagonal K[i, i] = -(spring_consts[i] + spring_consts[i+1])/masses[i] # defining the two other diagonals - nearest neighbor interaction for i in range(1, L): K[i, i-1] = spring_consts[i]/masses[i] K[i-1, i] = spring_consts[i]/masses[i-1] # determining the eigenvectors and the eigenvalues lambd, Xi = np.linalg.eig(K) # calculate the eigenfrequencies from the eigenvalues omega = np.sqrt(-lambd) self.disp_message('Elapsed time for _eigen_values_:' ' {:f} s'.format(time()-t1)) # save the result to file self.save(full_filename, {'Xi': Xi, 'omega': omega}, '_eigen_values_') return Xi, omega
[docs] def get_energy_per_eigenmode(self, A, B): r"""get_energy_per_eigenmode Returns the sorted energy per Eigenmode of the coherent phonons of the 1D sample. .. math:: E_j = \frac{1}{2} (A^2_j + B^2_j)\, \omega_j^2\, m_j \, \| \Xi_j\|^2 Frequencies are in [Hz] and energy per mode in [J]. Args: A (ndarray[float]): coefficient vector A of general solution. B (ndarray[float]): coefficient vector B of general solution. Returns: (tuple): - *omega (ndarray[float])* - eigenfrequencies. - *E (ndarray[float])* - energy per eigenmode. """ # initialize L = self.S.get_number_of_layers() M = A.shape[0] # nb of delays E = np.zeros([M, L]) masses = self.S.get_layer_property_vector('_mass_unit_area') # get the eigenVectors and eigenFrequencies Xi, omega = self.solve_eigenproblem() # sort the frequencies and remember the permutation of indices idx = np.argsort(omega) # traverse time for i in range(M): # calculate the energy for the jth mode E[i, :] = 0.5 * (A[i, :].T**2 + B[i, :].T**2) * omega**2 * masses * np.sum(Xi**2, 0).T return omega[idx], E[:, idx]