Crack distributions: isotropic or parallel
The same cracks, the same crack density, two orientation distributions — and two different effective media. This is the shortest example that exercises the three things a cracked RVE needs: a density instead of a volume fraction, an orientation distribution, and a scheme that can cope with a phase of zero volume.
The material is a solid of
The two microstructures
The realizations are drawn with a fixed seed, so the two pictures differ only in the orientation rule — the crack positions are the same.
using MeanFieldHomogenization
using TensND
using LinearAlgebra
using Printf
using Plots
gr()
# Interactive-3-D helpers; `pkgdir` rather than `@__DIR__` so the include also
# resolves inside a Documenter `@example` block.
include(joinpath(pkgdir(MeanFieldHomogenization), "scripts", "common", "docviz.jl"))
const E₀, ν₀ = 1.0, 0.2
const C₀ = iso_stiffness_E_nu(E₀, ν₀)
const ε = 0.6
const k₀, μ₀ = k_mu(C₀)
@printf "intact solid : k = %.6f μ = %.6f\n" k₀ μ₀intact solid : k = 0.555556 μ = 0.416667An isotropic distribution: every orientation equally likely, so the effective medium is isotropic however anisotropic each single crack is.
plotly_scene(
rve_traces(; n = 70, semi_axes = (0.10, 0.10, 0.004), seed = 2024);
uid = "cd-rve-iso", height = 470,
title = "Isotropic distribution of penny cracks"
)Parallel cracks: one common normal, here the direction of spherical angles
const n̂ = (sin(π / 4) * cos(π / 3), sin(π / 4) * sin(π / 3), cos(π / 4))
plotly_scene(
rve_traces(; n = 70, semi_axes = (0.10, 0.10, 0.004), seed = 2024,
orientation = n̂);
uid = "cd-rve-aligned", height = 470,
title = "Parallel cracks, all normals along n̂ = (√2/4, √6/4, √2/2)"
)Building the two RVEs
A crack phase differs from an inclusion phase in two places only: it is declared with density instead of fraction, and its property dictionary carries the matrix stiffness — the crack-opening machinery needs the medium the crack opens in, not a stiffness of its own (a crack has none).
The orientation distribution is the symmetrize keyword. IsoSymmetrize() performs the exact SO(3) average of the concentration tensor; leaving it out keeps the single orientation carried by the crack's own frame.
function rve_isotropic(ε)
r = RVE()
add_phase!(r, :SOLID, Spheroid(1.0), Dict(:C => C₀); fraction = :rest)
add_phase!(
r, :CRACK, PennyCrack(1.0), Dict(:C => C₀);
density = ε, symmetrize = IsoSymmetrize()
)
return r
end
function rve_parallel(ε; angles = (π / 4, π / 3))
r = RVE()
add_phase!(r, :SOLID, Spheroid(1.0), Dict(:C => C₀); fraction = :rest)
add_phase!(
r, :CRACK, PennyCrack(1.0; euler_angles = angles), Dict(:C => C₀);
density = ε
)
return r
end
# The two angles of `euler_angles` are read here as the polar and azimuthal
# angles of the crack normal, which is the third column of the crack frame.
crack = PennyCrack(1.0; euler_angles = (π / 4, π / 3))
normal = [TensND.vecbasis(crack.basis, :cov)[i, 3] for i in 1:3]
@printf "crack normal : (%.6f, %.6f, %.6f)\n" normal...crack normal : (0.353553, 0.612372, 0.707107)Isotropic distribution
Three schemes, and they do not merely differ in value — they differ in behavior. MoriTanaka and DiluteDual coincide exactly here: a crack has no volume, so the Mori-Tanaka denominator reduces to the identity and the scheme collapses onto the dilute compliance estimate. That is not a bug, it is what the crack limit does to the algebra.
const SC = SelfConsistent(; abstol = 1.0e-8, maxiters = 400, select_best = true)
const ASC = AsymmetricSelfConsistent(; abstol = 1.0e-8, maxiters = 400, select_best = true)
kμ_iso(rve, scheme) = k_mu(best_fit_iso(homogenize(rve, scheme, :C)))
for (name, scheme) in (("Mori-Tanaka", MoriTanaka()), ("DiluteDual", DiluteDual()),
("SelfConsistent", SC), ("Asym. SC", ASC))
k, μ = kμ_iso(rve_isotropic(ε), scheme)
@printf "%-16s : k = %.6f μ = %.6f (3k, 2μ) = (%.6f, %.6f)\n" name k μ 3k 2μ
endMori-Tanaka : k = 0.205255 μ = 0.218125 (3k, 2μ) = (0.615764, 0.436249)
DiluteDual : k = 0.205255 μ = 0.218125 (3k, 2μ) = (0.615764, 0.436249)
SelfConsistent : k = 0.133754 μ = 0.139589 (3k, 2μ) = (0.401262, 0.279179)
Asym. SC : k = 0.000000 μ = 0.000000 (3k, 2μ) = (0.000000, 0.000000)Against Echoes 1.0 at the same density. Echoes reports an isotropic tensor through its two Walpole coefficients
echoes_iso = Dict( # captured from a run of Echoes 1.0
"MT" => (k = 0.205254, μ = 0.218124),
"SC" => (k = 0.133754, μ = 0.139589),
)
for (name, scheme) in (("MT", MoriTanaka()), ("SC", SC))
k, μ = kμ_iso(rve_isotropic(ε), scheme)
ref = echoes_iso[name]
@printf "%s : Δk = %.2e Δμ = %.2e\n" name abs(k - ref.k) abs(μ - ref.μ)
endMT : Δk = 5.16e-07 Δμ = 7.09e-07
SC : Δk = 1.64e-08 Δμ = 2.83e-07Both agree to the last captured digit. Note that it is the symmetric SelfConsistent that matches Echoes' SC on cracks, not AsymmetricSelfConsistent: the two solve different fixed points, and the next section is where that becomes visible.
Two self-consistent forms, two percolation thresholds
SelfConsistent iterates on the stiffness, AsymmetricSelfConsistent on the compliance. Both percolate — the effective moduli reach zero at a finite crack density, unlike Mori-Tanaka, which only decays asymptotically — but they do so at different densities, and that is the practical difference between them. The compliance form gives up first.
εs = range(0.0, 1.3; length = 131)
curves = Dict(
"Mori-Tanaka" => [kμ_iso(rve_isotropic(e), MoriTanaka()) for e in εs],
"SelfConsistent" => [kμ_iso(rve_isotropic(e), SC) for e in εs],
"Asym. SC" => [kμ_iso(rve_isotropic(e), ASC) for e in εs],
)Dict{String, Vector{Tuple{Float64, Float64}}} with 3 entries:
"Asym. SC" => [(0.555556, 0.416667), (0.539899, 0.410327), (0.52453, 0.…
"SelfConsistent" => [(0.555556, 0.416667), (0.540115, 0.410376), (0.525361, 0…
"Mori-Tanaka" => [(0.555556, 0.416667), (0.54019, 0.41044), (0.525652, 0.4…Theory gives both thresholds, independently of the matrix Poisson ratio:
Reading them off the numerics needs one precaution: the fixed point converges ever more slowly near a threshold, so just past it the solver returns a small positive k that is not converged and merely tracks the solver tolerance. Extrapolate the linear decay from the region where the answer is tolerance-independent instead.
The tolerance has to be asked for correctly, and this is the place where it shows. The stopping test is ‖Δx‖ ≤ abstol + reltol · ‖x‖, so an abstol alone buys nothing while reltol sits at its 1e-8 default: the relative term still decides. Here the requirement really is relative — the stiffness collapses by orders of magnitude across the threshold — so abstol goes to zero and reltol carries the request.
const SC_TIGHT = SelfConsistent(; abstol = 0.0, reltol = 1.0e-14, maxiters = 50_000, select_best = true)
const ASC_TIGHT = AsymmetricSelfConsistent(; abstol = 0.0, reltol = 1.0e-14, maxiters = 50_000, select_best = true)
function percolation_threshold(scheme, ε₁, ε₂)
k₁ = kμ_iso(rve_isotropic(ε₁), scheme)[1]
k₂ = kμ_iso(rve_isotropic(ε₂), scheme)[1]
slope = (k₂ - k₁) / (ε₂ - ε₁)
return ε₂ - k₂ / slope
end
# Both pairs sit where `k/k₀` is a few 10⁻³ — small enough for the linear
# extrapolation to be short, large enough to be converged.
ε_asc = percolation_threshold(ASC_TIGHT, 0.5600, 0.5620)
ε_sc = percolation_threshold(SC_TIGHT, 1.1560, 1.1570)
@printf "percolation : Asym. SC at ε = %.6f (9/16 = %.6f, Δ = %.1e)\n" ε_asc 9/16 abs(ε_asc - 9/16)
@printf " SC at ε = %.6f — later by a factor %.3f\n" ε_sc ε_sc / ε_asc
p_iso = plot(;
xlabel = "crack density ε", ylabel = "normalized modulus",
framestyle = :box, legend = :topright, size = (780, 500),
title = "Isotropic crack distribution (E₀ = 1, ν₀ = 0.2)"
)
for (name, color) in (("Mori-Tanaka", :black), ("SelfConsistent", :orange),
("Asym. SC", :red))
plot!(p_iso, εs, [c[1] / k₀ for c in curves[name]]; label = "$name k/k₀",
color = color, lw = 2)
plot!(p_iso, εs, [c[2] / μ₀ for c in curves[name]]; label = "$name μ/μ₀",
color = color, lw = 2, ls = :dash)
end
vline!(p_iso, [ε_asc]; color = :red, ls = :dot, lw = 2,
label = "Asym. SC percolation")
vline!(p_iso, [ε_sc]; color = :orange, ls = :dot, lw = 2,
label = "SC percolation")
scatter!(p_iso, [ε, ε], [echoes_iso["MT"].k / k₀, echoes_iso["MT"].μ / μ₀];
marker = :circle, ms = 5, color = :black, label = "Echoes MT")
scatter!(p_iso, [ε, ε], [echoes_iso["SC"].k / k₀, echoes_iso["SC"].μ / μ₀];
marker = :diamond, ms = 5, color = :orange, label = "Echoes SC")
p_iso
The numerics confirm both:
That gap is what makes the chosen AsymmetricSelfConsistent returns zero there while SelfConsistent still returns the finite medium that Echoes reports. Neither is wrong — they are two different definitions of "embed each phase in the effective medium", and which one to trust is a modeling decision, not a numerical one.
Parallel cracks
One orientation, so the effective tensor is transversely isotropic about the crack normal. Reading it back needs one convention to be clear, and it is the one that trips people up:
C_par = homogenize(rve_parallel(ε), MoriTanaka(), :C)
typeof(C_par)TensND.TensRotated{4, 3, Float64, Tensors.SymmetricTensor{4, 3, Float64, 36}}The result is a TensRotated: it carries its own frame. TensND.KM then returns the components in that frame — the crack frame — not in the global one. Both are useful, and they are different matrices:
C_local = TensND.KM(C_par) # crack frame
C_global = TensND.KM(TensND.components_canon(C_par)) # global frame
round.(C_local; digits = 6)6×6 Matrix{Float64}:
1.0574 0.224068 0.062941 0.0 0.0 0.0
0.224068 1.0574 0.062941 0.0 0.0 0.0
0.062941 0.062941 0.251762 0.0 0.0 0.0
0.0 0.0 0.0 0.344037 0.0 0.0
0.0 0.0 0.0 0.0 0.344037 0.0
0.0 0.0 0.0 0.0 0.0 0.833333In the crack frame the transverse isotropy is plain: the 3-axis is the normal, the
round.(C_global; digits = 6)6×6 Matrix{Float64}:
0.902533 0.166717 0.154314 -0.060764 -0.208074 -0.180198
0.166717 0.639222 0.175933 -0.284582 0.008689 -0.142291
0.154314 0.175933 0.53078 -0.246676 -0.142418 0.026478
-0.060764 -0.284582 -0.246676 0.590902 0.00128 -0.029472
-0.208074 0.008689 -0.142418 0.00128 0.589424 -0.158264
-0.180198 -0.142291 0.026478 -0.029472 -0.158264 0.635111The global matrix is full, and it is the one to compare against another code that knows nothing of the crack frame. The five Walpole coefficients — the reporting form Echoes prints as Param(size=5) — come straight from the local matrix:
walpole = TensND.ti_params_from_KM(C_local)
round.(collect(walpole); digits = 6)5-element Vector{Float64}:
0.251762
1.28147
0.089011
0.833333
0.344037Against Echoes 1.0, same problem, same density:
echoes_parallel = [0.251762, 1.28147, 0.0890113, 0.833333, 0.344036]
@printf "max |Δ| on the five Walpole coefficients : %.2e\n" maximum(
abs.(collect(walpole) .- echoes_parallel)
)max |Δ| on the five Walpole coefficients : 6.97e-07The fourth coefficient is exactly
One scheme is unavailable here
SelfConsistent on a single-orientation crack phase raises a SingularException: its strain-concentration tensor degenerates when the inclusion has no volume and no orientation average smooths it. Use AsymmetricSelfConsistent, as the cracks manual prescribes — bearing in mind, from the previous section, that it is a different fixed point from Echoes' SC.
try
homogenize(rve_parallel(ε), SC, :C)
catch err
println("SelfConsistent : ", first(sprint(showerror, err), 60))
end
C_par_asc = homogenize(rve_parallel(ε), ASC, :C)
round.(collect(TensND.ti_params_from_KM(TensND.KM(C_par_asc))); digits = 6)5-element Vector{Float64}:
0.014504
1.251813
0.005128
0.833333
0.192086Isotropic against parallel, at equal density
The last comparison is the point of the whole page: same cracks, same
function young_along(C, d)
# Uniaxial stress along the unit vector `d`; E = σ / ε_dd.
S = inv(Array(TensND.components_canon(C)) |> A -> TensND.KM(TensND.Tens(A)))
e = [d[1]^2, d[2]^2, d[3]^2, √2 * d[2] * d[3], √2 * d[1] * d[3], √2 * d[1] * d[2]]
return 1 / (e' * S * e)
end
C_iso = homogenize(rve_isotropic(ε), MoriTanaka(), :C)
angles = range(0, π; length = 181)
# Directions swept in the plane containing the crack normal.
t̂ = normalize(cross(collect(normal), [0.0, 0.0, 1.0]))
dirs = [cos(a) .* collect(normal) .+ sin(a) .* t̂ for a in angles]
p_dir = plot(;
proj = :polar, size = (620, 560), legend = :bottomleft,
title = "Young's modulus in the plane of the crack normal, ε = $ε"
)
plot!(p_dir, angles, [young_along(C_iso, d) for d in dirs];
lw = 2.5, color = :black, label = "isotropic distribution")
plot!(p_dir, angles, [young_along(C_par, d) for d in dirs];
lw = 2.5, color = :red, label = "parallel cracks")
p_dir
At
This page was generated using Literate.jl.