pantometry_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 `pantometry-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 pantometry_units::{
24 Density, Diffusivity, Energy, HeatCapacity, LatentHeat, Length, Mass, Pressure, SpecificHeat,
25 Temperature, ThermalConductivity, ThermalExpansion, Velocity, Volume,
26};
27use serde::{Deserialize, Serialize};
28
29/// A material, as much of it as is known.
30///
31/// # A key this type does not know is refused, not dropped
32///
33/// `deny_unknown_fields`, here and on all four property blocks. `serde` discards unknown keys by
34/// default, which is right for a wire protocol that must tolerate a newer peer and wrong for a
35/// material somebody wrote down: a mistyped `"thermalz"` would leave the whole thermal block absent
36/// and the substance would run as one whose conductivity is *unknown* rather than as one whose file
37/// has a typo in it.
38///
39/// The same rule `pantometry-world`'s scene format has, for the same reason, and it was added after a
40/// test asked whether a typo was caught and found that it was not.
41#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct Substance {
44 /// What it is called. Free text: a catalogue designation, a common name, whatever the
45 /// caller will recognise in a violation message.
46 pub name: String,
47 /// kg·m⁻³. The one property everything has, which is why it is not optional.
48 pub density: Density,
49 /// Conductivity, specific heat, emissivity and a service limit, if they are known.
50 ///
51 /// Optional because a substance is often only known as far as it needed to be. A domain
52 /// asking for what is not here gets `None` rather than a plausible default, which is the
53 /// difference between "unknown" and "zero".
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub thermal: Option<ThermalProps>,
56 /// What it takes to melt it, if it is a substance that melts at a temperature.
57 ///
58 /// Absent for most of them, and absent is not zero — it means the substance is being modelled
59 /// as never changing phase, which is the right model for a heat sink and the wrong one for ice.
60 /// A domain that finds it absent does not change phase; one that finds it present must account
61 /// for the latent heat or its books will not balance.
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub fusion: Option<FusionProps>,
64 /// Stiffness, restitution and friction, if they are known.
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub mechanical: Option<MechanicalProps>,
67 /// Sound speed and absorption, if they are known.
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub acoustic: Option<AcousticProps>,
70}
71
72/// What it does with heat.
73///
74/// # The two fields worth checking against the real part
75///
76/// `specific_heat` and `emissivity` are where a wrong number does the most damage, and they are
77/// the two a user is most likely to carry over from something that looked close enough.
78///
79/// **A composite assembly is not a billet of its main metal.** A BLDC motor is copper, electrical
80/// steel, magnets and air; its bulk `c_p` is nearer 450 J/kg/K than aluminium's 896. Reaching for
81/// [`Substance::aluminium_6061`] because it is the metal in the catalogue **doubles the thermal
82/// time constant** and changes the conclusion, with nothing to warn you — the answer stays
83/// plausible, it is just for a different object. Use [`Substance::with_specific_heat`] on
84/// whichever entry is closest and put the real figure in.
85///
86/// **Emissivity is a surface, not a substance.** The same 6061 is 0.09 polished and about 0.9
87/// anodised, a factor of ten in the radiative path — which
88/// [`Environment::loss_from`](../../pantometry_thermal/struct.Environment.html) says is the same order
89/// as still-air convection at room temperature. [`Substance::with_emissivity`] exists so a finish
90/// does not have to become a new material.
91#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
92#[serde(deny_unknown_fields)]
93pub struct ThermalProps {
94 /// Fourier's `k`: how fast heat moves through it.
95 pub conductivity: ThermalConductivity,
96 /// `c_p`: how much heat it takes to warm it.
97 pub specific_heat: SpecificHeat,
98 /// Linear expansion per kelvin — the property that turns absorbed light into
99 /// a focus shift.
100 pub expansion: ThermalExpansion,
101 /// Emissivity, 0..1, for radiative exchange. 1 is a blackbody; polished metal
102 /// is near 0.05, which is why a shiny shield works.
103 pub emissivity: f64,
104}
105
106/// What it does under load.
107#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct MechanicalProps {
110 /// Young's modulus.
111 pub youngs_modulus: Pressure,
112 /// Poisson's ratio, dimensionless — how much it bulges sideways when squeezed.
113 pub poisson_ratio: f64,
114 /// Where it stops coming back. For a brittle material this is the fracture
115 /// stress, and there is no plastic region before it.
116 pub yield_strength: Pressure,
117}
118
119/// What it takes to melt it.
120///
121/// # One temperature, and the substances that do not have one
122///
123/// A pure substance melts at a temperature; an alloy, a polymer and a rock melt over a *range*, and
124/// this cannot say so. That is a real restriction rather than a simplification to be embarrassed
125/// about — the sharp-interface problem is the one with an exact solution to check against, and a
126/// mushy range is a different model with a different closed form.
127///
128/// So this is right for water, for a pure metal and for a paraffin phase-change material sold on
129/// its plateau. It is wrong for solder, and a domain given it for solder will put the whole latent
130/// heat on one temperature instead of spreading it over the twenty kelvin it really occupies.
131#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
132#[serde(deny_unknown_fields)]
133pub struct FusionProps {
134 /// The temperature at which it changes phase, and holds there while it does.
135 pub melting_point: Temperature,
136 /// The heat one kilogram absorbs melting, at no change in temperature.
137 pub latent_heat: LatentHeat,
138 /// What the **liquid** conducts and holds, if it differs from the solid.
139 ///
140 /// `None` is the **one-phase** model: the liquid is taken to have the solid's conductivity and
141 /// specific heat. That is not a simplification to apologise for — it is exact whenever the liquid
142 /// sits at the melting point, because then no heat flows through it whatever its properties are,
143 /// and it is the case Stefan's original problem and Neumann's solution are about.
144 ///
145 /// It is wrong the moment the liquid is **superheated**, and wrong by a lot. Water conducts a
146 /// quarter of what ice does and holds twice as much, and a liquid 20 K above freezing slows a
147 /// front by **16%** — from 15.85 mm to 13.33 mm at 900 s. That is far more than any
148 /// discretisation error, so the one-phase answer is not a slightly worse two-phase answer.
149 ///
150 /// The same type as the solid's, because a phase is a thing that conducts and holds heat and there
151 /// is no reason to describe it differently. `expansion` and `emissivity` are carried and unused by
152 /// conduction; give the liquid's if they are known.
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub liquid: Option<ThermalProps>,
155}
156
157impl FusionProps {
158 /// A one-phase description: a melting point and a latent heat, and no separate liquid.
159 ///
160 /// # Because adding `liquid` broke every literal, twice over
161 ///
162 /// `Substance` gained `fusion` and every struct literal outside this crate stopped compiling;
163 /// builders were added so the next field would not do it again. Then `FusionProps` gained `liquid`
164 /// and did exactly that one level down, to the tests written the week before.
165 ///
166 /// So this pair exists for the same reason `Substance::with_*` does. A field added below here
167 /// costs nothing to a caller who went through `new` and [`with_liquid`](FusionProps::with_liquid).
168 pub fn new(melting_point: Temperature, latent_heat: LatentHeat) -> FusionProps {
169 FusionProps {
170 melting_point,
171 latent_heat,
172 liquid: None,
173 }
174 }
175
176 /// Name the liquid phase's conductivity and specific heat, making a block **two-phase**.
177 ///
178 /// Read [`liquid`](FusionProps::liquid) before reaching for this: it is the right model for a
179 /// superheated liquid and it costs first-order accuracy at the interface, so it is not a strictly
180 /// better version of the one-phase model.
181 pub fn with_liquid(mut self, liquid: ThermalProps) -> FusionProps {
182 self.liquid = Some(liquid);
183 self
184 }
185
186 /// How many kelvin of sensible heat the phase change is worth: `L / c_p`.
187 ///
188 /// The reciprocal of the Stefan number, and the number that says whether latent heat matters at
189 /// all in a given problem. For ice it is **163 K**, so a freezing front driven by a 10 K
190 /// undercooling is overwhelmingly a latent-heat problem and only incidentally a conduction one.
191 pub fn sensible_equivalent(&self, specific_heat: SpecificHeat) -> Temperature {
192 Temperature::from_si(self.latent_heat.to_si() / specific_heat.to_si())
193 }
194}
195
196/// What it does with sound.
197#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
198#[serde(deny_unknown_fields)]
199pub struct AcousticProps {
200 /// Longitudinal speed of sound.
201 pub sound_speed: Velocity,
202}
203
204impl Substance {
205 /// Thermal diffusivity, `α = k / (ρ c_p)` — the m²/s that decides how fast a
206 /// temperature *front* moves, as opposed to how much heat flows.
207 ///
208 /// This is the number that sets an explicit heat solver's stability limit
209 /// (`dt < dx²/2α`), so a thermal domain asks for it before it can say how big
210 /// a step it can take.
211 pub fn diffusivity(&self) -> Option<Diffusivity> {
212 let t = self.thermal?;
213 Some(Diffusivity::from_si(
214 t.conductivity.to_si() / (self.density.to_si() * t.specific_heat.to_si()),
215 ))
216 }
217
218 /// Heat capacity of a given volume of this substance.
219 pub fn heat_capacity(&self, volume: Volume) -> Option<HeatCapacity> {
220 let t = self.thermal?;
221 Some(self.mass_of(volume) * t.specific_heat)
222 }
223
224 /// The joules a given volume absorbs changing phase, or `None` if it does not.
225 ///
226 /// The companion to [`heat_capacity`](Substance::heat_capacity), and the pair is what a domain
227 /// needs to keep books that balance across a melting front: one buys kelvin and the other buys
228 /// none. For a cubic millimetre of ice they are 1.88 mJ/K and **306 mJ** — so the phase change
229 /// is worth 163 K of warming, and a scheme that dropped it would run the front 163 times too
230 /// fast rather than slightly wrong.
231 pub fn latent_energy(&self, volume: Volume) -> Option<Energy> {
232 let f = self.fusion?;
233 Some(self.mass_of(volume) * f.latent_heat)
234 }
235
236 /// Mass of a given volume of it.
237 pub fn mass_of(&self, volume: Volume) -> Mass {
238 self.density * volume
239 }
240
241 /// How much a length of this substance grows for a temperature rise. Linear,
242 /// which is a good approximation for the tens of kelvin an instrument sees and
243 /// a poor one for hundreds.
244 pub fn expansion_of(&self, length: Length, rise: Temperature) -> Option<Length> {
245 let t = self.thermal?;
246 Some(Length::from_si(
247 length.to_si() * t.expansion.to_si() * rise.to_si(),
248 ))
249 }
250
251 /// Stress produced by preventing that expansion — the reason a lens bonded
252 /// rigidly into a metal mount cracks when it warms up.
253 ///
254 /// `σ = E α ΔT`, independent of size, which is why scaling the part down does
255 /// not help.
256 pub fn constrained_stress(&self, rise: Temperature) -> Option<Pressure> {
257 let t = self.thermal?;
258 let m = self.mechanical?;
259 Some(Pressure::from_si(
260 m.youngs_modulus.to_si() * t.expansion.to_si() * rise.to_si(),
261 ))
262 }
263
264 /// Whether that stress would break it.
265 pub fn survives(&self, rise: Temperature) -> Option<bool> {
266 let stress = self.constrained_stress(rise)?;
267 let limit = self.mechanical?.yield_strength;
268 Some(stress < limit)
269 }
270
271 /// Every substance this crate ships, by the short name a *file* writes.
272 ///
273 /// The constructors below are the Rust door and are enough for a caller who knows at compile
274 /// time what the thing is made of. This is the other door: a name that arrived as text, from
275 /// JSON, a command line, or a spreadsheet column.
276 ///
277 /// # Why a slug and not [`Substance::name`]
278 ///
279 /// `name` is free text meant for a human reading a violation message — `"Al 6061"`,
280 /// `"N-BK7"`. A key that a file is matched against has to be stable, lowercase and
281 /// unpunctuated, and the two are different jobs: renaming `"Al 6061"` to `"Aluminium 6061-T6"`
282 /// improves one and breaks every file that used the other.
283 ///
284 /// # This list and [`Substance::from_name`] are checked against each other
285 ///
286 /// Both directions, in `any_material.rs`: every slug here resolves, and every constructor is
287 /// reachable through some slug here. A pair of hand-written lists that agree until they do not
288 /// is how a catalogue grows an entry that exists and cannot be named — which is what had
289 /// happened to `water`, present in this crate since 0.1.0 and unreachable from a scene file for
290 /// **eleven** releases — every version in which a scene could name a material at all — because
291 /// the scene format kept its own eight-name copy of this.
292 pub const CATALOGUE: [&'static str; 9] = [
293 "aluminium",
294 "borosilicate",
295 "copper",
296 "electrical_steel",
297 "fr4",
298 "ice",
299 "pla",
300 "stainless_304",
301 "water",
302 ];
303
304 /// Look one up by the name in [`Substance::CATALOGUE`], or `None`.
305 ///
306 /// `None` rather than a panic or a default: a name that arrived as text is a name that can be
307 /// wrong, and the caller is the one who knows what to say about it and where the text came from.
308 /// Substituting a plausible material for an unrecognised name is the failure this whole file is
309 /// arranged against.
310 ///
311 /// **A catalogue is not the answer to "any material".** Nine entries cannot be, and adding a
312 /// tenth does not change that. The answer is that a `Substance` is data — `Deserialize` and
313 /// [`Substance::check`] — so anything with a datasheet can be declared without this crate
314 /// learning it exists. This function is for the common case where the material is one of nine
315 /// and typing out its properties would be worse.
316 pub fn from_name(name: &str) -> Option<Substance> {
317 Some(match name {
318 "aluminium" => Substance::aluminium_6061(),
319 "borosilicate" => Substance::borosilicate_crown(),
320 "copper" => Substance::copper(),
321 "electrical_steel" => Substance::electrical_steel(),
322 "fr4" => Substance::fr4(),
323 // `ice` is the only entry that changes phase, and so the only one for which a domain
324 // reports a melted volume. `water` is the *liquid* — no `fusion`, because a substance
325 // whose fusion is present is one being modelled as freezable, and a coolant mass in a
326 // network is not. The pair is deliberate and they are not interchangeable.
327 "ice" => Substance::ice(),
328 "water" => Substance::water(),
329 "pla" => Substance::pla(),
330 "stainless_304" => Substance::stainless_304(),
331 _ => return None,
332 })
333 }
334
335 /// N-BK7, the borosilicate crown that most of an optical bench is made of.
336 pub fn borosilicate_crown() -> Substance {
337 Substance {
338 name: "N-BK7".to_string(),
339 density: Density::g_per_cm3(2.51),
340 thermal: Some(ThermalProps {
341 conductivity: ThermalConductivity::w_per_m_k(1.114),
342 specific_heat: SpecificHeat::j_per_kg_k(858.0),
343 expansion: ThermalExpansion::ppm_per_k(7.1),
344 emissivity: 0.90,
345 }),
346 fusion: None,
347 mechanical: Some(MechanicalProps {
348 youngs_modulus: Pressure::from_si(82.0e9),
349 poisson_ratio: 0.206,
350 // Brittle: this is a fracture stress, not a yield point.
351 yield_strength: Pressure::from_si(60.0e6),
352 }),
353 acoustic: Some(AcousticProps {
354 sound_speed: Velocity::m_per_s(5_680.0),
355 }),
356 }
357 }
358
359 /// 6061 aluminium: what the mount holding the glass is made of, and the
360 /// reason a mount-and-lens pair moves when the room does — its expansion is
361 /// three times the glass's.
362 pub fn aluminium_6061() -> Substance {
363 Substance {
364 name: "Al 6061".to_string(),
365 density: Density::g_per_cm3(2.70),
366 thermal: Some(ThermalProps {
367 conductivity: ThermalConductivity::w_per_m_k(167.0),
368 specific_heat: SpecificHeat::j_per_kg_k(896.0),
369 expansion: ThermalExpansion::ppm_per_k(23.6),
370 emissivity: 0.09,
371 }),
372 fusion: None,
373 mechanical: Some(MechanicalProps {
374 youngs_modulus: Pressure::from_si(68.9e9),
375 poisson_ratio: 0.33,
376 yield_strength: Pressure::from_si(276.0e6),
377 }),
378 acoustic: Some(AcousticProps {
379 sound_speed: Velocity::m_per_s(6_320.0),
380 }),
381 }
382 }
383
384 /// The same substance with a different surface finish.
385 ///
386 /// Emissivity is a property of the surface and not of the material, so anodised aluminium is
387 /// not a new entry in the catalogue — it is `aluminium_6061().with_emissivity(0.9)`. The
388 /// factor of ten between polished and anodised 6061 lands squarely on the radiative loss
389 /// path, which is the same order as still-air convection at room temperature.
390 ///
391 /// Clamped to `0..=1`: a surface cannot radiate more than a blackbody, and a negative
392 /// emissivity would make a body warm itself.
393 ///
394 /// Does nothing to a substance whose thermal properties are unknown, because `None` means
395 /// unknown rather than zero and inventing three of the four fields to set the fourth would
396 /// be worse than declining.
397 pub fn with_emissivity(mut self, emissivity: f64) -> Substance {
398 if let Some(t) = self.thermal.as_mut() {
399 t.emissivity = emissivity.clamp(0.0, 1.0);
400 }
401 self
402 }
403
404 /// The same substance with a different heat capacity.
405 ///
406 /// For the assembly case: a motor, a populated board, a printed part with infill. The bulk
407 /// `c_p` of a mixture is not the `c_p` of its main constituent, and this is the field where
408 /// that difference is worth a factor of two.
409 ///
410 /// Does nothing to a substance whose thermal properties are unknown, for the reason in
411 /// [`Substance::with_emissivity`].
412 pub fn with_specific_heat(mut self, specific_heat: SpecificHeat) -> Substance {
413 if let Some(t) = self.thermal.as_mut() {
414 t.specific_heat = specific_heat;
415 }
416 self
417 }
418
419 /// Austenitic stainless, 304/18-8. What a portafilter basket, a boiler and most food-contact
420 /// hardware is.
421 ///
422 /// # It is a poor conductor and that is the point
423 ///
424 /// 16.2 W/m/K against aluminium's 167 — a factor of ten — while holding **more** heat per unit
425 /// volume, 4.0 MJ/m³/K against 2.4. So a steel part is a better *reservoir* and a worse
426 /// *spreader* than an aluminium one of the same size, which is why a group head is brass and
427 /// a basket is not.
428 ///
429 /// For an explicit conduction solver that combination is also the difference between a step of
430 /// 2.4 ms and one of 41 ms on a millimetre grid, because the limit goes as the diffusivity and
431 /// steel's is seventeen times lower. Reaching for aluminium because it is the metal already in
432 /// the catalogue costs an order of magnitude in run time *and* understates the thermal mass.
433 pub fn stainless_304() -> Substance {
434 Substance {
435 name: "304 stainless".to_string(),
436 density: Density::g_per_cm3(8.00),
437 thermal: Some(ThermalProps {
438 conductivity: ThermalConductivity::w_per_m_k(16.2),
439 specific_heat: SpecificHeat::j_per_kg_k(500.0),
440 expansion: ThermalExpansion::ppm_per_k(17.3),
441 // Rolled and passivated rather than mirror-polished, which is what a basket is.
442 emissivity: 0.28,
443 }),
444 fusion: None,
445 mechanical: Some(MechanicalProps {
446 youngs_modulus: Pressure::from_si(193.0e9),
447 poisson_ratio: 0.29,
448 yield_strength: Pressure::from_si(215.0e6),
449 }),
450 acoustic: Some(AcousticProps {
451 sound_speed: Velocity::m_per_s(5_790.0),
452 }),
453 }
454 }
455
456 /// Electrolytic tough-pitch copper: windings, heat spreaders, planes.
457 ///
458 /// The values are uncontroversial to three figures. The emissivity is not: this is **bright
459 /// polished** copper at 0.04, and copper oxidises — a tarnished surface runs 0.4 to 0.8, a
460 /// factor of fifteen on the radiative path. If the part has been in air for a week, say so
461 /// with [`Substance::with_emissivity`].
462 pub fn copper() -> Substance {
463 Substance {
464 name: "Cu ETP".to_string(),
465 density: Density::g_per_cm3(8.96),
466 thermal: Some(ThermalProps {
467 conductivity: ThermalConductivity::w_per_m_k(401.0),
468 specific_heat: SpecificHeat::j_per_kg_k(385.0),
469 expansion: ThermalExpansion::ppm_per_k(16.5),
470 emissivity: 0.04,
471 }),
472 fusion: None,
473 mechanical: Some(MechanicalProps {
474 youngs_modulus: Pressure::from_si(117.0e9),
475 poisson_ratio: 0.34,
476 yield_strength: Pressure::from_si(70.0e6),
477 }),
478 acoustic: Some(AcousticProps {
479 sound_speed: Velocity::m_per_s(4_760.0),
480 }),
481 }
482 }
483
484 /// FR-4 glass-epoxy laminate: the board a driver sits on.
485 ///
486 /// **The conductivity is the through-plane one**, 0.3 W/m/K, and that is the number a
487 /// designer wants because it is the one heat has to cross to reach the far side. In-plane it
488 /// is nearer 0.8, because the copper-free glass weave conducts better along its fibres —
489 /// a factor of about three, and `ThermalProps` carries one scalar, so the choice has to be
490 /// stated rather than averaged. Any real board is dominated by its copper pour anyway, which
491 /// is not laminate at all.
492 ///
493 /// The expansion is likewise in-plane, 14 ppm/K. Through-thickness FR-4 expands four to five
494 /// times faster and goes higher again above its glass transition, which is what breaks
495 /// plated through-holes; that regime is not modelled here.
496 pub fn fr4() -> Substance {
497 Substance {
498 name: "FR-4".to_string(),
499 density: Density::g_per_cm3(1.85),
500 thermal: Some(ThermalProps {
501 conductivity: ThermalConductivity::w_per_m_k(0.30),
502 specific_heat: SpecificHeat::j_per_kg_k(1_100.0),
503 expansion: ThermalExpansion::ppm_per_k(14.0),
504 emissivity: 0.90,
505 }),
506 fusion: None,
507 mechanical: Some(MechanicalProps {
508 youngs_modulus: Pressure::from_si(22.0e9),
509 poisson_ratio: 0.16,
510 yield_strength: Pressure::from_si(300.0e6),
511 }),
512 acoustic: None,
513 }
514 }
515
516 /// Non-oriented silicon electrical steel: motor and transformer laminations.
517 ///
518 /// **Grade-dependent, and the spread is wide.** Silicon content trades core loss against
519 /// conductivity: 25 W/m/K here is mid-range for non-oriented sheet, and grades run from about
520 /// 20 to 30. Stacked laminations conduct far worse *across* the stack than the sheet does,
521 /// because the interlaminar varnish dominates — a stack is not this substance at all, and
522 /// treating it as one overstates the conduction out of a motor.
523 ///
524 /// Emissivity 0.3 is varnished sheet; bare mill finish is lower and rusty is much higher.
525 pub fn electrical_steel() -> Substance {
526 Substance {
527 name: "electrical steel (non-oriented)".to_string(),
528 density: Density::g_per_cm3(7.65),
529 thermal: Some(ThermalProps {
530 conductivity: ThermalConductivity::w_per_m_k(25.0),
531 specific_heat: SpecificHeat::j_per_kg_k(460.0),
532 expansion: ThermalExpansion::ppm_per_k(12.0),
533 emissivity: 0.30,
534 }),
535 fusion: None,
536 mechanical: Some(MechanicalProps {
537 youngs_modulus: Pressure::from_si(200.0e9),
538 poisson_ratio: 0.29,
539 yield_strength: Pressure::from_si(350.0e6),
540 }),
541 acoustic: Some(AcousticProps {
542 sound_speed: Velocity::m_per_s(5_100.0),
543 }),
544 }
545 }
546
547 /// Solid cast PLA: printed structure, if it were solid, which it is not.
548 ///
549 /// **A printed part is not this substance.** Infill and layer adhesion move the effective
550 /// conductivity and density more than the polymer chemistry does: at 20% infill the density
551 /// is a fifth of this and the through-layer conductivity is lower again, because the path
552 /// crosses voids and weld lines rather than bulk. Scale the density by the infill fraction
553 /// at the very least, and treat the conductivity as an upper bound.
554 ///
555 /// That is not a caveat about precision. It is the difference between a part that survives
556 /// and one that creeps: PLA softens around 60 °C, and [`Substance::survives`] is checking
557 /// against a number the print may not reach in practice.
558 pub fn pla() -> Substance {
559 Substance {
560 name: "PLA (solid)".to_string(),
561 density: Density::g_per_cm3(1.24),
562 thermal: Some(ThermalProps {
563 conductivity: ThermalConductivity::w_per_m_k(0.13),
564 specific_heat: SpecificHeat::j_per_kg_k(1_800.0),
565 expansion: ThermalExpansion::ppm_per_k(70.0),
566 emissivity: 0.90,
567 }),
568 fusion: None,
569 mechanical: Some(MechanicalProps {
570 youngs_modulus: Pressure::from_si(3.5e9),
571 poisson_ratio: 0.36,
572 yield_strength: Pressure::from_si(50.0e6),
573 }),
574 acoustic: None,
575 }
576 }
577
578 /// Water at 20 °C.
579 pub fn water() -> Substance {
580 Substance {
581 name: "water".to_string(),
582 density: Density::g_per_cm3(0.998),
583 thermal: Some(ThermalProps {
584 conductivity: ThermalConductivity::w_per_m_k(0.598),
585 specific_heat: SpecificHeat::j_per_kg_k(4_182.0),
586 expansion: ThermalExpansion::ppm_per_k(69.0),
587 emissivity: 0.96,
588 }),
589 fusion: None,
590 mechanical: None,
591 acoustic: Some(AcousticProps {
592 sound_speed: Velocity::m_per_s(1_482.0),
593 }),
594 }
595 }
596
597 /// Ice at 0 °C, and the only entry in this catalogue that changes phase.
598 ///
599 /// The canonical Stefan material, and the numbers are the ones the closed-form tests need.
600 /// 2.22 W/m·K is **four times** liquid water's 0.598, which is the thing about ice that surprises
601 /// people and the reason a lake freezes downward at all.
602 ///
603 /// # This is the solid, and the one-phase model uses it for both sides
604 ///
605 /// A domain given this for a melting problem is taking the liquid's conductivity and specific
606 /// heat to be the solid's, which they are not — water conducts a quarter as well and holds twice
607 /// as much. That is **Stefan's original one-phase problem**, and it is exact when the liquid is
608 /// already at the melting point so no heat flows through it: a lake freezing from a cold sky,
609 /// where all the resistance is in the ice.
610 ///
611 /// It is not right for melting a block of ice into water that then warms up. Use
612 /// [`Substance::water`] for the liquid and note that a cell cannot currently be both.
613 pub fn ice() -> Substance {
614 Substance {
615 name: "ice".to_string(),
616 density: Density::g_per_cm3(0.917),
617 thermal: Some(ThermalProps {
618 conductivity: ThermalConductivity::w_per_m_k(2.22),
619 specific_heat: SpecificHeat::j_per_kg_k(2_050.0),
620 // Ice's expansion is anisotropic and this is the polycrystalline mean.
621 expansion: ThermalExpansion::ppm_per_k(51.0),
622 emissivity: 0.97,
623 }),
624 fusion: Some(FusionProps {
625 melting_point: Temperature::celsius(0.0),
626 latent_heat: LatentHeat::kj_per_kg(333.55),
627 // **One-phase, deliberately, and this was measured before being decided.**
628 //
629 // Giving this entry water as its liquid made the one-phase answer *worse*: the front
630 // in `a_freezing_front.rs` went from 0.43% out to 6.9% at forty cells, sixteen times
631 // worse for a problem whose physics had not changed. The cause is the mushy cell,
632 // whose conductivity is a mixture — so the cell holding the interface conducts partly
633 // like water, and the heat reaching the interface has less conductance than the exact
634 // solution gives it. That is a first-order error at the front and it is the price of
635 // two phases.
636 //
637 // A default that silently costs a caller a factor of sixteen is the wrong default. Two
638 // phases are opt-in: `Substance::ice().with_fusion(FusionProps { liquid: Some(..), .. })`
639 // and `crates/pantometry-thermal/tests/two_phase_stefan.rs` shows it against the two-phase
640 // Neumann solution, where it is the *right* answer and the one-phase model is 16% out.
641 liquid: None,
642 }),
643 mechanical: Some(MechanicalProps {
644 youngs_modulus: Pressure::from_si(9.1e9),
645 poisson_ratio: 0.33,
646 // Tensile strength, and ice is brittle: there is no yield before it.
647 yield_strength: Pressure::from_si(1.0e6),
648 }),
649 acoustic: Some(AcousticProps {
650 sound_speed: Velocity::m_per_s(3_840.0),
651 }),
652 }
653 }
654
655 /// Give it thermal properties, or replace the ones it has.
656 ///
657 /// # Why builders exist, when the fields are already public
658 ///
659 /// Because a struct literal names **every** field, so it breaks the moment this type learns one.
660 /// `fusion` was added for latent heat and every literal outside this crate stopped compiling —
661 /// allowed in `0.x`, and still a cost paid by exactly the callers this catalogue is least able to
662 /// help: the ones whose material is not in it.
663 ///
664 /// A chain of `with_*` on [`bulk`](Substance::bulk) is immune to that, and it is how any real
665 /// material becomes expressible without waiting for it to be added here:
666 ///
667 /// ```
668 /// # use pantometry_core::substance::{MechanicalProps, Substance, ThermalProps};
669 /// # use pantometry_core::units::*;
670 /// // Ti-6Al-4V, from a datasheet rather than from this crate.
671 /// let titanium = Substance::bulk("Ti-6Al-4V", Density::g_per_cm3(4.43))
672 /// .with_thermal(ThermalProps {
673 /// conductivity: ThermalConductivity::w_per_m_k(6.7),
674 /// specific_heat: SpecificHeat::j_per_kg_k(526.0),
675 /// expansion: ThermalExpansion::ppm_per_k(8.6),
676 /// emissivity: 0.30,
677 /// })
678 /// .with_mechanical(MechanicalProps {
679 /// youngs_modulus: Pressure::from_si(113.8e9),
680 /// poisson_ratio: 0.342,
681 /// yield_strength: Pressure::from_si(880.0e6),
682 /// });
683 /// assert!(titanium.check().is_ok());
684 /// ```
685 ///
686 /// **Enumeration does not reach "every material" and data does.** This catalogue holds nine
687 /// entries because each is a set of numbers somebody has to be answerable for; a caller with a
688 /// datasheet is answerable for theirs. [`check`](Substance::check) is what the library can still
689 /// do for them.
690 pub fn with_thermal(mut self, thermal: ThermalProps) -> Substance {
691 self.thermal = Some(thermal);
692 self
693 }
694
695 /// Give it mechanical properties, or replace the ones it has.
696 pub fn with_mechanical(mut self, mechanical: MechanicalProps) -> Substance {
697 self.mechanical = Some(mechanical);
698 self
699 }
700
701 /// Give it acoustic properties, or replace the ones it has.
702 pub fn with_acoustic(mut self, acoustic: AcousticProps) -> Substance {
703 self.acoustic = Some(acoustic);
704 self
705 }
706
707 /// Give it a phase change, or replace the one it has.
708 pub fn with_fusion(mut self, fusion: FusionProps) -> Substance {
709 self.fusion = Some(fusion);
710 self
711 }
712
713 /// Every problem with this substance's numbers, or `Ok` if there are none.
714 ///
715 /// For a material that came from outside this crate — a datasheet, a JSON file, a builder chain —
716 /// where nobody has checked the numbers against anything. It cannot tell whether a conductivity is
717 /// *right*; it can tell whether it is **possible**, and an impossible one otherwise produces an
718 /// answer that is plausible and wrong.
719 ///
720 /// Reports all of them at once rather than the first, because a material transcribed from the
721 /// wrong column is usually wrong in several places.
722 ///
723 /// # The one check that is not a bound on a single field
724 ///
725 /// If a substance states both a sound speed and elastic constants, those are **three independent
726 /// numbers describing one thing**, and they have to agree. A longitudinal wave in a solid is
727 /// bounded below by the rod speed `sqrt(E/rho)` — free to bulge sideways — and above by the bulk
728 /// speed `sqrt((lambda+2mu)/rho)`, fully constrained, so a stated speed must sit near one of them.
729 ///
730 /// **15%**, and it is measured rather than chosen: across this catalogue every entry is within
731 /// 6.2% of whichever it means, and the gap is there because a tensile test and an ultrasonic
732 /// measurement are not the same measurement — read as a bulk wave, copper's stated speed implies
733 /// 132 GPa against the 117 in its own entry. So the bound cannot be tighter than that, and at 15%
734 /// it still catches a **shear** speed transcribed by mistake, which sits 45% below the rod speed.
735 pub fn check(&self) -> Result<(), String> {
736 let mut wrong: Vec<String> = Vec::new();
737 let positive = |what: &str, v: f64, out: &mut Vec<String>| {
738 if !(v.is_finite() && v > 0.0) {
739 out.push(format!("{what} must be finite and positive, is {v}"));
740 }
741 };
742 positive("density", self.density.to_si(), &mut wrong);
743 if let Some(t) = self.thermal {
744 positive("conductivity", t.conductivity.to_si(), &mut wrong);
745 positive("specific_heat", t.specific_heat.to_si(), &mut wrong);
746 if !(0.0..=1.0).contains(&t.emissivity) {
747 wrong.push(format!(
748 "emissivity is a fraction of a blackbody's and must be in 0..=1, is {}",
749 t.emissivity
750 ));
751 }
752 if !t.expansion.to_si().is_finite() {
753 wrong.push("expansion must be finite".to_string());
754 }
755 }
756 if let Some(m) = self.mechanical {
757 positive("youngs_modulus", m.youngs_modulus.to_si(), &mut wrong);
758 positive("yield_strength", m.yield_strength.to_si(), &mut wrong);
759 // The same range `pantometry-elastic` refuses outside of: at one half the material is
760 // incompressible and lambda is infinite, at minus one the shear modulus diverges.
761 if !(-1.0 < m.poisson_ratio && m.poisson_ratio < 0.5) {
762 wrong.push(format!(
763 "poisson_ratio must be in (-1, 0.5) for a stable isotropic solid, is {}",
764 m.poisson_ratio
765 ));
766 }
767 }
768 if let Some(a) = self.acoustic {
769 positive("sound_speed", a.sound_speed.to_si(), &mut wrong);
770 }
771 if let Some(f) = self.fusion {
772 positive("latent_heat", f.latent_heat.to_si(), &mut wrong);
773 positive("melting_point", f.melting_point.to_si(), &mut wrong);
774 }
775 // The cross-check, and only when there is something to cross.
776 if let (Some(m), Some(a)) = (self.mechanical, self.acoustic) {
777 let (e, nu, rho) = (
778 m.youngs_modulus.to_si(),
779 m.poisson_ratio,
780 self.density.to_si(),
781 );
782 if e > 0.0 && rho > 0.0 && -1.0 < nu && nu < 0.5 {
783 let rod = (e / rho).sqrt();
784 let bulk = (e * (1.0 - nu) / ((1.0 + nu) * (1.0 - 2.0 * nu) * rho)).sqrt();
785 let c = a.sound_speed.to_si();
786 let gap = (c / rod - 1.0).abs().min((c / bulk - 1.0).abs());
787 if gap > 0.15 {
788 wrong.push(format!(
789 "sound_speed {c:.0} m/s is {:.0}% from the nearer of the rod speed {rod:.0} \
790 and the bulk speed {bulk:.0} that its own E, nu and density give — so one of \
791 the four is not this material's, or the speed is a shear wave",
792 gap * 100.0
793 ));
794 }
795 }
796 }
797 if wrong.is_empty() {
798 Ok(())
799 } else {
800 Err(format!("{}: {}", self.name, wrong.join("; ")))
801 }
802 }
803
804 /// A substance with nothing known but how heavy it is.
805 pub fn bulk(name: &str, density: Density) -> Substance {
806 Substance {
807 name: name.to_string(),
808 density,
809 thermal: None,
810 fusion: None,
811 mechanical: None,
812 acoustic: None,
813 }
814 }
815}
816
817#[cfg(test)]
818mod tests {
819 use super::*;
820 use pantometry_units::Length;
821
822 /// Diffusivity against the published figure: N-BK7 is about 5.2e-7 m²/s, and
823 /// aluminium is 130 times faster, which is why one of them is a heat spreader
824 /// and the other is not.
825 #[test]
826 fn diffusivity_matches_the_published_figures() {
827 let glass = Substance::borosilicate_crown().diffusivity().unwrap();
828 let metal = Substance::aluminium_6061().diffusivity().unwrap();
829 assert!(
830 (glass.to_si() - 5.17e-7).abs() < 1e-8,
831 "N-BK7 diffusivity {glass:?}"
832 );
833 assert!(
834 (metal.to_si() - 6.9e-5).abs() < 1e-6,
835 "aluminium diffusivity {metal:?}"
836 );
837 assert!(metal.to_si() / glass.to_si() > 100.0);
838 }
839
840 /// The number an explicit heat solver needs: over a 1 mm cell, N-BK7 is stable
841 /// to about a second and aluminium to about 7 ms. That two-orders-of-magnitude
842 /// gap between two parts of the same instrument is exactly why
843 /// `Schedule::Multirate` exists.
844 #[test]
845 fn stability_limits_differ_by_two_orders_of_magnitude() {
846 let cell = Length::mm(1.0);
847 let limit = |s: &Substance| {
848 let a = s.diffusivity().unwrap().to_si();
849 cell.to_si() * cell.to_si() / (2.0 * a)
850 };
851 let glass = limit(&Substance::borosilicate_crown());
852 let metal = limit(&Substance::aluminium_6061());
853 assert!((glass - 0.97).abs() < 0.1, "glass limit {glass} s");
854 assert!((metal - 0.0072).abs() < 0.001, "metal limit {metal} s");
855 assert!(glass / metal > 100.0);
856 }
857
858 /// Heat capacity of a real piece of glass: a 25 mm disc 5 mm thick is 6.2 g
859 /// and holds 5.3 J per kelvin.
860 #[test]
861 fn a_lens_sized_piece_holds_a_few_joules_per_kelvin() {
862 let glass = Substance::borosilicate_crown();
863 let volume = Volume::from_si(std::f64::consts::PI * (0.0125f64).powi(2) * 0.005);
864 let mass = glass.mass_of(volume);
865 assert!((mass.to_si() * 1e3 - 6.16).abs() < 0.05, "{mass:?}");
866 let capacity = glass.heat_capacity(volume).unwrap();
867 assert!((capacity.to_si() - 5.28).abs() < 0.05, "{capacity:?}");
868 }
869
870 /// Thermal expansion, and the reason a bonded lens cracks: constrained
871 /// stress is `E α ΔT` and does not depend on the size of the part, so a 60 K
872 /// rise breaks N-BK7 whatever shape it is in.
873 #[test]
874 fn constrained_expansion_breaks_glass_before_metal() {
875 let glass = Substance::borosilicate_crown();
876 let metal = Substance::aluminium_6061();
877
878 // 20 K over 100 mm of glass is 14 micrometres — small, and far more than
879 // a wavelength.
880 let growth = glass
881 .expansion_of(Length::mm(100.0), Temperature::from_si(20.0))
882 .unwrap();
883 assert!((growth.in_um() - 14.2).abs() < 0.1, "{growth:?}");
884
885 // Held rigidly, that same 20 K is 11.6 MPa: survivable.
886 let stress = glass
887 .constrained_stress(Temperature::from_si(20.0))
888 .unwrap();
889 assert!((stress.to_si() / 1e6 - 11.6).abs() < 0.2, "{stress:?}");
890 assert_eq!(glass.survives(Temperature::from_si(20.0)), Some(true));
891 // 120 K is not.
892 assert_eq!(glass.survives(Temperature::from_si(120.0)), Some(false));
893 // The aluminium mount takes it easily despite expanding three times more,
894 // because it yields at 276 MPa rather than fracturing at 60.
895 assert_eq!(metal.survives(Temperature::from_si(120.0)), Some(true));
896 }
897
898 /// A property that is not known reports that, rather than defaulting to a
899 /// plausible number that would be silently wrong.
900 #[test]
901 fn unknown_properties_are_absent_not_guessed() {
902 let unknown = Substance::bulk("unobtainium", Density::g_per_cm3(19.0));
903 assert_eq!(unknown.diffusivity(), None);
904 assert_eq!(unknown.heat_capacity(Volume::from_si(1e-6)), None);
905 assert_eq!(unknown.survives(Temperature::from_si(50.0)), None);
906 // But what *is* known still works.
907 assert!((unknown.mass_of(Volume::from_si(1e-6)).to_si() - 0.019).abs() < 1e-9);
908 // Water has no mechanical properties, and asking gives None rather than
909 // an answer about a Young's modulus it does not have.
910 assert_eq!(
911 Substance::water().constrained_stress(Temperature::from_si(10.0)),
912 None
913 );
914 assert!(Substance::water().diffusivity().is_some());
915 }
916
917 #[test]
918 fn substances_round_trip_through_json() {
919 let glass = Substance::borosilicate_crown();
920 let json = serde_json::to_string(&glass).unwrap();
921 assert_eq!(serde_json::from_str::<Substance>(&json).unwrap(), glass);
922 // Absent properties are omitted rather than serialised as null.
923 let plain = Substance::bulk("x", Density::kg_per_m3(1.0));
924 let json = serde_json::to_string(&plain).unwrap();
925 assert!(!json.contains("thermal"), "{json}");
926 }
927 /// The builders change one field and leave the rest alone.
928 ///
929 /// Emissivity is a surface and not a substance, so anodised 6061 has to be reachable without
930 /// a second catalogue entry — that was the reported friction, and the workaround was reaching
931 /// into `thermal.as_mut()` by hand.
932 #[test]
933 fn a_finish_is_not_a_new_material() {
934 let polished = Substance::aluminium_6061();
935 let anodised = Substance::aluminium_6061().with_emissivity(0.9);
936 let (p, a) = (polished.thermal.unwrap(), anodised.thermal.unwrap());
937
938 assert_eq!(p.emissivity, 0.09);
939 assert_eq!(a.emissivity, 0.9);
940 // Everything else survives, including the name: it is the same alloy.
941 assert_eq!(p.conductivity, a.conductivity);
942 assert_eq!(p.specific_heat, a.specific_heat);
943 assert_eq!(p.expansion, a.expansion);
944 assert_eq!(polished.density, anodised.density);
945 assert_eq!(polished.name, anodised.name);
946
947 // A surface cannot out-radiate a blackbody, nor warm itself.
948 assert_eq!(
949 Substance::aluminium_6061()
950 .with_emissivity(4.0)
951 .thermal
952 .unwrap()
953 .emissivity,
954 1.0
955 );
956 assert_eq!(
957 Substance::aluminium_6061()
958 .with_emissivity(-1.0)
959 .thermal
960 .unwrap()
961 .emissivity,
962 0.0
963 );
964 }
965
966 /// **The trap the docs now warn about, as a number.**
967 ///
968 /// A lumped time constant is `C/(hA)` and `C` is `rho V c_p`, so reaching for aluminium's
969 /// 896 J/kg/K to stand in for a motor's ~450 doubles it. That was the reported failure: the
970 /// catalogue offered exactly one metal, reaching for it was the reasonable thing to do, and
971 /// it changed a conclusion with nothing to say so.
972 ///
973 /// Asserted on the ratio rather than on either value, because the ratio is the claim.
974 #[test]
975 fn the_specific_heat_a_user_borrows_is_worth_a_factor_of_two() {
976 let volume = Volume::from_si(3.456e-4);
977 let billet = Substance::aluminium_6061();
978 let assembly =
979 Substance::aluminium_6061().with_specific_heat(SpecificHeat::j_per_kg_k(450.0));
980
981 let c_billet = billet.heat_capacity(volume).unwrap().to_si();
982 let c_assembly = assembly.heat_capacity(volume).unwrap().to_si();
983 let ratio = c_billet / c_assembly;
984 assert!(
985 (ratio - 896.0 / 450.0).abs() < 1e-12,
986 "the capacity ratio is the specific-heat ratio: {ratio}"
987 );
988 assert!(ratio > 1.9, "a borrowed c_p is worth about two: {ratio}");
989 }
990
991 /// The new entries carry heat capacity and expansion, and their ordering is the physics.
992 ///
993 /// Not asserting the values against themselves — that would check nothing. The orderings
994 /// are the claims: copper conducts far better than steel and steel far better than laminate
995 /// and plastic; a polymer expands several times faster than a metal; and copper stores less
996 /// heat per kilogram than aluminium while storing more per unit volume, which is why a heat
997 /// spreader is copper and a heatsink is aluminium.
998 #[test]
999 fn the_new_entries_are_ordered_the_way_the_physics_is() {
1000 let cu = Substance::copper().thermal.unwrap();
1001 let steel = Substance::electrical_steel().thermal.unwrap();
1002 let fr4 = Substance::fr4().thermal.unwrap();
1003 let pla = Substance::pla().thermal.unwrap();
1004 let al = Substance::aluminium_6061().thermal.unwrap();
1005
1006 // Conduction, over four orders of magnitude.
1007 assert!(cu.conductivity > al.conductivity);
1008 assert!(al.conductivity > steel.conductivity);
1009 assert!(steel.conductivity.to_si() > 50.0 * fr4.conductivity.to_si());
1010 assert!(fr4.conductivity > pla.conductivity);
1011
1012 // Expansion: a polymer moves about three times faster than the fastest metal here.
1013 // 70 ppm/K against 6061's 23.6 is 2.97, and the first version of this asserted 3.0 --
1014 // a claim written from the adjective rather than from the numbers.
1015 let ratio = pla.expansion.to_si() / al.expansion.to_si();
1016 assert!(
1017 (2.5..3.5).contains(&ratio),
1018 "PLA against 6061 is {ratio:.2}x"
1019 );
1020 assert!(al.expansion > cu.expansion && cu.expansion > steel.expansion);
1021
1022 // Per kilogram copper stores less than aluminium; per unit volume it stores more.
1023 let v = Volume::from_si(1e-3);
1024 assert!(cu.specific_heat < al.specific_heat);
1025 assert!(
1026 Substance::copper().heat_capacity(v).unwrap()
1027 > Substance::aluminium_6061().heat_capacity(v).unwrap()
1028 );
1029
1030 // The insulators are the emitters, which is why a black plastic case sheds heat a bare
1031 // metal one does not.
1032 assert!(fr4.emissivity > 0.8 && pla.emissivity > 0.8);
1033 assert!(cu.emissivity < 0.1);
1034 }
1035}