Skip to main content

dualis_core/
substance.rs

1//! What a piece of matter is, across every domain that cares.
2//!
3//! N-BK7 is not only a refractive index. It is 2.51 g/cm³, it conducts 1.11 W/m·K,
4//! it holds 858 J/kg·K, it expands 7.1 ppm per kelvin and it fails at about
5//! 60 MPa. A thermal solver needs the middle three, a mechanical one the last two,
6//! and an optical one the index — but it is *one piece of glass*, and if each
7//! domain carries its own idea of what it is made of then nothing can be coupled,
8//! because there is no single object for a coupling to be about.
9//!
10//! So the properties live together and each domain reads the part it needs.
11//! Everything is [`Option`]: a thermal-only simulation is not made to invent a
12//! Young's modulus, and a property that is absent says so rather than defaulting
13//! to a plausible lie.
14//!
15//! # Optics is deliberately missing
16//!
17//! There is no optical field here. This crate is the kernel and must not know that
18//! optics exists — refractive index is `dualis-optics`'s
19//! `Material`, and a consumer that needs both pairs them. Putting it here would
20//! make the kernel depend on a domain, which is the one structural rule the split
21//! exists to enforce.
22
23use dualis_units::{
24    Density, Diffusivity, HeatCapacity, Length, Mass, Pressure, SpecificHeat, Temperature,
25    ThermalConductivity, ThermalExpansion, Velocity, Volume,
26};
27use serde::{Deserialize, Serialize};
28
29/// A material, as much of it as is known.
30#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
31pub struct Substance {
32    /// What it is called. Free text: a catalogue designation, a common name, whatever the
33    /// caller will recognise in a violation message.
34    pub name: String,
35    /// kg·m⁻³. The one property everything has, which is why it is not optional.
36    pub density: Density,
37    /// Conductivity, specific heat, emissivity and a service limit, if they are known.
38    ///
39    /// Optional because a substance is often only known as far as it needed to be. A domain
40    /// asking for what is not here gets `None` rather than a plausible default, which is the
41    /// difference between "unknown" and "zero".
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub thermal: Option<ThermalProps>,
44    /// Stiffness, restitution and friction, if they are known.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub mechanical: Option<MechanicalProps>,
47    /// Sound speed and absorption, if they are known.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub acoustic: Option<AcousticProps>,
50}
51
52/// What it does with heat.
53///
54/// # The two fields worth checking against the real part
55///
56/// `specific_heat` and `emissivity` are where a wrong number does the most damage, and they are
57/// the two a user is most likely to carry over from something that looked close enough.
58///
59/// **A composite assembly is not a billet of its main metal.** A BLDC motor is copper, electrical
60/// steel, magnets and air; its bulk `c_p` is nearer 450 J/kg/K than aluminium's 896. Reaching for
61/// [`Substance::aluminium_6061`] because it is the metal in the catalogue **doubles the thermal
62/// time constant** and changes the conclusion, with nothing to warn you — the answer stays
63/// plausible, it is just for a different object. Use [`Substance::with_specific_heat`] on
64/// whichever entry is closest and put the real figure in.
65///
66/// **Emissivity is a surface, not a substance.** The same 6061 is 0.09 polished and about 0.9
67/// anodised, a factor of ten in the radiative path — which
68/// [`Environment::loss_from`](../../dualis_thermal/struct.Environment.html) says is the same order
69/// as still-air convection at room temperature. [`Substance::with_emissivity`] exists so a finish
70/// does not have to become a new material.
71#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
72pub struct ThermalProps {
73    /// Fourier's `k`: how fast heat moves through it.
74    pub conductivity: ThermalConductivity,
75    /// `c_p`: how much heat it takes to warm it.
76    pub specific_heat: SpecificHeat,
77    /// Linear expansion per kelvin — the property that turns absorbed light into
78    /// a focus shift.
79    pub expansion: ThermalExpansion,
80    /// Emissivity, 0..1, for radiative exchange. 1 is a blackbody; polished metal
81    /// is near 0.05, which is why a shiny shield works.
82    pub emissivity: f64,
83}
84
85/// What it does under load.
86#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
87pub struct MechanicalProps {
88    /// Young's modulus.
89    pub youngs_modulus: Pressure,
90    /// Poisson's ratio, dimensionless — how much it bulges sideways when squeezed.
91    pub poisson_ratio: f64,
92    /// Where it stops coming back. For a brittle material this is the fracture
93    /// stress, and there is no plastic region before it.
94    pub yield_strength: Pressure,
95}
96
97/// What it does with sound.
98#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
99pub struct AcousticProps {
100    /// Longitudinal speed of sound.
101    pub sound_speed: Velocity,
102}
103
104impl Substance {
105    /// Thermal diffusivity, `α = k / (ρ c_p)` — the m²/s that decides how fast a
106    /// temperature *front* moves, as opposed to how much heat flows.
107    ///
108    /// This is the number that sets an explicit heat solver's stability limit
109    /// (`dt < dx²/2α`), so a thermal domain asks for it before it can say how big
110    /// a step it can take.
111    pub fn diffusivity(&self) -> Option<Diffusivity> {
112        let t = self.thermal?;
113        Some(Diffusivity::from_si(
114            t.conductivity.to_si() / (self.density.to_si() * t.specific_heat.to_si()),
115        ))
116    }
117
118    /// Heat capacity of a given volume of this substance.
119    pub fn heat_capacity(&self, volume: Volume) -> Option<HeatCapacity> {
120        let t = self.thermal?;
121        Some(self.mass_of(volume) * t.specific_heat)
122    }
123
124    /// Mass of a given volume of it.
125    pub fn mass_of(&self, volume: Volume) -> Mass {
126        self.density * volume
127    }
128
129    /// How much a length of this substance grows for a temperature rise. Linear,
130    /// which is a good approximation for the tens of kelvin an instrument sees and
131    /// a poor one for hundreds.
132    pub fn expansion_of(&self, length: Length, rise: Temperature) -> Option<Length> {
133        let t = self.thermal?;
134        Some(Length::from_si(
135            length.to_si() * t.expansion.to_si() * rise.to_si(),
136        ))
137    }
138
139    /// Stress produced by preventing that expansion — the reason a lens bonded
140    /// rigidly into a metal mount cracks when it warms up.
141    ///
142    /// `σ = E α ΔT`, independent of size, which is why scaling the part down does
143    /// not help.
144    pub fn constrained_stress(&self, rise: Temperature) -> Option<Pressure> {
145        let t = self.thermal?;
146        let m = self.mechanical?;
147        Some(Pressure::from_si(
148            m.youngs_modulus.to_si() * t.expansion.to_si() * rise.to_si(),
149        ))
150    }
151
152    /// Whether that stress would break it.
153    pub fn survives(&self, rise: Temperature) -> Option<bool> {
154        let stress = self.constrained_stress(rise)?;
155        let limit = self.mechanical?.yield_strength;
156        Some(stress < limit)
157    }
158
159    /// N-BK7, the borosilicate crown that most of an optical bench is made of.
160    pub fn borosilicate_crown() -> Substance {
161        Substance {
162            name: "N-BK7".to_string(),
163            density: Density::g_per_cm3(2.51),
164            thermal: Some(ThermalProps {
165                conductivity: ThermalConductivity::w_per_m_k(1.114),
166                specific_heat: SpecificHeat::j_per_kg_k(858.0),
167                expansion: ThermalExpansion::ppm_per_k(7.1),
168                emissivity: 0.90,
169            }),
170            mechanical: Some(MechanicalProps {
171                youngs_modulus: Pressure::from_si(82.0e9),
172                poisson_ratio: 0.206,
173                // Brittle: this is a fracture stress, not a yield point.
174                yield_strength: Pressure::from_si(60.0e6),
175            }),
176            acoustic: Some(AcousticProps {
177                sound_speed: Velocity::m_per_s(5_680.0),
178            }),
179        }
180    }
181
182    /// 6061 aluminium: what the mount holding the glass is made of, and the
183    /// reason a mount-and-lens pair moves when the room does — its expansion is
184    /// three times the glass's.
185    pub fn aluminium_6061() -> Substance {
186        Substance {
187            name: "Al 6061".to_string(),
188            density: Density::g_per_cm3(2.70),
189            thermal: Some(ThermalProps {
190                conductivity: ThermalConductivity::w_per_m_k(167.0),
191                specific_heat: SpecificHeat::j_per_kg_k(896.0),
192                expansion: ThermalExpansion::ppm_per_k(23.6),
193                emissivity: 0.09,
194            }),
195            mechanical: Some(MechanicalProps {
196                youngs_modulus: Pressure::from_si(68.9e9),
197                poisson_ratio: 0.33,
198                yield_strength: Pressure::from_si(276.0e6),
199            }),
200            acoustic: Some(AcousticProps {
201                sound_speed: Velocity::m_per_s(6_320.0),
202            }),
203        }
204    }
205
206    /// The same substance with a different surface finish.
207    ///
208    /// Emissivity is a property of the surface and not of the material, so anodised aluminium is
209    /// not a new entry in the catalogue — it is `aluminium_6061().with_emissivity(0.9)`. The
210    /// factor of ten between polished and anodised 6061 lands squarely on the radiative loss
211    /// path, which is the same order as still-air convection at room temperature.
212    ///
213    /// Clamped to `0..=1`: a surface cannot radiate more than a blackbody, and a negative
214    /// emissivity would make a body warm itself.
215    ///
216    /// Does nothing to a substance whose thermal properties are unknown, because `None` means
217    /// unknown rather than zero and inventing three of the four fields to set the fourth would
218    /// be worse than declining.
219    pub fn with_emissivity(mut self, emissivity: f64) -> Substance {
220        if let Some(t) = self.thermal.as_mut() {
221            t.emissivity = emissivity.clamp(0.0, 1.0);
222        }
223        self
224    }
225
226    /// The same substance with a different heat capacity.
227    ///
228    /// For the assembly case: a motor, a populated board, a printed part with infill. The bulk
229    /// `c_p` of a mixture is not the `c_p` of its main constituent, and this is the field where
230    /// that difference is worth a factor of two.
231    ///
232    /// Does nothing to a substance whose thermal properties are unknown, for the reason in
233    /// [`Substance::with_emissivity`].
234    pub fn with_specific_heat(mut self, specific_heat: SpecificHeat) -> Substance {
235        if let Some(t) = self.thermal.as_mut() {
236            t.specific_heat = specific_heat;
237        }
238        self
239    }
240
241    /// Electrolytic tough-pitch copper: windings, heat spreaders, planes.
242    ///
243    /// The values are uncontroversial to three figures. The emissivity is not: this is **bright
244    /// polished** copper at 0.04, and copper oxidises — a tarnished surface runs 0.4 to 0.8, a
245    /// factor of fifteen on the radiative path. If the part has been in air for a week, say so
246    /// with [`Substance::with_emissivity`].
247    pub fn copper() -> Substance {
248        Substance {
249            name: "Cu ETP".to_string(),
250            density: Density::g_per_cm3(8.96),
251            thermal: Some(ThermalProps {
252                conductivity: ThermalConductivity::w_per_m_k(401.0),
253                specific_heat: SpecificHeat::j_per_kg_k(385.0),
254                expansion: ThermalExpansion::ppm_per_k(16.5),
255                emissivity: 0.04,
256            }),
257            mechanical: Some(MechanicalProps {
258                youngs_modulus: Pressure::from_si(117.0e9),
259                poisson_ratio: 0.34,
260                yield_strength: Pressure::from_si(70.0e6),
261            }),
262            acoustic: Some(AcousticProps {
263                sound_speed: Velocity::m_per_s(4_760.0),
264            }),
265        }
266    }
267
268    /// FR-4 glass-epoxy laminate: the board a driver sits on.
269    ///
270    /// **The conductivity is the through-plane one**, 0.3 W/m/K, and that is the number a
271    /// designer wants because it is the one heat has to cross to reach the far side. In-plane it
272    /// is nearer 0.8, because the copper-free glass weave conducts better along its fibres —
273    /// a factor of about three, and `ThermalProps` carries one scalar, so the choice has to be
274    /// stated rather than averaged. Any real board is dominated by its copper pour anyway, which
275    /// is not laminate at all.
276    ///
277    /// The expansion is likewise in-plane, 14 ppm/K. Through-thickness FR-4 expands four to five
278    /// times faster and goes higher again above its glass transition, which is what breaks
279    /// plated through-holes; that regime is not modelled here.
280    pub fn fr4() -> Substance {
281        Substance {
282            name: "FR-4".to_string(),
283            density: Density::g_per_cm3(1.85),
284            thermal: Some(ThermalProps {
285                conductivity: ThermalConductivity::w_per_m_k(0.30),
286                specific_heat: SpecificHeat::j_per_kg_k(1_100.0),
287                expansion: ThermalExpansion::ppm_per_k(14.0),
288                emissivity: 0.90,
289            }),
290            mechanical: Some(MechanicalProps {
291                youngs_modulus: Pressure::from_si(22.0e9),
292                poisson_ratio: 0.16,
293                yield_strength: Pressure::from_si(300.0e6),
294            }),
295            acoustic: None,
296        }
297    }
298
299    /// Non-oriented silicon electrical steel: motor and transformer laminations.
300    ///
301    /// **Grade-dependent, and the spread is wide.** Silicon content trades core loss against
302    /// conductivity: 25 W/m/K here is mid-range for non-oriented sheet, and grades run from about
303    /// 20 to 30. Stacked laminations conduct far worse *across* the stack than the sheet does,
304    /// because the interlaminar varnish dominates — a stack is not this substance at all, and
305    /// treating it as one overstates the conduction out of a motor.
306    ///
307    /// Emissivity 0.3 is varnished sheet; bare mill finish is lower and rusty is much higher.
308    pub fn electrical_steel() -> Substance {
309        Substance {
310            name: "electrical steel (non-oriented)".to_string(),
311            density: Density::g_per_cm3(7.65),
312            thermal: Some(ThermalProps {
313                conductivity: ThermalConductivity::w_per_m_k(25.0),
314                specific_heat: SpecificHeat::j_per_kg_k(460.0),
315                expansion: ThermalExpansion::ppm_per_k(12.0),
316                emissivity: 0.30,
317            }),
318            mechanical: Some(MechanicalProps {
319                youngs_modulus: Pressure::from_si(200.0e9),
320                poisson_ratio: 0.29,
321                yield_strength: Pressure::from_si(350.0e6),
322            }),
323            acoustic: Some(AcousticProps {
324                sound_speed: Velocity::m_per_s(5_100.0),
325            }),
326        }
327    }
328
329    /// Solid cast PLA: printed structure, if it were solid, which it is not.
330    ///
331    /// **A printed part is not this substance.** Infill and layer adhesion move the effective
332    /// conductivity and density more than the polymer chemistry does: at 20% infill the density
333    /// is a fifth of this and the through-layer conductivity is lower again, because the path
334    /// crosses voids and weld lines rather than bulk. Scale the density by the infill fraction
335    /// at the very least, and treat the conductivity as an upper bound.
336    ///
337    /// That is not a caveat about precision. It is the difference between a part that survives
338    /// and one that creeps: PLA softens around 60 °C, and [`Substance::survives`] is checking
339    /// against a number the print may not reach in practice.
340    pub fn pla() -> Substance {
341        Substance {
342            name: "PLA (solid)".to_string(),
343            density: Density::g_per_cm3(1.24),
344            thermal: Some(ThermalProps {
345                conductivity: ThermalConductivity::w_per_m_k(0.13),
346                specific_heat: SpecificHeat::j_per_kg_k(1_800.0),
347                expansion: ThermalExpansion::ppm_per_k(70.0),
348                emissivity: 0.90,
349            }),
350            mechanical: Some(MechanicalProps {
351                youngs_modulus: Pressure::from_si(3.5e9),
352                poisson_ratio: 0.36,
353                yield_strength: Pressure::from_si(50.0e6),
354            }),
355            acoustic: None,
356        }
357    }
358
359    /// Water at 20 °C.
360    pub fn water() -> Substance {
361        Substance {
362            name: "water".to_string(),
363            density: Density::g_per_cm3(0.998),
364            thermal: Some(ThermalProps {
365                conductivity: ThermalConductivity::w_per_m_k(0.598),
366                specific_heat: SpecificHeat::j_per_kg_k(4_182.0),
367                expansion: ThermalExpansion::ppm_per_k(69.0),
368                emissivity: 0.96,
369            }),
370            mechanical: None,
371            acoustic: Some(AcousticProps {
372                sound_speed: Velocity::m_per_s(1_482.0),
373            }),
374        }
375    }
376
377    /// A substance with nothing known but how heavy it is.
378    pub fn bulk(name: &str, density: Density) -> Substance {
379        Substance {
380            name: name.to_string(),
381            density,
382            thermal: None,
383            mechanical: None,
384            acoustic: None,
385        }
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use dualis_units::Length;
393
394    /// Diffusivity against the published figure: N-BK7 is about 5.2e-7 m²/s, and
395    /// aluminium is 130 times faster, which is why one of them is a heat spreader
396    /// and the other is not.
397    #[test]
398    fn diffusivity_matches_the_published_figures() {
399        let glass = Substance::borosilicate_crown().diffusivity().unwrap();
400        let metal = Substance::aluminium_6061().diffusivity().unwrap();
401        assert!(
402            (glass.to_si() - 5.17e-7).abs() < 1e-8,
403            "N-BK7 diffusivity {glass:?}"
404        );
405        assert!(
406            (metal.to_si() - 6.9e-5).abs() < 1e-6,
407            "aluminium diffusivity {metal:?}"
408        );
409        assert!(metal.to_si() / glass.to_si() > 100.0);
410    }
411
412    /// The number an explicit heat solver needs: over a 1 mm cell, N-BK7 is stable
413    /// to about a second and aluminium to about 7 ms. That two-orders-of-magnitude
414    /// gap between two parts of the same instrument is exactly why
415    /// `Schedule::Multirate` exists.
416    #[test]
417    fn stability_limits_differ_by_two_orders_of_magnitude() {
418        let cell = Length::mm(1.0);
419        let limit = |s: &Substance| {
420            let a = s.diffusivity().unwrap().to_si();
421            cell.to_si() * cell.to_si() / (2.0 * a)
422        };
423        let glass = limit(&Substance::borosilicate_crown());
424        let metal = limit(&Substance::aluminium_6061());
425        assert!((glass - 0.97).abs() < 0.1, "glass limit {glass} s");
426        assert!((metal - 0.0072).abs() < 0.001, "metal limit {metal} s");
427        assert!(glass / metal > 100.0);
428    }
429
430    /// Heat capacity of a real piece of glass: a 25 mm disc 5 mm thick is 6.2 g
431    /// and holds 5.3 J per kelvin.
432    #[test]
433    fn a_lens_sized_piece_holds_a_few_joules_per_kelvin() {
434        let glass = Substance::borosilicate_crown();
435        let volume = Volume::from_si(std::f64::consts::PI * (0.0125f64).powi(2) * 0.005);
436        let mass = glass.mass_of(volume);
437        assert!((mass.to_si() * 1e3 - 6.16).abs() < 0.05, "{mass:?}");
438        let capacity = glass.heat_capacity(volume).unwrap();
439        assert!((capacity.to_si() - 5.28).abs() < 0.05, "{capacity:?}");
440    }
441
442    /// Thermal expansion, and the reason a bonded lens cracks: constrained
443    /// stress is `E α ΔT` and does not depend on the size of the part, so a 60 K
444    /// rise breaks N-BK7 whatever shape it is in.
445    #[test]
446    fn constrained_expansion_breaks_glass_before_metal() {
447        let glass = Substance::borosilicate_crown();
448        let metal = Substance::aluminium_6061();
449
450        // 20 K over 100 mm of glass is 14 micrometres — small, and far more than
451        // a wavelength.
452        let growth = glass
453            .expansion_of(Length::mm(100.0), Temperature::from_si(20.0))
454            .unwrap();
455        assert!((growth.in_um() - 14.2).abs() < 0.1, "{growth:?}");
456
457        // Held rigidly, that same 20 K is 11.6 MPa: survivable.
458        let stress = glass
459            .constrained_stress(Temperature::from_si(20.0))
460            .unwrap();
461        assert!((stress.to_si() / 1e6 - 11.6).abs() < 0.2, "{stress:?}");
462        assert_eq!(glass.survives(Temperature::from_si(20.0)), Some(true));
463        // 120 K is not.
464        assert_eq!(glass.survives(Temperature::from_si(120.0)), Some(false));
465        // The aluminium mount takes it easily despite expanding three times more,
466        // because it yields at 276 MPa rather than fracturing at 60.
467        assert_eq!(metal.survives(Temperature::from_si(120.0)), Some(true));
468    }
469
470    /// A property that is not known reports that, rather than defaulting to a
471    /// plausible number that would be silently wrong.
472    #[test]
473    fn unknown_properties_are_absent_not_guessed() {
474        let unknown = Substance::bulk("unobtainium", Density::g_per_cm3(19.0));
475        assert_eq!(unknown.diffusivity(), None);
476        assert_eq!(unknown.heat_capacity(Volume::from_si(1e-6)), None);
477        assert_eq!(unknown.survives(Temperature::from_si(50.0)), None);
478        // But what *is* known still works.
479        assert!((unknown.mass_of(Volume::from_si(1e-6)).to_si() - 0.019).abs() < 1e-9);
480        // Water has no mechanical properties, and asking gives None rather than
481        // an answer about a Young's modulus it does not have.
482        assert_eq!(
483            Substance::water().constrained_stress(Temperature::from_si(10.0)),
484            None
485        );
486        assert!(Substance::water().diffusivity().is_some());
487    }
488
489    #[test]
490    fn substances_round_trip_through_json() {
491        let glass = Substance::borosilicate_crown();
492        let json = serde_json::to_string(&glass).unwrap();
493        assert_eq!(serde_json::from_str::<Substance>(&json).unwrap(), glass);
494        // Absent properties are omitted rather than serialised as null.
495        let plain = Substance::bulk("x", Density::kg_per_m3(1.0));
496        let json = serde_json::to_string(&plain).unwrap();
497        assert!(!json.contains("thermal"), "{json}");
498    }
499    /// The builders change one field and leave the rest alone.
500    ///
501    /// Emissivity is a surface and not a substance, so anodised 6061 has to be reachable without
502    /// a second catalogue entry — that was the reported friction, and the workaround was reaching
503    /// into `thermal.as_mut()` by hand.
504    #[test]
505    fn a_finish_is_not_a_new_material() {
506        let polished = Substance::aluminium_6061();
507        let anodised = Substance::aluminium_6061().with_emissivity(0.9);
508        let (p, a) = (polished.thermal.unwrap(), anodised.thermal.unwrap());
509
510        assert_eq!(p.emissivity, 0.09);
511        assert_eq!(a.emissivity, 0.9);
512        // Everything else survives, including the name: it is the same alloy.
513        assert_eq!(p.conductivity, a.conductivity);
514        assert_eq!(p.specific_heat, a.specific_heat);
515        assert_eq!(p.expansion, a.expansion);
516        assert_eq!(polished.density, anodised.density);
517        assert_eq!(polished.name, anodised.name);
518
519        // A surface cannot out-radiate a blackbody, nor warm itself.
520        assert_eq!(
521            Substance::aluminium_6061()
522                .with_emissivity(4.0)
523                .thermal
524                .unwrap()
525                .emissivity,
526            1.0
527        );
528        assert_eq!(
529            Substance::aluminium_6061()
530                .with_emissivity(-1.0)
531                .thermal
532                .unwrap()
533                .emissivity,
534            0.0
535        );
536    }
537
538    /// **The trap the docs now warn about, as a number.**
539    ///
540    /// A lumped time constant is `C/(hA)` and `C` is `rho V c_p`, so reaching for aluminium's
541    /// 896 J/kg/K to stand in for a motor's ~450 doubles it. That was the reported failure: the
542    /// catalogue offered exactly one metal, reaching for it was the reasonable thing to do, and
543    /// it changed a conclusion with nothing to say so.
544    ///
545    /// Asserted on the ratio rather than on either value, because the ratio is the claim.
546    #[test]
547    fn the_specific_heat_a_user_borrows_is_worth_a_factor_of_two() {
548        let volume = Volume::from_si(3.456e-4);
549        let billet = Substance::aluminium_6061();
550        let assembly =
551            Substance::aluminium_6061().with_specific_heat(SpecificHeat::j_per_kg_k(450.0));
552
553        let c_billet = billet.heat_capacity(volume).unwrap().to_si();
554        let c_assembly = assembly.heat_capacity(volume).unwrap().to_si();
555        let ratio = c_billet / c_assembly;
556        assert!(
557            (ratio - 896.0 / 450.0).abs() < 1e-12,
558            "the capacity ratio is the specific-heat ratio: {ratio}"
559        );
560        assert!(ratio > 1.9, "a borrowed c_p is worth about two: {ratio}");
561    }
562
563    /// The new entries carry heat capacity and expansion, and their ordering is the physics.
564    ///
565    /// Not asserting the values against themselves — that would check nothing. The orderings
566    /// are the claims: copper conducts far better than steel and steel far better than laminate
567    /// and plastic; a polymer expands several times faster than a metal; and copper stores less
568    /// heat per kilogram than aluminium while storing more per unit volume, which is why a heat
569    /// spreader is copper and a heatsink is aluminium.
570    #[test]
571    fn the_new_entries_are_ordered_the_way_the_physics_is() {
572        let cu = Substance::copper().thermal.unwrap();
573        let steel = Substance::electrical_steel().thermal.unwrap();
574        let fr4 = Substance::fr4().thermal.unwrap();
575        let pla = Substance::pla().thermal.unwrap();
576        let al = Substance::aluminium_6061().thermal.unwrap();
577
578        // Conduction, over four orders of magnitude.
579        assert!(cu.conductivity > al.conductivity);
580        assert!(al.conductivity > steel.conductivity);
581        assert!(steel.conductivity.to_si() > 50.0 * fr4.conductivity.to_si());
582        assert!(fr4.conductivity > pla.conductivity);
583
584        // Expansion: a polymer moves about three times faster than the fastest metal here.
585        // 70 ppm/K against 6061's 23.6 is 2.97, and the first version of this asserted 3.0 --
586        // a claim written from the adjective rather than from the numbers.
587        let ratio = pla.expansion.to_si() / al.expansion.to_si();
588        assert!(
589            (2.5..3.5).contains(&ratio),
590            "PLA against 6061 is {ratio:.2}x"
591        );
592        assert!(al.expansion > cu.expansion && cu.expansion > steel.expansion);
593
594        // Per kilogram copper stores less than aluminium; per unit volume it stores more.
595        let v = Volume::from_si(1e-3);
596        assert!(cu.specific_heat < al.specific_heat);
597        assert!(
598            Substance::copper().heat_capacity(v).unwrap()
599                > Substance::aluminium_6061().heat_capacity(v).unwrap()
600        );
601
602        // The insulators are the emitters, which is why a black plastic case sheds heat a bare
603        // metal one does not.
604        assert!(fr4.emissivity > 0.8 && pla.emissivity > 0.8);
605        assert!(cu.emissivity < 0.1);
606    }
607}