Skip to main content

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}