use pantometry_core::mixture::Mix;
use pantometry_core::substance::{FusionProps, Substance, ThermalProps};
use pantometry_core::units::{
Density, LatentHeat, SpecificHeat, Temperature, ThermalConductivity, ThermalExpansion, Volume,
};
fn wax() -> Substance {
Substance::bulk("n-octadecane", Density::from_si(814.0))
.with_thermal(ThermalProps {
conductivity: ThermalConductivity::w_per_m_k(0.358),
specific_heat: SpecificHeat::j_per_kg_k(1934.0),
expansion: ThermalExpansion::ppm_per_k(800.0),
emissivity: 0.9,
})
.with_fusion(FusionProps::new(
Temperature::from_si(301.3),
LatentHeat::kj_per_kg(244.0),
))
}
#[test]
fn a_mixture_that_does_not_add_up_is_refused() {
let (a, b) = (Substance::aluminium_6061(), Substance::copper());
let err = Mix::of(&[(a.clone(), 0.45), (b.clone(), 0.5)]).expect_err("refused");
assert!(
err.contains("sum to 1") && err.contains("0.95"),
"the message should say what they summed to: {err}"
);
for bad in [-0.5, 0.0, f64::NAN, f64::INFINITY] {
assert!(
Mix::of(&[(a.clone(), bad), (b.clone(), 1.0 - bad)]).is_err(),
"a fraction of {bad} was accepted"
);
}
assert!(Mix::of(&[]).is_err(), "an empty mixture was accepted");
let third = 1.0 / 3.0;
assert!(Mix::of(&[(a.clone(), third), (b.clone(), third), (a, third)]).is_ok());
}
#[test]
fn a_mixture_of_one_reproduces_its_only_part() {
for s in [
Substance::aluminium_6061(),
Substance::borosilicate_crown(),
wax(),
] {
let m = Mix::of(&[(s.clone(), 1.0)]).expect("one part sums to one");
let t = s.thermal.expect("all three state thermal properties");
assert_eq!(m.density(), s.density);
assert_eq!(m.specific_heat(), Some(t.specific_heat));
let (lo, hi) = m.conductivity_bounds().expect("bounds");
assert_eq!(lo, t.conductivity, "{}: Reuss", s.name);
assert_eq!(hi, t.conductivity, "{}: Voigt", s.name);
assert_eq!(m.mass_fraction(0), Some(1.0));
}
}
#[test]
fn the_exact_properties_are_exact_and_the_specific_heat_is_mass_weighted() {
let cases: [(Substance, Substance, f64, f64); 3] = [
(
Substance::aluminium_6061(),
Substance::borosilicate_crown(),
0.5,
7.8945e-4,
),
(
Substance::aluminium_6061(),
Substance::copper(),
0.5,
0.27253,
),
(Substance::copper(), Substance::fr4(), 0.5, 0.46337),
];
for (a, b, fa, expect_trap) in cases {
let m = Mix::of(&[(a.clone(), fa), (b.clone(), 1.0 - fa)]).expect("sums to one");
let (ta, tb) = (a.thermal.expect("a"), b.thermal.expect("b"));
let want_rho = fa * a.density.to_si() + (1.0 - fa) * b.density.to_si();
assert!(
(m.density().to_si() / want_rho - 1.0).abs() < 1e-15,
"{} + {}: density {} against {want_rho}",
a.name,
b.name,
m.density().to_si()
);
let want_volumetric = fa * a.density.to_si() * ta.specific_heat.to_si()
+ (1.0 - fa) * b.density.to_si() * tb.specific_heat.to_si();
let got_volumetric = m.density().to_si() * m.specific_heat().expect("c_p").to_si();
assert!(
(got_volumetric / want_volumetric - 1.0).abs() < 1e-15,
"{} + {}: rho c {got_volumetric} against {want_volumetric}",
a.name,
b.name
);
let (wa, wb) = (
m.mass_fraction(0).expect("a"),
m.mass_fraction(1).expect("b"),
);
assert!((wa + wb - 1.0).abs() < 1e-15, "mass fractions sum to one");
let by_mass = wa * ta.specific_heat.to_si() + wb * tb.specific_heat.to_si();
assert!(
(m.specific_heat().expect("c_p").to_si() / by_mass - 1.0).abs() < 1e-15,
"{} + {}: c_p is the mass-weighted mean",
a.name,
b.name
);
let by_volume = fa * ta.specific_heat.to_si() + (1.0 - fa) * tb.specific_heat.to_si();
let trap = (by_volume / m.specific_heat().expect("c_p").to_si() - 1.0).abs();
println!(
" {:>22} + {:<14} c_p {:.1} correct, {:.1} volume-weighted — {:.2}% out",
a.name,
b.name,
m.specific_heat().expect("c_p").to_si(),
by_volume,
trap * 100.0
);
assert!(
(trap / expect_trap - 1.0).abs() < 0.005,
"{} + {}: the volume-weighting error is {:.4}%, was measured at {:.2}%",
a.name,
b.name,
trap * 100.0,
expect_trap * 100.0
);
let v = Volume::from_si(1e-6);
assert!(
(m.mass_of(v).to_si() / (want_rho * 1e-6) - 1.0).abs() < 1e-15,
"the mass of a volume follows from the density"
);
assert!(
(m.heat_capacity(v).expect("capacity").to_si() / (want_volumetric * 1e-6) - 1.0).abs()
< 1e-15,
"as does the capacity"
);
}
}
#[test]
fn the_bounds_are_ordered_at_every_fraction() {
let pairs = [
(Substance::aluminium_6061(), Substance::copper()),
(Substance::aluminium_6061(), Substance::borosilicate_crown()),
(Substance::copper(), Substance::fr4()),
];
for (a, b) in pairs {
for tenths in 1..10 {
let f = tenths as f64 / 10.0;
let m = Mix::of(&[(a.clone(), f), (b.clone(), 1.0 - f)]).expect("sums to one");
let (reuss, voigt) = m.conductivity_bounds().expect("bounds");
let (hs_lo, hs_hi) = m.hashin_shtrikman().expect("two parts");
assert!(
reuss.to_si() < hs_lo.to_si()
&& hs_lo.to_si() <= hs_hi.to_si()
&& hs_hi.to_si() < voigt.to_si(),
"{} {f} + {}: {} <= {} <= {} <= {} is out of order",
a.name,
b.name,
reuss.to_si(),
hs_lo.to_si(),
hs_hi.to_si(),
voigt.to_si()
);
let (ka, kb) = (
a.thermal.expect("a").conductivity.to_si(),
b.thermal.expect("b").conductivity.to_si(),
);
let (small, large) = (ka.min(kb), ka.max(kb));
assert!(reuss.to_si() > small && voigt.to_si() < large);
}
}
let cu = Substance::copper();
let k = cu.thermal.expect("copper").conductivity;
let same = Mix::of(&[(cu.clone(), 0.3), (cu, 0.7)]).expect("sums to one");
let (reuss, voigt) = same.conductivity_bounds().expect("bounds");
let (hs_lo, hs_hi) = same.hashin_shtrikman().expect("two parts");
for (what, got) in [
("Reuss", reuss),
("Voigt", voigt),
("HS-", hs_lo),
("HS+", hs_hi),
] {
assert!(
(got.to_si() / k.to_si() - 1.0).abs() < 1e-15,
"{what}: a mixture of copper and copper conducts {} and not {}",
got.to_si(),
k.to_si()
);
}
}
#[test]
fn hashin_shtrikman_is_maxwell_garnett_with_the_matrix_as_host() {
let matrix = Substance::fr4();
let filler = Substance::copper();
let kh = matrix.thermal.expect("matrix").conductivity.to_si();
let kg = filler.thermal.expect("filler").conductivity.to_si();
let mut worst = 0.0f64;
for phi in [0.001, 0.01, 0.05, 0.1, 0.25, 0.5, 0.75, 0.9] {
let m =
Mix::of(&[(matrix.clone(), 1.0 - phi), (filler.clone(), phi)]).expect("sums to one");
let (hs_lo, _) = m.hashin_shtrikman().expect("two parts");
let mg = kh * (1.0 + 3.0 * phi * (kg - kh) / (kg + 2.0 * kh - phi * (kg - kh)));
let off = (hs_lo.to_si() / mg - 1.0).abs();
println!(
" phi {phi:<6} HS- {:.6} against Maxwell-Garnett {mg:.6} — off {off:.2e}",
hs_lo.to_si()
);
assert!(
off < 1e-14,
"phi {phi}: HS- is {} and Maxwell-Garnett is {mg}",
hs_lo.to_si()
);
worst = worst.max(off);
}
println!(" worst {worst:.2e} over three decades of filler fraction");
}
#[test]
fn a_phase_change_composite_dilutes_its_latent_heat_by_mass() {
let m = Mix::of(&[(wax(), 0.8), (Substance::aluminium_6061(), 0.2)]).expect("sums to one");
let (point, latent) = m
.fusion()
.expect("one part melts")
.expect("so there is a fusion");
assert_eq!(
point,
Temperature::from_si(301.3),
"the melting point is the melting part's, undiluted — diluting a temperature is meaningless"
);
let w = m.mass_fraction(0).expect("the wax");
assert!(
(latent.to_si() / (w * 244_000.0) - 1.0).abs() < 1e-15,
"latent heat is the mass fraction times the wax's: {} against {}",
latent.to_si(),
w * 244_000.0
);
println!(
" wax 80% by volume is {:.2}% by mass: {:.1} kJ/kg, against {:.1} if volume were used \
— {:.1}% high",
w * 100.0,
latent.to_si() / 1000.0,
0.8 * 244.0,
(0.8 * 244_000.0 / latent.to_si() - 1.0) * 100.0
);
assert!(
((0.8 * 244_000.0 / latent.to_si() - 1.0) - 0.4634).abs() < 0.001,
"the volume-fraction mistake is 46.3% here"
);
let inert = Mix::of(&[
(Substance::aluminium_6061(), 0.5),
(Substance::copper(), 0.5),
])
.expect("sums to one");
assert_eq!(inert.fusion(), Ok(None));
let both = Mix::of(&[(wax(), 0.5), (Substance::ice(), 0.5)]).expect("sums to one");
let err = both.fusion().expect_err("refused");
assert!(
err.contains("octadecane") && err.contains("ice") && err.contains("melting point"),
"the message should name both and say what the problem is: {err}"
);
}
#[test]
fn a_composite_substance_refuses_an_impossible_conductivity() {
let m = Mix::of(&[
(Substance::aluminium_6061(), 0.5),
(Substance::borosilicate_crown(), 0.5),
])
.expect("sums to one");
let (reuss, voigt) = m.conductivity_bounds().expect("bounds");
let (hs_lo, hs_hi) = m.hashin_shtrikman().expect("two parts");
for k in [
reuss,
voigt,
ThermalConductivity::from_si(0.5 * (hs_lo.to_si() + hs_hi.to_si())),
] {
let s = m
.as_substance("Al/BK7", k, 0.9)
.unwrap_or_else(|e| panic!("{} should be allowed: {e}", k.to_si()));
let t = s.thermal.expect("it states thermal properties");
assert_eq!(t.conductivity, k, "the choice is carried through");
assert_eq!(s.density, m.density());
assert_eq!(Some(t.specific_heat), m.specific_heat());
let alpha = k.to_si() / (m.density().to_si() * m.specific_heat().expect("c_p").to_si());
assert!((s.diffusivity().expect("alpha").to_si() / alpha - 1.0).abs() < 1e-15);
assert!(s.check().is_ok(), "{}", s.check().unwrap_err());
}
for outside in [
reuss.to_si() * 0.999,
voigt.to_si() * 1.001,
0.0,
-1.0,
f64::NAN,
] {
let err = m
.as_substance("Al/BK7", ThermalConductivity::from_si(outside), 0.9)
.expect_err("refused");
assert!(err.contains("no microstructure"), "{outside}: {err}");
}
for bad in [-0.1, 1.1] {
assert!(
m.as_substance("Al/BK7", voigt, bad).is_err(),
"an emissivity of {bad} was accepted"
);
}
let unknown = Mix::of(&[
(Substance::bulk("mystery", Density::g_per_cm3(2.0)), 0.5),
(Substance::copper(), 0.5),
])
.expect("sums to one");
assert_eq!(unknown.conductivity_bounds(), None);
assert_eq!(unknown.specific_heat(), None);
assert!(unknown.as_substance("x", voigt, 0.9).is_err());
assert!((unknown.density().to_si() - 0.5 * (2000.0 + 8960.0)).abs() < 1e-12);
}
#[test]
fn mixing_in_two_steps_matches_mixing_in_one() {
let (a, b, c) = (
Substance::aluminium_6061(),
Substance::borosilicate_crown(),
Substance::copper(),
);
let inner = Mix::of(&[(a.clone(), 0.5), (b.clone(), 0.5)]).expect("sums to one");
let (reuss_in, voigt_in) = inner.conductivity_bounds().expect("bounds");
let flat = Mix::of(&[(a, 0.2), (b, 0.2), (c.clone(), 0.6)]).expect("sums to one");
for (k_inner, pick) in [(reuss_in, "Reuss"), (voigt_in, "Voigt")] {
let composite = inner
.as_substance("Al/BK7", k_inner, 0.9)
.expect("a bound is allowed");
let nested = Mix::of(&[(composite, 0.4), (c.clone(), 0.6)]).expect("sums to one");
assert!(
(nested.density().to_si() / flat.density().to_si() - 1.0).abs() < 1e-15,
"{pick}: density is associative"
);
let volumetric = |m: &Mix| m.density().to_si() * m.specific_heat().expect("c_p").to_si();
assert!(
(volumetric(&nested) / volumetric(&flat) - 1.0).abs() < 1e-15,
"{pick}: volumetric heat capacity is associative"
);
let (reuss_n, voigt_n) = nested.conductivity_bounds().expect("bounds");
let (reuss_f, voigt_f) = flat.conductivity_bounds().expect("bounds");
let want = if pick == "Reuss" { reuss_f } else { voigt_f };
let got = if pick == "Reuss" { reuss_n } else { voigt_n };
assert!(
(got.to_si() / want.to_si() - 1.0).abs() < 1e-15,
"{pick}: nesting gives {} and flattening gives {}",
got.to_si(),
want.to_si()
);
}
let composite = inner
.as_substance("Al/BK7", voigt_in, 0.9)
.expect("allowed");
let nested = Mix::of(&[(composite, 0.4), (c, 0.6)]).expect("sums to one");
let (nested_lo, _) = nested.hashin_shtrikman().expect("two parts");
assert_eq!(
flat.hashin_shtrikman(),
None,
"HS is a two-phase result and a three-part mixture is not two phases"
);
println!(
" nesting gives an HS- of {:.4}; flattening gives no HS pair, which is the correct answer",
nested_lo.to_si()
);
}
#[test]
fn the_elastic_bounds_are_mori_tanaka_with_the_matrix_as_reference() {
let matrix = Substance::pla();
let filler = Substance::aluminium_6061();
let moduli = |s: &Substance| {
let m = s.mechanical.expect("both state mechanical properties");
let (e, nu) = (m.youngs_modulus.to_si(), m.poisson_ratio);
(e / (3.0 * (1.0 - 2.0 * nu)), e / (2.0 * (1.0 + nu)))
};
let (km, gm) = moduli(&matrix);
let (ki, gi) = moduli(&filler);
assert!(km < ki && gm < gi, "PLA is softer than aluminium in both");
let mut worst = 0.0f64;
for f in [0.01, 0.05, 0.1, 0.3, 0.5, 0.7, 0.9] {
let mix = Mix::of(&[(matrix.clone(), 1.0 - f), (filler.clone(), f)]).expect("sums to one");
let (hs_k, _) = mix.bulk_hashin_shtrikman().expect("two phases");
let (hs_g, _) = mix.shear_hashin_shtrikman().expect("two phases");
let mt_k = km + f * (ki - km) / (1.0 + (1.0 - f) * (ki - km) / (km + 4.0 * gm / 3.0));
let h = gm * (9.0 * km + 8.0 * gm) / (6.0 * (km + 2.0 * gm));
let mt_g = gm + f * (gi - gm) / (1.0 + (1.0 - f) * (gi - gm) / (gm + h));
let (off_k, off_g) = (
(hs_k.to_si() / mt_k - 1.0).abs(),
(hs_g.to_si() / mt_g - 1.0).abs(),
);
println!(
" f {f:<5} K {:.6} GPa against Mori-Tanaka {:.6} — off {off_k:.2e}; G {:.6} against \
{:.6} — off {off_g:.2e}",
hs_k.to_si() / 1e9,
mt_k / 1e9,
hs_g.to_si() / 1e9,
mt_g / 1e9
);
assert!(
off_k < 1e-14 && off_g < 1e-14,
"f {f}: HS is not Mori-Tanaka"
);
worst = worst.max(off_k).max(off_g);
}
println!(" worst {worst:.2e} over two decades of inclusion fraction");
}
#[test]
fn the_elastic_bounds_are_ordered_at_every_fraction() {
let pairs = [
(Substance::aluminium_6061(), Substance::pla()),
(Substance::aluminium_6061(), Substance::borosilicate_crown()),
(Substance::stainless_304(), Substance::ice()),
];
for (a, b) in pairs {
for tenths in 1..10 {
let f = tenths as f64 / 10.0;
let m = Mix::of(&[(a.clone(), f), (b.clone(), 1.0 - f)]).expect("sums to one");
for (what, outer, inner) in [
("bulk", m.bulk_bounds(), m.bulk_hashin_shtrikman()),
("shear", m.shear_bounds(), m.shear_hashin_shtrikman()),
] {
let (reuss, voigt) = outer.expect("both state mechanical properties");
let (hs_lo, hs_hi) = inner.expect("two phases");
assert!(
reuss.to_si() < hs_lo.to_si()
&& hs_lo.to_si() < hs_hi.to_si()
&& hs_hi.to_si() < voigt.to_si(),
"{} + {} at {f}, {what}: {:.4} <= {:.4} <= {:.4} <= {:.4} GPa is out of order",
a.name,
b.name,
reuss.to_si() / 1e9,
hs_lo.to_si() / 1e9,
hs_hi.to_si() / 1e9,
voigt.to_si() / 1e9
);
}
}
}
let m = Mix::of(&[(Substance::aluminium_6061(), 0.5), (Substance::pla(), 0.5)])
.expect("sums to one");
let (reuss, voigt) = m.shear_bounds().expect("bounds");
let (hs_lo, hs_hi) = m.shear_hashin_shtrikman().expect("two phases");
println!(
" aluminium + PLA shear: Voigt-Reuss {:.3}x, Hashin-Shtrikman {:.3}x",
voigt.to_si() / reuss.to_si(),
hs_hi.to_si() / hs_lo.to_si()
);
assert!(
(voigt.to_si() / reuss.to_si() - 5.545).abs() < 0.01
&& (hs_hi.to_si() / hs_lo.to_si() - 2.820).abs() < 0.01,
"the two ranges are 5.545 and 2.820"
);
let al = Substance::aluminium_6061();
let same = Mix::of(&[(al.clone(), 0.3), (al, 0.7)]).expect("sums to one");
for (lo, hi) in [
same.bulk_bounds().expect("k"),
same.bulk_hashin_shtrikman().expect("k hs"),
same.shear_bounds().expect("g"),
same.shear_hashin_shtrikman().expect("g hs"),
] {
assert!(
(lo.to_si() / hi.to_si() - 1.0).abs() < 1e-15,
"a mixture of one material with itself has no range: {:.6} to {:.6}",
lo.to_si(),
hi.to_si()
);
}
}