The custom-inclusion contract: three entry gates, one answer
MeanFieldHom 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 levelled: you implement the lowest gate you can reach and the package derives everything above it.
| Gate | You supply | You get |
|---|---|---|
| A | the Hill tensor $\mathbb P$ | the 8 localization tensors, the 4 contribution tensors, every scheme |
| B | the localization tensor $\mathbb A$ (both sides of it if the inclusion is heterogeneous) | the derived localizations, the contributions, every scheme |
| C | the contribution tensors $\mathbb N$, $\mathbb H$ | 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 MeanFieldHom
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 = MeanFieldHom.inclusion_basis(ell)3×3 TensND.RotatedBasis{3, Float64}:
0.730682 -0.644218 0.226026
0.615445 0.764842 0.190379
-0.29552 0.0 0.955336Gate A — the Hill tensor
One callback. Everything else is derived: the eight localization 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 localization tensor
For a morphology whose $\mathbb P$ has no convenient closed form, or when an external solver returns $\mathbb A$ directly.
The inclusion here is homogeneous, so the strain-side tensor alone is enough: the stress side follows from $\mathbb A_{\sigma\varepsilon} = \mathbb C_1:\mathbb A_{\varepsilon\varepsilon}$. A heterogeneous inclusion has no such $\mathbb C_1$ and must supply stress_strain_loc (and flux_gradient_loc) as well — LayeredSphere implements all four. Omitting them is silent: Dilute and MoriTanaka stay right, only the self-consistent schemes drift.
gate_B = CustomInclusion(
semi; basis,
strain_strain_loc = (P₁, P₀; kw...) -> strain_strain_loc(ell, P₁, P₀; kw...),
gradient_gradient_loc = (P₁, P₀; kw...) -> gradient_gradient_loc(ell, P₁, P₀; kw...),
)CustomInclusion{3, Float64, TensND.RotatedBasis{3, Float64}, @NamedTuple{strain_strain_loc::Main.var"#9#10", gradient_gradient_loc::Main.var"#11#12"}}((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, (strain_strain_loc = Main.var"#9#10"(), gradient_gradient_loc = Main.var"#11#12"()))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 $\mathbb A$, so Mori-Tanaka and the self-consistent schemes stay out of reach.
gate_C = CustomInclusion(
semi; basis,
stiffness_contribution = (P₁, P₀; kw...) -> stiffness_contribution(ell, P₁, P₀; kw...),
compliance_contribution = (P₁, P₀; kw...) -> compliance_contribution(ell, P₁, P₀; kw...),
conductivity_contribution = (P₁, P₀; kw...) -> conductivity_contribution(ell, P₁, P₀; kw...),
resistivity_contribution = (P₁, P₀; kw...) -> resistivity_contribution(ell, P₁, P₀; kw...),
)CustomInclusion{3, Float64, TensND.RotatedBasis{3, Float64}, @NamedTuple{stiffness_contribution::Main.var"#21#22", compliance_contribution::Main.var"#23#24", conductivity_contribution::Main.var"#25#26", resistivity_contribution::Main.var"#27#28"}}((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, (stiffness_contribution = Main.var"#21#22"(), compliance_contribution = Main.var"#23#24"(), conductivity_contribution = Main.var"#25#26"(), resistivity_contribution = Main.var"#27#28"()))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 localization and contribution tensors are derived
✓ CustomInclusion{3, Float64, TensND.RotatedBasis{3, Float64}, @NamedTuple{stiffness_contribution::Main.var"#21#22", compliance_contribution::Main.var"#23#24", conductivity_contribution::Main.var"#25#26", resistivity_contribution::Main.var"#27#28"}} satisfies the inclusion contract (physics = :elasticity, amount = :fraction)
✓ level 1, gate C: `stiffness_contribution` / `compliance_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(:M)
add_matrix!(r, Ellipsoid(1.0), Dict(key => prop_m))
add_phase!(r, :I, geom, Dict(key => prop_i); fraction = 0.25)
return r
end
c1111(C) = get_array(C)[1, 1, 1, 1]
k11(K) = get_array(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 $f\,\mathbb N$, and there is nothing to implement. For a flat object measured by a density the prefactor is geometric — $4\pi/3$ for an elliptical crack of Budiansky density $\varepsilon^{3\mathrm d} = N a b^2$ — and that is exactly the seam density_factor fills.
crack = EllipticCrack(1.0, 0.25)
flat = CustomInclusion(
(1.0, 0.25, 0.0);
basis = MeanFieldHom.inclusion_basis(crack),
density_factor = 4π / 3,
compliance_contribution = (P₀; kw...) -> compliance_contribution(crack, P₀; kw...),
stiffness_contribution = (P₀; kw...) -> stiffness_contribution(crack, P₀; kw...),
)
function cracked(geom, ε)
r = RVE(:M)
add_matrix!(r, Ellipsoid(1.0), Dict(:C => C₀))
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(:M)
add_matrix!(r, Ellipsoid(1.0), Dict(:C => C₀))
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 = MeanFieldHom.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(get_array(C_ref)[3, 3, 3, 3], digits = 10)
)
println(
" custom : ", typeof(C_cus).name.name, " C₃₃₃₃ = ",
round(get_array(C_cus)[3, 3, 3, 3], digits = 10)
)
@printf " max |Δ| = %.3e\n" maximum(abs, get_array(C_cus) .- get_array(C_ref))
Orientation distribution (12 polar bins) + TISymmetrize
------------------------------------------------------------------------------
native : TensTI C₃₃₃₃ = 49.5682276961
custom : TensTI C₃₃₃₃ = 49.5682276961
max |Δ| = 0.000e+00The exact transverse-isotropic average is applied identically in both cases: the custom inclusion never had to know that orientation averaging existed.
This page was generated using Literate.jl.