Skip to main content

pantometry_core/
mixture.rs

1//! Two substances made into one, and the honest answer about what that one is.
2//!
3//! A motor is copper, electrical steel, magnets and air. A populated board is FR-4, copper and
4//! solder. A printed part is PLA and voids. A thermal buffer is a wax in an aluminium matrix. Every
5//! one of those wants to be **one** [`Substance`] so a lumped model or a coarse grid can hold it, and
6//! the properties of that one substance are not the properties of its main constituent —
7//! [`Substance::with_specific_heat`] says so and is worth a factor of two on a motor.
8//!
9//! What this module refuses to do is make the numbers up. The properties of a mixture divide into
10//! three kinds, and conflating them is the whole failure mode:
11//!
12//! | | what a mixture rule can say |
13//! | --- | --- |
14//! | density, volumetric heat capacity, latent heat | **exact**, from conservation alone |
15//! | conductivity, stiffness | **bounded**. No single value exists without knowing the microstructure |
16//! | emissivity | **nothing**. It is a property of the surface, and a mixture has no surface |
17//!
18//! The middle row is the one that matters, and it is not a small effect. A 50/50 aluminium and
19//! borosilicate composite conducts somewhere between 2.21 and 84.06 W/m·K — a factor of **38** — and
20//! which end depends entirely on whether the glass is in plates across the flux or in fibres along it.
21//! Both extremes are realisable. A library that answered `0.5·167 + 0.5·1.114` would be handing back
22//! the upper bound of a 38-fold range as if it were a measurement. On a half-copper, half-FR-4 board it
23//! is a **335-fold** range.
24//!
25//! So [`Mix`] reports the exact properties as values and the bounded ones as **bounds**, and
26//! [`Mix::as_substance`] makes the caller supply the conductivity and refuses one outside the bounds.
27//! Choosing is the caller's job; the library's job is to refuse an impossible choice.
28//!
29//! # The bounds, and why there are two pairs
30//!
31//! **Voigt and Reuss** are the arithmetic and harmonic means by volume fraction. They hold for any
32//! microstructure whatever and they are *attained* — a laminate loaded along its layers is exactly
33//! Voigt, and the same laminate across its layers is exactly Reuss. `a_composite.rs` measures both on
34//! one block, which is also the demonstration that no single number can exist: the same composite has
35//! two different conductivities depending on the direction of the flux.
36//!
37//! **Hashin–Shtrikman** are tighter and buy that with an assumption: the microstructure is
38//! statistically *isotropic*. A laminate is not, which is why HS does not contain the laminate's own
39//! answers and is not a replacement for the outer pair. For a foam, a filled polymer or a packed
40//! powder it is the pair to use, and it is narrower — for the aluminium and glass above, 4.33 to 67.6
41//! against 2.21 to 84.06, which takes the range from 38-fold to 16-fold. Narrower and still wide: with
42//! a 150-fold contrast between the phases no bound is going to be comfortable, and that is the honest
43//! state of the problem rather than a deficiency of the bound.
44//!
45//! There is an HS pair for stiffness too — [`Mix::shear_hashin_shtrikman`] and
46//! [`Mix::bulk_hashin_shtrikman`] — and how *tight* it is turns out to depend on which modulus you ask
47//! about. For a three-dimensional checkerboard, measured, the upper bound on the shear modulus is tight to
48//! within 0.5% and the one on the bulk modulus is at least 2.8% loose. That is not something the algebra
49//! says, and `a_checkerboard.rs` is where it is measured.
50//!
51//! # Stiffness, and the reason there is no effective `(E, ν)`
52//!
53//! [`Mix::shear_bounds`] and [`Mix::p_wave_modulus_bounds`] arrived once `Waves::fill` existed, because
54//! until there was per-element material in `pantometry-elastic` there was nothing in this workspace a bound
55//! on stiffness could be *checked* against, and a bound nothing can falsify is a comment rather than an
56//! API. Both are now checked against **Backus averaging** — the exact long-wavelength moduli of a layered
57//! elastic medium — in `crates/pantometry-elastic/tests/a_layered_wave.rs`, and the harmonic end again
58//! statically in `a_layered_block.rs`, which gets it nine orders sharper because an elliptic solve has no
59//! time in it: `4.8e-13` against the wave's `3.5e-4`.
60//!
61//! What is deliberately absent is a Young's modulus and a Poisson ratio for the mixture, and that
62//! absence is the physics rather than a gap. **A composite of two isotropic materials is generally
63//! anisotropic.** A laminate has a different stiffness along its layers than across them — measured, the
64//! shear modulus differs by a factor of **5.5** for aluminium against PLA — so there is no single pair
65//! `(E, ν)` that describes it, and a function returning one would be inventing an isotropy the material
66//! does not have. `Mix` therefore does not produce an [`crate::substance::MechanicalProps`] and
67//! [`Mix::as_substance`] leaves the mechanical block absent.
68//!
69//! No yield strength either, and that one is not a missing feature. A composite's yield is governed by
70//! the weaker phase and by the interface between them, so it is not a mixture of the two yields in any
71//! ordering — Voigt–Reuss does not bound it and a rule of mixtures for it would be wrong rather than
72//! imprecise.
73
74use crate::substance::{FusionProps, Substance, ThermalProps};
75use pantometry_units::{
76    Density, HeatCapacity, LatentHeat, Mass, Pressure, SpecificHeat, Temperature,
77    ThermalConductivity, ThermalExpansion, Volume,
78};
79
80/// A composite: substances and the fraction of the **volume** each occupies.
81///
82/// Volume and not mass, because volume is what the geometry gives and what every bound is written in.
83/// The mass fractions are derived — see [`Mix::mass_fraction`] — and getting the two the wrong way
84/// round is the classic mistake this type exists to prevent.
85#[derive(Clone, Debug, PartialEq)]
86pub struct Mix {
87    parts: Vec<(Substance, f64)>,
88}
89
90impl Mix {
91    /// A mixture from substances and their volume fractions.
92    ///
93    /// The fractions must be positive and sum to one to within `1e-9`. Refused rather than normalised,
94    /// and that is deliberate: fractions that do not sum to one are a transcription mistake, and
95    /// normalising them silently would turn 45% and 50% into 47.4% and 52.6% and answer a question
96    /// nobody asked. `1e-9` and not exact equality because `0.3 + 0.3 + 0.4` is not `1.0` in binary.
97    ///
98    /// A single part is allowed and is not a mistake — a mixture of one is how a caller writes "this
99    /// is not a composite after all" without changing the shape of the code around it, and every bound
100    /// below collapses to the substance's own value.
101    pub fn of(parts: &[(Substance, f64)]) -> Result<Mix, String> {
102        if parts.is_empty() {
103            return Err("a mixture needs at least one substance".to_string());
104        }
105        let mut total = 0.0;
106        for (s, f) in parts {
107            if !(f.is_finite() && *f > 0.0) {
108                return Err(format!(
109                    "{}: a volume fraction must be finite and positive, is {f}",
110                    s.name
111                ));
112            }
113            total += f;
114        }
115        if (total - 1.0).abs() > 1e-9 {
116            return Err(format!(
117                "volume fractions must sum to 1, sum to {total} — they are not normalised for you, \
118                 because 45% and 50% is a transcription mistake and not a request for 47.4% and 52.6%"
119            ));
120        }
121        Ok(Mix {
122            parts: parts.to_vec(),
123        })
124    }
125
126    /// The substances and their volume fractions, in the order given.
127    pub fn parts(&self) -> &[(Substance, f64)] {
128        &self.parts
129    }
130
131    /// **Exact.** `ρ = Σ φᵢ ρᵢ`, which is mass conservation and not a model.
132    pub fn density(&self) -> Density {
133        Density::from_si(
134            self.parts
135                .iter()
136                .map(|(s, f)| f * s.density.to_si())
137                .sum::<f64>(),
138        )
139    }
140
141    /// What fraction of the **mass** the `i`th part is, or `None` if there is no such part.
142    ///
143    /// `wᵢ = φᵢ ρᵢ / ρ`. The conversion that makes the difference between a correct specific heat and
144    /// one that is wrong by the density ratio.
145    pub fn mass_fraction(&self, i: usize) -> Option<f64> {
146        let (s, f) = self.parts.get(i)?;
147        Some(f * s.density.to_si() / self.density().to_si())
148    }
149
150    /// **Exact**, and the field where the volume-and-mass confusion costs the most.
151    ///
152    /// Volumetric heat capacity `ρc` is volume-additive — `ρc = Σ φᵢ ρᵢ cᵢ` — because a joule stored in
153    /// a cubic metre of composite is the joules stored in each part's share of that cubic metre. Divide
154    /// by the mixture's own density and the per-kilogram figure is **mass**-weighted:
155    /// `c = Σ wᵢ cᵢ`.
156    ///
157    /// Volume-weighting `c_p` directly is the mistake, and how large it is depends entirely on which
158    /// pair — which is what makes it dangerous, because it is invisible on the example somebody checks
159    /// with. Measured, at half and half by volume:
160    ///
161    /// ```text
162    ///   aluminium + borosilicate    877.7 correct    877.0 volume-weighted     0.08% out
163    ///   aluminium + copper          503.3            640.5                    27.25%
164    ///   copper + FR-4               507.4            742.5                    46.34%
165    /// ```
166    ///
167    /// The first pair have nearly the same density, so the two rules agree to a tenth of a percent and a
168    /// caller who tried it there would conclude the distinction does not matter. On a copper and FR-4
169    /// board — the case this module's opening paragraph names — it is 46%. The error always runs toward
170    /// the **lighter** constituent, because volume weighting over-counts a light phase whose `c_p` per
171    /// kilogram is high.
172    ///
173    /// `None` if any part does not state a specific heat, because a mixture containing an unknown is
174    /// unknown and not the average of what happens to be known.
175    pub fn specific_heat(&self) -> Option<SpecificHeat> {
176        let mut volumetric = 0.0;
177        for (s, f) in &self.parts {
178            let t = s.thermal?;
179            volumetric += f * s.density.to_si() * t.specific_heat.to_si();
180        }
181        Some(SpecificHeat::from_si(volumetric / self.density().to_si()))
182    }
183
184    /// What a volume of the mixture holds per kelvin. Exact, for the reason above.
185    pub fn heat_capacity(&self, volume: Volume) -> Option<HeatCapacity> {
186        Some(HeatCapacity::from_si(
187            volume.to_si() * self.density().to_si() * self.specific_heat()?.to_si(),
188        ))
189    }
190
191    /// The mass of a volume of the mixture.
192    pub fn mass_of(&self, volume: Volume) -> Mass {
193        Mass::from_si(volume.to_si() * self.density().to_si())
194    }
195
196    /// **Voigt and Reuss**, in that order: the arithmetic and harmonic means by volume fraction.
197    ///
198    /// `(k_low, k_high)` — Reuss first, because a returned pair should read low to high.
199    ///
200    /// These hold for **any** microstructure and both are attained, so they are the widest correct
201    /// answer and also the tightest one that assumes nothing. A laminate is the witness for both:
202    /// flux along the layers gives Voigt exactly and across them gives Reuss exactly, which
203    /// `a_composite.rs` measures on one block to machine precision.
204    ///
205    /// `None` if any part's conductivity is unknown.
206    pub fn conductivity_bounds(&self) -> Option<(ThermalConductivity, ThermalConductivity)> {
207        let mut voigt = 0.0;
208        let mut reciprocal = 0.0;
209        for (s, f) in &self.parts {
210            let k = s.thermal?.conductivity.to_si();
211            voigt += f * k;
212            reciprocal += f / k;
213        }
214        Some((
215            ThermalConductivity::from_si(1.0 / reciprocal),
216            ThermalConductivity::from_si(voigt),
217        ))
218    }
219
220    /// **Hashin–Shtrikman**, for a mixture of exactly two substances whose microstructure is
221    /// statistically isotropic — a foam, a filled polymer, a packed powder.
222    ///
223    /// `(k_low, k_high)`, and both lie inside [`Mix::conductivity_bounds`]. The price of the tighter
224    /// pair is the isotropy assumption, so this is **not** a strictly better answer: a laminate is
225    /// anisotropic and its own exact conductivities fall outside these. Reaching for HS on a layered
226    /// material is the one way to be wrong with it.
227    ///
228    /// ```text
229    ///   k± = k_a + φ_b / ( 1/(k_b − k_a) + φ_a/(3 k_a) )
230    /// ```
231    ///
232    /// with `a` the more conductive phase for the upper bound and the less for the lower. The bounds
233    /// are attained by coated-sphere assemblages, which is why they are bounds and not a fit.
234    ///
235    /// `None` unless there are exactly two parts with known conductivities. Two parts of *equal*
236    /// conductivity give a degenerate pair, which is correct — both bounds are that conductivity.
237    pub fn hashin_shtrikman(&self) -> Option<(ThermalConductivity, ThermalConductivity)> {
238        if self.parts.len() != 2 {
239            return None;
240        }
241        let (k0, f0) = (
242            self.parts[0].0.thermal?.conductivity.to_si(),
243            self.parts[0].1,
244        );
245        let (k1, f1) = (
246            self.parts[1].0.thermal?.conductivity.to_si(),
247            self.parts[1].1,
248        );
249        if k0 == k1 {
250            return Some((
251                ThermalConductivity::from_si(k0),
252                ThermalConductivity::from_si(k0),
253            ));
254        }
255        // `host` is the phase the assemblage is built around; taking the stiffer one gives the upper
256        // bound and the softer one the lower.
257        let bound = |host: f64, host_f: f64, guest: f64, guest_f: f64| {
258            host + guest_f / (1.0 / (guest - host) + host_f / (3.0 * host))
259        };
260        let (hi_host, hi_f, hi_guest, hi_gf) = if k0 > k1 {
261            (k0, f0, k1, f1)
262        } else {
263            (k1, f1, k0, f0)
264        };
265        Some((
266            ThermalConductivity::from_si(bound(hi_guest, hi_gf, hi_host, hi_f)),
267            ThermalConductivity::from_si(bound(hi_host, hi_f, hi_guest, hi_gf)),
268        ))
269    }
270
271    /// **Voigt and Reuss on the shear modulus**, low first: `(⟨1/G⟩⁻¹, ⟨G⟩)`.
272    ///
273    /// `G = E/(2(1+ν))` for each part, weighted by volume fraction. Both ends are **attained**, and by
274    /// the same witness in two directions — which is what makes this a range of achievable values rather
275    /// than a hedge:
276    ///
277    /// - a laminate sheared **in** its layer planes carries a uniform shear strain, so the stresses add
278    ///   and the effective modulus is `⟨G⟩` exactly. That is `C66` in the layered-medium literature;
279    /// - the same laminate sheared **across** its layers carries a uniform shear stress, so the strains
280    ///   add and it is `⟨1/G⟩⁻¹` exactly. That is `C44`.
281    ///
282    /// Both are Backus's 1962 results for a finely layered elastic medium, and both are measured against
283    /// marched wave speeds in `a_layered_wave.rs` — **5.5× apart** for aluminium against PLA, from one
284    /// block, with each end converging at second order to 0.06% or better.
285    ///
286    /// `None` if any part does not state its mechanical properties.
287    pub fn shear_bounds(&self) -> Option<(Pressure, Pressure)> {
288        self.moduli_bounds(|e, nu| e / (2.0 * (1.0 + nu)))
289    }
290
291    /// **Voigt and Reuss on the bulk modulus**, low first: `(⟨1/K⟩⁻¹, ⟨K⟩)`.
292    ///
293    /// `K = E/(3(1−2ν))`. Neither end is attained by a laminate, and no witness in this workspace attains
294    /// either — a laminate under hydrostatic stress has lateral constraint between its layers, so it
295    /// reaches neither the uniform-stress nor the uniform-strain state. They are correct bounds all the
296    /// same, and [`Mix::bulk_hashin_shtrikman`] is the pair to prefer for anything isotropic.
297    ///
298    /// Stated because it is the difference between this pair and [`Mix::shear_bounds`], whose ends both
299    /// *are* attained and measured. A bound that is reachable and a bound that merely holds are different
300    /// things to a caller choosing a number inside them.
301    pub fn bulk_bounds(&self) -> Option<(Pressure, Pressure)> {
302        self.moduli_bounds(|e, nu| e / (3.0 * (1.0 - 2.0 * nu)))
303    }
304
305    /// **Hashin–Shtrikman on the shear modulus** for exactly two phases, low first.
306    ///
307    /// ```text
308    ///   G± = G_r + f_o / [ 1/(G_o − G_r) + 6 f_r (K_r + 2G_r) / (5 G_r (3K_r + 4G_r)) ]
309    /// ```
310    ///
311    /// with `r` the reference phase — the stiffer one for the upper bound, the softer for the lower — and
312    /// `o` the other. Hashin and Shtrikman 1963, and the same trade the conductivity pair makes: tighter
313    /// than Voigt–Reuss, at the price of assuming the microstructure is statistically **isotropic**. For
314    /// aluminium against PLA at half and half it takes the range from 5.5-fold to 2.8-fold.
315    ///
316    /// # The check that says this is the right algebra
317    ///
318    /// Taken with the **matrix** as reference it is identically the **Mori–Tanaka** estimate for spherical
319    /// inclusions — a separately derived result written as a different rational function — and
320    /// `a_mixture.rs` measures the two agreeing to `2.2e-16` across two decades of inclusion fraction.
321    /// That equivalence is a theorem rather than a coincidence: the bound is attained by a coated-sphere
322    /// assemblage, which is what Mori–Tanaka describes.
323    ///
324    /// # What no witness here attains
325    ///
326    /// Unlike the conductivity pair, the elastic HS bounds are **not** bracketed from below by a
327    /// measurement in this workspace, and the reason is worth knowing. A resolved isotropic geometry has
328    /// to be driven by something, and an affine displacement on the boundary is a *kinematically
329    /// admissible* field — so its energy is an **upper** estimate of the effective modulus, above the true
330    /// value however fine the mesh. `a_checkerboard.rs` measures that estimate converging down to
331    /// **1.005×** the upper bound for a well-resolved board and states plainly that it cannot cross it.
332    /// That is evidence the bound is nearly tight and it is not the same as bracketing.
333    ///
334    /// `None` unless there are exactly two parts with mechanical properties.
335    pub fn shear_hashin_shtrikman(&self) -> Option<(Pressure, Pressure)> {
336        self.hashin_shtrikman_pair(false)
337    }
338
339    /// **Hashin–Shtrikman on the bulk modulus** for exactly two phases, low first.
340    ///
341    /// ```text
342    ///   K± = K_r + f_o / [ 1/(K_o − K_r) + 3 f_r / (3K_r + 4G_r) ]
343    /// ```
344    ///
345    /// Everything [`Mix::shear_hashin_shtrikman`] says applies, including the Mori–Tanaka equivalence,
346    /// which for `K` is the more familiar of the two. Note the shear modulus of the *reference* phase
347    /// appears in a bound on `K`: a stiff inclusion resists the hydrostatic compression of its
348    /// surroundings partly in shear, so the two moduli do not separate.
349    pub fn bulk_hashin_shtrikman(&self) -> Option<(Pressure, Pressure)> {
350        self.hashin_shtrikman_pair(true)
351    }
352
353    /// Both elastic Hashin–Shtrikman pairs, since they differ only in one term.
354    ///
355    /// # The reference phase is not chosen, it is tried both ways
356    ///
357    /// The textbook prescription is "put the stiffest phase in the reference position for the upper bound
358    /// and the softest for the lower", and that instruction only means something for a **well-ordered**
359    /// pair — one phase larger in both `K` and `G`. Aluminium against borosilicate is not: aluminium has
360    /// the larger bulk modulus, 67.5 GPa against 46.5, and the *smaller* shear modulus, 25.9 against 34.0.
361    ///
362    /// A first version tested which phase had the larger value of the modulus being bounded and used that
363    /// one as the upper reference. For that pair at a tenth aluminium it returned a lower bound of 48.2312
364    /// GPa above an upper bound of 48.1922 — **the pair inverted**, by 0.08%, which is small enough that
365    /// only a test sweeping fractions and pairs would see it.
366    ///
367    /// So both evaluations are computed and then **ordered**. That is what "interchange which phase is
368    /// subscripted one" actually prescribes, it needs no notion of stiffer, and it is right for a
369    /// well-ordered pair and for the other kind alike.
370    fn hashin_shtrikman_pair(&self, bulk: bool) -> Option<(Pressure, Pressure)> {
371        if self.parts.len() != 2 {
372            return None;
373        }
374        let of = |i: usize| -> Option<(f64, f64, f64)> {
375            let m = self.parts[i].0.mechanical?;
376            let (e, nu) = (m.youngs_modulus.to_si(), m.poisson_ratio);
377            Some((
378                e / (3.0 * (1.0 - 2.0 * nu)),
379                e / (2.0 * (1.0 + nu)),
380                self.parts[i].1,
381            ))
382        };
383        let (a, b) = (of(0)?, of(1)?);
384        let bound = |r: (f64, f64, f64), o: (f64, f64, f64)| {
385            let (kr, gr, fr) = r;
386            let (ko, go, fo) = o;
387            if bulk {
388                kr + fo / (1.0 / (ko - kr) + 3.0 * fr / (3.0 * kr + 4.0 * gr))
389            } else {
390                gr + fo
391                    / (1.0 / (go - gr)
392                        + 6.0 * fr * (kr + 2.0 * gr) / (5.0 * gr * (3.0 * kr + 4.0 * gr)))
393            }
394        };
395        // Equal moduli make the pair degenerate, which is correct — a mixture of two things with the same
396        // `G` has that `G` — and the expression divides by their difference, so it has to come first.
397        let (ma, mb) = if bulk { (a.0, b.0) } else { (a.1, b.1) };
398        if ma == mb {
399            return Some((Pressure::from_si(ma), Pressure::from_si(ma)));
400        }
401        let (one, other) = (bound(a, b), bound(b, a));
402        Some((
403            Pressure::from_si(one.min(other)),
404            Pressure::from_si(one.max(other)),
405        ))
406    }
407
408    /// **Voigt and Reuss on the P-wave modulus** `M = λ + 2μ`, low first: `(⟨1/M⟩⁻¹, ⟨M⟩)`.
409    ///
410    /// `M = E(1−ν)/((1+ν)(1−2ν))`, the modulus that relates stress to strain when the lateral strain is
411    /// held at zero — so it is the one a compression wave travels on and the one a thin layer bonded
412    /// between stiff neighbours actually feels.
413    ///
414    /// **Both ends are attained**, and a first draft of this documentation said the high end was not —
415    /// the measurement is what corrected it.
416    ///
417    /// - a laminate compressed **across** its layers carries a uniform normal stress, so the compliances
418    ///   add and `⟨1/M⟩⁻¹` is exact. Backus's `C33`, and the speed of a compression wave through the
419    ///   stack;
420    /// - the same laminate compressed **along** its layers, with the lateral strain held at zero
421    ///   *pointwise*, carries a uniform strain, so the stresses add and `⟨M⟩` is exact.
422    ///
423    /// The second needs that constraint said out loud, because it is what the Voigt bound *is*. A laminate
424    /// whose lateral contraction is free gives neither bound: the layers each want to contract differently
425    /// and the ones beside them prevent it, and the answer is Backus's `C11`, which carries a correction
426    /// term in `⟨λ/M⟩` and for aluminium against PLA is 43.77 GPa against `⟨M⟩`'s 53.98 — **18.9% below**.
427    /// `a_layered_wave.rs` says why it does not measure that one: it needs the lateral strain zero on
428    /// average but free locally, and `Waves::hold` holds a component everywhere or nowhere.
429    ///
430    /// `None` if any part does not state its mechanical properties.
431    pub fn p_wave_modulus_bounds(&self) -> Option<(Pressure, Pressure)> {
432        self.moduli_bounds(|e, nu| e * (1.0 - nu) / ((1.0 + nu) * (1.0 - 2.0 * nu)))
433    }
434
435    /// The Voigt and Reuss pair for any modulus derivable from `(E, ν)`, low first.
436    ///
437    /// One helper because the two public pairs differ only in which modulus, and writing the weighting
438    /// twice is how two bounds come to disagree about what a volume fraction is.
439    fn moduli_bounds(&self, modulus: impl Fn(f64, f64) -> f64) -> Option<(Pressure, Pressure)> {
440        let mut voigt = 0.0;
441        let mut reciprocal = 0.0;
442        for (s, f) in &self.parts {
443            let m = s.mechanical?;
444            let value = modulus(m.youngs_modulus.to_si(), m.poisson_ratio);
445            if !(value.is_finite() && value > 0.0) {
446                return None;
447            }
448            voigt += f * value;
449            reciprocal += f / value;
450        }
451        Some((
452            Pressure::from_si(1.0 / reciprocal),
453            Pressure::from_si(voigt),
454        ))
455    }
456
457    /// **Exact.** The latent heat per kilogram *of the mixture*, when exactly one part melts.
458    ///
459    /// `L = w L_part` at that part's melting point, by mass conservation. This is the phase-change
460    /// composite — a wax in an aluminium matrix, a salt hydrate in a foam — and it is the case where a
461    /// mixture rule is not an approximation at all: the joules are the joules, and diluting the PCM
462    /// dilutes them in exact proportion to its mass fraction.
463    ///
464    /// `w` and not the volume fraction, and here the difference is the whole answer rather than a
465    /// refinement. Wax at 814 kg/m³ filling **80% of the volume** of an aluminium matrix is only
466    /// **54.7% of the mass**, so a composite whose wax stores 244 kJ/kg stores 133.4 — and using the
467    /// volume fraction would claim 195, **46% high**, on the one property a buffer is bought for.
468    ///
469    /// `None` if nothing melts. **`Err` if two or more parts melt**, because two melting points is not
470    /// one melting point and a composite with two plateaux cannot be described by a type that has one
471    /// `melting_point` field. Refused rather than answered with the larger, the first, or an average.
472    #[allow(clippy::type_complexity)]
473    pub fn fusion(&self) -> Result<Option<(Temperature, LatentHeat)>, String> {
474        let melting: Vec<usize> = (0..self.parts.len())
475            .filter(|i| self.parts[*i].0.fusion.is_some())
476            .collect();
477        match melting.as_slice() {
478            [] => Ok(None),
479            [i] => {
480                let f = self.parts[*i].0.fusion.expect("filtered on it");
481                let w = self.mass_fraction(*i).expect("index came from the list");
482                Ok(Some((
483                    f.melting_point,
484                    LatentHeat::from_si(w * f.latent_heat.to_si()),
485                )))
486            }
487            many => Err(format!(
488                "{} of the parts melt — {} — and a composite with two plateaux is not describable by \
489                 one melting point. Mix the non-melting parts and handle the phase change as its own \
490                 region, or model the second one as inert",
491                many.len(),
492                many.iter()
493                    .map(|i| format!("{:?}", self.parts[*i].0.name))
494                    .collect::<Vec<_>>()
495                    .join(" and ")
496            )),
497        }
498    }
499
500    /// The mixture as one [`Substance`], with the conductivity and emissivity the caller chose.
501    ///
502    /// # Why those two are arguments
503    ///
504    /// **The conductivity, because no single value exists.** The bounds are as far as physics goes
505    /// without the microstructure, and picking a point inside them is a modelling decision with a
506    /// reason attached — the midpoint of the Hashin–Shtrikman pair for a foam, the Voigt bound for
507    /// unidirectional fibres along the flux, a measured value if there is one. A value outside
508    /// [`Mix::conductivity_bounds`] is **refused**: it is not conservative or approximate, it is
509    /// impossible, and no microstructure realises it.
510    ///
511    /// **The emissivity, because a mixture has no surface.** It is not a bulk property and it does not
512    /// mix. A half-copper, half-FR-4 board is 0.05 if it is bare metal on the outside and 0.9 if it is
513    /// green solder mask, and the volume fractions say nothing about which.
514    ///
515    /// Expansion is volume-weighted, and that one *is* an approximation rather than a bound — the Voigt
516    /// rule for CTE, which overestimates when the stiff phase is the one that expands less, because the
517    /// stiff phase restrains the compliant one. Turner's and Kerner's rules weight by stiffness and are
518    /// what a thermal-stress calculation wants; this is the number for a rough length change, and it
519    /// is documented here rather than silently returned as if it were the exact ones above.
520    ///
521    /// `Err` if any part's thermal properties are unknown, if the conductivity is outside the bounds,
522    /// if the emissivity is not a fraction, or if more than one part melts.
523    pub fn as_substance(
524        &self,
525        name: &str,
526        conductivity: ThermalConductivity,
527        emissivity: f64,
528    ) -> Result<Substance, String> {
529        let (low, high) = self
530            .conductivity_bounds()
531            .ok_or_else(|| format!("{name}: a part does not state its conductivity"))?;
532        let k = conductivity.to_si();
533        if !(k.is_finite() && k >= low.to_si() * (1.0 - 1e-12) && k <= high.to_si() * (1.0 + 1e-12))
534        {
535            return Err(format!(
536                "{name}: no microstructure of these parts conducts {k} W/m/K — the Voigt and Reuss \
537                 bounds are {} to {}, and they are attained, so this is impossible rather than \
538                 merely unlikely",
539                low.to_si(),
540                high.to_si()
541            ));
542        }
543        if !(0.0..=1.0).contains(&emissivity) {
544            return Err(format!(
545                "{name}: emissivity is a fraction of a blackbody's and must be in 0..=1, is \
546                 {emissivity}"
547            ));
548        }
549        let specific_heat = self
550            .specific_heat()
551            .ok_or_else(|| format!("{name}: a part does not state its specific heat"))?;
552        let expansion = ThermalExpansion::from_si(
553            self.parts
554                .iter()
555                .map(|(s, f)| f * s.thermal.map_or(f64::NAN, |t| t.expansion.to_si()))
556                .sum::<f64>(),
557        );
558        let mut out = Substance::bulk(name, self.density()).with_thermal(ThermalProps {
559            conductivity,
560            specific_heat,
561            expansion,
562            emissivity,
563        });
564        if let Some((point, latent)) = self.fusion().map_err(|e| format!("{name}: {e}"))? {
565            out = out.with_fusion(FusionProps::new(point, latent));
566        }
567        Ok(out)
568    }
569}