Source code for grains.simulation
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Functions
---------
.. autosummary::
:nosignatures:
:toctree: functions/
data_Pierre
hallpetch_constants
hallpetch
hallpetch_plot
change_domain
nature_of_deformation
"""
from math import sqrt
import numpy as np
from scipy.stats import kurtosis
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
from matplotlib.table import table
import matplotlib.gridspec as gridspec
from grains.analysis import label_image_skeleton, thicken_skeleton
[docs]def data_Pierre():
"""Yield stresses and average grain diameters from Pierre's thesis.
Returns
-------
sigma_y : list of floats
Yield stresses.
d : list of floats
Diameters of the grains.
"""
sigma_y = [220, 75, 100]
d = [0.03, 3, 0.5]
return sigma_y, d
[docs]def hallpetch_constants(sigma_y, d):
"""Determines the two Hall-Petch constants.
Given available measurements for the grains sizes and the yield stresses,
the two constants in the Hall-Petch formula are computed.
Parameters
----------
sigma_y : list of floats
Yield stresses.
d : list of floats
Diameters of the grains.
Returns
-------
sigma_0 : float
Starting stress for dislocation movement (material constant).
k : float
Strengthening coefficient (material constant).
Notes
-----
If two data points are given in the inputs (corresponding to two measurements),
the output parameters have unique values:
k = (sigma_y[0] - sigma_y[1]) / (1/sqrt(d[0]) - 1/sqrt(d[1]))
sigma_0 = sigma_y[0] - k/sqrt(d[0])
If there are more than two measurements, the resulting linear system is
overdetermined.
In both cases, the outputs are determined using least squares fitting.
"""
if not isinstance(sigma_y, list) or not isinstance(d, list):
raise Exception("Inputs must be lists.")
if len(sigma_y) != len(d):
raise Exception("Inputs must have the same number of elements.")
if len(d) < 2:
raise Exception("At least two data points required for both inputs.")
# Solve the (possibly overdetermined) linear system
A = np.hstack([np.vstack(np.ones_like(d)), np.vstack(1/np.sqrt(d))])
b = np.vstack(sigma_y)
x = np.linalg.lstsq(A, b, rcond=None)[0]
# Unpack numpy column vector
sigma_0 = x[0][0]
k = x[1][0]
return sigma_0, k
[docs]def hallpetch(sigma_0, k, d):
"""Computes the yield stress from the Hall-Petch relation.
Parameters
----------
sigma_0 : float
Starting stress for dislocation movement (material constant).
k : float
Strengthening coefficient (material constant).
d : float or list of floats
Diameter of the grain.
Returns
-------
sigma_y : float or list of floats
Yield stress.
"""
if isinstance(d, list):
sigma_y = [sigma_0 + k/sqrt(d_i) for d_i in d]
else:
sigma_y = sigma_0 + k/sqrt(d)
return sigma_y
[docs]def hallpetch_plot(sigma_y, d, units=("MPa", "mm")):
"""Plots the Hall-Petch formula for given grain sizes and yield stresses.
Parameters
----------
sigma_y : ndarray
Yield stresses.
d : ndarray
Grain diameters.
units : 2-tuple of str, optional
Units for the yield stress and the grain diameters.
The default is ("MPa","mm").
Returns
-------
fig : matplotlib.figure.Figure
The figure object is returned in case further manipulations are necessary.
"""
fig, ax = plt.subplots()
# Hall-Petch relation
inv_sqrt_d = 1/np.sqrt(d)
hp = ax.plot(inv_sqrt_d, sigma_y)
# Yield stress for monocrystals
sigma_0 = hallpetch_constants(sigma_y[0:2], d[0:2])[0]
# Region of the plot that represents monocrystals
d_max = np.amax(inv_sqrt_d)
sigma_y_max = np.amax(sigma_y)
monocrystals = Rectangle((0, sigma_0 - 0.25*(sigma_y_max - sigma_0)), d_max,
0.25*(sigma_y_max - sigma_0), angle=0,
facecolor="blue", alpha=0.5)
ax.add_patch(monocrystals)
ax.text(1/2*d_max, 1/8*(9*sigma_0 - sigma_y_max), "monocrystals",
horizontalalignment="center", verticalalignment="center")
# Axis title, limits and labels
ax.set_xlim(0, d_max)
ax.set_ylim(sigma_0 - 1/4*(sigma_y_max - sigma_0), sigma_y_max)
xlabel = "$d^{{-\\frac{{1}}{{2}}}}$ " \
"$\\left[\\mathrm{{{0}}}^{{-\\frac{{1}}{{2}}}}\\right]$".format(units[1])
ylabel = "$\\sigma_y$ [{0}]".format(units[0])
ax.set_xlabel(xlabel)
ax.set_ylabel(ylabel)
ax.set_title("Effect of the grain diameter on the yield strength")
ax.legend(hp, ["Hall-Petch"])
return fig
[docs]def change_domain(image, left, right, bottom, top, padding_value=np.nan):
"""Extends or crops the domain an image fills.
The original image is extended, cropped or left unchanged in the left, right, bottom and top
directions by padding the corresponding 2D or 3D array with a given value or removing
existing elements. Non-integer image length is avoided by rounding up. This choice prevents
ending up with an empty image during cropping or no added pixel during extension.
Parameters
----------
image : ndarray
2D or 3D numpy array representing the original image.
left, right : float
When positive, the domain is extended, when negative, the domain is cropped by the given
value relative to the image width.
bottom, top : float
When positive, the domain is extended, when negative, the domain is cropped by the given
value relative to the image height.
padding_value : float, optional
Value for the added pixels. The default is numpy.nan.
Returns
-------
changed_image : ndarray
2D or 3D numpy array representing the modified domain.
Examples
--------
Crop an image at the top, extended it at the bottom and on the left,
and leave it unchanged on the right. Note the rounding for non-integer pixels.
>>> import numpy as np
>>> image = np.array([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]])
>>> modified = change_domain(image, 0.5, 0, 1/3, -1/3, 0)
>>> image # no in-place modification, the original image is not overwritten
array([[0.1, 0.2, 0.3],
[0.4, 0.5, 0.6],
[0.7, 0.8, 0.9]])
>>> modified
array([[0. , 0. , 0.4, 0.5, 0.6],
[0. , 0. , 0.7, 0.8, 0.9],
[0. , 0. , 0. , 0. , 0. ]])
"""
# TODO: Negative resulting image size must not be allowed. It means that left + right > width
# and top + bottom>height must be fulfilled.
height, width = np.shape(image)
# number of pixels (entries in the matrix) to be added
left = np.ceil(width*left).astype(np.int)
right = np.ceil(width*right).astype(np.int)
bottom = np.ceil(height*bottom).astype(np.int)
top = np.ceil(height*top).astype(np.int)
# Shrink the domain (crop the matrix)
changed_image = image[abs(min(top, 0)):height - abs(min(bottom, 0)),
abs(min(left, 0)):width - abs(min(right, 0))]
# Extend the domain (pad the matrix)
pad_width = ((max(top, 0), max(bottom, 0)), (max(left, 0), max(right, 0)))
changed_image = np.pad(changed_image, pad_width, constant_values=padding_value)
return changed_image
[docs]def nature_of_deformation(microstructure, strain_field, interface_width=3, visualize=True):
"""Characterizes the intergranular/intragranular deformations.
To decide whether the strain localization is intergranular (happens along grain boundaries,
also called interfaces) or intragranular in a given microstructure, the strain field is
projected on the microstructure. Here, by strain field we mean a scalar field, often called
`equivalent strain` that is derived from a strain tensor.
It is irrelevant for this function whether the strain field is obtained from a numerical
simulation or from a (post-processed) full-field measurement. All what matters is that the
strain field be available on a grid of the same size as the microstructure.
The strain field is assumed to be localized on an interface if its neighborhood, with band
width defined by the user, contains higher strain values than what is outside the band (i.e.
the grain interiors). A too large band width identifies small grains to have boundary only,
without any interior. This means that even if the strain field in reality localizes inside
such small grains, the localization is classified as intergranular. However, even for
extreme deformations, one should not expect that the strain localizes on an interface with a
single-point width. Moreover, using a too small band width is susceptible to the exact position
of the interfaces, which are extracted from the grain microstructure. A judicial balance needs
to be achieved in practice.
Parameters
----------
microstructure : ndarray
Labelled image corresponding to the segmented grains in a grain microstructure.
strain_field : ndarray
Discrete scalar field of the same size as the :code:`microstructure`.
interface_width : int, optional
Thickness of the band around the interfaces.
visualize : bool, optional
If True, three plots are created. Two of them show the deformation field within the bands
and outside the bands. They are linked together, so when you pan or zoom on one,
the other plot will follow. The third plot contains two histograms on top of each other,
giving the frequency of the strain values within the bands and outside the bands.
Returns
-------
boundary_strain : ndarray
Copy of :code:`strain_field`, but values outside the band are set to NaN.
bulk_strain : ndarray
Copy of :code:`strain_field`, but values within the band are set to NaN.
bands : ndarray
Boolean array of the same size as :code:`strain_field`, with True values
corresponding to the band.
See Also
--------
:meth:`grains.dic.DIC.strain` : Computes a strain tensor from the displacement field.
:meth:`grains.dic.DIC.equivalent_strain` : Extracts a scalar quantity from a strain tensor.
:func:`matplotlib.pyplot.hist` : Plots a histogram.
Notes
-----
1. From the modelling viewpoint, it is important to know whether the strain localizes to the
grain boundaries or it is dominant within the grains as well. In the former case,
simplifications in the models save computational time in the simulations.
2. In dynamics, the evolution of the strain field is relevant. E.g. an initially
intergranular deformation can turn into diffuse localization that occurs within the grains
as well. In that case, a strain field must be obtained at each time step, and this
function can be called for each such instance.
Examples
--------
The following figure was created by this function with :code:`visualize` set to `True` and
:code:`band_width` chosen to be 3.
.. image:: ../images/intergranular-intragranular_deformations.*
"""
# Bands around the interfaces
bands = thicken_skeleton(label_image_skeleton(microstructure), interface_width)
# Strain values in the bands and outside the bands
boundary_strain = strain_field.copy()
bulk_strain = strain_field.copy()
boundary_strain[~bands] = np.nan
bulk_strain[bands] = np.nan
if visualize:
# Set up figure for showing the results
fig = plt.figure(tight_layout=True)
gs = gridspec.GridSpec(2, 2)
# Strain field localized onto the bands and onto the grain interiors
ax_1 = fig.add_subplot(gs[0, 0])
ax_1.imshow(boundary_strain)
ax_1.set_title('Strain field in the bands around the grain boundaries')
ax_2 = fig.add_subplot(gs[1, 0], sharex=ax_1, sharey=ax_1)
ax_2.imshow(bulk_strain)
ax_2.set_title('Strain field in the interior of the grains')
# Histogram to show the strain value distributions
ax_3 = fig.add_subplot(gs[0, 1])
ax_3.set_title('Normalized strain distribution')
normalized_boundary_strain = boundary_strain[bands] / max(boundary_strain[bands])
normalized_bulk_strain = bulk_strain[~bands] / max(bulk_strain[~bands])
_, _, h_boundary = ax_3.hist(boundary_strain[bands], bins='auto', alpha=0.5, color='blue')
_, _, h_bulk = ax_3.hist(bulk_strain[~bands], bins='auto', alpha=0.5, color='red')
plt.legend([h_boundary, h_bulk], ['band', 'interior'])
# Relevant statistical measures
rows = ('mean', 'kurtosis')
columns = ('band', 'interior')
data = [[np.mean(normalized_boundary_strain), np.mean(normalized_bulk_strain)],
[kurtosis(normalized_boundary_strain), kurtosis(normalized_bulk_strain)]]
ax_4 = fig.add_subplot(gs[1, 1])
ax_4.set_axis_off()
data_table = table(ax_4, cellText=np.array(data).astype(str), rowLabels=rows,
colLabels=columns, loc='center')
# data_table.auto_set_font_size(False) # https://stackoverflow.com/a/15514091/4892892
# data_table.set_fontsize(10)
return boundary_strain, bulk_strain, bands