The custom-inclusion contract: three entry gates, one answer
The whole page as one script
Every block of this page, in order, is scripts/80_custom_inclusion_contract.jl. From a clone of the repository, julia --project=docs -e 'using Pkg; Pkg.instantiate()' once, then julia scripts/80_custom_inclusion_contract.jl, runs it end to end.
Before this page
The tutorial Hill polarization tensors in practice, whose Hill tensor is the first of the three entry gates, and the theory page Concentration and contribution tensors, which derives every tensor of the contract from that one.
MeanFieldHomogenization lets an arbitrary morphology take part in every homogenization scheme, in elasticity and in transport, without touching the package. This is the counterpart of the user_inclusion mechanism of the C++/Python echoes codebase, but leveled: you implement the lowest gate you can reach and the package derives everything above it.
| Gate | You supply | You get |
|---|---|---|
| A | the Hill tensor | the 8 concentration tensors, the 4 contribution tensors, every scheme |
| B | the concentration tensor | the derived concentrations, the contributions, every scheme |
| C | the contribution tensors | the contribution-based schemes |
This script drives the same physical inclusion through all three gates and checks that the schemes cannot tell them apart. The point is not the numbers — they are identical by construction — but that the plumbing is identical.
See the manual page Custom inclusions and the developer page Adding a new inclusion for the full contract.
using MeanFieldHomogenization
using TensND
using Printf
using LinearAlgebraThe reference: a tilted prolate spheroid
Tilted, so that the effective tensor is genuinely anisotropic and a dropped rotation cannot hide behind an isotropic answer.
C₀ = TensISO{3}(3 * 30.0, 2 * 10.0) # matrix
C₁ = TensISO{3}(3 * 60.0, 2 * 20.0) # inclusion
K₀ = TensISO{3}(2.0) # matrix conductivity
K₁ = TensISO{3}(7.0)
ell = Ellipsoid(3.0, 1.0, 1.0; euler_angles = (0.3, 0.7, 0.0))
semi = (3.0, 1.0, 1.0)
basis = MeanFieldHomogenization.inclusion_basis(ell)TensND.RotatedBasis{3, Float64}
→ basis: 3×3 Matrix{Float64}:
0.730682 -0.644218 0.226026
0.615445 0.764842 0.190379
-0.29552 0.0 0.955336
→ dual basis: 3×3 Matrix{Float64}:
0.730682 -0.644218 0.226026
0.615445 0.764842 0.190379
-0.29552 0.0 0.955336
→ covariant metric tensor: 3×3 TensND.Id2{3, Float64}:
1.0 ⋅ ⋅
⋅ 1.0 ⋅
⋅ ⋅ 1.0
→ contravariant metric tensor: 3×3 TensND.Id2{3, Float64}:
1.0 ⋅ ⋅
⋅ 1.0 ⋅
⋅ ⋅ 1.0Gate A — the Hill tensor
One callback. Everything else is derived: the eight concentration tensors, the four contribution tensors, and every scheme.
gate_A = CustomInclusion(
semi; basis,
hill_tensor = (P₀; kw...) -> hill_tensor(ell, P₀; kw...),
)CustomInclusion{3, Float64, TensND.RotatedBasis{3, Float64}, @NamedTuple{hill_tensor::Main.var"#3#4"}}((3.0, 1.0, 1.0), [0.7306816499355124 -0.644217687237691 0.22602632124962302; 0.6154446635582734 0.7648421872844885 0.19037934406737264; -0.29552020666133955 0.0 0.955336489125606], true, nothing, (hill_tensor = Main.var"#3#4"(),))P₀ is a 4th-order stiffness in elasticity and a 2nd-order conductivity in transport, so a single callback serves both physics.
Gate B — the concentration tensor
For a morphology whose
The inclusion here is homogeneous, so the strain-side tensor alone is enough: the stress side follows from flux_gradient_concentration as well, as LayeredSphere does. Omitting it is silent: Dilute and MoriTanaka stay right, only the self-consistent schemes drift. As for gate A, each callback serves both physics.
gate_B = CustomInclusion(
semi; basis,
gradient_gradient_concentration = (P₁, P₀; kw...) -> gradient_gradient_concentration(ell, P₁, P₀; kw...),
)CustomInclusion{3, Float64, TensND.RotatedBasis{3, Float64}, @NamedTuple{gradient_gradient_concentration::Main.var"#7#8"}}((3.0, 1.0, 1.0), [0.7306816499355124 -0.644217687237691 0.22602632124962302; 0.6154446635582734 0.7648421872844885 0.19037934406737264; -0.29552020666133955 0.0 0.955336489125606], true, nothing, (gradient_gradient_concentration = Main.var"#7#8"(),))Gate C — the contribution tensors
The last resort, and the one that buys the least: contribution tensors alone do not determine the dilute concentration tensor
gate_C = CustomInclusion(
semi; basis,
property_contribution = (P₁, P₀; kw...) -> property_contribution(ell, P₁, P₀; kw...),
inverse_property_contribution = (P₁, P₀; kw...) -> inverse_property_contribution(ell, P₁, P₀; kw...),
)CustomInclusion{3, Float64, TensND.RotatedBasis{3, Float64}, @NamedTuple{property_contribution::Main.var"#13#14", inverse_property_contribution::Main.var"#15#16"}}((3.0, 1.0, 1.0), [0.7306816499355124 -0.644217687237691 0.22602632124962302; 0.6154446635582734 0.7648421872844885 0.19037934406737264; -0.29552020666133955 0.0 0.955336489125606], true, nothing, (property_contribution = Main.var"#13#14"(), inverse_property_contribution = Main.var"#15#16"()))Checking conformance before wiring anything
check_inclusion_interface reports the level-0 methods, the gate it found, and — this is the useful part — what that gate leaves unavailable.
println("="^78)
check_inclusion_interface(gate_A)
println()
check_inclusion_interface(gate_C)
println("="^78)==============================================================================
✓ CustomInclusion{3, Float64, TensND.RotatedBasis{3, Float64}, @NamedTuple{hill_tensor::Main.var"#3#4"}} satisfies the inclusion contract (physics = :elasticity, amount = :fraction)
✓ level 1, gate A: `hill_tensor` — all concentration and contribution tensors are derived
✓ CustomInclusion{3, Float64, TensND.RotatedBasis{3, Float64}, @NamedTuple{property_contribution::Main.var"#13#14", inverse_property_contribution::Main.var"#15#16"}} satisfies the inclusion contract (physics = :elasticity, amount = :fraction)
✓ level 1, gate C: `property_contribution` / `inverse_property_contribution` supplied directly
! gate C alone does not provide the dilute concentration tensor, so `MoriTanaka`, `SelfConsistent` and `AsymmetricSelfConsistent` are unavailable for this volume-fraction phase (Voigt, Reuss, Dilute, DiluteDual, Maxwell, PonteCastanedaWillis and DifferentialScheme work).
==============================================================================The schemes cannot tell them apart
function rve_with(geom, prop_m, prop_i, key)
r = RVE(; distribution_shape = Ellipsoid(1.0))
add_phase!(r, :M, Ellipsoid(1.0), Dict(key => prop_m); fraction = :rest)
add_phase!(r, :I, geom, Dict(key => prop_i); fraction = 0.25)
return r
end
c1111(C) = canonical_components(C)[1, 1, 1, 1]
k11(K) = canonical_components(K)[1, 1]
ALL = (
"Dilute" => Dilute(), "DiluteDual" => DiluteDual(),
"MoriTanaka" => MoriTanaka(), "Maxwell" => Maxwell(),
"PCW" => PonteCastanedaWillis(), "SelfConsistent" => SelfConsistent(),
"ASC" => AsymmetricSelfConsistent(), "Differential" => DifferentialScheme(),
)
GATE_C_OK = ("Dilute", "DiluteDual", "Maxwell", "PCW", "Differential")
println("\nElasticity — C_eff[1,1,1,1]\n", "-"^78)
@printf "%-16s %14s %14s %14s %14s\n" "scheme" "Ellipsoid" "gate A" "gate B" "gate C"
for (name, sch) in ALL
ref = c1111(homogenize(rve_with(ell, C₀, C₁, :C), sch, :C))
vA = c1111(homogenize(rve_with(gate_A, C₀, C₁, :C), sch, :C))
vB = c1111(homogenize(rve_with(gate_B, C₀, C₁, :C), sch, :C))
if name in GATE_C_OK
vC = c1111(homogenize(rve_with(gate_C, C₀, C₁, :C), sch, :C))
@printf "%-16s %14.8f %14.8f %14.8f %14.8f\n" name ref vA vB vC
else
@printf "%-16s %14.8f %14.8f %14.8f %14s\n" name ref vA vB "n/a"
end
end
println("\nConduction — K_eff[1,1]\n", "-"^78)
@printf "%-16s %14s %14s %14s %14s\n" "scheme" "Ellipsoid" "gate A" "gate B" "gate C"
for (name, sch) in (
"Dilute" => Dilute(), "DiluteDual" => DiluteDual(),
"MoriTanaka" => MoriTanaka(), "Maxwell" => Maxwell(),
)
ref = k11(homogenize(rve_with(ell, K₀, K₁, :K), sch, :K))
vA = k11(homogenize(rve_with(gate_A, K₀, K₁, :K), sch, :K))
vB = k11(homogenize(rve_with(gate_B, K₀, K₁, :K), sch, :K))
if name in GATE_C_OK
vC = k11(homogenize(rve_with(gate_C, K₀, K₁, :K), sch, :K))
@printf "%-16s %14.8f %14.8f %14.8f %14.8f\n" name ref vA vB vC
else
@printf "%-16s %14.8f %14.8f %14.8f %14s\n" name ref vA vB "n/a"
end
end
Elasticity — C_eff[1,1,1,1]
------------------------------------------------------------------------------
scheme Ellipsoid gate A gate B gate C
Dilute 50.34089121 50.34089121 50.34089121 50.34089121
DiluteDual 51.73523441 51.73523441 51.73523441 51.73523441
MoriTanaka 50.99598143 50.99598143 50.99598143 n/a
Maxwell 51.11639531 51.11639531 51.11639531 51.11639531
PCW 51.11639531 51.11639531 51.11639531 51.11639531
SelfConsistent 51.07400466 51.07400466 51.07400466 n/a
ASC 51.14371392 51.14371392 51.14371392 n/a
Differential 51.06857736 51.06857736 51.06857736 51.06857736
Conduction — K_eff[1,1]
------------------------------------------------------------------------------
scheme Ellipsoid gate A gate B gate C
Dilute 2.80034593 2.80034593 2.80034593 2.80034593
DiluteDual 3.42310410 3.42310410 3.42310410 3.42310410
MoriTanaka 2.87178043 2.87178043 2.87178043 n/a
Maxwell 2.93327474 2.93327474 2.93327474 2.93327474Every column agrees to the last digit: the gates are algebraically equivalent reformulations of the same information, and the schemes consume whichever is available.
Level 2 — the amount × contribution seam
A scheme adds up an amount times a size-independent contribution. For a volume fraction that is simply density_factor fills.
crack = EllipticCrack(1.0, 0.25)
flat = CustomInclusion(
(1.0, 0.25, 0.0);
basis = MeanFieldHomogenization.inclusion_basis(crack),
density_factor = 4π / 3,
inverse_property_contribution = (P₀; kw...) -> inverse_property_contribution(crack, P₀; kw...),
property_contribution = (P₀; kw...) -> property_contribution(crack, P₀; kw...),
)
function cracked(geom, ε)
r = RVE(; distribution_shape = Ellipsoid(1.0))
add_phase!(r, :M, Ellipsoid(1.0), Dict(:C => C₀); fraction = :rest)
add_phase!(r, :cr, geom, Dict(:C => C₀); density = ε)
return r
end
println("\nFlat object with a density amount — C_eff[1,1,1,1]\n", "-"^78)
@printf "%-8s %18s %18s\n" "ε" "EllipticCrack" "CustomInclusion"
for ε in (0.0, 0.02, 0.05, 0.1)
@printf "%-8.2f %18.10f %18.10f\n" ε c1111(homogenize(cracked(crack, ε), MoriTanaka(), :C)) c1111(homogenize(cracked(flat, ε), MoriTanaka(), :C))
end
Flat object with a density amount — C_eff[1,1,1,1]
------------------------------------------------------------------------------
ε EllipticCrack CustomInclusion
0.00 43.3333333333 43.3333333333
0.02 41.0671881033 41.0671881033
0.05 38.8743485537 38.8743485537
0.10 36.7513143409 36.7513143409What is free: orientation averaging
IsoSymmetrize / TISymmetrize are applied by the scheme, after the callback returns — so a custom inclusion inherits them at no cost. The only obligations are to return tensors in the global frame, and to know that under symmetrization the scheme hands the kernel a pre-projected reference medium (isotropic by default).
function oriented(geom_at, nbins, f)
r = RVE(; distribution_shape = Ellipsoid(1.0))
add_phase!(r, :M, Ellipsoid(1.0), Dict(:C => C₀); fraction = :rest)
for (i, bin) in enumerate(polar_orientation_bins(nbins))
add_phase!(
r, Symbol(:I, i), geom_at(bin.θ), Dict(:C => C₁);
fraction = f * bin.weight, symmetrize = TISymmetrize((0.0, 0.0, 1.0))
)
end
return r
end
spheroid_at(θ) = Ellipsoid(3.0, 1.0, 1.0; euler_angles = (θ, 0.0, 0.0))
custom_at(θ) = let e = spheroid_at(θ)
CustomInclusion(
semi; basis = MeanFieldHomogenization.inclusion_basis(e),
hill_tensor = (P₀; kw...) -> hill_tensor(e, P₀; kw...)
)
end
C_ref = homogenize(oriented(spheroid_at, 12, 0.2), MoriTanaka(), :C)
C_cus = homogenize(oriented(custom_at, 12, 0.2), MoriTanaka(), :C)
println("\nOrientation distribution (12 polar bins) + TISymmetrize\n", "-"^78)
println(
" native : ", typeof(C_ref).name.name, " C₃₃₃₃ = ",
round(canonical_components(C_ref)[3, 3, 3, 3], digits = 10)
)
println(
" custom : ", typeof(C_cus).name.name, " C₃₃₃₃ = ",
round(canonical_components(C_cus)[3, 3, 3, 3], digits = 10)
)
@printf " ‖Δ‖ = %.3e\n" norm(C_cus - C_ref)
Orientation distribution (12 polar bins) + TISymmetrize
------------------------------------------------------------------------------
native : TensTI C₃₃₃₃ = 49.5682276961
custom : TensTI C₃₃₃₃ = 49.5682276961
‖Δ‖ = 0.000e+00The exact transverse-isotropic average is applied identically in both cases: the custom inclusion never had to know that orientation averaging existed.
Where to go next
The callbacks above return closed-form tensors, but the contract only asks for tensors. An inclusion whose response is a neural network supplies them from a trained surrogate, through gates A and B, and examines what stays exact whatever the quality of the fit. Morphologies solved by finite elements enter through the same gates, as described in Finite-element inclusions.
This page was generated using Literate.jl.