pantometry 0.16.0

Physics for simulated worlds: units, kernel, eleven domains and a scene layer in one dependency
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
//! A material that is not in the catalogue, applied to every domain that takes one.
//!
//! The catalogue holds nine entries and there are hundreds of thousands of materials. Enumeration
//! never closes that gap, so the question is whether a caller with a datasheet can express theirs and
//! get the same treatment — and until `with_thermal`, `with_mechanical`, `with_acoustic` and
//! `with_fusion` existed, they could not: `Substance::bulk` sets all four blocks to `None` and there
//! was no way to fill any of them except a struct literal naming every field.
//!
//! That literal path is the one that breaks. Adding `fusion` for latent heat stopped every literal
//! outside the crate from compiling, and the callers it cost were exactly the ones the catalogue was
//! least able to help.
//!
//! # What the library can still do for a number it did not choose
//!
//! Not check that it is right. `check` checks that it is **possible** — bounds on each field, and one
//! cross-check that is not a bound: a substance stating both a sound speed and elastic constants has
//! three independent numbers describing one thing, and a longitudinal wave has to sit near the rod
//! speed `sqrt(E/rho)` or the bulk speed `sqrt((lambda+2mu)/rho)` those give.
//!
//! An impossible material otherwise produces an answer that is plausible and wrong, which is the
//! failure this workspace is organised around not having.

use pantometry::prelude::*;
use pantometry::units::LatentHeat;
use pantometry_core::substance::{AcousticProps, FusionProps, MechanicalProps, ThermalProps};
use pantometry_elastic::{Axis, Elastic, Waves};

/// Ti-6Al-4V, from a datasheet. Not in the catalogue and it does not need to be.
fn titanium() -> Substance {
    Substance::bulk("Ti-6Al-4V", Density::g_per_cm3(4.43))
        .with_thermal(ThermalProps {
            conductivity: ThermalConductivity::w_per_m_k(6.7),
            specific_heat: SpecificHeat::j_per_kg_k(526.0),
            expansion: ThermalExpansion::ppm_per_k(8.6),
            emissivity: 0.30,
        })
        .with_mechanical(MechanicalProps {
            youngs_modulus: Pressure::from_si(113.8e9),
            poisson_ratio: 0.342,
            yield_strength: Pressure::from_si(880.0e6),
        })
        .with_acoustic(AcousticProps {
            sound_speed: Velocity::m_per_s(6100.0),
        })
}

/// Paraffin wax, which is what a phase-change thermal buffer actually is.
fn paraffin() -> Substance {
    Substance::bulk("paraffin RT44", Density::g_per_cm3(0.80))
        .with_thermal(ThermalProps {
            conductivity: ThermalConductivity::w_per_m_k(0.2),
            specific_heat: SpecificHeat::j_per_kg_k(2000.0),
            expansion: ThermalExpansion::ppm_per_k(300.0),
            emissivity: 0.90,
        })
        .with_fusion(FusionProps::new(
            Temperature::celsius(44.0),
            LatentHeat::kj_per_kg(250.0),
        ))
}

/// **Every entry in the catalogue passes its own validator.**
///
/// The first thing `check` has to be is true of the nine materials this crate is answerable for. If it
/// were not, the bounds would be wrong rather than the materials — and a validator that the shipped
/// data fails is a validator nobody will keep.
#[test]
fn the_catalogue_passes_its_own_check() {
    for s in [
        Substance::aluminium_6061(),
        Substance::borosilicate_crown(),
        Substance::stainless_304(),
        Substance::copper(),
        Substance::fr4(),
        Substance::electrical_steel(),
        Substance::pla(),
        Substance::water(),
        Substance::ice(),
    ] {
        let name = s.name.clone();
        assert!(s.check().is_ok(), "{name}: {}", s.check().unwrap_err());
    }
    // And so do the two materials this file invents, which is the point of the file.
    for s in [titanium(), paraffin()] {
        assert!(s.check().is_ok(), "{}", s.check().unwrap_err());
    }
    // A substance known only by its density passes too: unknown is not wrong, and every block it
    // could not fill in is honestly absent rather than zero.
    assert!(Substance::bulk("mystery", Density::g_per_cm3(2.0))
        .check()
        .is_ok());
}

/// **The catalogue's list of names and its lookup agree, in both directions.**
///
/// Two hand-written lists that have to match are the shape of defect this workspace keeps finding, and
/// this one was live: no scene file could name `water`, because the scene format kept its own copy of
/// the catalogue's spelling and that copy was written by hand. It began as five names in v0.3.0 and was
/// still missing `water` in v0.13.0 — **eleven releases**, for every version in which a scene could
/// name a material at all. Nothing could have noticed: a name absent from a lookup is not a wrong
/// answer, it is a substance that never appears.
///
/// So both directions, and neither is enough alone:
///
/// - every slug in `CATALOGUE` resolves — catches a name added to the list and not the lookup;
/// - every **constructor** is reachable through some slug — catches the reverse, which is the one that
///   was live and the one no error message can ever report.
///
/// The second direction is why the nine constructors are written out here rather than iterated: there
/// is no way to enumerate a type's associated functions, so this list is the only thing that can hold
/// the lookup to account. A tenth constructor with no slug fails here.
#[test]
fn every_catalogue_entry_can_be_named_and_every_name_resolves() {
    for slug in Substance::CATALOGUE {
        let found = Substance::from_name(slug);
        assert!(found.is_some(), "{slug:?} is listed and does not resolve");
        assert!(
            found.unwrap().check().is_ok(),
            "{slug:?} resolves to something impossible"
        );
    }

    for s in [
        Substance::aluminium_6061(),
        Substance::borosilicate_crown(),
        Substance::stainless_304(),
        Substance::copper(),
        Substance::fr4(),
        Substance::electrical_steel(),
        Substance::pla(),
        Substance::water(),
        Substance::ice(),
    ] {
        // Compared by value, not by name: `Substance::name` is free text and two entries could share
        // it, and a slug pointing at the *wrong* constructor is a defect this catches and a name
        // comparison would not.
        assert!(
            Substance::CATALOGUE
                .iter()
                .any(|slug| Substance::from_name(slug).as_ref() == Some(&s)),
            "{} is in the catalogue and no name reaches it",
            s.name
        );
    }

    assert_eq!(
        Substance::CATALOGUE.len(),
        9,
        "nine entries; this number is quoted in four documents"
    );
    // And a name that is not one of them is `None` rather than a plausible default.
    for wrong in ["aluminum", "Aluminium", "al6061", "", "steel"] {
        assert!(
            Substance::from_name(wrong).is_none(),
            "{wrong:?} resolved to something"
        );
    }
}

/// **An impossible material is refused, and the message names the field.**
///
/// Each of these is a transcription mistake somebody makes: a percentage where a fraction belongs, a
/// sign, a column read one across. Every one of them would otherwise run and produce an answer.
///
/// The last is the interesting one. Its numbers are individually plausible — an aluminium density with
/// a steel modulus — and only the **cross-check** catches it: those two do not give a sound speed
/// anywhere near the one stated beside them.
#[test]
fn an_impossible_material_is_refused_by_the_field_that_is_wrong() {
    let base = || Substance::bulk("suspect", Density::g_per_cm3(2.7));
    let cases: Vec<(&str, Substance, &str)> = vec![
        (
            "emissivity as a percentage",
            base().with_thermal(ThermalProps {
                conductivity: ThermalConductivity::w_per_m_k(167.0),
                specific_heat: SpecificHeat::j_per_kg_k(896.0),
                expansion: ThermalExpansion::ppm_per_k(23.6),
                emissivity: 9.0,
            }),
            "emissivity",
        ),
        (
            "a negative conductivity",
            base().with_thermal(ThermalProps {
                conductivity: ThermalConductivity::w_per_m_k(-167.0),
                specific_heat: SpecificHeat::j_per_kg_k(896.0),
                expansion: ThermalExpansion::ppm_per_k(23.6),
                emissivity: 0.1,
            }),
            "conductivity",
        ),
        (
            "an incompressible Poisson ratio",
            base().with_mechanical(MechanicalProps {
                youngs_modulus: Pressure::from_si(68.9e9),
                poisson_ratio: 0.5,
                yield_strength: Pressure::from_si(276.0e6),
            }),
            "poisson_ratio",
        ),
        (
            "a latent heat of nothing",
            base().with_fusion(FusionProps::new(
                Temperature::celsius(44.0),
                LatentHeat::kj_per_kg(0.0),
            )),
            "latent_heat",
        ),
        (
            "a shear speed where the longitudinal one belongs",
            base()
                .with_mechanical(MechanicalProps {
                    youngs_modulus: Pressure::from_si(68.9e9),
                    poisson_ratio: 0.33,
                    yield_strength: Pressure::from_si(276.0e6),
                })
                .with_acoustic(AcousticProps {
                    // 6061's shear speed, not its longitudinal one.
                    sound_speed: Velocity::m_per_s(3100.0),
                }),
            "sound_speed",
        ),
        (
            "a modulus from the row below",
            base()
                .with_mechanical(MechanicalProps {
                    youngs_modulus: Pressure::from_si(200.0e9),
                    poisson_ratio: 0.29,
                    yield_strength: Pressure::from_si(276.0e6),
                })
                .with_acoustic(AcousticProps {
                    sound_speed: Velocity::m_per_s(6320.0),
                }),
            "sound_speed",
        ),
    ];
    for (why, s, field) in cases {
        let Err(message) = s.check() else {
            panic!("{why} must be refused");
        };
        println!("  {why:44} -> {message}");
        assert!(
            message.contains(field),
            "{why}: the message must name {field}, says {message}"
        );
    }

    // Several wrong at once are all reported, because a material read off the wrong column usually is.
    let doomed = Substance::bulk("doomed", Density::from_si(-1.0)).with_thermal(ThermalProps {
        conductivity: ThermalConductivity::w_per_m_k(0.0),
        specific_heat: SpecificHeat::j_per_kg_k(-1.0),
        expansion: ThermalExpansion::ppm_per_k(1.0),
        emissivity: -0.5,
    });
    let message = doomed.check().expect_err("four problems");
    let named = ["density", "conductivity", "specific_heat", "emissivity"]
        .iter()
        .filter(|f| message.contains(**f))
        .count();
    println!("  four problems at once, {named} named: {message}");
    assert_eq!(named, 4, "all of them, not the first: {message}");
}

/// **A material nobody in this crate chose runs in three domains and gets the same treatment.**
///
/// Titanium through conduction and through elastic waves, and paraffin through a phase change. The
/// point is not that the numbers are special — it is that nothing along the way asks whether the
/// substance came from the catalogue.
///
/// Each is checked against a closed form rather than against a picture, on the same footing as a
/// catalogue material: the stability limit, the wave speed ratio, and the melting plateau.
#[test]
fn a_datasheet_material_works_in_every_domain_that_takes_one() {
    let ti = titanium();

    // Conduction. The limit is `dx^2/(6 alpha)` and titanium's diffusivity is a third of stainless
    // steel's, so the step it allows is a number about the material and not about the catalogue.
    let block = pantometry::thermal::Solid3D::new(
        "ti",
        ti.clone(),
        (5, 5, 5),
        Length::mm(1.0),
        Temperature::celsius(20.0),
    );
    let alpha = ti.diffusivity().expect("it has thermal properties").to_si();
    let limit = block.max_stable_dt(Time::from_si(0.0)).to_si();
    let closed = 1e-6 / (6.0 * alpha);
    println!(
        "  Ti: alpha {alpha:.4e} m2/s, conduction limit {limit:.4e} s against dx^2/6alpha {closed:.4e}"
    );
    assert!(
        (limit / closed - 1.0).abs() < 1e-12,
        "a datasheet material gets the same limit formula: {limit:.6e} against {closed:.6e}"
    );

    // Elastic waves. The ratio is `sqrt(2(1-nu)/(1-2nu))` and nu = 0.342 is a value no catalogue entry
    // has, so this cannot be passing on a memorised number.
    let e = Elastic::from_substance(&ti).expect("it has mechanical properties");
    let ratio = e.p_wave_speed().to_si() / e.s_wave_speed().to_si();
    println!(
        "  Ti: c_p {:.0} m/s, c_s {:.0}, ratio {ratio:.6} against sqrt(2(1-v)/(1-2v)) {:.6}",
        e.p_wave_speed().to_si(),
        e.s_wave_speed().to_si(),
        e.speed_ratio()
    );
    assert!(
        (ratio / e.speed_ratio() - 1.0).abs() < 1e-14,
        "the identity holds for a material this crate never saw"
    );
    let mut w = Waves::new("ti bar", (1, 1, 16), Length::mm(1.0), e);
    w.hold(Axis::X);
    w.hold(Axis::Y);
    w.clamp_ends(Axis::Z);
    w.release_mode(1, Axis::Z, Axis::Z, Length::from_si(1e-9));
    let dt = Time::from_si(w.max_stable_dt(Time::from_si(0.0)).to_si() * 0.5);
    for n in 0..200 {
        w.step(
            Time::from_si(n as f64 * dt.to_si()),
            dt,
            &mut Exchange::new(),
        )
        .expect("a datasheet material is as stable as a catalogue one");
    }
    assert!(
        w.strain_energy().to_si() > 0.0,
        "and it is actually ringing"
    );

    // A phase change. Paraffin's plateau is `rho L V / P`, exactly as ice's is.
    let wax = paraffin();
    let mut buffer = pantometry::thermal::Solid3D::new(
        "wax",
        wax.clone(),
        (1, 1, 1),
        Length::mm(1.0),
        Temperature::celsius(44.0),
    );
    let volume = Volume::from_si(1e-9);
    let latent = wax.latent_energy(volume).expect("it melts").to_si();
    let power = 1e-3;
    let (dt, mut t, mut left_at) = (0.05, 0.0, None);
    while t < 2.0 * latent / power {
        buffer.deposit(0, 0, 0, Energy::from_si(power * dt));
        t += dt;
        if left_at.is_none()
            && buffer.temperature_at(0, 0, 0).to_si() > Temperature::celsius(44.0).to_si()
        {
            left_at = Some(t);
        }
    }
    let left = left_at.expect("it warms eventually");
    println!(
        "  paraffin: held at 44 C for {left:.2} s against rho L V / P = {:.2}",
        latent / power
    );
    assert!(
        (left - latent / power).abs() <= dt * 1.001,
        "the plateau is the latent heat over the power, for a wax as much as for ice"
    );
}

/// **A material survives being written to JSON and read back, so a file of them is a supported path.**
///
/// The route to "every material" that does not involve this crate learning any of them. `Substance`
/// has derived `Serialize` and `Deserialize` all along; what was missing was any statement that the
/// round trip is exact, and a format nobody checks is a format that drifts.
///
/// Asserted from the parsed value rather than from a second serialisation — comparing
/// `to_string(parse(x))` with `to_string(parse(to_string(parse(x))))` puts serialiser output on both
/// sides and cannot see a field the parser dropped. `pantometry-world` learned that one the hard way.
#[test]
fn a_material_round_trips_through_json_so_a_file_can_hold_a_catalogue() {
    for original in [titanium(), paraffin(), Substance::ice()] {
        let text = serde_json::to_string(&original).expect("it serialises");
        let back: Substance = serde_json::from_str(&text).expect("and parses");
        assert_eq!(back, original, "the round trip is exact");
        assert!(back.check().is_ok(), "and still valid");
    }

    // Hand-written, which is what a file actually is — and the absent blocks stay absent rather than
    // arriving as zeros.
    let text = r#"{
        "name": "PEEK",
        "density": 1320.0,
        "thermal": { "conductivity": 0.25, "specific_heat": 1340.0,
                     "expansion": 4.7e-5, "emissivity": 0.9 }
    }"#;
    let peek: Substance = serde_json::from_str(text).expect("a partial material parses");
    assert_eq!(peek.name, "PEEK");
    assert!(peek.check().is_ok(), "{}", peek.check().unwrap_err());
    assert!(
        peek.mechanical.is_none() && peek.acoustic.is_none() && peek.fusion.is_none(),
        "what the file did not say is absent, not zero"
    );
    assert!(
        peek.diffusivity().is_some(),
        "and what it did say is usable: {:?}",
        peek.diffusivity()
    );

    // A key this format does not know is refused rather than dropped, which is the same rule the
    // scene format has and for the same reason: a discarded key makes the file say something the code
    // will not do.
    let typo = r#"{ "name": "x", "density": 1000.0, "thermalz": {} }"#;
    assert!(
        serde_json::from_str::<Substance>(typo).is_err(),
        "an unknown key must not be silently dropped"
    );
}