"""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, )