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}