Source code for udkm1Dsim.simulations.heat

#!/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__ = ['Heat']

__docformat__ = 'restructuredtext'

from .simulation import Simulation
from .. import u, Q_
from ..helpers import make_hash_md5, finderb, multi_gauss
import numpy as np
from scipy.optimize import brentq
from scipy.interpolate import interp2d
from scipy.integrate import solve_ivp
from time import time
from os import path
import warnings
from tqdm.notebook import tqdm


[docs] class Heat(Simulation): """Heat Heat simulations including laser excitation and N-temperature model. 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. heat_diffusion (boolean): true when including heat diffusion in the calculations. intp_at_interface (int): number of additional spacial points at the interface of each layer. backend (str): pde solver backend - either default scipy or matlab. 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. heat_diffusion (boolean): true when including heat diffusion in the calculations. intp_at_interface (int): number of additional spacial points at the interface of each layer. backend (str): pde solver backend - either default scipy or matlab. excitation (dict{ndarray[float, Quantity]}): excitation parameters fluence, delay_pump, pulse_width, wavelength, theta, polarization, multilayer_absorption, backside boundary_conditions (dict{str, float, Quantity}): boundary conditions of the top and bottom boundary for the heat diffusion calculation. ``top_type`` or ``bottom_type`` must be one of ``boundary_types``. For the last two types the corresponding value, ``top_value`` and ``bottom_value`` have to be set as :math:`K \\times 1` array, where :math:`K` is the number of sub-systems. boundary_types (list[str]): description of boundary types. * isolator * temperature * flux distances (ndarray[float, Quantity]): spatial grid for heat diffusion [m] ode_options (dict): options for scipy solve_ivp ode solver ode_options_matlab (dict): dict with options for the MATLAB pdepe solver. matlab_engine (module): MATLAB to Python API engine required for calculating heat diffusion. """ def __init__(self, S, force_recalc, **kwargs): super().__init__(S, force_recalc, **kwargs) self.heat_diffusion = kwargs.get('heat_diffusion', False) self.intp_at_interface = kwargs.get('intp_at_interface', 11) self.backend = kwargs.get('backend', 'scipy') self._excitation = {'fluence': [], 'delay_pump': [0], 'pulse_width': [0], 'wavelength': 800e-9, 'theta': np.pi/2, # 'polarization': 'p', 'multilayer_absorption': True, 'backside': False} self._distances = np.array([]) self.boundary_types = ['isolator', 'temperature', 'flux'] self._boundary_conditions = { 'top_type': 0, 'top_value': np.array([]), 'bottom_type': 0, 'bottom_value': np.array([]), } self.ode_options = { 'method': 'Radau', 'first_step': None, 'max_step': np.inf, 'rtol': 1e-3, 'atol': 1e-6, } self.ode_options_matlab = {'RelTol': 1e-3} self.matlab_engine = [] def __str__(self, output=[]): """String representation of this class""" output = [['excitation fluence', self.excitation['fluence']], ['excitation delay', self.excitation['delay_pump']], ['excitation pulse length', self.excitation['pulse_width']], ['excitation wavelength', self.excitation['wavelength']], ['excitation theta', self.excitation['theta']], # ['excitation polarization', self.excitation['polarization']], ['excitation multilayer absorption', self.excitation['multilayer_absorption']], ['excitation backside', self.excitation['backside']], ['heat diffusion', self.heat_diffusion], ['interpolate at interfaces', self.intp_at_interface], ['backend', self.backend], ['distances', 'no distance mesh is set for heat diffusion calculations' if self.distances.size == 0 else 'a distance mesh is set for heat diffusion calculations.'], ['top boundary type', self.boundary_conditions['top_type']], ] + output if self._boundary_conditions['top_type'] == 1: output += [['top boundary temperature', str(self.boundary_conditions['top_value'])]] elif self._boundary_conditions['top_type'] == 2: output += [['top boundary flux', str(self.boundary_conditions['top_value'])]] output += [['bottom boundary type', self.boundary_conditions['bottom_type']]] if self._boundary_conditions['bottom_type'] == 1: output += [['bottom boundary temperature', str(self.boundary_conditions['bottom_value'])]] elif self._boundary_conditions['bottom_type'] == 2: output += [['bottom boundary flux', str(self.boundary_conditions['bottom_value'])]] class_str = 'Heat simulation properties:\n\n' class_str += super().__str__(output) return class_str
[docs] def get_hash(self, delays, init_temp, **kwargs): """get_hash Returns a unique hash given by the ``delays`` and ``init_temp`` as well as the sample structure hash for relevant thermal parameters. Args: delays (ndarray[float]): delay grid for the simulation. init_temp (ndarray[float]): initial spatial temperature profile. **kwargs (float, optional): optional parameters. Returns: hash (str): unique hash. """ param = [delays, init_temp, self.heat_diffusion, self.intp_at_interface, self._excitation, self._boundary_conditions, self.distances] for value in kwargs.values(): param.append(value) return self.S.get_hash(types='heat') + '_' + make_hash_md5(param)
[docs] def check_initial_temperature(self, init_temp, distances=[]): """check_initial_temperature Initial temperature for heat simulations can be either a scalar temperature which is assumed to be valid for all layers in the structure or a temperature profile is given with one temperature for each layer in the structure and for each subsystem. Alternatively a spatial grid can be provided. Args: init_temp (float, Quantity, ndarray[float, Quantity]): initial temperature scalar or array [K]. distances (ndarray[float, Quantity], optional): spatial grid of the initial temperatrue. Returns: init_temp (ndarray[float]): checked initial temperature as array on the according spatial grid. """ try: init_temp = init_temp.to('K').magnitude except AttributeError: pass N = len(distances) if N == 0: N = self.S.get_number_of_layers() K = self.S.num_sub_systems # check size of initTemp if np.size(init_temp) == 1: # it is the same initial temperature for all layers init_temp = init_temp*np.ones([N, K]) elif np.shape(init_temp) != (N, K): # init_temp is a vector but has not as many elements as layers raise ValueError('The initial temperature vector must have 1 or ' 'NxK elements, where N is the number of layers ' 'in the structure and K the number of subsystems!') return init_temp
[docs] def check_excitation(self, delays): r"""check_excitation The optical excitation is a dictionary with fluence :math:`F` [J/m²], delays :math:`t` [s] of the pump events, and pulse width :math:`\tau` [s]. :math:`N` is the number of pump events. Traverse excitation vector to update the ``delay_pump`` :math:`t_p` vector for finite pulse durations :math:`w(i)` as follows .. math:: [t_p(i)-\mbox{window}\cdot w(i)]:[t_p(i)+\mbox{window}\cdot w(i)]: [w(i)/\mbox{intp}] and to combine excitations which have overlapping intervals. Moreover the incidence angle :math:`\vartheta` is taken into account for the user-defined incidence fluence in order to project the laser footprint onto the sample surface. Args: delays (ndarray[Quantity]): delays range of simulation [s]. Returns: (tuple): - *res (list[ndarray[float]])* - resulting list of excitations with interpolated delay density around excitations. - *fluence (ndarray[float])* - projected excitation fluences. - *delay_pump (ndarray[float])* - delays of the excitations. - *pulse_width (ndarray[float])* - pulse widths of the excitations. """ delays = delays.to('s').magnitude fluence = self._excitation['fluence'] delay_pump = self._excitation['delay_pump'] pulse_width = self._excitation['pulse_width'] theta = self._excitation['theta'] fluence = fluence*np.sin(theta) self.disp_message('Surface incidence fluence scaled by factor {:5.4f}' ' due to incidence angle theta={:5.2f} deg'.format( np.sin(theta), np.rad2deg(theta))) # throw warnings if heat diffusion should be enabled if (self.S.num_sub_systems > 1) and not self.heat_diffusion: warnings.warn('If you are introducing more than 1 subsystem you ' 'should enable heat diffusion!') if np.sum(pulse_width) > 0 and not self.heat_diffusion: pulse_width = np.zeros_like(fluence) warnings.warn('The effect of finite pulse duration of the excitation ' 'is only considered if heat diffusion is enabled! ' 'All pulse durations are set to 0!') n_excitation = [] # the result of the traversed excitation is a cell vector window = 1.5 # window factor for finite pulse duration intp = 1000 # interpolation factor for finite pulse duration i = 0 # start counter while i < len(delay_pump): k = i temp = [] # check all upcoming excitations if they overlap with the current while k < len(delay_pump): temp.append([delay_pump[k], pulse_width[k], fluence[k]]) if (k+1 < len(delay_pump)) and \ ((delay_pump[k] + window*pulse_width[k]) >= (delay_pump[k+1] - window*pulse_width[k+1])): # there is an overlap in time so add the next # excitation to the current element k += 1 if pulse_width[k] == 0: # an overlapping pulse cannot have a pulseWidth # of 0! Throw an error! raise ValueError('Overlapping pulse must have duration > 0!') else: # no overlap, so go to the next iteration of the outer while loop break # calculate the new time vector of the current excitation delta_delay = np.min(pulse_width[i:(k+1)])/intp if delta_delay == 0: # its pulse_width = 0 or no heat diffusion was enabled # so calculate just at a single delay step interval = np.array([delay_pump[i]]) else: interval = np.r_[(delay_pump[i] - window*pulse_width[i]): (delay_pump[k] + window*pulse_width[k]): delta_delay] # update the new excitation list n_excitation.append([interval, [t[0] for t in temp], [t[1] for t in temp], [t[2] for t in temp]]) i = k+1 # increase counter # traverse the n_excitation list and add additional time vectors between # the pump events for the later temperature calculation res = [] # initialize the result list # check for delay < delay_pump[0] if np.size(delays[delays < np.min(n_excitation[0][0])]) > 0: res.append([delays[delays < n_excitation[0][0][0]], [], [], []]) else: warnings.warn('Please add more delay steps before the first excitation!') # traverse n_excitation for i, excitation in enumerate(n_excitation): res.append(excitation) # print(excitation[0][-1]) # print(n_excitation[i+1][0][0]) if i+1 < len(n_excitation): # there is an upcoming pump event if np.size(delays[np.logical_and(delays > excitation[0][-1], delays < n_excitation[i+1][0][0])]) > 0: # there are times between the current and next excitation temp = [delays[np.logical_and(delays > excitation[0][-1], delays < n_excitation[i+1][0][0])], [], [], []] res.append(temp) else: # this is the last pump event if np.size(delays[delays > excitation[0][-1]]) > 0: # there are times after the current last excitation temp = [delays[delays > excitation[0][-1]], [], [], []] res.append(temp) return res, fluence, delay_pump, pulse_width
[docs] def get_absorption_profile(self, distances=[], backside=False): r"""get_absorption_profile Returns the differential absorption profile :math:`\mbox{d}A/\mbox{d}z`. Args: distances (ndarray[float], optional): spatial grid for calculation. backside (boolean, optional): backside or frontside excitation. Returns: dAdz (ndarray[float]): differential absorption within each layer calculated either by Lambert-Beers law or by a multilayer absorption formalism. """ if self._excitation['multilayer_absorption']: dAdz, _, _, _ = self.get_multilayers_absorption_profile(distances, backside) return dAdz else: return self.get_Lambert_Beer_absorption_profile(distances, backside)
[docs] def get_Lambert_Beer_absorption_profile(self, distances=[], backside=False): r"""get_Lambert_Beer_absorption_profile The transmission is given by: .. math:: \tau = \frac{I}{I_0} = \exp(-z/ \zeta) and the absorption by: .. math:: A = 1 - \tau = 1 - \exp(-z/\zeta) The absorption profile can be derived from the spatial derivative: .. math:: \frac{\mbox{d}A(z)}{\mbox{d}z} = \frac{1}{\zeta} \exp(-z/\zeta) Args: distances (ndarray[float], optional): spatial grid for calculation. backside (boolean, optional): backside or frontside excitation. Returns: dAdz (ndarray[float]): differential absorption within each layer calculated by Lambert-Beers law. """ self.disp_message('Absorption profile is calculated by Lambert-Beer\'s law.') if backside: self.disp_message('Backside excitation is enabled.') structure = self.S.reverse() else: structure = self.S if len(distances) == 0: # if no distances are set, calculate the extinction on # the middle of each unit cell d_start, _, distances = structure.get_distances_of_layers(False) else: d_start, d_end, _ = structure.get_distances_of_layers(False) if backside: distances = np.flip(d_end[-1]-distances) interfaces = structure.get_distances_of_interfaces(False) N = len(distances) dAdz = np.zeros(N) # initialize relative absorbed energies I0 = 1 # initial intensity k = 0 # counter for first layer for i in range(len(interfaces)-1): # find the first layer and get properties index = finderb(interfaces[i], d_start) layer = structure.get_layer_handle(index[0]) opt_pen_depth = layer.opt_pen_depth.to('m').magnitude # get all distances in the current layer we have to # calculate the absorption profile for if i >= len(interfaces)-2: # last layer z = distances[np.logical_and(distances >= interfaces[i], distances <= interfaces[i+1])] else: z = distances[np.logical_and(distances >= interfaces[i], distances < interfaces[i+1])] m = len(z) if not np.isinf(opt_pen_depth): # the layer is absorbing dAdz[k:k+m] = I0/opt_pen_depth*np.exp(-(z-interfaces[i])/opt_pen_depth) # calculate the remaining intensity for the next layer I0 = I0*np.exp(-(interfaces[i+1]-interfaces[i])/opt_pen_depth) k = k+m # set the counter if backside: # for backside excitation the result must be reversed dAdz = np.flip(dAdz) return dAdz
[docs] def get_multilayers_absorption_profile(self, distances=[], backside=False): """get_multilayers_absorption_profile Calculates the intensity, differential absorption and temperature increase profiles in each layer of a multilayers structure for :math:`p` -polarized light. Calculation based on the method in Ref [5]_ and code developed Matlab by L. Le Guyader, see Ref [6]_. Copyright (2012-2014) Loïc Le Guyader <loic.le_guyader@helmholtz-berlin.de> Args: distances (ndarray[float], optional): spatial grid for calculation. backside (boolean, optional): backside or frontside excitation. Returns: (tuple): - *dAdz (ndarray[float])* - differential absorption within each layer. - *Ints (ndarray[float])* - intensity profiles within each layer. - *R_total (float)* - total amount of reflection from the multilayer. - *T_total (float)* - total transmission in the last layer of the multilayer. References: .. [5] K. Ohta & H. Ishida, *Matrix formalism for calculation of the light beam intensity in stratified multilayered films, and its use in the analysis of emission spectra*, `Appl. Opt. 29, 2466 (1990). <https://doi.org/10.1364/AO.29.002466>`_ .. [6] L. Le Guyader, A. Kleibert, F. Nolting, L. Joly, P.M. Derlet, R.V. Pisarev, A. Kirilyuk, Th. Rasing & A.V. Kimel, *Dynamics of laser-induced spin reorientation in Co/SmFeO_3 heterostructure*, `Phys. Rev. B 87, 054437 (2013). <https://doi.org/10.1103/PhysRevB.87.054437>`_ """ self.disp_message('Absorption profile is calculated by multilayer formalism.') if backside: self.disp_message('Backside excitation is enabled.') structure = self.S.reverse() else: structure = self.S if len(distances) == 0: # if no distances are set, calculate the extinction on # the middle of each unit cell d_start, _, distances = structure.get_distances_of_layers(False) else: d_start, d_end, _ = structure.get_distances_of_layers(False) if backside: distances = np.flip(d_end[-1]-distances) interfaces = structure.get_distances_of_interfaces(False) N = len(interfaces) # if a substrate is included add it at the end if structure.substrate != []: M = N + 1 else: M = N opt_ref_indices = np.empty(M, dtype=complex) thicknesses = np.empty(M, dtype=float) # first layer is vacuum/air opt_ref_indices[0] = 1+0.0j thicknesses[0] = 1e-9 for i in range(N-1): index = finderb(interfaces[i], d_start) layer = structure.get_layer_handle(index[0]) opt_ref_indices[i+1] = layer.opt_ref_index thicknesses[i+1] = interfaces[i+1]-interfaces[i] if M != N: opt_ref_indices[N] = structure.substrate.get_layer_handle(0).opt_ref_index thicknesses[N] = structure.substrate.get_thickness(False) # Snell laws alpha = np.empty(M, dtype=complex) alpha[0] = np.pi/2 - self._excitation['theta'] alpha[1:] = np.arcsin(opt_ref_indices[0]/opt_ref_indices[1:]*np.sin(alpha[0])) # fresnel coefficient rfresnel = np.empty(M-1, dtype=complex) tfresnel = np.empty(M-1, dtype=complex) # if self._excitation['polarization'] == 's': # rfresnel[:] = (opt_ref_indices[0:-1]*np.cos(alpha[0:-1]) # - opt_ref_indices[1:]*np.cos(alpha[1:])) \ # / (opt_ref_indices[0:-1]*np.cos(alpha[0:-1]) # + opt_ref_indices[1:]*np.cos(alpha[1:])) # tfresnel[:] = 2.0*opt_ref_indices[0:-1]*np.cos(alpha[0:-1]) \ # / (opt_ref_indices[0:-1]*np.cos(alpha[0:-1]) # + opt_ref_indices[1:]*np.cos(alpha[1:])) # else: # p-polarization rfresnel[:] = (opt_ref_indices[1:]*np.cos(alpha[0:-1]) - opt_ref_indices[0:-1]*np.cos(alpha[1:])) \ / (opt_ref_indices[1:]*np.cos(alpha[0:-1]) + opt_ref_indices[0:-1]*np.cos(alpha[1:])) tfresnel[:] = 2.0*opt_ref_indices[0:-1]*np.cos(alpha[0:-1]) \ / (opt_ref_indices[1:]*np.cos(alpha[0:-1]) + opt_ref_indices[0:-1]*np.cos(alpha[1:])) # interface change matrix Jnm = np.empty((2, 2, M-1), dtype=complex) Jnm[0, 0, :] = 1.0/tfresnel Jnm[0, 1, :] = rfresnel/tfresnel Jnm[1, 0, :] = rfresnel/tfresnel Jnm[1, 1, :] = 1.0/tfresnel # calculating z-component of the wave vector k_z = 2.0*np.pi/self._excitation['wavelength']*opt_ref_indices*np.cos(alpha) # phase changes beta = k_z*thicknesses Ln = np.empty((2, 2, M-1), dtype=complex) Ln[:, :, 0] = [[1, 0], [0, 1]] Ln[0, 0, 1:] = np.exp(-1.0j*beta[1:-1]) Ln[0, 1, 1:] = 0 Ln[1, 0, 1:] = 0 Ln[1, 1, 1:] = np.exp(1.0j*beta[1:-1]) # calculating propagation matrix S = Jnm[:, :, M-2] for i in range(M-3, -1, -1): S = np.dot(Jnm[:, :, i], np.dot(Ln[:, :, i+1], S)) # Total transmission and reflection of the multilayer R_total = np.abs(S[1, 0]/S[0, 0])**2 T_total = (np.real(np.conj(opt_ref_indices[M-1])*np.cos(alpha[M-1]) / (opt_ref_indices[0]*np.cos(alpha[0]))) * np.abs(1/S[0, 0])**2) # calculating D matrix for intermediate field Dn = np.empty((2, 2, M), dtype=complex) Dn[0, 0, M-1] = 1.0/S[0, 0] Dn[0, 1, M-1] = 0.0 Dn[1, 0, M-1] = 0.0 Dn[1, 1, M-1] = 1.0/S[0, 0] for i in range(M-2, -1, -1): Temp = np.dot(Ln[:, :, i], np.dot(Jnm[:, :, i], Dn[:, :, i+1])) Dn[0, 0, i] = Temp[0, 0] Dn[0, 1, i] = Temp[0, 1] Dn[1, 0, i] = Temp[1, 0] Dn[1, 1, i] = Temp[1, 1] K = len(distances) Ints = np.empty(K, dtype=float) # initialize relative intensities dAdz = np.empty(K, dtype=float) # initialize relative absorbed energies k = 0 # counter for first layer for i in range(1, N): # get all distances in the current layer we have to # calculate the absorption profile for if i >= N-1: # last layer z = distances[np.logical_and(distances >= interfaces[i-1], distances <= interfaces[i])] else: z = distances[np.logical_and(distances >= interfaces[i-1], distances < interfaces[i])] m = len(z) z -= interfaces[i-1] # relative position within the layer # For each layer except the first, compute Intensity, Absorption Ep = Dn[0, 0, i]*np.exp(1.0j*k_z[i]*z) Em = Dn[1, 0, i]*np.exp(-1.0j*k_z[i]*z) Etx = 0.0*Ep + 0.0*Em Ety = np.cos(alpha[i])*Ep - np.cos(alpha[i])*Em Etz = -np.sin(alpha[i])*Ep - np.sin(alpha[i])*Em Ints[k:k+m] = np.real(Etx * np.conj(Etx) + Ety*np.conj(Ety) + Etz*np.conj(Etz)) dAdz[k:k+m] = np.real(opt_ref_indices[i]*np.cos(alpha[i]) / (opt_ref_indices[0]*np.cos(alpha[0]))) \ * 2.0*np.imag(k_z[i])*Ints[k:k+m] k = k+m # set the counter self.disp_message('Total reflectivity of {:0.1f} % and transmission ' 'of {:0.1f} %.'.format(np.round(R_total*100, 1), np.round(T_total*100, 1))) if backside: # for backside excitation the results must be reversed dAdz = np.flip(dAdz) Ints = np.flip(Ints) return dAdz, Ints, R_total, T_total
[docs] def get_temperature_after_delta_excitation(self, fluence, init_temp, distances=[]): r"""get_temperature_after_delta_excitation Calculate the final temperature and temperature change for each layer of the sample structure after an optical excitation with a fluence :math:`F` [J/m^2] and an initial temperature :math:`T_1` [K]: .. math:: \Delta E = \int_{T_1}^{T_2} m \, c(T)\, \mbox{d}T where :math:`\Delta E` is the absorbed energy in each layer and :math:`c(T)` is the temperature-dependent heat capacity [J/kg K] and :math:`m` is the mass [kg]. The absorbed energy per layer can be linearized from the absorption profile :math:`\mbox{d} \alpha / \mbox{d}z` as .. math:: \Delta E = \frac{\mbox{d} \alpha}{\mbox{d}z} E_0 \Delta z where :math:`E_0` is the initial energy impinging on the first layer given by the fluence :math:`F = E / A`. :math:`\Delta z` is equal to the thickness of each layer. Finally, one has to minimize the following modulus to obtain the final temperature :math:`T_2` of each layer: .. math:: \left| \int_{T_1}^{T_2} m c(T)\, \mbox{d}T - \frac{\mbox{d}\alpha} {\mbox{d}z} E_0 \Delta z \right| \stackrel{!}{=} 0 Args: fluence (float, Quantity): incident fluence [J/m²]. init_temp (float, Quantity, ndarray[float, Quantity]): initial temperature scalar or array [K]. distances (ndarray[float], optional): spatial grid for calculation. Returns: (tuple): - *final_temp (ndarray[float])* - final temperature after delta excitation. - *delta_T (ndarray[float])* - temperature change. """ # initialize t1 = time() backside = self._excitation['backside'] if len(distances) == 0: # if no distances are set, calculate the extinction on # the middle of each unit cell d_start, _, distances = self.S.get_distances_of_layers(False) else: d_start, _, _ = self.S.get_distances_of_layers(False) # absorption profile from Lambert-Beer's law or multilayer absorption dAdz = self.get_absorption_profile(distances=distances, backside=backside) try: fluence = fluence.to('J/m**2').magnitude except AttributeError: pass int_heat_capacities = self.S.get_layer_property_vector('_int_heat_capacity') thicknesses = self.S.get_layer_property_vector('_thickness') masses = self.S.get_layer_property_vector('_mass_unit_area') # masses are normalized to 1Ang^2 E0 = np.array(fluence)*(1*u.angstrom**2).to_base_units().magnitude # check the initial temperature init_temp = self.check_initial_temperature(init_temp, distances) final_temp = init_temp # traverse layers for i, dist in enumerate(distances): idx = finderb(dist, d_start)[0] if dAdz[i] > 0: # if there is absorption in the current layer del_E = dAdz[i]*E0*thicknesses[idx] def fun(final_temp): return (masses[idx]*(int_heat_capacities[idx][0](final_temp) - int_heat_capacities[idx][0](init_temp[i, 0])) - del_E) final_temp[i, 0] = brentq(fun, init_temp[i, 0], 1e5) delta_T = final_temp - init_temp # this is the temperature change self.disp_message('Elapsed time for _temperature_after_delta_excitation_:' ' {:f} s'.format(time()-t1)) return final_temp, delta_T
[docs] def get_temp_map(self, delays, init_temp): """get_temp_map Returns a temperature profile for the sample structure after optical excitation. 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]. init_temp (float, Quantity, ndarray[float, Quantity]): initial temperature scalar or array [K]. Returns: (tuple): - *temp_map (ndarray[float])* - spatio-temporal temperature map. - *delta_temp_map (ndarray[float])* - spatio-temporal temperature change map. """ init_temp = self.check_initial_temperature(init_temp) # check the initial temperature filename = 'temp_map_' \ + self.get_hash(delays, init_temp) \ + '.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) temp_map = tmp['temp_map'] delta_temp_map = tmp['delta_temp_map'] self.disp_message('_temp_map_ loaded from file:\n\t' + filename) else: # file does not exist so calculate and save temp_map, delta_temp_map, _ = \ self.calc_temp_map(delays, init_temp) self.save(full_filename, {'temp_map': temp_map, 'delta_temp_map': delta_temp_map}, '_temp_map_') return temp_map, delta_temp_map
[docs] def calc_temp_map(self, delays, input_init_temp): """calc_temp_map Calculates the temperature profile for the sample structure after optical excitation. Heat diffusion can be included if ``heat_diffusion = true``. Args: delays (ndarray[Quantity]): delays range of simulation [s]. input_init_temp (float, Quantity, ndarray[float, Quantity]): initial temperature scalar or array [K]. Returns: (tuple): - *temp_map (ndarray[float])* - spatio-temporal temperature map. - *delta_temp_map (ndarray[float])* - spatio-temporal temperature change map. - *checked_excitations (list[ndarray[float]])* - resulting list of checked excitations. """ t1 = time() # initialize L = self.S.get_number_of_layers() K = self.S.num_sub_systems F = len(self._excitation['fluence']) # total number of excitations # there is an initial time step for the init_temp - we will remove it later on # check the initial temperature input_init_temp = self.check_initial_temperature(input_init_temp) checked_excitation, _, _, _ = self.check_excitation(delays) # check excitation _, _, d_mid = self.S.get_distances_of_layers(False) if self.heat_diffusion: # in case heat diffusion is enabled all calculation are first done # on an interpolated grid at the interfaces (or defined by the user) if len(self.distances) == 0: # no user-defined distaces are given, so calculate heat diffusion # by layer and also interpolate at the interfaces distances, _ = self.S.interp_distance_at_interfaces(self.intp_at_interface, False) else: # a user-defined distances vector is given, so determine the # indices for final assignment per layer distances = self._distances else: distances = d_mid N = len(distances) temp_map = np.zeros([1, N, K]) # interpolate the initial temperature onto distance grid init_temp = np.zeros([N, K]) for iii in range(K): init_temp[:, iii] = np.interp(distances, d_mid, input_init_temp[:, iii]) temp_map[0, :, :] = init_temp # this is initial temperature before the simulation starts num_ex = 1 # excitation counter excitation_delays = np.array([]) temp = [] # traverse excitations for i, excitation in enumerate(checked_excitation): # reset initial temperature for delta excitation with heat diffusion enabled special_init_temp = [] # extract excitation parameters for the current iteration sub_delays = excitation[0] excitation_delays = np.concatenate((excitation_delays, sub_delays)) delay_pump = excitation[1] pulse_width = excitation[2] fluence = excitation[3] # determine if a temperature gradient is present and if # heat diffusion is required temp_gradient = np.sum(np.sum(np.abs(np.diff(temp_map[-1, :, :], 1, 0)))) if self.heat_diffusion and (len(sub_delays) > 2) \ and ((np.sum(fluence) == 0 and temp_gradient != 0) or (np.sum(fluence) > 0 and np.sum(pulse_width) > 0) or (self._boundary_conditions['top_type'] + self._boundary_conditions['bottom_type']) > 0): # heat diffusion enabled and more than 2 time steps AND # either no excitation with temperature gradient or excitation with finite pulse # duration if np.sum(fluence) == 0: self.disp_message('Calculating _heat_diffusion_ without excitation...') else: if len(fluence) == 1: self.disp_message('Calculating _heat_diffusion_ for excitation ' + '{:d}:{:d} ...'.format(num_ex, F)) elif len(fluence) > 1: self.disp_message('Calculating _heat_diffusion_ for excitation ' + '{:d}-{:d}:{:d}...'.format(num_ex, num_ex+len(fluence)-1, F)) start = 0 stop = 0 if i > 0: # check if there was a interval before and add # last time of this interval to the current sub_delays = np.r_[checked_excitation[i-1][0][-1], sub_delays] start = 1 if i < len(checked_excitation)-1 and \ np.sum(checked_excitation[i+1][3]) > 0 and \ np.sum(checked_excitation[i+1][2]) == 0: # there is a next interval of delta excitation so # we add this time at the end of the current # interval sub_delays = np.r_[sub_delays, checked_excitation[i+1][0][0]] stop = 1 # calc heat diffusion temp = self.calc_heat_diffusion(init_temp, distances, sub_delays, delay_pump, pulse_width, fluence) if stop == 1: # there is an upcoming delta excitation so we have # to set the initial temperature for this next # interval separately special_init_temp = temp[-1, :, :] temp = temp[start:-1, :, :] else: temp = temp[start:, :, :].copy() # cut the before added time steps elif np.sum(fluence) > 0 and (not self.heat_diffusion or (self.heat_diffusion and np.sum(pulse_width) == 0)): # excitation with no heat diffusion -> only delta excitation # possible in this case OR excitation with heat diffusion and # pulse_width equal to 0 temp, _ = self.get_temperature_after_delta_excitation(fluence, init_temp, distances) temp = np.reshape(temp, [1, np.size(temp, 0), np.size(temp, 1)]) else: # no excitation and no heat diffusion or not enough time # step to calculate heat diffusion, so just repeat the # initial temperature + every unhandled case temp = np.tile(np.reshape(init_temp, [1, N, K]), [len(sub_delays), 1, 1]) # concat results temp_map = np.append(temp_map, temp, axis=0) # set the initial temperature for the next iteration if len(special_init_temp) > 0: init_temp = special_init_temp else: init_temp = temp_map[-1, :, :].copy() # increase excitation counter if np.sum(fluence) > 0: num_ex += len(fluence) if not np.array_equal(excitation_delays, delays.to('s').magnitude) or self.heat_diffusion: # if the time grid for the calculation is not the same as # the grid to return the results on. Then extrapolate the # results on the original delay array but keep the first # element in time for the deltaTempMap calculation. temp = temp_map.copy() temp_map = np.zeros([len(delays)+1, L, K]) for iii in range(K): init = np.interp(d_mid, distances, temp[0, :, iii]).reshape([1, L]) f = interp2d(distances, excitation_delays, temp[1:, :, iii], kind='linear') temp_map[:, :, iii] = np.append(init, f(d_mid, delays.to('s').magnitude), axis=0) # calculate the difference temperature map delta_temp_map = np.diff(temp_map, axis=0) # delete the initial temperature that was added at the beginning temp_map = temp_map[1:, :, :] self.disp_message('Elapsed time for _temp_map_:' ' {:f} s'.format(time()-t1)) return np.squeeze(temp_map), np.squeeze(delta_temp_map), checked_excitation
[docs] def calc_heat_diffusion(self, init_temp, distances, delays, delay_pump, pulse_width, fluence): r"""calc_heat_diffusion Calculates a temperature profile including heat diffusion for a given delay array and initial temperature profile. Here we have to solve the 1D heat diffusion equation for :math:`N` subsystems: .. math:: c_1(T_1) \rho \frac{\partial T_1}{\partial t} & = & \frac{\partial}{\partial z} \left( k_1(T_1) \frac{\partial T_1}{\partial z} \right) + G_1(T_1,...,T_N) + S(z,t) \\ & \vdots & \\ c_N(T_N) \rho \frac{\partial T_N}{\partial t} & = & \frac{\partial}{\partial z} \left( k_N(T_N) \frac{\partial T_N}{\partial z} \right) + G_N(T_1,...,T_N) where :math:`T_i` is the temperature [K], :math:`z` the distance [m], :math:`t` the delay [s], :math:`c_i(T)` the temperature dependent heat capacity [J/kg K], :math:`\rho` the density [kg/m³] and :math:`k_i(T)` is the temperature-dependent thermal conductivity [W/m K] and :math:`S(z,t)` is a source term [W/m³]. The energy flow between the subsystems is given by the ``sub_system_coupling`` parameter :math:`G_i(T_1,...,T_N)` of the individual layers. The index :math:`i` refers to the :math:`i`-th subsystem. The 1D heat diffusion equation can be either solved with SciPy or Matlab as backend. Args: init_temp (float, Quantity, ndarray[float, Quantity]): initial temperature scalar or array [K]. distances (ndarray[float]): spatial grid for calculation. delays (ndarray[Quantity]): delays range of simulation [s]. delay_pump (ndarray[float]): delays of the excitations. pulse_width (ndarray[float]): pulse widths of the excitations. fluence (ndarray[float]): excitation fluences. Returns: temp_map (ndarray[float]): spatio-temporal temperature map. """ t1 = time() M = len(delays) K = self.S.num_sub_systems backside = self._excitation['backside'] init_temp = self.check_initial_temperature(init_temp, distances) d_start, _, _ = self.S.get_distances_of_layers(False) d_distances = np.diff(distances) N = len(distances) if np.any(fluence): dAdz = self.get_absorption_profile(distances=distances, backside=backside) else: dAdz = np.zeros_like(distances) if self.backend == 'matlab': # use of matlab backend for heat diffusion calculation # first try to import required python-matlab bridge try: import matlab.engine except ImportError: raise Warning('You need to have a working MATLAB installation ' 'on your machine with installed matlab.engine for ' 'Python.\n' 'See ' 'https://de.mathworks.com/help/matlab/matlab-engine-for-python.html ' 'for details.') # start MATLAB engine if not already done if self.matlab_engine == []: self.matlab_engine = matlab.engine.start_matlab() # add path of matlab script to matlab's search path matlab_path = path.join(path.dirname(path.abspath(__file__)), 'matlab') self.matlab_engine.addpath(matlab_path) temp_map = self.matlab_engine.calc_heat_diffusion( K, matlab.double(init_temp.tolist()), matlab.double(d_start.tolist()), matlab.double(distances.tolist()), matlab.double(fluence), matlab.double(pulse_width), matlab.double(delay_pump), matlab.double(dAdz.tolist()), matlab.double(delays.tolist()), self.S.get_layer_property_vector('therm_cond_str'), self.S.get_layer_property_vector('heat_capacity_str'), matlab.double(self.S.get_layer_property_vector('_density').tolist()), self.S.get_layer_property_vector('sub_system_coupling_str'), matlab.int32([self._boundary_conditions['top_type']+1]), matlab.double([self._boundary_conditions['top_value'].tolist()]), matlab.int32([self._boundary_conditions['bottom_type']+1]), matlab.double([self._boundary_conditions['bottom_value'].tolist()]), self.ode_options_matlab ) else: # use python scipy backend 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 indices = finderb(distances, d_start) densities = self.S.get_layer_property_vector('_density') # solve pdepe with method-of-lines sol = solve_ivp( Heat.odefunc, [delays[0], delays[-1]], np.reshape(init_temp, K*N, order='F'), args=(N, K, d_distances, d_start, self.S.get_layer_property_vector('therm_cond'), self.S.get_layer_property_vector('heat_capacity'), self.S.get_layer_property_vector('sub_system_coupling'), densities[indices], indices, dAdz, fluence, delay_pump, pulse_width, self._boundary_conditions['top_type'], self._boundary_conditions['top_value'], self._boundary_conditions['bottom_type'], self._boundary_conditions['bottom_value'], pbar, state), t_eval=delays, **self.ode_options) if pbar is not None: # close tqdm progressbar if used pbar.close() temp_map = sol.y.T temp_map = np.array(temp_map).reshape([M, N, K], order='F') if np.any(fluence): self.disp_message('Elapsed time for _heat_diffusion_ with {:d} ' 'excitation(s): {:f} s'.format(len(fluence), time()-t1)) else: self.disp_message('Elapsed time for _heat_diffusion_: {:f} s'.format(time()-t1)) return temp_map
[docs] @staticmethod def odefunc(t, u, N, K, d_x_grid, x, thermal_conds, heat_capacities, sub_system_coupling, densities, indices, dAdz, fluence, delay_pump, pulse_length, bc_top_type, bc_top_value, bc_bottom_type, bc_bottom_value, pbar, state): """odefunc Ordinary differential equation that is solved for 1D heat diffusion. Args: t (ndarray[float]): internal time steps of the ode solver. u (ndarray[float]): internal variable of the ode solver. N (int): number of spatial grid points. K (int): number of sub-systems. d_x_grid (ndarray[float]): derivative of spatial grid. x (ndarray[float]): start point of actual layers. thermal_conds (ndarray[@lambda]): T-dependent thermal conductivity function handles. heat_capacities (ndarray[@lambda]): T-dependent heat capacity function handles. sub_system_coupling (ndarray[@lambda]): T-dependent sub-system coupling. densities (ndarray[float]): density of layers. indices (ndarray[int]): indices of actual layers in respect to interpolated spatial grid. dAdz (ndarray[float]): differential absorption profile. fluence (ndarray[float]): excitation fluences. delay_pump (ndarray[float]): delay of excitations. pulse_length (ndarray[float]): pulse widths of excitations. bc_top_type (int): top boundary type. bc_top_value (ndarray[float]): top boundary value. bc_bottom_type (int): bottom boundary type. bc_bottom_value (ndarray[float]): bottom boundary value. pbar (tqdm): tqdm progressbar. state (list[float]): state variables for progress bar. Returns: dudt (ndarray[float]): temporal derivative of internal variable. """ # state is a list containing last updated time t: # state = [last_t, dt] # I used a list because its values can be carried between function # calls throughout the ODE integration last_t, dt = state try: n = int((t - last_t)/dt) except ValueError: n = 0 if n >= 1: pbar.update(n) pbar.set_description('Delay = {:.3f} ps'.format(t*1e12)) state[0] = t elif n < 0: state[0] = t # reshape input temperature u = np.array(u).reshape([N, K], order='F') # initialize arrays dudt = np.zeros([N, K]) ks = np.zeros([N, K]) cs = np.zeros([N, K]) rhos = densities # calculate external source source = np.zeros([N, K]) if np.any(fluence): source[:, 0] = \ dAdz * multi_gauss(t, s=pulse_length, x0=delay_pump, A=fluence) # calculate temperature-dependent parameters for ii in range(N): idx = indices[ii] for iii in range(K): try: # temperature argument should be scalar ks[ii, iii] = thermal_conds[idx][iii](u[ii, iii]) except (IndexError, TypeError): # temperature argument should be a vector ks[ii, iii] = thermal_conds[idx][iii](u[ii, :]) cs[ii, iii] = heat_capacities[idx][iii](u[ii, iii]) source[ii, iii] = source[ii, iii] + sub_system_coupling[idx][iii](u[ii, :]) # boundary conditions if bc_top_type == 1: # temperature u[0, :] = bc_top_value elif bc_top_type == 2: # flux dudt[0, :] = ((ks[0, :]*(u[1, :] - u[0, :])/d_x_grid[0] + bc_top_value)/d_x_grid[0] + source[0, :])/cs[0, :]/rhos[0] else: # isolator dudt[0, :] = (ks[0, :]*(u[1, :] - u[0, :])/d_x_grid[0]**2 + source[0, :])/cs[0, :]/rhos[0] if bc_bottom_type == 1: # temperature u[-1, :] = bc_bottom_value elif bc_bottom_type == 2: # flux dudt[-1, :] = ((bc_bottom_value - ks[-1, :]*(u[-1, :] - u[-2, :])/d_x_grid[-1])/d_x_grid[-1] + source[-1, :])/cs[-1, :]/rhos[-1] else: # isolator dudt[-1, :] = (ks[-1, :]*(u[-1, :] - u[-2, :])/d_x_grid[-1]**2 + source[-1, :])/cs[-1, :]/rhos[-1] # calculate derivative for ii in range(1, N-1): dudt[ii, :] = (( ks[ii+1, :]*(u[ii+1, :] - u[ii, :])/(d_x_grid[ii]) - ks[ii, :]*(u[ii, :] - u[ii-1, :])/(d_x_grid[ii-1])) / ((d_x_grid[ii]+d_x_grid[ii-1])/2) + source[ii, :])/cs[ii, :]/rhos[ii] return np.reshape(dudt, K*N, order='F')
@property def backend(self): return self._backend @backend.setter def backend(self, backend): if backend in ['scipy', 'matlab']: self._backend = backend else: warnings.warn('Backend must be either _scipy_ or _matlab_. ' 'Set to _scipy_ default!') self._backend = 'scipy' @property def excitation(self): # Convert to from default SI units to real quantities excitation = {'fluence': Q_(self._excitation['fluence'], u.J/u.m**2).to('mJ/cm**2'), 'delay_pump': Q_(self._excitation['delay_pump'], u.s).to('ps'), 'pulse_width': Q_(self._excitation['pulse_width'], u.s).to('ps'), 'wavelength': Q_(self._excitation['wavelength'], u.m).to('nm'), 'theta': Q_(self._excitation['theta'], u.rad).to('deg'), # 'polarization': self._excitation['polarization'], 'multilayer_absorption': self._excitation['multilayer_absorption'], 'backside': self._excitation['backside']} return excitation @excitation.setter def excitation(self, excitation): # check the size of excitation, if we have a multipulse excitation if isinstance(excitation, Q_): # just a fluence is given self._excitation['fluence'] = [excitation.to('J/m**2').magnitude] elif isinstance(excitation, dict): if 'fluence' in excitation: self._excitation['fluence'] = excitation['fluence'].to('J/m**2').magnitude if 'delay_pump' in excitation: self._excitation['delay_pump'] = excitation['delay_pump'].to('s').magnitude if 'pulse_width' in excitation: self._excitation['pulse_width'] = excitation['pulse_width'].to('s').magnitude if 'wavelength' in excitation: self._excitation['wavelength'] = excitation['wavelength'].to('m').magnitude if 'theta' in excitation: self._excitation['theta'] = excitation['theta'].to('rad').magnitude # if 'polarization' in excitation: # if excitation['polarization'] in ['s', 'p']: # self._excitation['polarization'] = excitation['polarization'] # else: # self._excitation['polarization'] = 'p' # raise Warning('Polarization musted be either _s_ or _p_!') if 'multilayer_absorption' in excitation: self._excitation['multilayer_absorption'] = \ bool(excitation['multilayer_absorption']) if 'backside' in excitation: self._excitation['backside'] = \ bool(excitation['backside']) else: raise ValueError('_excitation_ must be either a float/int or dict!') if not (len(self._excitation['fluence']) == len(self._excitation['delay_pump']) == len(self._excitation['pulse_width'])): raise ValueError('Elements of excitation dict, fluence, delay_pump, ' 'and pulse_width must have ' 'the same number of elements!') # check the elements of the delay_pump vector if len(self._excitation['delay_pump']) != len(np.unique(self._excitation['delay_pump'])): raise ValueError('The excitations have to be unique in delays!') else: self._excitation['delay_pump'] = np.sort(self._excitation['delay_pump']) @property def boundary_conditions(self): # Converted from default SI units to real quantities. boundary_conditions = {'top_type': self.boundary_types[self._boundary_conditions['top_type']], } if self._boundary_conditions['top_type'] == 1: boundary_conditions['top_value'] = Q_(self._boundary_conditions['top_value'], 'K') elif self._boundary_conditions['top_type'] == 2: boundary_conditions['top_value'] = Q_(self._boundary_conditions['top_value'], 'W/m**2') boundary_conditions['bottom_type'] = \ self.boundary_types[self._boundary_conditions['bottom_type']] if self._boundary_conditions['bottom_type'] == 1: boundary_conditions['bottom_value'] = Q_(self._boundary_conditions['bottom_value'], 'K') elif self._boundary_conditions['bottom_type'] == 2: boundary_conditions['bottom_value'] = Q_(self._boundary_conditions['bottom_value'], 'W/m**2') return boundary_conditions @boundary_conditions.setter def boundary_conditions(self, boundary_conditions): if isinstance(boundary_conditions, dict): if 'top_type' in boundary_conditions: try: btype = self.boundary_types.index(boundary_conditions['top_type']) except ValueError: raise ValueError('boundary_type must be either _isolator_, ' '_temperature_ or _flux_!') self._boundary_conditions['top_type'] = btype if 'bottom_type' in boundary_conditions: try: btype = self.boundary_types.index(boundary_conditions['bottom_type']) except ValueError: raise ValueError('boundary_type must be either _isolator_, ' '_temperature_ or _flux_!') self._boundary_conditions['bottom_type'] = btype if 'top_value' in boundary_conditions: if self._boundary_conditions['top_type'] == 1: self._boundary_conditions['top_value'] = \ np.array(boundary_conditions['top_value'].to('K').magnitude) elif self._boundary_conditions['top_type'] == 2: self._boundary_conditions['top_value'] = \ np.array(boundary_conditions['top_value'].to('W/m**2').magnitude) if 'bottom_value' in boundary_conditions: if self._boundary_conditions['bottom_type'] == 1: self._boundary_conditions['bottom_value'] = \ np.array(boundary_conditions['bottom_value'].to('K').magnitude) elif self._boundary_conditions['bottom_type'] == 2: self._boundary_conditions['bottom_value'] = \ np.array(boundary_conditions['bottom_value'].to('W/m**2').magnitude) else: raise ValueError('_boundary_conditions_ must be a dict!') K = self.S.num_sub_systems if (self._boundary_conditions['top_type'] > 0) \ and (np.size(self._boundary_conditions['top_value']) != K): raise ValueError('Non-isolating top boundary conditions must have the ' 'same dimensionality as the number of sub-systems K!') if (self._boundary_conditions['bottom_type'] > 0) \ and (np.size(self._boundary_conditions['bottom_value']) != K): raise ValueError('Non-isolating bottom boundary conditions must have the ' 'same dimensionality as the number of sub-systems K!') @property def distances(self): return Q_(self._distances, u.meter).to('nm') @distances.setter def distances(self, distances): self._distances = distances.to_base_units().magnitude def __del__(self): # stop matlab engine if exists try: self.matlab_engine.quit() except AttributeError: pass