examples.phase package¶
Subpackages¶
Submodules¶
examples.phase.anisotropy module¶
Solve a dendritic solidification problem.
To convert a liquid material to a solid, it must be cooled to a temperature below its melting point (known as “undercooling” or “supercooling”). The rate of solidification is often assumed (and experimentally found) to be proportional to the undercooling. Under the right circumstances, the solidification front can become unstable, leading to dendritic patterns. Warren, Kobayashi, Lobkovsky and Carter [13] have described a phase field model (“Allen-Cahn”, “non-conserved Ginsberg-Landau”, or “model A” of Hohenberg & Halperin) of such a system, including the effects of discrete crystalline orientations (anisotropy).
We start with a regular 2D Cartesian mesh
>>> from fipy import Variable, CellVariable, Grid2D, TransientTerm, DiffusionTerm, ImplicitSourceTerm, Viewer, Matplotlib2DGridViewer
>>> from fipy.tools import numerix
>>> dx = dy = 0.025
>>> if __name__ == '__main__':
... nx = ny = 500
... else:
... nx = ny = 20
>>> mesh = Grid2D(dx=dx, dy=dy, nx=nx, ny=ny)
and we’ll take fixed timesteps
>>> dt = 5e-4
We consider the simultaneous evolution of a “phase field” variable
(taken to be 0 in the liquid phase and 1 in the solid)
>>> phase = CellVariable(name=r'$\phi$', mesh=mesh, hasOld=True)
and a dimensionless undercooling
(
at the melting point)
>>> dT = CellVariable(name=r'$\Delta T$', mesh=mesh, hasOld=True)
The hasOld
flag causes the storage of the value of variable from the
previous timestep. This is necessary for solving equations with
non-linear coefficients or for coupling between PDEs.
The governing equation for the temperature field is the heat flux equation, with a source due to the latent heat of solidification
>>> DT = 2.25
>>> heatEq = (TransientTerm()
... == DiffusionTerm(DT)
... + (phase - phase.old) / dt)
The governing equation for the phase field is
where
represents a source of anisotropy. The coefficient
is an anisotropic diffusion tensor in two dimensions
where ,
,
,
is the orientation, and
is the symmetry.
>>> alpha = 0.015
>>> c = 0.02
>>> N = 6.
>>> theta = numerix.pi / 8.
>>> psi = theta + numerix.arctan2(phase.faceGrad[1],
... phase.faceGrad[0])
>>> Phi = numerix.tan(N * psi / 2)
>>> PhiSq = Phi**2
>>> beta = (1. - PhiSq) / (1. + PhiSq)
>>> DbetaDpsi = -N * 2 * Phi / (1 + PhiSq)
>>> Ddia = (1.+ c * beta)
>>> Doff = c * DbetaDpsi
>>> I0 = Variable(value=((1, 0), (0, 1)))
>>> I1 = Variable(value=((0, -1), (1, 0)))
>>> D = alpha**2 * (1.+ c * beta) * (Ddia * I0 + Doff * I1)
With these expressions defined, we can construct the phase field equation as
>>> tau = 3e-4
>>> kappa1 = 0.9
>>> kappa2 = 20.
>>> phaseEq = (TransientTerm(tau)
... == DiffusionTerm(D)
... + ImplicitSourceTerm((phase - 0.5 - kappa1 / numerix.pi * numerix.arctan(kappa2 * dT))
... * (1 - phase)))
We seed a circular solidified region in the center
>>> radius = dx * 5.
>>> C = (nx * dx / 2, ny * dy / 2)
>>> x, y = mesh.cellCenters
>>> phase.setValue(1., where=((x - C[0])**2 + (y - C[1])**2) < radius**2)
and quench the entire simulation domain below the melting point
>>> dT.setValue(-0.5)
In a real solidification process, dendritic branching is induced by small thermal
fluctuations along an otherwise smooth surface, but the granularity of the
Mesh
is enough “noise” in this case, so we don’t need to explicitly
introduce randomness, the way we did in the Cahn-Hilliard problem.
FiPy’s viewers are utilitarian, striving to let the user see something, regardless of their operating system or installed packages, so you won’t be able to simultaneously view two fields “out of the box”, but, because all of Python is accessible and FiPy is object oriented, it is not hard to adapt one of the existing viewers to create a specialized display:
>>> if __name__ == "__main__":
... try:
... import pylab
... class DendriteViewer(Matplotlib2DGridViewer):
... def __init__(self, phase, dT, title=None, limits={}, **kwlimits):
... self.phase = phase
... self.contour = None
... Matplotlib2DGridViewer.__init__(self, vars=(dT,), title=title,
... cmap=pylab.cm.hot,
... limits=limits, **kwlimits)
...
... def _plot(self):
... Matplotlib2DGridViewer._plot(self)
...
... if self.contour is not None:
... for c in self.contour.collections:
... c.remove()
...
... mesh = self.phase.mesh
... shape = mesh.shape
... x, y = mesh.cellCenters
... z = self.phase.value
... x, y, z = [a.reshape(shape, order='F') for a in (x, y, z)]
...
... self.contour = self.axes.contour(x, y, z, (0.5,))
...
... viewer = DendriteViewer(phase=phase, dT=dT,
... title=r"%s & %s" % (phase.name, dT.name),
... datamin=-0.1, datamax=0.05)
... except ImportError:
... viewer = MultiViewer(viewers=(Viewer(vars=phase),
... Viewer(vars=dT,
... datamin=-0.5,
... datamax=0.5)))
and iterate the solution in time, plotting as we go,
>>> if __name__ == '__main__':
... steps = 10000
... else:
... steps = 10
>>> from builtins import range
>>> for i in range(steps):
... phase.updateOld()
... dT.updateOld()
... phaseEq.solve(phase, dt=dt)
... heatEq.solve(dT, dt=dt)
... if __name__ == "__main__" and (i % 10 == 0):
... viewer.plot()
The non-uniform temperature results from the release of latent heat at the solidifying interface. The dendrite arms grow fastest where the temperature gradient is steepest.
We note that this FiPy simulation is written in about 50 lines of code (excluding the custom viewer), compared with over 800 lines of (fairly lucid) FORTRAN code used for the figures in [13].
examples.phase.anisotropyOLD module¶
Attention
This example remains only for exact comparison against Ryo Kobayashi’s
FORTRAN code. See examples.phase.anisotropy
for a better, although not
numerically identical implementation.
In this example we solve a coupled phase and temperature equation to model solidification, and eventually dendritic growth, based on the work of Warren, Kobayashi, Lobkovsky and Carter [13].
We start from a circular seed in a 2D mesh:
>>> from fipy import CellVariable, Grid2D, TransientTerm, DiffusionTerm, ExplicitDiffusionTerm, ImplicitSourceTerm, Viewer
>>> from fipy.tools import numerix
>>> numberOfCells = 40
>>> Length = numberOfCells * 2.5 / 100.
>>> nx = numberOfCells
>>> ny = numberOfCells
>>> dx = Length / nx
>>> dy = Length / ny
>>> radius = Length / 4.
>>> seedCenter = (Length / 2., Length / 2.)
>>> initialTemperature = -0.4
>>> mesh = Grid2D(dx=dx, dy=dy, nx=nx, ny=ny)
Dendritic growth will not be observed with this small test system. If
you wish to see dendritic growth reset the following parameters such
that numberOfCells = 500
, steps = 10000
, radius = dx * 5.
seedCenter = (0. , 0.)
and initialTemperature = -0.5
.
The governing equation for the phase field is given by:
where
The coefficients and
are given by,
and
where ,
psi = theta + arctan frac{ phi_y } { phi_x }` and
and
.
The governing equation for temperature is given by:
Here the phase and temperature equations are solved with an explicit and implicit technique, respectively.
The parameters for these equations are
>>> timeStepDuration = 5e-5
>>> tau = 3e-4
>>> alpha = 0.015
>>> c = 0.02
>>> N = 4.
>>> kappa1 = 0.9
>>> kappa2 = 20.
>>> tempDiffusionCoeff = 2.25
>>> theta = 0.
The phase
variable is 0 for a liquid and 1 for a solid. Here,
the ``phase
variable is initialized as a liquid,
>>> phase = CellVariable(name='phase field', mesh=mesh, hasOld=1)
The hasOld
flag keeps the old value of the variable. This is
necessary for a transient solution. In this example we wish to set up
an interior region that is solid.
The domain is seeded with a circular solidified region with parameters
seedCenter
and radius
representing the center and radius of the
seed.
>>> x, y = mesh.cellCenters
>>> phase.setValue(1., where=((x - seedCenter[0])**2
... + (y - seedCenter[1])**2) < radius**2)
The temperature field is initialized to a value of -0.4 throughout:
>>> temperature = CellVariable(
... name='temperature',
... mesh=mesh,
... value=initialTemperature,
... hasOld=1)
The variable
is created from the phase
and temperature
variables.
>>> mVar = phase - 0.5 - kappa1 / numerix.pi * numerix.arctan(kappa2 * temperature)
The following section of code builds up the and
coefficients.
>>> phaseY = phase.faceGrad.dot((0, 1))
>>> phaseX = phase.faceGrad.dot((1, 0))
>>> psi = theta + numerix.arctan2(phaseY, phaseX)
>>> Phi = numerix.tan(N * psi / 2)
>>> PhiSq = Phi**2
>>> beta = (1. - PhiSq) / (1. + PhiSq)
>>> betaPsi = -N * 2 * Phi / (1 + PhiSq)
>>> A = alpha**2 * c * (1.+ c * beta) * betaPsi
>>> D = alpha**2 * (1.+ c * beta)**2
The variable (
dxi
),
given by ,
is constructed by first obtaining
using getFaceGrad()
. The axes are rotated ninety degrees.
>>> dxi = phase.faceGrad.dot(((0, 1), (-1, 0)))
>>> anisotropySource = (A * dxi).divergence
The phase equation can now be constructed.
>>> phaseEq = TransientTerm(tau) == ExplicitDiffusionTerm(D) + \
... ImplicitSourceTerm(mVar * ((mVar < 0) - phase)) + \
... ((mVar > 0.) * mVar * phase + anisotropySource)
The temperature equation is built in the following way,
>>> temperatureEq = TransientTerm() == \
... DiffusionTerm(tempDiffusionCoeff) + \
... (phase - phase.old) / timeStepDuration
If we are running this example interactively, we create viewers for the phase and temperature fields
>>> if __name__ == '__main__':
... phaseViewer = Viewer(vars=phase)
... temperatureViewer = Viewer(vars=temperature,
... datamin=-0.5, datamax=0.5)
... phaseViewer.plot()
... temperatureViewer.plot()
we iterate the solution in time, plotting as we go if running interactively,
>>> steps = 10
>>> from builtins import range
>>> for i in range(steps):
... phase.updateOld()
... temperature.updateOld()
... phaseEq.solve(phase, dt=timeStepDuration)
... temperatureEq.solve(temperature, dt=timeStepDuration)
... if i%10 == 0 and __name__ == '__main__':
... phaseViewer.plot()
... temperatureViewer.plot()
The solution is compared with test data. The test data was created for
steps = 10
with a FORTRAN code written by Ryo Kobayashi for phase
field modeling. The following code opens the file anisotropy.gz
extracts
the data and compares it with the phase variable.
>>> import os
>>> from future.utils import text_to_native_str
>>> testData = numerix.loadtxt(os.path.splitext(__file__)[0] + text_to_native_str('.gz'))
>>> print(phase.allclose(testData))
1
examples.phase.binary module¶
It is straightforward to extend a phase field model to include binary alloys.
As in examples.phase.simple
, we will examine a 1D problem
>>> from fipy import CellVariable, Variable, Grid1D, TransientTerm, DiffusionTerm, ImplicitSourceTerm, PowerLawConvectionTerm, DefaultAsymmetricSolver, Viewer
>>> from fipy.tools import numerix
>>> nx = 400
>>> dx = 5e-6 # cm
>>> L = nx * dx
>>> mesh = Grid1D(dx=dx, nx=nx)
The Helmholtz free energy functional can be written as the integral [1] [3] [24]
over the volume as a function of phase
[#phi]_
>>> phase = CellVariable(name="phase", mesh=mesh, hasOld=1)
composition
>>> C = CellVariable(name="composition", mesh=mesh, hasOld=1)
and temperature [#T]_
>>> T = Variable(name="temperature")
Frequently, the gradient energy term in concentration is ignored and we can derive governing equations
(1)¶
for phase and
(2)¶
for solute.
The free energy density can be constructed in many
different ways. One approach is to construct free energy densities for
each of the pure components, as functions of phase, e.g.
where ,
,
, and
are the free energy densities of the pure components. There are a
variety of choices for the interpolation function
and the
barrier function
,
such as those shown in examples.phase.simple
>>> def p(phi):
... return phi**3 * (6 * phi**2 - 15 * phi + 10)
>>> def g(phi):
... return (phi * (1 - phi))**2
The desired thermodynamic model can then be applied to obtain
, such as for a regular solution,
where
>>> R = 8.314 # J / (mol K)
is the gas constant and and
are the
regular solution interaction parameters for solid and liquid.
Another approach is useful when the free energy densities
and
of the alloy in the solid and liquid phases are
known. This might be the case when the two different phases have
different thermodynamic models or when one or both is obtained from a
Calphad code. In this case, we can construct
When the thermodynamic models are the same in both phases, both approaches should yield the same result.
We choose the first approach and make the simplifying assumptions of an ideal solution and that
and likewise for component .
>>> LA = 2350. # J / cm**3
>>> LB = 1728. # J / cm**3
>>> TmA = 1728. # K
>>> TmB = 1358. # K
>>> enthalpyA = LA * (T - TmA) / TmA
>>> enthalpyB = LB * (T - TmB) / TmB
This relates the difference between the free energy densities of the
pure solid and pure liquid phases to the latent heat and the
pure component melting point
, such that
With these assumptions
(3)¶
and
(4)¶
where and
are the classical chemical potentials
for the binary species.
and
are the
partial derivatives of of
and
with respect to
>>> def pPrime(phi):
... return 30. * g(phi)
>>> def gPrime(phi):
... return 2. * phi * (1 - phi) * (1 - 2 * phi)
is the molar volume, which we take to be independent of
concentration and phase
>>> Vm = 7.42 # cm**3 / mol
On comparison with examples.phase.simple
, we can see that the
present form of the phase field equation is identical to the one found
earlier, with the source now composed of the concentration-weighted average
of the source for either pure component. We let the pure component barriers
equal the previous value
>>> deltaA = deltaB = 1.5 * dx
>>> sigmaA = 3.7e-5 # J / cm**2
>>> sigmaB = 2.9e-5 # J / cm**2
>>> betaA = 0.33 # cm / (K s)
>>> betaB = 0.39 # cm / (K s)
>>> kappaA = 6 * sigmaA * deltaA # J / cm
>>> kappaB = 6 * sigmaB * deltaB # J / cm
>>> WA = 6 * sigmaA / deltaA # J / cm**3
>>> WB = 6 * sigmaB / deltaB # J / cm**3
and define the averages
>>> W = (1 - C) * WA / 2. + C * WB / 2.
>>> enthalpy = (1 - C) * enthalpyA + C * enthalpyB
We can now linearize the source exactly as before
>>> mPhi = -((1 - 2 * phase) * W + 30 * phase * (1 - phase) * enthalpy)
>>> dmPhidPhi = 2 * W - 30 * (1 - 2 * phase) * enthalpy
>>> S1 = dmPhidPhi * phase * (1 - phase) + mPhi * (1 - 2 * phase)
>>> S0 = mPhi * phase * (1 - phase) - S1 * phase
Using the same gradient energy coefficient and phase field mobility
>>> kappa = (1 - C) * kappaA + C * kappaB
>>> Mphi = TmA * betaA / (6 * LA * deltaA)
we define the phase field equation
>>> phaseEq = (TransientTerm(1/Mphi) == DiffusionTerm(coeff=kappa)
... + S0 + ImplicitSourceTerm(coeff=S1))
When coding explicitly, it is typical to simply write a function to
evaluate the chemical potentials and
and then
perform the finite differences necessary to calculate their gradient and
divergence, e.g.,:
def deltaChemPot(phase, C, T):
return ((Vm * (enthalpyB * p(phase) + WA * g(phase)) + R * T * log(1 - C)) -
(Vm * (enthalpyA * p(phase) + WA * g(phase)) + R * T * log(C)))
for j in range(faces):
flux[j] = ((Mc[j+.5] + Mc[j-.5]) / 2) * (deltaChemPot(phase[j+.5], C[j+.5], T) - deltaChemPot(phase[j-.5], C[j-.5], T)) / dx
for j in range(cells):
diffusion = (flux[j+.5] - flux[j-.5]) / dx
where we neglect the details of the outer boundaries (j = 0
and j = N
)
or exactly how to translate j+.5
or j-.5
into an array index,
much less the complexities of higher dimensions. FiPy can handle all of
these issues automatically, so we could just write:
chemPotA = Vm * (enthalpyA * p(phase) + WA * g(phase)) + R * T * log(C)
chemPotB = Vm * (enthalpyB * p(phase) + WB * g(phase)) + R * T * log(1-C)
flux = Mc * (chemPotB - chemPotA).faceGrad
eq = TransientTerm() == flux.divergence
Although the second syntax would essentially work as written, such an explicit implementation would be very slow. In order to take advantage of FiPy’s implicit solvers, it is necessary to reduce Eq. (2) to the canonical form of Eq. (2), hence we must expand Eq. (4) as
In either bulk phase, , so
we can then reduce Eq. (2) to
(5)¶
and, by comparison with Fick’s second law
we can associate the mobility with the intrinsic diffusivity
by
and write Eq. (2) as
(6)¶
The first term is clearly a DiffusionTerm
. The second is less
obvious, but by factoring out , we can see that this is a
ConvectionTerm
with a velocity
due to phase transformation, such that
or
>>> Dl = Variable(value=1e-5) # cm**2 / s
>>> Ds = Variable(value=1e-9) # cm**2 / s
>>> D = (Ds - Dl) * phase.arithmeticFaceValue + Dl
>>> phaseTransformationVelocity = (((enthalpyB - enthalpyA) * p(phase).faceGrad
... + 0.5 * (WB - WA) * g(phase).faceGrad)
... * D * (1. - C).harmonicFaceValue * Vm / (R * T))
>>> diffusionEq = (TransientTerm()
... == DiffusionTerm(coeff=D)
... + PowerLawConvectionTerm(coeff=phaseTransformationVelocity))
We initialize the phase field to a step function in the middle of the domain
>>> phase.setValue(1.)
>>> phase.setValue(0., where=mesh.cellCenters[0] > L/2.)
and start with a uniform composition field
>>> C.setValue(0.5)
In equilibrium, and
and, for ideal solutions, we can
deduce the liquidus and solidus compositions as
>>> Cl = ((1. - numerix.exp(-enthalpyA * Vm / (R * T)))
... / (numerix.exp(-enthalpyB * Vm / (R * T)) - numerix.exp(-enthalpyA * Vm / (R * T))))
>>> Cs = numerix.exp(-enthalpyB * Vm / (R * T)) * Cl
The phase fraction is predicted by the lever rule
>>> Cavg = C.cellVolumeAverage
>>> fraction = (Cl - Cavg) / (Cl - Cs)
For the special case of fraction = Cavg = 0.5
, a little bit of algebra
reveals that the temperature that leaves the phase fraction unchanged is
given by
>>> T.setValue((LA + LB) * TmA * TmB / (LA * TmB + LB * TmA))
In this simple, binary, ideal solution case, we can derive explicit expressions for the solidus and liquidus compositions. In general, this may not be possible or practical. In that event, the root-finding facilities in SciPy can be used.
We’ll need a function to return the two conditions for equilibrium
>>> def equilibrium(C):
... return [numerix.array(enthalpyA * Vm
... + R * T * numerix.log(1 - C[0])
... - R * T * numerix.log(1 - C[1])),
... numerix.array(enthalpyB * Vm
... + R * T * numerix.log(C[0])
... - R * T * numerix.log(C[1]))]
and we’ll have much better luck if we also supply the Jacobian
>>> def equilibriumJacobian(C):
... return R * T * numerix.array([[-1. / (1 - C[0]), 1. / (1 - C[1])],
... [ 1. / C[0], -1. / C[1]]])
>>> try:
... from scipy.optimize import fsolve
... CsRoot, ClRoot = fsolve(func=equilibrium, x0=[0.5, 0.5],
... fprime=equilibriumJacobian)
... except ImportError:
... ClRoot = CsRoot = 0
... print("The SciPy library is not available to calculate the solidus and ... liquidus concentrations")
>>> print(Cl.allclose(ClRoot))
1
>>> print(Cs.allclose(CsRoot))
1
We plot the result against the sharp interface solution
>>> sharp = CellVariable(name="sharp", mesh=mesh)
>>> x = mesh.cellCenters[0]
>>> sharp.setValue(Cs, where=x < L * fraction)
>>> sharp.setValue(Cl, where=x >= L * fraction)
>>> if __name__ == '__main__':
... viewer = Viewer(vars=(phase, C, sharp),
... datamin=0., datamax=1.)
... viewer.plot()
Because the phase field interface will not move, and because we’ve seen in earlier examples that the diffusion problem is unconditionally stable, we need take only one very large timestep to reach equilibrium
>>> dt = 1.e5
Because the phase field equation is coupled to the composition through
enthalpy
and W
and the diffusion equation is coupled to the phase
field through phaseTransformationVelocity
, it is necessary sweep this
non-linear problem to convergence. We use the “residual” of the equations
(a measure of how well they think they have solved the given set of linear
equations) as a test for how long to sweep. Because of the
ConvectionTerm
, the solution matrix for diffusionEq
is asymmetric
and cannot be solved by the default LinearPCGSolver
. Therefore, we use a
DefaultAsymmetricSolver
for this equation.
We now use the “sweep()
” method instead of
“solve()
” because we require the residual.
>>> solver = DefaultAsymmetricSolver(tolerance=1e-10)
>>> phase.updateOld()
>>> C.updateOld()
>>> phaseRes = 1e+10
>>> diffRes = 1e+10
>>> while phaseRes > 1e-3 or diffRes > 1e-3:
... phaseRes = phaseEq.sweep(var=phase, dt=dt)
... diffRes = diffusionEq.sweep(var=C, dt=dt, solver=solver)
>>> from fipy import input
>>> if __name__ == '__main__':
... viewer.plot()
... input("Stationary phase field. Press <return> to proceed...")
We verify that the bulk phases have shifted to the predicted solidus and liquidus compositions
>>> X = mesh.faceCenters[0]
>>> print(Cs.allclose(C.faceValue[X.value==0], atol=2e-4))
True
>>> print(Cl.allclose(C.faceValue[X.value==L], atol=2e-4))
True
and that the phase fraction remains unchanged
>>> print(fraction.allclose(phase.cellVolumeAverage, atol=2e-4))
1
while conserving mass overall
>>> print(Cavg.allclose(0.5, atol=1e-8))
1
We now quench by ten degrees
>>> T.setValue(T() - 10.) # K
>>> sharp.setValue(Cs, where=x < L * fraction)
>>> sharp.setValue(Cl, where=x >= L * fraction)
Because this lower temperature will induce the phase interface to move (solidify), we will need to take much smaller timesteps (the time scales of diffusion and of phase transformation compete with each other).
The CFL limit requires that no interface should advect more than one grid spacing in a timestep. We can get a rough idea for the maximum timestep we can take by looking at the velocity of convection induced by phase transformation in Eq. (6). If we assume that the phase changes from 1 to 0 in a single grid spacing, that the diffusivity is Dl at the interface, and that the term due to the difference in barrier heights is negligible:
To get a , we need a
time step of about
.
>>> dt = 1.e-5
>>> if __name__ == '__main__':
... timesteps = 100
... else:
... timesteps = 10
>>> from builtins import range
>>> for i in range(timesteps):
... phase.updateOld()
... C.updateOld()
... phaseRes = 1e+10
... diffRes = 1e+10
... while phaseRes > 1e-3 or diffRes > 1e-3:
... phaseRes = phaseEq.sweep(var=phase, dt=dt)
... diffRes = diffusionEq.sweep(var=C, dt=dt, solver=solver)
... if __name__ == '__main__':
... viewer.plot()
>>> from fipy import input
>>> if __name__ == '__main__':
... input("Moving phase field. Press <return> to proceed...")
We see that the composition on either side of the interface approach the sharp-interface solidus and liquidus, but it will take a great many more timesteps to reach equilibrium. If we waited sufficiently long, we could again verify the final concentrations and phase fraction against the expected values.
Footnotes
examples.phase.binaryCoupled module¶
Simultaneously solve a phase-field evolution and solute diffusion problem in one-dimension.
It is straightforward to extend a phase field model to include binary alloys.
As in examples.phase.simple
, we will examine a 1D problem
>>> from fipy import CellVariable, Variable, Grid1D, TransientTerm, DiffusionTerm, ImplicitSourceTerm, LinearLUSolver, Viewer
>>> from fipy.tools import numerix
>>> nx = 400
>>> dx = 5e-6 # cm
>>> L = nx * dx
>>> mesh = Grid1D(dx=dx, nx=nx)
The Helmholtz free energy functional can be written as the integral [1] [3] [24]
over the volume as a function of phase
[#phi]_
>>> phase = CellVariable(name="phase", mesh=mesh, hasOld=1)
composition
>>> C = CellVariable(name="composition", mesh=mesh, hasOld=1)
and temperature [#T]_
>>> T = Variable(name="temperature")
Frequently, the gradient energy term in concentration is ignored and we can derive governing equations
(7)¶
for phase and
(8)¶
for solute.
The free energy density can be constructed in many
different ways. One approach is to construct free energy densities for
each of the pure components, as functions of phase, e.g.
where ,
,
, and
are the free energy densities of the pure components. There are a
variety of choices for the interpolation function
and the
barrier function
,
such as those shown in examples.phase.simple
>>> def p(phi):
... return phi**3 * (6 * phi**2 - 15 * phi + 10)
>>> def g(phi):
... return (phi * (1 - phi))**2
The desired thermodynamic model can then be applied to obtain
, such as for a regular solution,
where
>>> R = 8.314 # J / (mol K)
is the gas constant and and
are the
regular solution interaction parameters for solid and liquid.
Another approach is useful when the free energy densities
and
of the alloy in the solid and liquid phases are
known. This might be the case when the two different phases have
different thermodynamic models or when one or both is obtained from a
Calphad code. In this case, we can construct
When the thermodynamic models are the same in both phases, both approaches should yield the same result.
We choose the first approach and make the simplifying assumptions of an ideal solution and that
and likewise for component .
>>> LA = 2350. # J / cm**3
>>> LB = 1728. # J / cm**3
>>> TmA = 1728. # K
>>> TmB = 1358. # K
>>> enthalpyA = LA * (T - TmA) / TmA
>>> enthalpyB = LB * (T - TmB) / TmB
This relates the difference between the free energy densities of the
pure solid and pure liquid phases to the latent heat and the
pure component melting point
, such that
With these assumptions
(9)¶
and
(10)¶
where and
are the classical chemical potentials
for the binary species.
and
are the
partial derivatives of of
and
with respect to
>>> def pPrime(phi):
... return 30. * g(phi)
>>> def gPrime(phi):
... return 2. * phi * (1 - phi) * (1 - 2 * phi)
is the molar volume, which we take to be independent of
concentration and phase
>>> Vm = 7.42 # cm**3 / mol
On comparison with examples.phase.simple
, we can see that the
present form of the phase field equation is identical to the one found
earlier, with the source now composed of the concentration-weighted average
of the source for either pure component. We let the pure component barriers
equal the previous value
>>> deltaA = deltaB = 1.5 * dx
>>> sigmaA = 3.7e-5 # J / cm**2
>>> sigmaB = 2.9e-5 # J / cm**2
>>> betaA = 0.33 # cm / (K s)
>>> betaB = 0.39 # cm / (K s)
>>> kappaA = 6 * sigmaA * deltaA # J / cm
>>> kappaB = 6 * sigmaB * deltaB # J / cm
>>> WA = 6 * sigmaA / deltaA # J / cm**3
>>> WB = 6 * sigmaB / deltaB # J / cm**3
and define the averages
>>> W = (1 - C) * WA / 2. + C * WB / 2.
>>> enthalpy = (1 - C) * enthalpyA + C * enthalpyB
We can now linearize the source exactly as before
>>> mPhi = -((1 - 2 * phase) * W + 30 * phase * (1 - phase) * enthalpy)
>>> dmPhidPhi = 2 * W - 30 * (1 - 2 * phase) * enthalpy
>>> S1 = dmPhidPhi * phase * (1 - phase) + mPhi * (1 - 2 * phase)
>>> S0 = mPhi * phase * (1 - phase) - S1 * phase
Using the same gradient energy coefficient and phase field mobility
>>> kappa = (1 - C) * kappaA + C * kappaB
>>> Mphi = TmA * betaA / (6 * LA * deltaA)
we define the phase field equation
>>> phaseEq = (TransientTerm(1/Mphi, var=phase) == DiffusionTerm(coeff=kappa, var=phase)
... + S0 + ImplicitSourceTerm(coeff=S1, var=phase))
When coding explicitly, it is typical to simply write a function to
evaluate the chemical potentials and
and then
perform the finite differences necessary to calculate their gradient and
divergence, e.g.,:
def deltaChemPot(phase, C, T):
return ((Vm * (enthalpyB * p(phase) + WA * g(phase)) + R * T * log(1 - C)) -
(Vm * (enthalpyA * p(phase) + WA * g(phase)) + R * T * log(C)))
for j in range(faces):
flux[j] = ((Mc[j+.5] + Mc[j-.5]) / 2) \
* (deltaChemPot(phase[j+.5], C[j+.5], T) \
- deltaChemPot(phase[j-.5], C[j-.5], T)) / dx
for j in range(cells):
diffusion = (flux[j+.5] - flux[j-.5]) / dx
where we neglect the details of the outer boundaries (j = 0
and j = N
)
or exactly how to translate j+.5
or j-.5
into an array index,
much less the complexities of higher dimensions. FiPy can handle all of
these issues automatically, so we could just write:
chemPotA = Vm * (enthalpyA * p(phase) + WA * g(phase)) + R * T * log(C)
chemPotB = Vm * (enthalpyB * p(phase) + WB * g(phase)) + R * T * log(1-C)
flux = Mc * (chemPotB - chemPotA).faceGrad
eq = TransientTerm() == flux.divergence
Although the second syntax would essentially work as written, such an explicit implementation would be very slow. In order to take advantage of FiPy’s implicit solvers, it is necessary to reduce Eq. (2) to the canonical form of Eq. (2), hence we must expand Eq. (4) as
In either bulk phase, , so
we can then reduce Eq. (2) to
(11)¶
and, by comparison with Fick’s second law
we can associate the mobility with the intrinsic diffusivity
by
and write Eq. (2) as
(12)¶
The first term is clearly a DiffusionTerm
in . The second is a
DiffusionTerm
in with a diffusion coefficient
such that
or
>>> Dl = Variable(value=1e-5) # cm**2 / s
>>> Ds = Variable(value=1e-9) # cm**2 / s
>>> Dc = (Ds - Dl) * phase.arithmeticFaceValue + Dl
>>> Dphi = ((Dc * C.harmonicFaceValue * (1 - C.harmonicFaceValue) * Vm / (R * T))
... * ((enthalpyB - enthalpyA) * pPrime(phase.arithmeticFaceValue)
... + 0.5 * (WB - WA) * gPrime(phase.arithmeticFaceValue)))
>>> diffusionEq = (TransientTerm(var=C)
... == DiffusionTerm(coeff=Dc, var=C)
... + DiffusionTerm(coeff=Dphi, var=phase))
>>> eq = phaseEq & diffusionEq
We initialize the phase field to a step function in the middle of the domain
>>> phase.setValue(1.)
>>> phase.setValue(0., where=mesh.cellCenters[0] > L/2.)
and start with a uniform composition field
>>> C.setValue(0.5)
In equilibrium, and
and, for ideal solutions, we can
deduce the liquidus and solidus compositions as
>>> Cl = ((1. - numerix.exp(-enthalpyA * Vm / (R * T)))
... / (numerix.exp(-enthalpyB * Vm / (R * T)) - numerix.exp(-enthalpyA * Vm / (R * T))))
>>> Cs = numerix.exp(-enthalpyB * Vm / (R * T)) * Cl
The phase fraction is predicted by the lever rule
>>> Cavg = C.cellVolumeAverage
>>> fraction = (Cl - Cavg) / (Cl - Cs)
For the special case of fraction = Cavg = 0.5
, a little bit of algebra
reveals that the temperature that leaves the phase fraction unchanged is
given by
>>> T.setValue((LA + LB) * TmA * TmB / (LA * TmB + LB * TmA))
In this simple, binary, ideal solution case, we can derive explicit expressions for the solidus and liquidus compositions. In general, this may not be possible or practical. In that event, the root-finding facilities in SciPy can be used.
We’ll need a function to return the two conditions for equilibrium
>>> def equilibrium(C):
... return [numerix.array(enthalpyA * Vm
... + R * T * numerix.log(1 - C[0])
... - R * T * numerix.log(1 - C[1])),
... numerix.array(enthalpyB * Vm
... + R * T * numerix.log(C[0])
... - R * T * numerix.log(C[1]))]
and we’ll have much better luck if we also supply the Jacobian
>>> def equilibriumJacobian(C):
... return R * T * numerix.array([[-1. / (1 - C[0]), 1. / (1 - C[1])],
... [ 1. / C[0], -1. / C[1]]])
>>> try:
... from scipy.optimize import fsolve
... CsRoot, ClRoot = fsolve(func=equilibrium, x0=[0.5, 0.5],
... fprime=equilibriumJacobian)
... except ImportError:
... ClRoot = CsRoot = 0
... print("The SciPy library is not available to calculate the solidus and ... liquidus concentrations")
>>> print(Cl.allclose(ClRoot))
1
>>> print(Cs.allclose(CsRoot))
1
We plot the result against the sharp interface solution
>>> sharp = CellVariable(name="sharp", mesh=mesh)
>>> x = mesh.cellCenters[0]
>>> sharp.setValue(Cs, where=x < L * fraction)
>>> sharp.setValue(Cl, where=x >= L * fraction)
>>> if __name__ == '__main__':
... viewer = Viewer(vars=(phase, C, sharp),
... datamin=0., datamax=1.)
... viewer.plot()
Because the phase field interface will not move, and because we’ve seen in earlier examples that the diffusion problem is unconditionally stable, we need take only one very large timestep to reach equilibrium
>>> dt = 1.e5
Because the phase field equation is coupled to the composition through
enthalpy
and W
and the diffusion equation is coupled to the phase
field through phaseTransformationVelocity
, it is necessary sweep this
non-linear problem to convergence. We use the “residual” of the equations
(a measure of how well they think they have solved the given set of linear
equations) as a test for how long to sweep.
We now use the “sweep()
” method instead of
“solve()
” because we require the residual.
>>> solver = LinearLUSolver(tolerance=1e-10)
>>> phase.updateOld()
>>> C.updateOld()
>>> res = 1.
>>> initialRes = None
>>> sweep = 0
>>> while res > 1e-4 and sweep < 20:
... res = eq.sweep(dt=dt, solver=solver)
... if initialRes is None:
... initialRes = res
... res = res / initialRes
... sweep += 1
>>> from fipy import input
>>> if __name__ == '__main__':
... viewer.plot()
... input("Stationary phase field. Press <return> to proceed...")
We verify that the bulk phases have shifted to the predicted solidus and liquidus compositions
>>> X = mesh.faceCenters[0]
>>> print(Cs.allclose(C.faceValue[X.value==0], atol=1e-2))
True
>>> print(Cl.allclose(C.faceValue[X.value==L], atol=1e-2))
True
and that the phase fraction remains unchanged
>>> print(fraction.allclose(phase.cellVolumeAverage, atol=2e-4))
1
while conserving mass overall
>>> print(Cavg.allclose(0.5, atol=1e-8))
1
We now quench by ten degrees
>>> T.setValue(T() - 10.) # K
>>> sharp.setValue(Cs, where=x < L * fraction)
>>> sharp.setValue(Cl, where=x >= L * fraction)
Because this lower temperature will induce the phase interface to move (solidify), we will need to take much smaller timesteps (the time scales of diffusion and of phase transformation compete with each other).
The CFL limit requires that no interface should advect more than one grid spacing in a timestep. We can get a rough idea for the maximum timestep we can take by looking at the velocity of convection induced by phase transformation in Eq. (6) (even though there is no explicit convection in the coupled form used for this example, the principle remains the same). If we assume that the phase changes from 1 to 0 in a single grid spacing, that the diffusivity is Dl at the interface, and that the term due to the difference in barrier heights is negligible:
To get a , we need a
time step of about
.
>>> dt = 1.e-5
>>> if __name__ == '__main__':
... timesteps = 100
... else:
... timesteps = 10
>>> from builtins import range
>>> for i in range(timesteps):
... phase.updateOld()
... C.updateOld()
... res = 1e+10
... sweep = 0
... while res > 1e-3 and sweep < 20:
... res = eq.sweep(dt=dt, solver=solver)
... sweep += 1
... if __name__ == '__main__':
... viewer.plot()
>>> from fipy import input
>>> if __name__ == '__main__':
... input("Moving phase field. Press <return> to proceed...")
We see that the composition on either side of the interface approach the sharp-interface solidus and liquidus, but it will take a great many more timesteps to reach equilibrium. If we waited sufficiently long, we could again verify the final concentrations and phase fraction against the expected values.
Footnotes
We will find that we need to “sweep” this non-linear problem
(see e.g. the composition-dependent diffusivity example in
examples.diffusion.mesh1D
), so we declare and
to retain an “old” value.
we are going to want to
examine different temperatures in this example, so we declare
as a
Variable
examples.phase.polyxtal module¶
Solve the dendritic growth of nuclei and subsequent grain impingement.
To convert a liquid material to a solid, it must be cooled to a temperature below its melting point (known as “undercooling” or “supercooling”). The rate of solidification is often assumed (and experimentally found) to be proportional to the undercooling. Under the right circumstances, the solidification front can become unstable, leading to dendritic patterns. Warren, Kobayashi, Lobkovsky and Carter [13] have described a phase field model (“Allen-Cahn”, “non-conserved Ginsberg-Landau”, or “model A” of Hohenberg & Halperin) of such a system, including the effects of discrete crystalline orientations (anisotropy).
We start with a regular 2D Cartesian mesh
>>> from fipy import CellVariable, Variable, ModularVariable, Grid2D, TransientTerm, DiffusionTerm, ImplicitSourceTerm, MatplotlibViewer, Matplotlib2DGridViewer, MultiViewer
>>> from fipy.tools import numerix
>>> dx = dy = 0.025
>>> if __name__ == "__main__":
... nx = ny = 200
... else:
... nx = ny = 200
>>> mesh = Grid2D(dx=dx, dy=dy, nx=nx, ny=ny)
and we’ll take fixed timesteps
>>> dt = 5e-4
We consider the simultaneous evolution of a “phase field” variable
(taken to be 0 in the liquid phase and 1 in the solid)
>>> phase = CellVariable(name=r'$\phi$', mesh=mesh, hasOld=True)
a dimensionless undercooling
(
at the melting point)
>>> dT = CellVariable(name=r'$\Delta T$', mesh=mesh, hasOld=True)
and an orientation
>>> theta = ModularVariable(name=r'$\theta$', mesh=mesh, hasOld=True)
>>> theta.value = -numerix.pi + 0.0001
The hasOld
flag causes the storage of the value of variable from the
previous timestep. This is necessary for solving equations with
non-linear coefficients or for coupling between PDEs.
The governing equation for the temperature field is the heat flux equation, with a source due to the latent heat of solidification
>>> DT = 2.25
>>> q = Variable(0.)
>>> T_0 = -0.1
>>> heatEq = (TransientTerm()
... == DiffusionTerm(DT)
... + (phase - phase.old) / dt
... + q * T_0 - ImplicitSourceTerm(q))
The governing equation for the phase field is
where
represents a source of anisotropy. The coefficient
is an anisotropic diffusion tensor in two dimensions
where ,
,
,
is the orientation, and
is the symmetry.
>>> alpha = 0.015
>>> c = 0.02
>>> N = 4.
>>> psi = theta.arithmeticFaceValue + numerix.arctan2(phase.faceGrad[1],
... phase.faceGrad[0])
>>> Phi = numerix.tan(N * psi / 2)
>>> PhiSq = Phi**2
>>> beta = (1. - PhiSq) / (1. + PhiSq)
>>> DbetaDpsi = -N * 2 * Phi / (1 + PhiSq)
>>> Ddia = (1.+ c * beta)
>>> Doff = c * DbetaDpsi
>>> I0 = Variable(value=((1, 0), (0, 1)))
>>> I1 = Variable(value=((0, -1), (1, 0)))
>>> D = alpha**2 * Ddia * (Ddia * I0 + Doff * I1)
With these expressions defined, we can construct the phase field equation as
>>> tau_phase = 3e-4
>>> kappa1 = 0.9
>>> kappa2 = 20.
>>> epsilon = 0.008
>>> s = 0.01
>>> thetaMag = theta.grad.mag
>>> phaseEq = (TransientTerm(tau_phase)
... == DiffusionTerm(D)
... + ImplicitSourceTerm((phase - 0.5 - kappa1 / numerix.pi * numerix.arctan(kappa2 * dT))
... * (1 - phase)
... - (2 * s + epsilon**2 * thetaMag) * thetaMag))
The governing equation for orientation is given by
where
The theta
equation is built in the following way. The details for
this equation are fairly involved, see J. A. Warren et al.. The main
detail is that a source must be added to correct for the
discretization of theta
on the circle.
>>> tau_theta = 3e-3
>>> mu = 1e3
>>> gamma = 1e3
>>> thetaSmallValue = 1e-6
>>> phaseMod = phase + ( phase < thetaSmallValue ) * thetaSmallValue
>>> beta_theta = 1e5
>>> expo = epsilon * beta_theta * theta.grad.mag
>>> expo = (expo < 100.) * (expo - 100.) + 100.
>>> Pfunc = 1. + numerix.exp(-expo) * (mu / epsilon - 1.)
>>> gradMagTheta = theta.faceGrad.mag
>>> eps = 1. / gamma / 10.
>>> gradMagTheta += (gradMagTheta < eps) * eps
>>> IGamma = (gradMagTheta > 1. / gamma) * (1 / gradMagTheta - gamma) + gamma
>>> v_theta = phase.arithmeticFaceValue * (s * IGamma + epsilon**2)
>>> D_theta = phase.arithmeticFaceValue**2 * (s * IGamma + epsilon**2)
The source term requires the evaluation of the face gradient without
the modular operator. theta
faceGradNoMod
evaluates the gradient without modular arithmetic.
>>> thetaEq = (TransientTerm(tau_theta * phaseMod**2 * Pfunc)
... == DiffusionTerm(D_theta)
... + (D_theta * (theta.faceGrad - theta.faceGradNoMod)).divergence)
We seed a circular solidified region in the center
>>> x, y = mesh.cellCenters
>>> numSeeds = 10
>>> numerix.random.seed(12345)
>>> for Cx, Cy, orientation in numerix.random.random([numSeeds, 3]):
... radius = dx * 5.
... seed = ((x - Cx * nx * dx)**2 + (y - Cy * ny * dy)**2) < radius**2
... phase[seed] = 1.
... theta[seed] = numerix.pi * (2 * orientation - 1)
and quench the entire simulation domain below the melting point
>>> dT.setValue(-0.5)
In a real solidification process, dendritic branching is induced by small thermal
fluctuations along an otherwise smooth surface, but the granularity of the
Mesh
is enough “noise” in this case, so we don’t need to explicitly
introduce randomness, the way we did in the Cahn-Hilliard problem.
FiPy’s viewers are utilitarian, striving to let the user see something, regardless of their operating system or installed packages, so you the default color scheme of grain orientation won’t be very informative “out of the box”. Because all of Python is accessible and FiPy is object oriented, it is not hard to adapt one of the existing viewers to create a specialized display:
>>> from builtins import zip
>>> if __name__ == "__main__":
... try:
... class OrientationViewer(Matplotlib2DGridViewer):
... def __init__(self, phase, orientation, title=None, limits={}, **kwlimits):
... self.phase = phase
... Matplotlib2DGridViewer.__init__(self, vars=(orientation,), title=title,
... limits=limits, colorbar=None, **kwlimits)
...
... # make room for non-existent colorbar
... # stolen from matplotlib.colorbar.make_axes
... # https://github.com/matplotlib/matplotlib/blob
... # /ec1cd2567521c105a451ce15e06de10715f8b54d/lib
... # /matplotlib/colorbar.py#L838
... fraction = 0.15
... pb = self.axes.get_position(original=True).frozen()
... pad = 0.05
... x1 = 1.0-fraction
... pb1, pbx, pbcb = pb.splitx(x1-pad, x1)
... panchor = (1.0, 0.5)
... self.axes.set_position(pb1)
... self.axes.set_anchor(panchor)
...
... # make the gnomon
... fig = self.axes.get_figure()
... self.gnomon = fig.add_axes([0.85, 0.425, 0.15, 0.15], polar=True)
... self.gnomon.set_thetagrids([180, 270, 0, 90],
... [r"$\pm\pi$", r"$-\frac{\pi}{2}$", "$0$", r"$+\frac{\pi}{2}$"],
... frac=1.3)
... self.gnomon.set_theta_zero_location("N")
... self.gnomon.set_theta_direction(-1)
... self.gnomon.set_rgrids([1.], [""])
... N = 100
... theta = numerix.arange(-numerix.pi, numerix.pi, 2 * numerix.pi / N)
... radii = numerix.ones((N,))
... bars = self.gnomon.bar(theta, radii, width=2 * numerix.pi / N, bottom=0.0)
... colors = self._orientation_and_phase_to_rgb(orientation=numerix.array([theta]), phase=1.)
... for c, t, bar in zip(colors[0], theta, bars):
... bar.set_facecolor(c)
... bar.set_edgecolor(c)
...
... def _reshape(self, var):
... '''return values of var in an 2D array'''
... return numerix.reshape(numerix.array(var),
... var.mesh.shape[::-1])[::-1]
...
... @staticmethod
... def _orientation_and_phase_to_rgb(orientation, phase):
... from matplotlib import colors
...
... hsv = numerix.empty(orientation.shape + (3,))
... hsv[..., 0] = (orientation / numerix.pi + 1) / 2.
... hsv[..., 1] = 1.
... hsv[..., 2] = phase
...
... return colors.hsv_to_rgb(hsv)
...
... @property
... def _data(self):
... '''convert phase and orientation to rgb image array
...
... orientation (-pi, pi) -> hue (0, 1)
... phase (0, 1) -> value (0, 1)
... '''
... orientation = self._reshape(self.vars[0])
... phase = self._reshape(self.phase)
...
... return self._orientation_and_phase_to_rgb(orientation, phase)
...
... def _plot(self):
... self.image.set_data(self._data)
...
... from matplotlib import pyplot
... pyplot.ion()
... w, h = pyplot.figaspect(1.)
... fig = pyplot.figure(figsize=(2*w, h))
... timer = fig.text(0.1, 0.9, "t = %.3f" % 0, fontsize=18)
...
... viewer = MultiViewer(viewers=(MatplotlibViewer(vars=dT,
... cmap=pyplot.cm.hot,
... datamin=-0.5,
... datamax=0.5,
... axes=fig.add_subplot(121)),
... OrientationViewer(phase=phase,
... orientation=theta,
... title=theta.name,
... axes=fig.add_subplot(122))))
... except ImportError:
... viewer = MultiViewer(viewers=(Viewer(vars=dT,
... datamin=-0.5,
... datamax=0.5),
... Viewer(vars=phase,
... datamin=0.,
... datamax=1.),
... Viewer(vars=theta,
... datamin=-numerix.pi,
... datamax=numerix.pi)))
... viewer.plot()
and iterate the solution in time, plotting as we go,
>>> if __name__ == "__main__":
... total_time = 2.
... else:
... total_time = dt * 10
>>> elapsed = 0.
>>> save_interval = 0.002
>>> save_at = save_interval
>>> while elapsed < total_time:
... if elapsed > 0.3:
... q.value = 100
... phase.updateOld()
... dT.updateOld()
... theta.updateOld()
... thetaEq.solve(theta, dt=dt)
... phaseEq.solve(phase, dt=dt)
... heatEq.solve(dT, dt=dt)
... elapsed += dt
... if __name__ == "__main__" and elapsed >= save_at:
... timer.set_text("t = %.3f" % elapsed)
... viewer.plot()
... save_at += save_interval
The non-uniform temperature results from the release of latent heat at the solidifying interface. The dendrite arms grow fastest where the temperature gradient is steepest.
examples.phase.polyxtalCoupled module¶
Simultaneously solve the dendritic growth of nuclei and subsequent grain impingement.
To convert a liquid material to a solid, it must be cooled to a temperature below its melting point (known as “undercooling” or “supercooling”). The rate of solidification is often assumed (and experimentally found) to be proportional to the undercooling. Under the right circumstances, the solidification front can become unstable, leading to dendritic patterns. Warren, Kobayashi, Lobkovsky and Carter [13] have described a phase field model (“Allen-Cahn”, “non-conserved Ginsberg-Landau”, or “model A” of Hohenberg & Halperin) of such a system, including the effects of discrete crystalline orientations (anisotropy).
We start with a regular 2D Cartesian mesh
>>> from fipy import CellVariable, Variable, ModularVariable, Grid2D, TransientTerm, DiffusionTerm, ImplicitSourceTerm, PowerLawConvectionTerm, MatplotlibViewer, Matplotlib2DGridViewer, MultiViewer
>>> from fipy.tools import numerix
>>> dx = dy = 0.025
>>> if __name__ == "__main__":
... nx = ny = 200
... else:
... nx = ny = 20
>>> mesh = Grid2D(dx=dx, dy=dy, nx=nx, ny=ny)
and we’ll take fixed timesteps
>>> dt = 5e-4
We consider the simultaneous evolution of a “phase field” variable
(taken to be 0 in the liquid phase and 1 in the solid)
>>> phase = CellVariable(name=r'$\phi$', mesh=mesh, hasOld=True)
a dimensionless undercooling
(
at the melting point)
>>> dT = CellVariable(name=r'$\Delta T$', mesh=mesh, hasOld=True)
and an orientation
>>> theta = ModularVariable(name=r'$\theta$', mesh=mesh, hasOld=True)
>>> theta.value = -numerix.pi + 0.0001
The hasOld
flag causes the storage of the value of variable from the
previous timestep. This is necessary for solving equations with
non-linear coefficients or for coupling between PDEs.
The governing equation for the temperature field is the heat flux equation, with a source due to the latent heat of solidification
>>> DT = 2.25
>>> q = Variable(0.)
>>> T_0 = -0.1
>>> heatEq = (TransientTerm(var=dT)
... == DiffusionTerm(coeff=DT, var=dT)
... + TransientTerm(var=phase)
... + q * T_0 - ImplicitSourceTerm(coeff=q, var=dT))
The governing equation for the phase field is
where
represents a source of anisotropy. The coefficient
is an anisotropic diffusion tensor in two dimensions
where ,
,
,
is the orientation, and
is the symmetry.
>>> alpha = 0.015
>>> c = 0.02
>>> N = 4.
>>> psi = theta.arithmeticFaceValue + numerix.arctan2(phase.faceGrad[1],
... phase.faceGrad[0])
>>> Phi = numerix.tan(N * psi / 2)
>>> PhiSq = Phi**2
>>> beta = (1. - PhiSq) / (1. + PhiSq)
>>> DbetaDpsi = -N * 2 * Phi / (1 + PhiSq)
>>> Ddia = (1.+ c * beta)
>>> Doff = c * DbetaDpsi
>>> I0 = Variable(value=((1, 0), (0, 1)))
>>> I1 = Variable(value=((0, -1), (1, 0)))
>>> D = alpha**2 * Ddia * (Ddia * I0 + Doff * I1)
With these expressions defined, we can construct the phase field equation as
>>> tau_phase = 3e-4
>>> kappa1 = 0.9
>>> kappa2 = 20.
>>> epsilon = 0.008
>>> s = 0.01
>>> thetaMag = theta.grad.mag
>>> phaseEq = (TransientTerm(coeff=tau_phase, var=phase)
... == DiffusionTerm(coeff=D, var=phase)
... + ImplicitSourceTerm(coeff=((phase - 0.5 - kappa1 / numerix.pi * numerix.arctan(kappa2 * dT))
... * (1 - phase)
... - (2 * s + epsilon**2 * thetaMag) * thetaMag),
... var=phase))
The governing equation for orientation is given by
where
The theta
equation is built in the following way. The details for
this equation are fairly involved, see J. A. Warren et al.. The main
detail is that a source must be added to correct for the
discretization of theta
on the circle.
>>> tau_theta = 3e-3
>>> mu = 1e3
>>> gamma = 1e3
>>> thetaSmallValue = 1e-6
>>> phaseMod = phase + ( phase < thetaSmallValue ) * thetaSmallValue
>>> beta_theta = 1e5
>>> expo = epsilon * beta_theta * theta.grad.mag
>>> expo = (expo < 100.) * (expo - 100.) + 100.
>>> Pfunc = 1. + numerix.exp(-expo) * (mu / epsilon - 1.)
>>> gradMagTheta = theta.faceGrad.mag
>>> eps = 1. / gamma / 10.
>>> gradMagTheta += (gradMagTheta < eps) * eps
>>> IGamma = (gradMagTheta > 1. / gamma) * (1 / gradMagTheta - gamma) + gamma
>>> v_theta = phase.arithmeticFaceValue * (s * IGamma + epsilon**2)
>>> D_theta = phase.arithmeticFaceValue**2 * (s * IGamma + epsilon**2)
The source term requires the evaluation of the face gradient without
the modular operator. theta
faceGradNoMod
evaluates the gradient without modular arithmetic.
>>> thetaEq = (TransientTerm(coeff=tau_theta * phaseMod**2 * Pfunc, var=theta)
... == DiffusionTerm(coeff=D_theta, var=theta)
... + PowerLawConvectionTerm(coeff=v_theta * (theta.faceGrad - theta.faceGradNoMod), var=phase))
We seed a circular solidified region in the center
>>> x, y = mesh.cellCenters
>>> numSeeds = 10
>>> numerix.random.seed(12345)
>>> for Cx, Cy, orientation in numerix.random.random([numSeeds, 3]):
... radius = dx * 5.
... seed = ((x - Cx * nx * dx)**2 + (y - Cy * ny * dy)**2) < radius**2
... phase[seed] = 1.
... theta[seed] = numerix.pi * (2 * orientation - 1)
and quench the entire simulation domain below the melting point
>>> dT.setValue(-0.5)
In a real solidification process, dendritic branching is induced by small thermal
fluctuations along an otherwise smooth surface, but the granularity of the
Mesh
is enough “noise” in this case, so we don’t need to explicitly
introduce randomness, the way we did in the Cahn-Hilliard problem.
FiPy’s viewers are utilitarian, striving to let the user see something, regardless of their operating system or installed packages, so you the default color scheme of grain orientation won’t be very informative “out of the box”. Because all of Python is accessible and FiPy is object oriented, it is not hard to adapt one of the existing viewers to create a specialized display:
>>> from builtins import zip
>>> if __name__ == "__main__":
... try:
... class OrientationViewer(Matplotlib2DGridViewer):
... def __init__(self, phase, orientation, title=None, limits={}, **kwlimits):
... self.phase = phase
... Matplotlib2DGridViewer.__init__(self, vars=(orientation,), title=title,
... limits=limits, colorbar=None, **kwlimits)
...
... # make room for non-existent colorbar
... # stolen from matplotlib.colorbar.make_axes
... # https://github.com/matplotlib/matplotlib/blob
... # /ec1cd2567521c105a451ce15e06de10715f8b54d/lib
... # /matplotlib/colorbar.py#L838
... fraction = 0.15
... pb = self.axes.get_position(original=True).frozen()
... pad = 0.05
... x1 = 1.0-fraction
... pb1, pbx, pbcb = pb.splitx(x1-pad, x1)
... panchor = (1.0, 0.5)
... self.axes.set_position(pb1)
... self.axes.set_anchor(panchor)
...
... # make the gnomon
... fig = self.axes.get_figure()
... self.gnomon = fig.add_axes([0.85, 0.425, 0.15, 0.15], polar=True)
... self.gnomon.set_thetagrids([180, 270, 0, 90],
... [r"$\pm\pi$", r"$-\frac{\pi}{2}$", "$0$", r"$+\frac{\pi}{2}$"],
... frac=1.3)
... self.gnomon.set_theta_zero_location("N")
... self.gnomon.set_theta_direction(-1)
... self.gnomon.set_rgrids([1.], [""])
... N = 100
... theta = numerix.arange(-numerix.pi, numerix.pi, 2 * numerix.pi / N)
... radii = numerix.ones((N,))
... bars = self.gnomon.bar(theta, radii, width=2 * numerix.pi / N, bottom=0.0)
... colors = self._orientation_and_phase_to_rgb(orientation=numerix.array([theta]), phase=1.)
... for c, t, bar in zip(colors[0], theta, bars):
... bar.set_facecolor(c)
... bar.set_edgecolor(c)
...
... def _reshape(self, var):
... '''return values of var in an 2D array'''
... return numerix.reshape(numerix.array(var),
... var.mesh.shape[::-1])[::-1]
...
... @staticmethod
... def _orientation_and_phase_to_rgb(orientation, phase):
... from matplotlib import colors
...
... hsv = numerix.empty(orientation.shape + (3,))
... hsv[..., 0] = (orientation / numerix.pi + 1) / 2.
... hsv[..., 1] = 1.
... hsv[..., 2] = phase
...
... return colors.hsv_to_rgb(hsv)
...
... @property
... def _data(self):
... '''convert phase and orientation to rgb image array
...
... orientation (-pi, pi) -> hue (0, 1)
... phase (0, 1) -> value (0, 1)
... '''
... orientation = self._reshape(self.vars[0])
... phase = self._reshape(self.phase)
...
... return self._orientation_and_phase_to_rgb(orientation, phase)
...
... def _plot(self):
... self.image.set_data(self._data)
...
... from matplotlib import pyplot
... pyplot.ion()
... w, h = pyplot.figaspect(1.)
... fig = pyplot.figure(figsize=(2*w, h))
... timer = fig.text(0.1, 0.9, "t = %.3f" % 0, fontsize=18)
...
... viewer = MultiViewer(viewers=(MatplotlibViewer(vars=dT,
... cmap=pyplot.cm.hot,
... datamin=-0.5,
... datamax=0.5,
... axes=fig.add_subplot(121)),
... OrientationViewer(phase=phase,
... orientation=theta,
... title=theta.name,
... axes=fig.add_subplot(122))))
... except ImportError:
... viewer = MultiViewer(viewers=(Viewer(vars=dT,
... datamin=-0.5,
... datamax=0.5),
... Viewer(vars=phase,
... datamin=0.,
... datamax=1.),
... Viewer(vars=theta,
... datamin=-numerix.pi,
... datamax=numerix.pi)))
... viewer.plot()
and iterate the solution in time, plotting as we go,
>>> eq = thetaEq & phaseEq & heatEq
>>> if __name__ == "__main__":
... total_time = 2.
... else:
... total_time = dt * 10
>>> elapsed = 0.
>>> save_interval = 0.002
>>> save_at = save_interval
>>> while elapsed < total_time:
... if elapsed > 0.3:
... q.value = 100
... phase.updateOld()
... dT.updateOld()
... theta.updateOld()
... eq.solve(dt=dt)
... elapsed += dt
... if __name__ == "__main__" and elapsed >= save_at:
... timer.set_text("t = %.3f" % elapsed)
... viewer.plot()
... save_at += save_interval
The non-uniform temperature results from the release of latent heat at the solidifying interface. The dendrite arms grow fastest where the temperature gradient is steepest.
examples.phase.quaternary module¶
Solve a phase-field evolution and diffusion of four species in one-dimension.
The same procedure used to construct the two-component phase field
diffusion problem in examples.phase.binary
can be used to build up a
system of multiple components. Once again, we’ll focus on 1D.
>>> from fipy import CellVariable, Grid1D, TransientTerm, DiffusionTerm, ImplicitSourceTerm, PowerLawConvectionTerm, DefaultAsymmetricSolver, Viewer
>>> from fipy.tools import numerix
>>> nx = 400
>>> dx = 0.01
>>> L = nx * dx
>>> mesh = Grid1D(dx = dx, nx = nx)
We consider a free energy density
that is a function of phase
>>> phase = CellVariable(mesh=mesh, name='phase', value=1., hasOld=1)
interstitial components
>>> interstitials = [
... CellVariable(mesh=mesh, name='C0', hasOld=1)
... ]
substitutional components
>>> substitutionals = [
... CellVariable(mesh=mesh, name='C1', hasOld=1),
... CellVariable(mesh=mesh, name='C2', hasOld=1),
... ]
a “solvent” that is constrained by the concentrations of the
other substitutional species, such that
,
>>> solvent = 1
>>> for Cj in substitutionals:
... solvent -= Cj
>>> solvent.name = 'CN'
and temperature
>>> T = 1000
The free energy density of such a system can be written as
where
>>> R = 8.314 # J / (mol K)
is the gas constant. As in the binary case,
is constructed with the free energies of the pure components in each phase, given the “tilting” function
>>> def p(phi):
... return phi**3 * (6 * phi**2 - 15 * phi + 10)
and the “double well” function
>>> def g(phi):
... return (phi * (1 - phi))**2
We consider a very simplified model that has partial molar volumes
for the “interstitials” and
for the “substitutionals”.
This approximation has been used in a number of models where density
effects are ignored, including the treatment of electrons in
electrodeposition processes [25] [26]. Under these
constraints
and
where and where
is the classical chemical potential of
component
for the binary species and
is the total molar density.
>>> rho = 1.
>>> for Cj in interstitials:
... rho += Cj
and
are the partial derivatives of of
and
with respect to
>>> def pPrime(phi):
... return 30. * g(phi)
>>> def gPrime(phi):
... return 2. * phi * (1 - phi) * (1 - 2 * phi)
We “cook” the standard potentials to give the desired solid and liquid concentrations, with a solid phase rich in interstitials and the solvent and a liquid phase rich in the two substitutional species.
>>> interstitials[0].S = 0.3
>>> interstitials[0].L = 0.4
>>> substitutionals[0].S = 0.4
>>> substitutionals[0].L = 0.3
>>> substitutionals[1].S = 0.2
>>> substitutionals[1].L = 0.1
>>> solvent.S = 1.
>>> solvent.L = 1.
>>> for Cj in substitutionals:
... solvent.S -= Cj.S
... solvent.L -= Cj.L
>>> rhoS = rhoL = 1.
>>> for Cj in interstitials:
... rhoS += Cj.S
... rhoL += Cj.L
>>> for Cj in interstitials + substitutionals + [solvent]:
... Cj.standardPotential = R * T * (numerix.log(Cj.L/rhoL)
... - numerix.log(Cj.S/rhoS))
>>> for Cj in interstitials:
... Cj.diffusivity = 1.
... Cj.barrier = 0.
>>> for Cj in substitutionals:
... Cj.diffusivity = 1.
... Cj.barrier = R * T
>>> solvent.barrier = R * T
We create the phase equation
with a semi-implicit source just as in examples.phase.simple
and
examples.phase.binary
>>> enthalpy = 0.
>>> barrier = 0.
>>> for Cj in interstitials + substitutionals + [solvent]:
... enthalpy += Cj * Cj.standardPotential
... barrier += Cj * Cj.barrier
>>> mPhi = -((1 - 2 * phase) * barrier + 30 * phase * (1 - phase) * enthalpy)
>>> dmPhidPhi = 2 * barrier - 30 * (1 - 2 * phase) * enthalpy
>>> S1 = dmPhidPhi * phase * (1 - phase) + mPhi * (1 - 2 * phase)
>>> S0 = mPhi * phase * (1 - phase) - S1 * phase
>>> phase.mobility = 1.
>>> phase.gradientEnergy = 25
>>> phase.equation = TransientTerm(coeff=1/phase.mobility) \
... == DiffusionTerm(coeff=phase.gradientEnergy) \
... + S0 + ImplicitSourceTerm(coeff = S1)
We could construct the diffusion equations one-by-one, in the manner of
examples.phase.binary
, but it is better to take advantage of the full
scripting power of the Python language, where we can easily loop over
components or even make “factory” functions if we desire. For the
interstitial diffusion equations, we arrange in canonical form as before:
>>> for Cj in interstitials:
... phaseTransformation = (rho.harmonicFaceValue / (R * T)) \
... * (Cj.standardPotential * p(phase).faceGrad
... + 0.5 * Cj.barrier * g(phase).faceGrad)
...
... CkSum = CellVariable(mesh=mesh, value=0.)
... for Ck in [Ck for Ck in interstitials if Ck is not Cj]:
... CkSum += Ck
...
... counterDiffusion = CkSum.faceGrad
...
... convectionCoeff = counterDiffusion + phaseTransformation
... convectionCoeff *= (Cj.diffusivity
... / (1. + CkSum.harmonicFaceValue))
...
... Cj.equation = (TransientTerm()
... == DiffusionTerm(coeff=Cj.diffusivity)
... + PowerLawConvectionTerm(coeff=convectionCoeff))
The canonical form of the substitutional diffusion equations is
>>> for Cj in substitutionals:
... phaseTransformation = (solvent.harmonicFaceValue / (R * T)) \
... * ((Cj.standardPotential - solvent.standardPotential) * p(phase).faceGrad
... + 0.5 * (Cj.barrier - solvent.barrier) * g(phase).faceGrad)
...
... CkSum = CellVariable(mesh=mesh, value=0.)
... for Ck in [Ck for Ck in substitutionals if Ck is not Cj]:
... CkSum += Ck
...
... counterDiffusion = CkSum.faceGrad
...
... convectionCoeff = counterDiffusion + phaseTransformation
... convectionCoeff *= (Cj.diffusivity
... / (1. - CkSum.harmonicFaceValue))
...
... Cj.equation = (TransientTerm()
... == DiffusionTerm(coeff=Cj.diffusivity)
... + PowerLawConvectionTerm(coeff=convectionCoeff))
We start with a sharp phase boundary
>>> x = mesh.cellCenters[0]
>>> phase.setValue(1.)
>>> phase.setValue(0., where=x > L / 2)
and with uniform concentration fields, initially equal to the average of the solidus and liquidus concentrations
>>> for Cj in interstitials + substitutionals:
... Cj.setValue((Cj.S + Cj.L) / 2.)
If we’re running interactively, we create a viewer
>>> if __name__ == '__main__':
... viewer = Viewer(vars=([phase]
... + interstitials + substitutionals
... + [solvent]),
... datamin=0, datamax=1)
... viewer.plot()
and again iterate to equilibrium
>>> solver = DefaultAsymmetricSolver(tolerance=1e-10)
>>> dt = 10000
>>> from builtins import range
>>> for i in range(5):
... for field in [phase] + substitutionals + interstitials:
... field.updateOld()
... phase.equation.solve(var = phase, dt = dt)
... for field in substitutionals + interstitials:
... field.equation.solve(var = field,
... dt = dt,
... solver = solver)
... if __name__ == '__main__':
... viewer.plot()
We can confirm that the far-field phases have remained separated
>>> X = mesh.faceCenters[0]
>>> print(numerix.allclose(phase.faceValue[X.value==0], 1.0, rtol = 1e-5, atol = 1e-5))
True
>>> print(numerix.allclose(phase.faceValue[X.value==L], 0.0, rtol = 1e-5, atol = 1e-5))
True
and that the concentration fields have appropriately segregated into their equilibrium values in each phase
>>> equilibrium = True
>>> for Cj in interstitials + substitutionals:
... equilibrium &= numerix.allclose(Cj.faceValue[X.value==0], Cj.S, rtol = 3e-3, atol = 3e-3).value
... equilibrium &= numerix.allclose(Cj.faceValue[X.value==L], Cj.L, rtol = 3e-3, atol = 3e-3).value
>>> print(equilibrium)
True
examples.phase.simple module¶
Solve a phase-field (Allen-Cahn) problem in one-dimension.
To run this example from the base FiPy directory, type
python examples/phase/simple/input.py
at the command line. A viewer
object should appear and, after being prompted to step through the different
examples, the word finished
in the terminal.
This example takes the user through assembling a simple problem with FiPy. It describes a steady 1D phase field problem with no-flux boundary conditions such that,
(13)¶
For solidification problems, the Helmholtz free energy is frequently given by
where is the double-well barrier height between phases,
is the latent
heat,
is the temperature, and
is the melting point.
One possible choice for the double-well function is
and for the interpolation function is
We create a 1D solution mesh
>>> from fipy import CellVariable, Variable, Grid1D, DiffusionTerm, TransientTerm, ImplicitSourceTerm, DummySolver, Viewer
>>> from fipy.tools import numerix
>>> L = 1.
>>> nx = 400
>>> dx = L / nx
>>> mesh = Grid1D(dx = dx, nx = nx)
We create the phase field variable
>>> phase = CellVariable(name = "phase",
... mesh = mesh)
and set a step-function initial condition
>>> x = mesh.cellCenters[0]
>>> phase.setValue(1.)
>>> phase.setValue(0., where=x > L/2)
If we are running interactively, we’ll want a viewer to see the results
>>> from fipy import input
>>> if __name__ == '__main__':
... viewer = Viewer(vars = (phase,))
... viewer.plot()
... input("Initial condition. Press <return> to proceed...")
We choose the parameter values,
>>> kappa = 0.0025
>>> W = 1.
>>> Lv = 1.
>>> Tm = 1.
>>> T = Tm
>>> enthalpy = Lv * (T - Tm) / Tm
We build the equation by assembling the appropriate terms. Since, with
we are interested in a steady-state solution, we omit the transient term
.
The analytical solution for this steady-state phase field problem, in an infinite domain, is
(14)¶
or
>>> x = mesh.cellCenters[0]
>>> analyticalArray = 0.5*(1 - numerix.tanh((x - L/2)/(2*numerix.sqrt(kappa/W))))
We treat the diffusion term
implicitly,
Note
“Diffusion” in FiPy is not limited to the movement of atoms, but rather refers to the spontaneous spreading of any quantity (e.g., solute, temperature, or in this case “phase”) by flow “down” the gradient of that quantity.
The source term is
where
.
The simplest approach is to add this source explicitly
>>> mPhi = -((1 - 2 * phase) * W + 30 * phase * (1 - phase) * enthalpy)
>>> S0 = mPhi * phase * (1 - phase)
>>> eq = S0 + DiffusionTerm(coeff=kappa)
After solving this equation
>>> eq.solve(var = phase, solver=DummySolver())
we obtain the surprising result that is zero everywhere.
>>> print(phase.allclose(analyticalArray, rtol = 1e-4, atol = 1e-4))
0
>>> from fipy import input
>>> if __name__ == '__main__':
... viewer.plot()
... input("Fully explicit source. Press <return> to proceed...")
On inspection, we can see that this occurs because, for our step-function initial condition,
everywhere,
hence we are actually only solving the simple implicit diffusion equation
,
which has exactly the uninteresting solution we obtained.
The resolution to this problem is to apply relaxation to obtain the desired answer, i.e., the solution is allowed to relax in time from the initial condition to the desired equilibrium solution. To do so, we reintroduce the transient term from Equation (1)
>>> eq = TransientTerm() == DiffusionTerm(coeff=kappa) + S0
>>> phase.setValue(1.)
>>> phase.setValue(0., where=x > L/2)
>>> from builtins import range
>>> for i in range(13):
... eq.solve(var = phase, dt=1.)
... if __name__ == '__main__':
... viewer.plot()
After 13 time steps, the solution has converged to the analytical solution
>>> print(phase.allclose(analyticalArray, rtol = 1e-4, atol = 1e-4))
1
>>> from fipy import input
>>> if __name__ == '__main__':
... input("Relaxation, explicit. Press <return> to proceed...")
Note
The solution is only found accurate
to
because the infinite-domain analytical
solution (2)
is not an exact representation for the solution in a finite domain of
length
.
Setting fixed-value boundary conditions of 1 and 0 would still require the relaxation method with the fully explicit source.
Solution performance can be improved if we exploit the dependence of the
source on . By doing so, we can make the source semi-implicit,
improving the rate of convergence over the fully explicit approach. The
source can only be semi-implicit because we employ sparse linear algebra
routines to solve the PDEs, i.e., there is no fully implicit way to
represent a term like
in the linear set of equations
.
By linearizing a source as
,
we make it more implicit by adding the coefficient
to the matrix diagonal. For numerical stability, this linear coefficient
must never be negative.
There are an infinite number of choices for this linearization, but many do not converge very well. One choice is that used by Ryo Kobayashi:
>>> S0 = mPhi * phase * (mPhi > 0)
>>> S1 = mPhi * ((mPhi < 0) - phase)
>>> eq = DiffusionTerm(coeff=kappa) + S0 \
... + ImplicitSourceTerm(coeff = S1)
Note
Because mPhi
is a variable field, the quantities (mPhi > 0)
and (mPhi < 0)
evaluate to variable fields of True and False,
instead of single Boolean values.
This expression converges to the same value given by the explicit relaxation approach, but in only 8 sweeps (note that because there is no transient term, these sweeps are not time steps, but rather repeated iterations at the same time step to reach a converged solution).
Note
We use solve()
instead of
sweep()
because we don’t care about the residual.
Either function would work, but solve()
is a bit faster.
>>> phase.setValue(1.)
>>> phase.setValue(0., where=x > L/2)
>>> from builtins import range
>>> for i in range(8):
... eq.solve(var = phase)
>>> print(phase.allclose(analyticalArray, rtol = 1e-4, atol = 1e-4))
1
>>> from fipy import input
>>> if __name__ == '__main__':
... viewer.plot()
... input("Kobayashi, semi-implicit. Press <return> to proceed...")
In general, the best convergence is obtained when the linearization gives a
good representation of the relationship between the source and the
dependent variable. The best practical advice is to perform a Taylor
expansion of the source about the previous value of the dependent variable
such that
.
Now, if our source term is represented by
,
then
and
.
In this way, the linearized source will be tangent to the curve of the
actual source as a function of the dependent variable.
For our source,
,
and
or
>>> dmPhidPhi = 2 * W - 30 * (1 - 2 * phase) * enthalpy
>>> S1 = dmPhidPhi * phase * (1 - phase) + mPhi * (1 - 2 * phase)
>>> S0 = mPhi * phase * (1 - phase) - S1 * phase
>>> eq = DiffusionTerm(coeff=kappa) + S0 \
... + ImplicitSourceTerm(coeff = S1)
Using this scheme, where the coefficient of the implicit source term is tangent to the source, we reach convergence in only 5 sweeps
>>> phase.setValue(1.)
>>> phase.setValue(0., where=x > L/2)
>>> from builtins import range
>>> for i in range(5):
... eq.solve(var = phase)
>>> print(phase.allclose(analyticalArray, rtol = 1e-4, atol = 1e-4))
1
>>> from fipy import input
>>> if __name__ == '__main__':
... viewer.plot()
... input("Tangent, semi-implicit. Press <return> to proceed...")
Although, for this simple problem, there is no appreciable difference in run-time between the fully explicit source and the optimized semi-implicit source, the benefit of 60% fewer sweeps should be obvious for larger systems and longer iterations.
This example has focused on just the region of the phase field interface in equilibrium. Problems of interest, though, usually involve the dynamics of one phase transforming to another. To that end, let us recast the problem using physical parameters and dimensions. We’ll need a new mesh
>>> nx = 400
>>> dx = 5e-6 # cm
>>> L = nx * dx
>>> mesh = Grid1D(dx = dx, nx = nx)
and thus must redeclare on the new mesh
>>> phase = CellVariable(name="phase",
... mesh=mesh,
... hasOld=1)
>>> x = mesh.cellCenters[0]
>>> phase.setValue(1.)
>>> phase.setValue(0., where=x > L/2)
We choose the parameter values appropriate for nickel, given in [23]
>>> Lv = 2350 # J / cm**3
>>> Tm = 1728. # K
>>> T = Variable(value=Tm)
>>> enthalpy = Lv * (T - Tm) / Tm # J / cm**3
The parameters of the phase field model can be related to the surface
energy and the interfacial thickness
by
We take .
>>> delta = 1.5 * dx
>>> sigma = 3.7e-5 # J / cm**2
>>> beta = 0.33 # cm / (K s)
>>> kappa = 6 * sigma * delta # J / cm
>>> W = 6 * sigma / delta # J / cm**3
>>> Mphi = Tm * beta / (6. * Lv * delta) # cm**3 / (J s)
>>> if __name__ == '__main__':
... displacement = L * 0.1
... else:
... displacement = L * 0.025
>>> analyticalArray = CellVariable(name="tanh", mesh=mesh,
... value=0.5 * (1 - numerix.tanh((x - (L / 2. + displacement))
... / (2 * delta))))
and make a new viewer
>>> if __name__ == '__main__':
... viewer2 = Viewer(vars = (phase, analyticalArray))
... viewer2.plot()
Now we can redefine the transient phase field equation, using the optimal form of the source term shown above
>>> mPhi = -((1 - 2 * phase) * W + 30 * phase * (1 - phase) * enthalpy)
>>> dmPhidPhi = 2 * W - 30 * (1 - 2 * phase) * enthalpy
>>> S1 = dmPhidPhi * phase * (1 - phase) + mPhi * (1 - 2 * phase)
>>> S0 = mPhi * phase * (1 - phase) - S1 * phase
>>> eq = TransientTerm(coeff=1/Mphi) == DiffusionTerm(coeff=kappa) \
... + S0 + ImplicitSourceTerm(coeff = S1)
In order to separate the effect of forming the phase field interface
from the kinetics of moving it, we first equilibrate at the melting
point. We now use the sweep()
method instead of
solve()
because we require the residual.
>>> timeStep = 1e-6
>>> from builtins import range
>>> for i in range(10):
... phase.updateOld()
... res = 1e+10
... while res > 1e-5:
... res = eq.sweep(var=phase, dt=timeStep)
>>> if __name__ == '__main__':
... viewer2.plot()
and then quench by 1 K
>>> T.setValue(T() - 1)
In order to have a stable numerical solution, the interface must not move
more than one grid point per time step,
we thus set the timestep according to the grid spacing ,
the linear kinetic coefficient
, and the undercooling
Again we use the
sweep()
method as a replacement for
solve()
.
>>> velocity = beta * abs(Tm - T()) # cm / s
>>> timeStep = .1 * dx / velocity # s
>>> elapsed = 0
>>> while elapsed < displacement / velocity:
... phase.updateOld()
... res = 1e+10
... while res > 1e-5:
... res = eq.sweep(var=phase, dt=timeStep)
... elapsed += timeStep
... if __name__ == '__main__':
... viewer2.plot()
A hyperbolic tangent is not an exact steady-state solution given the
quintic polynomial we chose for the function, but it gives a
reasonable approximation.
>>> print(phase.allclose(analyticalArray, rtol = 5, atol = 2e-3))
1
If we had made another common choice of ,
we would have found much better agreement, as that case does give an
exact
solution in steady state.
If SciPy is available, another way to compare against the expected result
is to do a least-squared fit to determine the interface velocity and
thickness
>>> try:
... def tanhResiduals(p, y, x, t):
... V, d = p
... return y - 0.5 * (1 - numerix.tanh((x - V * t - L / 2.) / (2*d)))
... from scipy.optimize import leastsq
... x = mesh.cellCenters[0]
... (V_fit, d_fit), msg = leastsq(tanhResiduals, [L/2., delta],
... args=(phase.globalValue, x.globalValue, elapsed))
... except ImportError:
... V_fit = d_fit = 0
... print("The SciPy library is unavailable to fit the interface \
... thickness and velocity")
>>> print(abs(1 - V_fit / velocity) < 4.1e-2)
True
>>> print(abs(1 - d_fit / delta) < 2e-2)
True
>>> from fipy import input
>>> if __name__ == '__main__':
... input("Dimensional, semi-implicit. Press <return> to proceed...")
examples.phase.symmetry module¶
This example creates four symmetric quadrilateral regions in a box.
We start with a CellVariable
object that contains the following
values:
We wish to create 4 symmetric regions such that
We create a square domain
>>> from fipy import CellVariable, Grid2D, Viewer
>>> from fipy.tools import numerix
>>> N = 20
>>> L = 1.
>>> dx = L / N
>>> dy = L / N
>>> mesh = Grid2D(
... dx = dx,
... dy = dy,
... nx = N,
... ny = N)
>>> var = CellVariable(name = "test", mesh = mesh)
First set the values as given in the above equation:
>>> x, y = mesh.cellCenters
>>> var.setValue(x * y)
>>> if __name__ == '__main__':
... viewer = Viewer(vars=var, datamin=0, datamax=L * L / 4.)
... viewer.plot()
The bottom-left quadrant is mirrored into each of the other three quadrants
>>> q = (x > L / 2.) & (y < L / 2.)
>>> var[q] = var(((L - x)[q], y[q]))
>>> q = (x < L / 2.) & (y > L / 2.)
>>> var[q] = var(( x[q], (L - y)[q]))
>>> q = (x > L / 2.) & (y > L / 2.)
>>> var[q] = var(((L - x)[q], (L - y)[q]))
>>> if __name__ == '__main__':
... viewer.plot()
The following code tests the results with a different algorithm:
>>> testResult = numerix.zeros((N // 2, N // 2), 'd')
>>> bottomRight = numerix.zeros((N // 2, N // 2), 'd')
>>> topLeft = numerix.zeros((N // 2, N // 2), 'd')
>>> topRight = numerix.zeros((N // 2, N // 2), 'd')
>>> from builtins import range
>>> for j in range(N // 2):
... for i in range(N // 2):
... x = dx * (i + 0.5)
... y = dx * (j + 0.5)
... testResult[i, j] = x * y
... bottomRight[i, j] = var(((L - x,), (y,)))[0]
... topLeft[i, j] = var(((x,), (L - y,)))[0]
... topRight[i, j] = var(((L - x,), (L - y,)))[0]
>>> numerix.allclose(testResult, bottomRight, atol = 1e-10)
1
>>> numerix.allclose(testResult, topLeft, atol = 1e-10)
1
>>> numerix.allclose(testResult, topRight, atol = 1e-10)
1