use dualis_units::{
Density, Diffusivity, HeatCapacity, Length, Mass, Pressure, SpecificHeat, Temperature,
ThermalConductivity, ThermalExpansion, Velocity, Volume,
};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Substance {
pub name: String,
pub density: Density,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thermal: Option<ThermalProps>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mechanical: Option<MechanicalProps>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub acoustic: Option<AcousticProps>,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct ThermalProps {
pub conductivity: ThermalConductivity,
pub specific_heat: SpecificHeat,
pub expansion: ThermalExpansion,
pub emissivity: f64,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct MechanicalProps {
pub youngs_modulus: Pressure,
pub poisson_ratio: f64,
pub yield_strength: Pressure,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct AcousticProps {
pub sound_speed: Velocity,
}
impl Substance {
pub fn diffusivity(&self) -> Option<Diffusivity> {
let t = self.thermal?;
Some(Diffusivity::from_si(
t.conductivity.to_si() / (self.density.to_si() * t.specific_heat.to_si()),
))
}
pub fn heat_capacity(&self, volume: Volume) -> Option<HeatCapacity> {
let t = self.thermal?;
Some(self.mass_of(volume) * t.specific_heat)
}
pub fn mass_of(&self, volume: Volume) -> Mass {
self.density * volume
}
pub fn expansion_of(&self, length: Length, rise: Temperature) -> Option<Length> {
let t = self.thermal?;
Some(Length::from_si(
length.to_si() * t.expansion.to_si() * rise.to_si(),
))
}
pub fn constrained_stress(&self, rise: Temperature) -> Option<Pressure> {
let t = self.thermal?;
let m = self.mechanical?;
Some(Pressure::from_si(
m.youngs_modulus.to_si() * t.expansion.to_si() * rise.to_si(),
))
}
pub fn survives(&self, rise: Temperature) -> Option<bool> {
let stress = self.constrained_stress(rise)?;
let limit = self.mechanical?.yield_strength;
Some(stress < limit)
}
pub fn borosilicate_crown() -> Substance {
Substance {
name: "N-BK7".to_string(),
density: Density::g_per_cm3(2.51),
thermal: Some(ThermalProps {
conductivity: ThermalConductivity::w_per_m_k(1.114),
specific_heat: SpecificHeat::j_per_kg_k(858.0),
expansion: ThermalExpansion::ppm_per_k(7.1),
emissivity: 0.90,
}),
mechanical: Some(MechanicalProps {
youngs_modulus: Pressure::from_si(82.0e9),
poisson_ratio: 0.206,
yield_strength: Pressure::from_si(60.0e6),
}),
acoustic: Some(AcousticProps {
sound_speed: Velocity::m_per_s(5_680.0),
}),
}
}
pub fn aluminium_6061() -> Substance {
Substance {
name: "Al 6061".to_string(),
density: Density::g_per_cm3(2.70),
thermal: Some(ThermalProps {
conductivity: ThermalConductivity::w_per_m_k(167.0),
specific_heat: SpecificHeat::j_per_kg_k(896.0),
expansion: ThermalExpansion::ppm_per_k(23.6),
emissivity: 0.09,
}),
mechanical: Some(MechanicalProps {
youngs_modulus: Pressure::from_si(68.9e9),
poisson_ratio: 0.33,
yield_strength: Pressure::from_si(276.0e6),
}),
acoustic: Some(AcousticProps {
sound_speed: Velocity::m_per_s(6_320.0),
}),
}
}
pub fn with_emissivity(mut self, emissivity: f64) -> Substance {
if let Some(t) = self.thermal.as_mut() {
t.emissivity = emissivity.clamp(0.0, 1.0);
}
self
}
pub fn with_specific_heat(mut self, specific_heat: SpecificHeat) -> Substance {
if let Some(t) = self.thermal.as_mut() {
t.specific_heat = specific_heat;
}
self
}
pub fn copper() -> Substance {
Substance {
name: "Cu ETP".to_string(),
density: Density::g_per_cm3(8.96),
thermal: Some(ThermalProps {
conductivity: ThermalConductivity::w_per_m_k(401.0),
specific_heat: SpecificHeat::j_per_kg_k(385.0),
expansion: ThermalExpansion::ppm_per_k(16.5),
emissivity: 0.04,
}),
mechanical: Some(MechanicalProps {
youngs_modulus: Pressure::from_si(117.0e9),
poisson_ratio: 0.34,
yield_strength: Pressure::from_si(70.0e6),
}),
acoustic: Some(AcousticProps {
sound_speed: Velocity::m_per_s(4_760.0),
}),
}
}
pub fn fr4() -> Substance {
Substance {
name: "FR-4".to_string(),
density: Density::g_per_cm3(1.85),
thermal: Some(ThermalProps {
conductivity: ThermalConductivity::w_per_m_k(0.30),
specific_heat: SpecificHeat::j_per_kg_k(1_100.0),
expansion: ThermalExpansion::ppm_per_k(14.0),
emissivity: 0.90,
}),
mechanical: Some(MechanicalProps {
youngs_modulus: Pressure::from_si(22.0e9),
poisson_ratio: 0.16,
yield_strength: Pressure::from_si(300.0e6),
}),
acoustic: None,
}
}
pub fn electrical_steel() -> Substance {
Substance {
name: "electrical steel (non-oriented)".to_string(),
density: Density::g_per_cm3(7.65),
thermal: Some(ThermalProps {
conductivity: ThermalConductivity::w_per_m_k(25.0),
specific_heat: SpecificHeat::j_per_kg_k(460.0),
expansion: ThermalExpansion::ppm_per_k(12.0),
emissivity: 0.30,
}),
mechanical: Some(MechanicalProps {
youngs_modulus: Pressure::from_si(200.0e9),
poisson_ratio: 0.29,
yield_strength: Pressure::from_si(350.0e6),
}),
acoustic: Some(AcousticProps {
sound_speed: Velocity::m_per_s(5_100.0),
}),
}
}
pub fn pla() -> Substance {
Substance {
name: "PLA (solid)".to_string(),
density: Density::g_per_cm3(1.24),
thermal: Some(ThermalProps {
conductivity: ThermalConductivity::w_per_m_k(0.13),
specific_heat: SpecificHeat::j_per_kg_k(1_800.0),
expansion: ThermalExpansion::ppm_per_k(70.0),
emissivity: 0.90,
}),
mechanical: Some(MechanicalProps {
youngs_modulus: Pressure::from_si(3.5e9),
poisson_ratio: 0.36,
yield_strength: Pressure::from_si(50.0e6),
}),
acoustic: None,
}
}
pub fn water() -> Substance {
Substance {
name: "water".to_string(),
density: Density::g_per_cm3(0.998),
thermal: Some(ThermalProps {
conductivity: ThermalConductivity::w_per_m_k(0.598),
specific_heat: SpecificHeat::j_per_kg_k(4_182.0),
expansion: ThermalExpansion::ppm_per_k(69.0),
emissivity: 0.96,
}),
mechanical: None,
acoustic: Some(AcousticProps {
sound_speed: Velocity::m_per_s(1_482.0),
}),
}
}
pub fn bulk(name: &str, density: Density) -> Substance {
Substance {
name: name.to_string(),
density,
thermal: None,
mechanical: None,
acoustic: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use dualis_units::Length;
#[test]
fn diffusivity_matches_the_published_figures() {
let glass = Substance::borosilicate_crown().diffusivity().unwrap();
let metal = Substance::aluminium_6061().diffusivity().unwrap();
assert!(
(glass.to_si() - 5.17e-7).abs() < 1e-8,
"N-BK7 diffusivity {glass:?}"
);
assert!(
(metal.to_si() - 6.9e-5).abs() < 1e-6,
"aluminium diffusivity {metal:?}"
);
assert!(metal.to_si() / glass.to_si() > 100.0);
}
#[test]
fn stability_limits_differ_by_two_orders_of_magnitude() {
let cell = Length::mm(1.0);
let limit = |s: &Substance| {
let a = s.diffusivity().unwrap().to_si();
cell.to_si() * cell.to_si() / (2.0 * a)
};
let glass = limit(&Substance::borosilicate_crown());
let metal = limit(&Substance::aluminium_6061());
assert!((glass - 0.97).abs() < 0.1, "glass limit {glass} s");
assert!((metal - 0.0072).abs() < 0.001, "metal limit {metal} s");
assert!(glass / metal > 100.0);
}
#[test]
fn a_lens_sized_piece_holds_a_few_joules_per_kelvin() {
let glass = Substance::borosilicate_crown();
let volume = Volume::from_si(std::f64::consts::PI * (0.0125f64).powi(2) * 0.005);
let mass = glass.mass_of(volume);
assert!((mass.to_si() * 1e3 - 6.16).abs() < 0.05, "{mass:?}");
let capacity = glass.heat_capacity(volume).unwrap();
assert!((capacity.to_si() - 5.28).abs() < 0.05, "{capacity:?}");
}
#[test]
fn constrained_expansion_breaks_glass_before_metal() {
let glass = Substance::borosilicate_crown();
let metal = Substance::aluminium_6061();
let growth = glass
.expansion_of(Length::mm(100.0), Temperature::from_si(20.0))
.unwrap();
assert!((growth.in_um() - 14.2).abs() < 0.1, "{growth:?}");
let stress = glass
.constrained_stress(Temperature::from_si(20.0))
.unwrap();
assert!((stress.to_si() / 1e6 - 11.6).abs() < 0.2, "{stress:?}");
assert_eq!(glass.survives(Temperature::from_si(20.0)), Some(true));
assert_eq!(glass.survives(Temperature::from_si(120.0)), Some(false));
assert_eq!(metal.survives(Temperature::from_si(120.0)), Some(true));
}
#[test]
fn unknown_properties_are_absent_not_guessed() {
let unknown = Substance::bulk("unobtainium", Density::g_per_cm3(19.0));
assert_eq!(unknown.diffusivity(), None);
assert_eq!(unknown.heat_capacity(Volume::from_si(1e-6)), None);
assert_eq!(unknown.survives(Temperature::from_si(50.0)), None);
assert!((unknown.mass_of(Volume::from_si(1e-6)).to_si() - 0.019).abs() < 1e-9);
assert_eq!(
Substance::water().constrained_stress(Temperature::from_si(10.0)),
None
);
assert!(Substance::water().diffusivity().is_some());
}
#[test]
fn substances_round_trip_through_json() {
let glass = Substance::borosilicate_crown();
let json = serde_json::to_string(&glass).unwrap();
assert_eq!(serde_json::from_str::<Substance>(&json).unwrap(), glass);
let plain = Substance::bulk("x", Density::kg_per_m3(1.0));
let json = serde_json::to_string(&plain).unwrap();
assert!(!json.contains("thermal"), "{json}");
}
#[test]
fn a_finish_is_not_a_new_material() {
let polished = Substance::aluminium_6061();
let anodised = Substance::aluminium_6061().with_emissivity(0.9);
let (p, a) = (polished.thermal.unwrap(), anodised.thermal.unwrap());
assert_eq!(p.emissivity, 0.09);
assert_eq!(a.emissivity, 0.9);
assert_eq!(p.conductivity, a.conductivity);
assert_eq!(p.specific_heat, a.specific_heat);
assert_eq!(p.expansion, a.expansion);
assert_eq!(polished.density, anodised.density);
assert_eq!(polished.name, anodised.name);
assert_eq!(
Substance::aluminium_6061()
.with_emissivity(4.0)
.thermal
.unwrap()
.emissivity,
1.0
);
assert_eq!(
Substance::aluminium_6061()
.with_emissivity(-1.0)
.thermal
.unwrap()
.emissivity,
0.0
);
}
#[test]
fn the_specific_heat_a_user_borrows_is_worth_a_factor_of_two() {
let volume = Volume::from_si(3.456e-4);
let billet = Substance::aluminium_6061();
let assembly =
Substance::aluminium_6061().with_specific_heat(SpecificHeat::j_per_kg_k(450.0));
let c_billet = billet.heat_capacity(volume).unwrap().to_si();
let c_assembly = assembly.heat_capacity(volume).unwrap().to_si();
let ratio = c_billet / c_assembly;
assert!(
(ratio - 896.0 / 450.0).abs() < 1e-12,
"the capacity ratio is the specific-heat ratio: {ratio}"
);
assert!(ratio > 1.9, "a borrowed c_p is worth about two: {ratio}");
}
#[test]
fn the_new_entries_are_ordered_the_way_the_physics_is() {
let cu = Substance::copper().thermal.unwrap();
let steel = Substance::electrical_steel().thermal.unwrap();
let fr4 = Substance::fr4().thermal.unwrap();
let pla = Substance::pla().thermal.unwrap();
let al = Substance::aluminium_6061().thermal.unwrap();
assert!(cu.conductivity > al.conductivity);
assert!(al.conductivity > steel.conductivity);
assert!(steel.conductivity.to_si() > 50.0 * fr4.conductivity.to_si());
assert!(fr4.conductivity > pla.conductivity);
let ratio = pla.expansion.to_si() / al.expansion.to_si();
assert!(
(2.5..3.5).contains(&ratio),
"PLA against 6061 is {ratio:.2}x"
);
assert!(al.expansion > cu.expansion && cu.expansion > steel.expansion);
let v = Volume::from_si(1e-3);
assert!(cu.specific_heat < al.specific_heat);
assert!(
Substance::copper().heat_capacity(v).unwrap()
> Substance::aluminium_6061().heat_capacity(v).unwrap()
);
assert!(fr4.emissivity > 0.8 && pla.emissivity > 0.8);
assert!(cu.emissivity < 0.1);
}
}