CompositePropCalc / compositecalc /core /self_consistent.py
tengfeiluo's picture
Upload folder using huggingface_hub
01a0b26 verified
"""Self-consistent scheme (SCS) for effective composite properties.
Three implementations span increasing generality:
1. **self_consistent** — 2-phase SCS with isotropic inclusions.
Algebraically identical to the symmetric Bruggeman equation; provided
as a named alias so callers can choose the physically descriptive name.
2. **self_consistent_ellipsoid** — 2-phase SCS for *randomly oriented*
ellipsoidal inclusions. Both phases are treated symmetrically (neither
is designated the host), each represented by the same depolarization
L-tuple. For spheres this reduces to **self_consistent**; for needles
or platelets it yields a different P_eff than the sphere formula.
3. **self_consistent_multiphase** — N-phase symmetric SCS for isotropic
(spherical) inclusions. Generalises Bruggeman's two-phase equation to
an arbitrary number of phases.
Mathematical background
-----------------------
The Budiansky (1965) / Hill (1965) self-consistent equation for N phases,
each with property P_i and volume fraction φ_i, using depolarization L:
.. math::
\\sum_{i} \\phi_i \\,
\\frac{P_i - P^*}{P^* + L(P_i - P^*)} = 0
For randomly oriented ellipsoids with depolarization factors
(L_a, L_b, L_c) summing to 1, the orientation-averaged polarizability
replaces the scalar factor:
.. math::
\\sum_{i} \\phi_i \\,(P_i - P^*)\\,
\\frac{1}{3}\\sum_{j\\in\\{a,b,c\\}}
\\frac{1}{P^* + L_j(P_i - P^*)} = 0
For spheres all three L_j equal 1/3 and the inner sum collapses to
3/(P* + 1/3 (P_i - P*)), recovering the scalar equation.
In both forms the physical root P* always lies in
[min(P_i), max(P_i)] and is found by Brent's method.
References
----------
Budiansky, B. (1965). *J. Mech. Phys. Solids*, 13, 223–227.
Hill, R. (1965). *J. Mech. Phys. Solids*, 13, 213–222.
Bruggeman, D.A.G. (1935). *Ann. Phys.*, 416, 636–664.
"""
from __future__ import annotations
import numpy as np
from scipy.optimize import brentq
# 2-phase, single-L SCS is identical to symmetric Bruggeman
from compositecalc.core.bruggeman import bruggeman as self_consistent
__all__ = [
"self_consistent",
"self_consistent_ellipsoid",
"self_consistent_multiphase",
]
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _orient_avg_factor(
P_eff: float,
P_i: float,
L_a: float,
L_b: float,
L_c: float,
) -> float:
"""Orientation-averaged polarizability for phase i at P_eff.
Computes (1/3)·Σⱼ 1/(P_eff + Lⱼ(Pᵢ − P_eff)).
This is the key building block of the ellipsoidal self-consistent
equation. For spheres (L_a = L_b = L_c = 1/3) it equals
1/(P_eff + 1/3 (Pᵢ − P_eff)), the standard scalar factor.
"""
delta = P_i - P_eff
# Each denominator P_eff + L_j·delta must be positive (guaranteed when
# P_eff ∈ [min(P_m, P_f), max(P_m, P_f)] and L_j ∈ [0, 1]).
s = (
1.0 / (P_eff + L_a * delta)
+ 1.0 / (P_eff + L_b * delta)
+ 1.0 / (P_eff + L_c * delta)
)
return s / 3.0
def _sc_ellipsoid_residual(
P_eff: float,
P_m: float,
P_f: float,
phi: float,
L_a: float,
L_b: float,
L_c: float,
) -> float:
"""Residual of the 2-phase ellipsoidal self-consistent equation."""
term_m = (1.0 - phi) * (P_m - P_eff) * _orient_avg_factor(
P_eff, P_m, L_a, L_b, L_c
)
term_f = phi * (P_f - P_eff) * _orient_avg_factor(
P_eff, P_f, L_a, L_b, L_c
)
return term_m + term_f
def _sc_multiphase_residual(
P_eff: float,
components: list[tuple[float, float]],
L: float,
) -> float:
"""Residual of the N-phase isotropic self-consistent equation."""
return sum(
phi_i * (P_i - P_eff) / (P_eff + L * (P_i - P_eff))
for P_i, phi_i in components
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def self_consistent_ellipsoid(
P_m: float | np.ndarray,
P_f: float | np.ndarray,
phi: float | np.ndarray,
L_tuple: tuple[float, float, float],
) -> float | np.ndarray:
"""2-phase self-consistent scheme for randomly oriented ellipsoidal inclusions.
Solves the implicit equation
.. math::
(1-\\phi)\\,(P_m - P^*)\\,\\frac{1}{3}
\\sum_{j} \\frac{1}{P^* + L_j(P_m - P^*)}
\\;+\\;
\\phi\\,(P_f - P^*)\\,\\frac{1}{3}
\\sum_{j} \\frac{1}{P^* + L_j(P_f - P^*)}
= 0
for P* using Brent's method. Both phases are treated symmetrically
with the same depolarization L-tuple.
Parameters
----------
P_m : float or np.ndarray
Matrix property (positive).
P_f : float or np.ndarray
Filler property (positive, same units as P_m).
phi : float or np.ndarray
Filler volume fraction ∈ [0, 1].
L_tuple : tuple of three floats
Depolarization factors (L_a, L_b, L_c) ∈ [0, 1] each; must sum
to 1 ± 1 × 10⁻⁸. Examples: sphere = (1/3, 1/3, 1/3);
needle = (0, 1/2, 1/2); platelet = (1/2, 1/2, 0).
Returns
-------
P_eff : float or np.ndarray
Effective composite property.
Raises
------
ValueError
If phi ∉ [0, 1]; P_m or P_f ≤ 0; any Lⱼ ∉ [0, 1];
or ΣLⱼ ≠ 1.
Notes
-----
* For spheres (L_a = L_b = L_c = 1/3) this is algebraically identical
to :func:`self_consistent` / :func:`~compositecalc.core.bruggeman.bruggeman`.
* Both phases are assigned the *same* L-tuple — appropriate for a
symmetric polycrystalline model where all grains have the same shape.
For a matrix-inclusion composite (matrix = spherical, filler =
ellipsoidal), use :func:`~compositecalc.core.maxwell_garnett.maxwell_garnett`
with an orientation-averaged effective L.
* The result satisfies the phase-swap symmetry:
``self_consistent_ellipsoid(P_m, P_f, φ, L) ==
self_consistent_ellipsoid(P_f, P_m, 1−φ, L)``.
* P* always lies in [min(P_m, P_f), max(P_m, P_f)].
References
----------
Budiansky, B. (1965). *J. Mech. Phys. Solids*, 13, 223–227.
Hill, R. (1965). *J. Mech. Phys. Solids*, 13, 213–222.
Examples
--------
>>> # Spheres — recovers bruggeman(1, 10, 0.3) ≈ 2.261
>>> self_consistent_ellipsoid(1.0, 10.0, 0.3, (1/3, 1/3, 1/3))
2.261...
>>> # Needles — symmetric polycrystalline model with needle grains
>>> self_consistent_ellipsoid(1.0, 10.0, 0.3, (0.0, 0.5, 0.5))
1.5...
"""
L_a, L_b, L_c = (float(l) for l in L_tuple)
if not (0.0 <= L_a <= 1.0 and 0.0 <= L_b <= 1.0 and 0.0 <= L_c <= 1.0):
raise ValueError("All depolarization factors must be in [0, 1].")
if abs(L_a + L_b + L_c - 1.0) > 1e-8:
raise ValueError(
f"L_a + L_b + L_c must equal 1 (got {L_a + L_b + L_c:.10f})."
)
P_m = np.asarray(P_m, dtype=float)
P_f = np.asarray(P_f, dtype=float)
phi = np.asarray(phi, dtype=float)
if np.any((phi < 0.0) | (phi > 1.0)):
raise ValueError("phi must be in [0, 1].")
if np.any(P_m <= 0.0):
raise ValueError("P_m must be positive.")
if np.any(P_f <= 0.0):
raise ValueError("P_f must be positive.")
def _scalar(p_m: float, p_f: float, ph: float) -> float:
if ph == 0.0:
return p_m
if ph == 1.0:
return p_f
if abs(p_m - p_f) < 1e-14 * max(p_m, p_f):
return p_m
lo, hi = min(p_m, p_f), max(p_m, p_f)
return brentq(
_sc_ellipsoid_residual,
lo,
hi,
args=(p_m, p_f, ph, L_a, L_b, L_c),
xtol=1e-12,
)
vec = np.vectorize(_scalar)
result = vec(P_m, P_f, phi)
return float(result) if result.ndim == 0 else result
def self_consistent_multiphase(
components: list[tuple[float, float]],
L: float = 1.0 / 3.0,
) -> float:
"""N-phase symmetric self-consistent scheme for isotropic inclusions.
Solves the implicit equation
.. math::
\\sum_{i=1}^{N} \\phi_i \\,
\\frac{P_i - P^*}{P^* + L(P_i - P^*)} = 0
for P*, where the sum runs over all N phases.
Parameters
----------
components : list of (P_i, phi_i) pairs
Each element is a ``(property, volume_fraction)`` tuple. All
P_i must be positive and the phi_i must sum to 1 ± 1 × 10⁻⁸.
L : float, optional
Depolarization factor for all phases ∈ [0, 1]. Defaults to
1/3 (spheres).
Returns
-------
P_eff : float
Effective composite property.
Raises
------
ValueError
If any P_i ≤ 0; Σφᵢ ≠ 1; L ∉ [0, 1]; or fewer than 2 phases
are provided.
Notes
-----
* For N = 2 phases this reduces to
:func:`self_consistent` / :func:`~compositecalc.core.bruggeman.bruggeman`.
* The root bracket [min(P_i), max(P_i)] is guaranteed to bracket the
physical root: at P* = min(P_i) the sum is ≥ 0; at P* = max(P_i)
the sum is ≤ 0.
* If all P_i are equal the function returns that common value.
References
----------
Budiansky, B. (1965). *J. Mech. Phys. Solids*, 13, 223–227.
Hill, R. (1965). *J. Mech. Phys. Solids*, 13, 213–222.
Examples
--------
>>> # 2-phase: recovers bruggeman(1, 10, 0.3) ≈ 2.261
>>> self_consistent_multiphase([(1.0, 0.7), (10.0, 0.3)])
2.261...
>>> # 3-phase example
>>> self_consistent_multiphase([(1.0, 0.5), (5.0, 0.3), (10.0, 0.2)])
3.0...
"""
if len(components) < 2:
raise ValueError("At least 2 phases are required.")
if not (0.0 <= L <= 1.0):
raise ValueError("L must be in [0, 1].")
props = []
phis = []
for P_i, phi_i in components:
if P_i <= 0.0:
raise ValueError(f"All properties must be positive (got P_i={P_i}).")
if phi_i < 0.0:
raise ValueError(
f"All volume fractions must be non-negative (got phi_i={phi_i})."
)
props.append(float(P_i))
phis.append(float(phi_i))
phi_sum = sum(phis)
if abs(phi_sum - 1.0) > 1e-8:
raise ValueError(
f"Volume fractions must sum to 1 (got {phi_sum:.10f})."
)
# Check if all properties are equal
lo, hi = min(props), max(props)
if abs(hi - lo) < 1e-14 * max(hi, 1.0):
return lo
comps = list(zip(props, phis))
return brentq(
_sc_multiphase_residual,
lo,
hi,
args=(comps, L),
xtol=1e-12,
)