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}