//! Analytic verification of heat transfer and thermal stress (docs/fem.md, docs/elements.md): //! steady conduction through a slab (prescribed temperatures, a heat source, convection), //! radial conduction through a thick cylinder, transient conduction into a semi-infinite solid //! (erfc), free and constrained thermal expansion, a bimetal strip (Timoshenko 1925) and the //! coupled temperature–displacement step. mod common; use common::*; use fieldcraft_model::*; use fieldcraft_solver::{Limits, solve_heat, solve_static}; const K: f64 = 45.0; /// Replace the grid's material with `elastic` plus thermal properties. fn thermal(g: &mut Grid, elastic: Elastic, density: Option, t: Thermal) { g.model.replace_material("Mat", Material { elastic: Some(elastic), density, thermal: Some(t), ..Material::default() }).unwrap(); } fn conductor(k: f64) -> Thermal { Thermal { conductivity: Some(Directional::Isotropic(k)), ..Thermal::default() } } fn hold(g: &mut Grid, name: &str, value: f64, f: impl Fn([f64; 3]) -> bool) { g.node_set(name, f); g.bc(name, BoundaryCondition::Temperature { target: Target::Set(name.into()), value }); } fn temperature_at(r: &fieldcraft_solver::HeatResult, label: u32) -> f64 { r.temperature()[r.nodes.binary_search(&label).unwrap()] } /// Abramowitz & Stegun 7.1.26 is too coarse here; erfc by its continued fraction for large x and /// the Taylor series for small x, both to ~1e-15. fn erfc(x: f64) -> f64 { if x < 2.0 { // erf x = 2/√π Σ (−1)ⁿ x^(2n+1) / (n! (2n + 1)). let mut sum = 0.0; let mut term = x; for n in 0..200 { let t = term / (2 * n + 1) as f64; sum += t; term *= -x * x / (n + 1) as f64; if t.abs() < 1e-17 { break; } } 1.0 - 2.0 / std::f64::consts::PI.sqrt() * sum } else { // Lentz evaluation of erfc x = e^(−x²)/√π · 1/(x + ½/(x + 1/(x + 3/2/(x + …)))). let mut f = x; for n in (1..200).rev() { f = x + n as f64 / 2.0 / f; } (-x * x).exp() / std::f64::consts::PI.sqrt() / f } } #[test] fn erfc_matches_reference_values() { for (x, want) in [(0.0, 1.0), (0.5, 0.479_500_122_186_953_5), (1.0, 0.157_299_207_050_285_1), (2.5, 4.069_520_174_449_59e-4)] { assert!((erfc(x) - want).abs() < 1e-13 * want.max(1e-3), "{x}: {} vs {want}", erfc(x)); } } #[test] fn slab_steady_conduction_is_linear_with_the_fourier_flux() { // A 4 × 1 × 1 slab, 20 at x = 0 and 100 at x = 4: T linear, q_x = −k ΔT / L everywhere. for (kind, f, name) in variants() { let mut g = grid([4, 1, 1], kind, |p| [4.0 * p[0], p[1], p[2]]); g.define(iso(2e5, 0.3), None, f, Procedure::HeatTransfer { transient: None }); thermal(&mut g, iso(2e5, 0.3), None, conductor(K)); hold(&mut g, "cold", 20.0, |p| near(p[0], 0.0)); hold(&mut g, "hot", 100.0, |p| near(p[0], 1.0)); let r = solve_heat(&g.model, None, &Limits::default()).unwrap(); for (l, p) in &g.param { let t = temperature_at(&r, *l); assert!((t - (20.0 + 80.0 * p[0])).abs() < 1e-9, "{name} node {l}: {t}"); } for e in &r.elements { for p in &e.points { assert!((p.flux[0] + K * 20.0).abs() < 1e-8 && p.flux[1].abs() < 1e-8 && p.flux[2].abs() < 1e-8, "{name}: {:?}", p.flux); } } // The heat entering at the hot face leaves at the cold one: q A = k ΔT / L · 1. let hot: f64 = g.nodes_where(|p| near(p[0], 1.0)).iter().map(|l| r.reaction[r.nodes.binary_search(l).unwrap()]).sum(); assert!((hot - K * 20.0).abs() < 1e-8, "{name}: {hot}"); assert!(r.reaction_total.abs() < 1e-8 && r.warnings.iter().all(|w| !w.contains("differ")), "{name}"); } } #[test] fn slab_with_a_heat_source_and_convection() { // Uniform source Q in a slab of length L held at 0 at x = 0, convecting to T∞ at x = L: // T(x) = −Q x²/(2k) + a x with k a − Q L = h (T∞ − T(L)) from the energy balance at x = L, // a = (Q L + h T∞ + h Q L²/(2k)) / (k + h L). Quadratic in x: exact at the nodes of the // quadratic elements. let (q, h, sink, l) = (300.0, 25.0, 60.0, 2.0); let a = (q * l + h * sink + h * q * l * l / (2.0 * K)) / (K + h * l); let exact = |x: f64| -q * x * x / (2.0 * K) + a * x; for (kind, f, name) in variants().into_iter().filter(|v| matches!(v.0, ElementKind::Hex20 | ElementKind::Tet10)) { let mut g = grid([4, 1, 1], kind, |p| [l * p[0], 0.5 * p[1], 0.5 * p[2]]); g.define(iso(2e5, 0.3), None, f, Procedure::HeatTransfer { transient: None }); thermal(&mut g, iso(2e5, 0.3), None, conductor(K)); hold(&mut g, "base", 0.0, |p| near(p[0], 0.0)); g.face_set("end", |p| near(p[0], 1.0)); g.load("film", Load::Convection { set: "end".into(), film: h, sink }); g.load("source", Load::HeatSource { set: "all".into(), power: q }); let r = solve_heat(&g.model, None, &Limits::default()).unwrap(); for (lb, p) in &g.param { let t = temperature_at(&r, *lb); assert!((t - exact(l * p[0])).abs() < 1e-9 * exact(l).abs(), "{name} x = {}: {t} vs {}", l * p[0], exact(l * p[0])); } // Steady balance: what the source and the film supply leaves through the held face, // (Q L + h (T∞ − T(L))) A. let supplied = (q * l + h * (sink - exact(l))) * 0.25; assert!((r.reaction_total + supplied).abs() < 1e-9 * supplied, "{name}: {} vs {supplied}", -r.reaction_total); assert!((r.applied_total - supplied).abs() < 1e-9 * supplied, "{name}"); } // A flux on a face instead of convection: T = −Q x²/(2k) + (Q L + q″) x / k. let qs = 700.0; let mut g = grid([4, 1, 1], ElementKind::Hex20, |p| [l * p[0], 0.5 * p[1], 0.5 * p[2]]); g.define(iso(2e5, 0.3), None, full(), Procedure::HeatTransfer { transient: None }); thermal(&mut g, iso(2e5, 0.3), None, conductor(K)); hold(&mut g, "base", 0.0, |p| near(p[0], 0.0)); g.face_set("end", |p| near(p[0], 1.0)); g.load("flux", Load::SurfaceFlux { set: "end".into(), flux: qs }); g.load("source", Load::HeatSource { set: "all".into(), power: q }); let r = solve_heat(&g.model, None, &Limits::default()).unwrap(); for (lb, p) in &g.param { let x = l * p[0]; let want = -q * x * x / (2.0 * K) + (q * l + qs) * x / K; assert!((temperature_at(&r, *lb) - want).abs() < 1e-9 * want.abs().max(1.0)); } } #[test] fn thick_cylinder_radial_conduction() { // A quarter of a hollow cylinder r = 1..2 held at 100 inside and 0 outside: // T = 100 (1 − ln r / ln 2). The cut faces are insulated by symmetry. for (kind, n, tol) in [(ElementKind::Hex20, [6, 6, 1], 2e-4), (ElementKind::Hex8, [16, 16, 1], 2e-3)] { let mut g = grid(n, kind, |p| { let (r, th) = (1.0 + p[0], std::f64::consts::FRAC_PI_2 * p[1]); [r * th.cos(), r * th.sin(), 0.3 * p[2]] }); g.define(iso(2e5, 0.3), None, full(), Procedure::HeatTransfer { transient: None }); thermal(&mut g, iso(2e5, 0.3), None, conductor(K)); hold(&mut g, "inner", 100.0, |p| near(p[0], 0.0)); hold(&mut g, "outer", 0.0, |p| near(p[0], 1.0)); let r = solve_heat(&g.model, None, &Limits::default()).unwrap(); let mut worst: f64 = 0.0; for (l, p) in &g.param { let rad = 1.0 + p[0]; worst = worst.max((temperature_at(&r, *l) - 100.0 * (1.0 - rad.ln() / 2f64.ln())).abs()); } assert!(worst < tol * 100.0, "{kind:?}: {worst}"); // The heat flow through the quarter: Q = (π/2) k L ΔT / ln 2. let q_exact = std::f64::consts::FRAC_PI_2 * K * 0.3 * 100.0 / 2f64.ln(); let q_in: f64 = g.nodes_where(|p| near(p[0], 0.0)).iter().map(|l| r.reaction[r.nodes.binary_search(l).unwrap()]).sum::().abs(); assert!((q_in - q_exact).abs() < 10.0 * tol * q_exact, "{kind:?}: {q_in} vs {q_exact}"); } } #[test] fn transient_conduction_into_a_semi_infinite_solid() { // A long bar at 0 whose end x = 0 is held at 1 from t = 0: T = erfc(x / (2√(κt))), κ = k/(ρc), // until the front reaches the far end (here 2√(κt) ≤ L/4). let (rho, c, length): (f64, f64, f64) = (7.8, 0.46, 10.0); let kappa = K / (rho * c); let end = (length / 8.0).powi(2) / kappa; for (theta, increments) in [(0.5, 200usize), (1.0, 800)] { let mut g = grid([100, 1, 1], ElementKind::Hex8, |p| [length * p[0], 0.1 * p[1], 0.1 * p[2]]); let tr = Transient { end_time: end, increment: end / increments as f64, theta, initial_temperature: 0.0 }; g.define(iso(2e5, 0.3), Some(rho), full(), Procedure::HeatTransfer { transient: Some(tr) }); thermal(&mut g, iso(2e5, 0.3), Some(rho), Thermal { specific_heat: Some(c), ..conductor(K) }); hold(&mut g, "end", 1.0, |p| near(p[0], 0.0)); let r = solve_heat(&g.model, None, &Limits::default()).unwrap(); assert_eq!(*r.times.last().unwrap(), end); let mut worst: f64 = 0.0; for (l, p) in &g.param { let x = length * p[0]; worst = worst.max((temperature_at(&r, *l) - erfc(x / (2.0 * (kappa * end).sqrt()))).abs()); } assert!(worst < 5e-3, "θ = {theta}: {worst}"); // Frames start at the initial temperature. assert!(r.frames[0].iter().all(|t| *t == 0.0) && r.times[0] == 0.0); } } /// A 4 × 1 × 1 bar of `kind` heated by `dt` through a temperature load, held as `hold` says. fn heated_bar(kind: ElementKind, f: Formulation, alpha: Directional, dt: f64, poisson: f64) -> Grid { let mut g = grid([4, 1, 1], kind, |p| [4.0 * p[0], p[1], p[2]]); g.define(iso(2e5, poisson), None, f, Procedure::Static {}); thermal(&mut g, iso(2e5, poisson), None, Thermal { expansion: Some(alpha), reference_temperature: 20.0, ..Thermal::default() }); g.node_set("everything", |_| true); g.load("heat", Load::Temperature { target: Target::Set("everything".into()), value: 20.0 + dt }); g } #[test] fn free_thermal_expansion_is_stress_free() { // Held only against rigid motion (3-2-1 by symmetry planes through a corner): ΔL = α L ΔT // along each axis, no stress. let alpha = [1.2e-5, 2.0e-5, 0.8e-5]; for (kind, f, name) in variants() { let mut g = heated_bar(kind, f, Directional::Orthotropic(alpha), 80.0, 0.3); g.symmetry("x0", Symmetry::X, |p| near(p[0], 0.0)); g.symmetry("y0", Symmetry::Y, |p| near(p[1], 0.0)); g.symmetry("z0", Symmetry::Z, |p| near(p[2], 0.0)); let r = solve_static(&g.model, None, &Limits::default()).unwrap(); for (i, l) in r.nodes.iter().enumerate() { let x = g.model.node(*l).unwrap().xyz; for k in 0..3 { assert!((r.displacement[i][k] - alpha[k] * 80.0 * x[k]).abs() < 1e-12, "{name} node {l}"); } assert!(r.stress[i].iter().all(|s| s.abs() < 1e-6), "{name}: {:?}", r.stress[i]); } assert!(r.reaction.iter().flatten().all(|v| v.abs() < 1e-6), "{name}"); } } #[test] fn constrained_bar_carries_minus_e_alpha_dt() { // Both ends held axially, the sides free: σ_xx = −E α ΔT, the others zero, for any ν. for (kind, f, name) in variants() { let mut g = heated_bar(kind, f, Directional::Isotropic(1.2e-5), 50.0, 0.3); g.symmetry("x0", Symmetry::X, |p| near(p[0], 0.0)); g.symmetry("x1", Symmetry::X, |p| near(p[0], 1.0)); g.symmetry("y0", Symmetry::Y, |p| near(p[1], 0.0)); g.symmetry("z0", Symmetry::Z, |p| near(p[2], 0.0)); let r = solve_static(&g.model, None, &Limits::default()).unwrap(); let want = -2e5 * 1.2e-5 * 50.0; for s in &r.stress { assert!((s[0] - want).abs() < 1e-8 * want.abs(), "{name}: {s:?}"); assert!(s[1..].iter().all(|v| v.abs() < 1e-8 * want.abs()), "{name}: {s:?}"); } // The ends push back with σ A. let rx: f64 = g.nodes_where(|p| near(p[0], 1.0)).iter().map(|l| r.reaction[r.nodes.binary_search(l).unwrap()][0]).sum(); assert!((rx - want).abs() < 1e-8 * want.abs(), "{name}: {rx}"); } } #[test] fn bimetal_strip_bends_as_timoshenko_says() { // Two equal layers (thickness t each, same E, ν = 0) with α₁, α₂, heated by ΔT, clamped at // x = 0: curvature κ = 3 Δα ΔT / (2 h) with h = 2t (Timoshenko 1925, m = n = 1), tip rise // κ L² / 2. let (len, t, dt, a1, a2) = (20.0, 0.5, 100.0, 1.0e-5, 2.0e-5); let mut g = grid([20, 1, 4], ElementKind::Hex20, |p| [len * p[0], 1.0 * p[1], 2.0 * t * p[2]]); let all: Vec = g.model.elements().keys().copied().collect(); let (bottom, top): (Vec, Vec) = all.into_iter().partition(|e| g.model.element(*e).unwrap().nodes.iter().all(|n| g.param[n][2] <= 0.5 + 1e-12)); g.model.add_set("bottom", Set { elements: bottom, ..Set::default() }).unwrap(); g.model.add_set("top", Set { elements: top, ..Set::default() }).unwrap(); for (name, alpha) in [("Low", a1), ("High", a2)] { g.model .add_material( name, Material { elastic: Some(iso(2e5, 0.0)), density: None, thermal: Some(Thermal { expansion: Some(Directional::Isotropic(alpha)), ..Thermal::default() }), ..Material::default() }, ) .unwrap(); } g.model.add_section("low", Section { set: "bottom".into(), material: "Low".into(), kind: SectionKind::Solid {}, formulation: full() }).unwrap(); g.model.add_section("high", Section { set: "top".into(), material: "High".into(), kind: SectionKind::Solid {}, formulation: full() }).unwrap(); g.model.add_step(Step::new("Step", Procedure::Static {}), None).unwrap(); g.fixed("root", |p| near(p[0], 0.0)); g.node_set("everything", |_| true); g.load("heat", Load::Temperature { target: Target::Set("everything".into()), value: dt }); let r = solve_static(&g.model, None, &Limits::default()).unwrap(); let kappa = 1.5 * (a2 - a1) * dt / (2.0 * t); let want = -kappa * len * len / 2.0; // The tip's mid-height nodes (the high-α layer on top lengthens, so the strip curls down). let tip: Vec = g .param .iter() .filter(|(_, p)| near(p[0], 1.0) && near(p[2], 0.5)) .map(|(l, _)| r.displacement[r.nodes.binary_search(l).unwrap()][2]) .collect(); assert!(!tip.is_empty()); for w in tip { assert!((w - want).abs() < 0.02 * want.abs(), "{w} vs {want}"); } } #[test] fn coupled_step_matches_heat_then_static() { // A slab held at 20 and 100 at its ends, free to expand (3-2-1): the coupled step gives the // heat step's temperatures and the static step's displacements for that field. let (alpha, tref) = (1.2e-5, 20.0); let build = |procedure: Procedure| { let mut g = grid([4, 1, 1], ElementKind::Hex20, |p| [4.0 * p[0], p[1], p[2]]); g.define(iso(2e5, 0.3), None, full(), procedure); let t = Thermal { expansion: Some(Directional::Isotropic(alpha)), reference_temperature: tref, ..conductor(K) }; thermal(&mut g, iso(2e5, 0.3), None, t); g }; let mut c = build(Procedure::CoupledTemperatureDisplacement {}); hold(&mut c, "cold", 20.0, |p| near(p[0], 0.0)); hold(&mut c, "hot", 100.0, |p| near(p[0], 1.0)); c.symmetry("x0", Symmetry::X, |p| near(p[0], 0.0)); c.symmetry("y0", Symmetry::Y, |p| near(p[1], 0.0)); c.symmetry("z0", Symmetry::Z, |p| near(p[2], 0.0)); let r = solve_static(&c.model, None, &Limits::default()).unwrap(); let temps = r.temperature.clone().unwrap(); for (i, l) in r.nodes.iter().enumerate() { let x = c.model.node(*l).unwrap().xyz; assert!((temps[i] - (20.0 + 20.0 * x[0])).abs() < 1e-9); } // The heat step alone gives the same temperatures. let mut h = build(Procedure::HeatTransfer { transient: None }); hold(&mut h, "cold", 20.0, |p| near(p[0], 0.0)); hold(&mut h, "hot", 100.0, |p| near(p[0], 1.0)); let hr = solve_heat(&h.model, None, &Limits::default()).unwrap(); assert_eq!(hr.temperature(), temps.as_slice()); // A static step with the same field as temperature loads (one per plane x = const) gives the // same displacements and stresses. let mut s = build(Procedure::Static {}); s.symmetry("x0", Symmetry::X, |p| near(p[0], 0.0)); s.symmetry("y0", Symmetry::Y, |p| near(p[1], 0.0)); s.symmetry("z0", Symmetry::Z, |p| near(p[2], 0.0)); for k in 0..=8 { let name = format!("plane{k}"); s.node_set(&name, |p| near(p[0], k as f64 / 8.0)); s.load(&name, Load::Temperature { target: Target::Set(name.clone()), value: 20.0 + 20.0 * 4.0 * k as f64 / 8.0 }); } let sr = solve_static(&s.model, None, &Limits::default()).unwrap(); let scale = sr.displacement.iter().flatten().fold(0.0f64, |m, v| m.max(v.abs())); assert!(scale > 0.0); for (a, b) in r.displacement.iter().flatten().zip(sr.displacement.iter().flatten()) { assert!((a - b).abs() < 1e-12 * scale, "{a} vs {b}"); } for (a, b) in r.stress.iter().flatten().zip(sr.stress.iter().flatten()) { assert!((a - b).abs() < 1e-6, "{a} vs {b}"); } assert!(sr.temperature.is_none()); } #[test] fn heat_steps_refuse_what_they_cannot_solve() { // Nothing holds the temperature level. let mut g = grid([2, 1, 1], ElementKind::Hex8, |p| p); g.define(iso(2e5, 0.3), None, full(), Procedure::HeatTransfer { transient: None }); thermal(&mut g, iso(2e5, 0.3), None, conductor(K)); g.face_set("end", |p| near(p[0], 1.0)); g.load("flux", Load::SurfaceFlux { set: "end".into(), flux: 10.0 }); let e = solve_heat(&g.model, None, &Limits::default()).unwrap_err().to_string(); assert!(e.contains("undetermined"), "{e}"); // No conductivity: the model check says so. let mut g = grid([2, 1, 1], ElementKind::Hex8, |p| p); g.define(iso(2e5, 0.3), None, full(), Procedure::HeatTransfer { transient: None }); hold(&mut g, "end", 1.0, |p| near(p[0], 0.0)); let e = solve_heat(&g.model, None, &Limits::default()).unwrap_err().to_string(); assert!(e.contains("conductivity"), "{e}"); // Transient without a specific heat. let mut g = grid([2, 1, 1], ElementKind::Hex8, |p| p); g.define(iso(2e5, 0.3), Some(1.0), full(), Procedure::HeatTransfer { transient: Some(Transient::new(1.0, 0.1)) }); thermal(&mut g, iso(2e5, 0.3), Some(1.0), conductor(K)); hold(&mut g, "end", 1.0, |p| near(p[0], 0.0)); let e = solve_heat(&g.model, None, &Limits::default()).unwrap_err().to_string(); assert!(e.contains("specific heat"), "{e}"); // Conflicting prescribed temperatures. let mut g = grid([2, 1, 1], ElementKind::Hex8, |p| p); g.define(iso(2e5, 0.3), None, full(), Procedure::HeatTransfer { transient: None }); thermal(&mut g, iso(2e5, 0.3), None, conductor(K)); hold(&mut g, "a", 1.0, |p| near(p[0], 0.0)); hold(&mut g, "b", 2.0, |p| near(p[0], 0.0)); assert!(solve_heat(&g.model, None, &Limits::default()).is_err()); // A hard cap on the unknowns. let mut g = grid([2, 1, 1], ElementKind::Hex8, |p| p); g.define(iso(2e5, 0.3), None, full(), Procedure::HeatTransfer { transient: None }); thermal(&mut g, iso(2e5, 0.3), None, conductor(K)); hold(&mut g, "a", 1.0, |p| near(p[0], 0.0)); let tight = Limits { max_dofs: 3, ..Limits::default() }; assert!(solve_heat(&g.model, None, &tight).unwrap_err().to_string().contains("limit")); // A static step is not a heat step. let g = heated_bar(ElementKind::Hex8, full(), Directional::Isotropic(1e-5), 10.0, 0.3); assert!(solve_heat(&g.model, None, &Limits::default()).is_err()); } /// One rod3 or two rod2 along X over [0, `len`], heated by `dt`, fixed at x = 0, the other /// nodes held in Y and Z, and the end held along X too when `held`. fn heated_rod(kind: ElementKind, len: f64, alpha: [f64; 3], dt: f64, held: bool) -> fieldcraft_solver::StaticResult { let mut m = Model::default(); for (label, x) in [(1, 0.0), (2, len), (3, 0.5 * len)] { m.set_node(label, [x, 0.0, 0.0]).unwrap(); } // One rod3, or two rod2 in a row. let elements = if kind == ElementKind::Rod3 { m.set_element(1, kind, vec![1, 2, 3]).unwrap(); vec![1] } else { m.set_element(1, kind, vec![1, 3]).unwrap(); m.set_element(2, kind, vec![3, 2]).unwrap(); vec![1, 2] }; m.add_set("rod", Set { elements, nodes: vec![1, 2, 3], ..Set::default() }).unwrap(); m.add_set("a", Set { nodes: vec![1], ..Set::default() }).unwrap(); m.add_set("b", Set { nodes: vec![2], ..Set::default() }).unwrap(); m.add_set("c", Set { nodes: vec![3], ..Set::default() }).unwrap(); let thermal = Thermal { expansion: Some(Directional::Orthotropic(alpha)), ..Thermal::default() }; m.add_material("Mat", Material { elastic: Some(iso(2e5, 0.3)), thermal: Some(thermal), ..Material::default() }).unwrap(); m.add_section("rod", Section { set: "rod".into(), material: "Mat".into(), kind: SectionKind::Rod { area: 4.0 }, formulation: full() }).unwrap(); let mut step = Step::new("Step", Procedure::Static {}); let lateral = [None, Some(0.0), Some(0.0)]; for (name, v) in [("a", [Some(0.0); 3]), ("b", if held { [Some(0.0); 3] } else { lateral }), ("c", lateral)] { let values = [v[0], v[1], v[2], None, None, None]; m.add_boundary_condition(name, BoundaryCondition::Displacement { target: Target::Set(name.into()), values }).unwrap(); step.boundary_conditions.insert(name.into()); } m.add_load("warm", Load::Temperature { target: Target::Set("rod".into()), value: dt }).unwrap(); step.loads.insert("warm".into()); m.add_step(step, None).unwrap(); solve_static(&m, None, &Limits::default()).unwrap() } #[test] fn trusses_take_thermal_strain_along_their_axis() { // Along X the member's thermal strain is αₓ ΔT (the projection Σ αₖ tₖ² is checked on the // kernel): free it lengthens by αₓ ΔT L stress-free; held at both ends it carries −E αₓ ΔT. let alpha = [1.0e-5, 2.0e-5, 3.0e-5]; let (e, area, dt, len) = (2e5, 4.0, 50.0, 3.0); let sigma = -e * alpha[0] * dt; for kind in [ElementKind::Rod2, ElementKind::Rod3] { let r = heated_rod(kind, len, alpha, dt, true); for p in r.elements.iter().flat_map(|el| &el.points) { assert!((p.stress[0] - sigma).abs() < 1e-9 * sigma.abs(), "{kind:?}: {:?}", p.stress); } let reaction = r.reaction[r.nodes.binary_search(&2).unwrap()][0]; assert!((reaction - sigma * area).abs() < 1e-9 * (sigma * area).abs(), "{kind:?}: {reaction}"); let r = heated_rod(kind, len, alpha, dt, false); let u = r.displacement[r.nodes.binary_search(&2).unwrap()][0]; assert!((u - alpha[0] * dt * len).abs() < 1e-12, "{kind:?}: {u}"); assert!(r.elements.iter().flat_map(|el| &el.points).all(|p| p.stress[0].abs() < 1e-9)); } } #[test] fn heat_factor_cap_reports_value_bytes_before_allocation() { let mut g = grid([2, 1, 1], ElementKind::Hex8, |p| [2.0 * p[0], p[1], p[2]]); g.define(iso(1000.0, 0.3), None, full(), Procedure::HeatTransfer { transient: None }); thermal(&mut g, iso(1000.0, 0.3), None, conductor(K)); hold(&mut g, "cold", 0.0, |p| near(p[0], 0.0)); let before = g.model.clone(); let tight = Limits { max_factor_entries: 1, ..Limits::default() }; let e = solve_heat(&g.model, None, &tight).unwrap_err().to_string(); assert!(e.contains("bytes of factor values alone") && e.contains("limit is 1 entries"), "{e}"); assert_eq!(g.model, before); assert!(solve_heat(&g.model, None, &Limits::default()).is_ok()); }