Skip to main content

outram_foam_basic_lib/steady_conduction/
closed_form.rs

1// SPDX-License-Identifier: GPL-3.0-only
2// Copyright (C) 2026 OUTRAM PARK contributors
3//
4// This file is part of OUTRAM PARK.
5//
6// OUTRAM PARK is free software: you can redistribute it and/or modify it
7// under the terms of the GNU General Public License as published by the
8// Free Software Foundation, either version 3 of the License, or (at your
9// option) any later version.
10//
11// OUTRAM PARK is distributed in the hope that it will be useful, but
12// WITHOUT ANY WARRANTY; without even the implied warranty of
13// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14// General Public License for more details.
15//
16// You should have received a copy of the GNU General Public License along
17// with OUTRAM PARK.  If not, see <https://www.gnu.org/licenses/>.
18
19//! Exact steady temperature for uniform heat generation in nested slab,
20//! cylinder and sphere shells: the verification reference for the
21//! finite-volume solve (gh:#949).
22//!
23//! ## Derivation
24//!
25//! Write the geometry with an exponent `m`: slab `m = 0` (`r` is the distance
26//! from the mid-plane), cylinder `m = 1`, sphere `m = 2`. In steady state the
27//! heat crossing radius `r` outwards, per unit of the geometry's measure
28//! (`r^m` times `1`, `2 pi H` or `4 pi`), is everything generated inside it:
29//!
30//! ```text
31//!   Q(r) = int_0^r q'''(s) s^m ds,         -k dT/dr = Q(r) / r^m
32//! ```
33//!
34//! Layer `j` spans `r_j <= r <= r_{j+1}` (with `r_0 = 0`) and carries a
35//! constant `k_j` and `q_j`. Inside it `Q(r) = q_j r^(m+1)/(m+1) + C_j` with
36//! `C_j = Q(r_j) - q_j r_j^(m+1)/(m+1)`, and integrating `dT/dr` gives
37//!
38//! ```text
39//!   T(r) = T(r_{j+1}) + [ q_j (r_{j+1}^2 - r^2) / (2(m+1)) + C_j G_m(r, r_{j+1}) ] / k_j
40//!   G_0 = R - r,   G_1 = ln(R/r),   G_2 = 1/r - 1/R
41//! ```
42//!
43//! `C_0 = 0`, so the centre is regular. The surface `R = r_N` is either held at
44//! a fixed temperature or cooled convectively, `T(R) = T_a + Q(R) / (R^m h)`.
45//!
46//! For one layer this is the textbook result, e.g. a sphere's
47//! `T(r) = T_s + q (R^2 - r^2) / (6 k)`. **Citation status:** derived here
48//! from the energy balance above; no textbook page from the literature corpus
49//! is cited yet (UNCITED: maintainer to supply a heat-conduction text from the
50//! corpus if a citation is wanted).
51
52use petir::mathf::RealMath;
53use uom::si::f64::{Length, ThermalConductivity, ThermodynamicTemperature, VolumetricPowerDensity};
54use uom::si::heat_transfer::watt_per_square_meter_kelvin;
55use uom::si::length::meter;
56use uom::si::thermal_conductivity::watt_per_meter_kelvin;
57use uom::si::thermodynamic_temperature::kelvin;
58use uom::si::volumetric_power_density::watt_per_cubic_meter;
59
60use super::mesh::RadialGeometry;
61use super::solve::ThermalBoundary;
62use super::SteadyConductionError;
63
64/// One layer of a nested radial body, from the previous layer's outer radius
65/// (or the centre) out to [`Self::outer_radius`].
66#[derive(Debug, Clone, Copy, PartialEq)]
67pub struct RadialLayer {
68    /// Outer radius of the layer (half-thickness for a slab).
69    pub outer_radius: Length,
70    /// Constant thermal conductivity of the layer.
71    pub conductivity: ThermalConductivity,
72    /// Uniform volumetric heat generation in the layer (zero for a cladding).
73    pub heat_source: VolumetricPowerDensity,
74}
75
76/// The exact steady temperature of a nested radial body (see module docs).
77#[derive(Debug, Clone)]
78pub struct RadialClosedForm {
79    m: i32,
80    /// Layer outer radii, m.
81    radii: Vec<f64>,
82    /// Layer conductivities, W/(m K).
83    k: Vec<f64>,
84    /// Layer sources, W/m^3.
85    q: Vec<f64>,
86    /// `C_j` of each layer.
87    c: Vec<f64>,
88    /// Temperature at each layer's outer radius, K.
89    t_outer: Vec<f64>,
90}
91
92impl RadialClosedForm {
93    /// Build the solution for `layers` (innermost first) with the surface
94    /// condition `surface`.
95    ///
96    /// # Errors
97    /// [`SteadyConductionError::InvalidInput`] for no layers, radii not
98    /// strictly increasing from zero, a non-positive conductivity, a negative
99    /// source, or an [`ThermalBoundary::Adiabatic`] surface (no steady state
100    /// unless nothing is generated) or a convective one with `h <= 0`.
101    pub fn new(
102        geometry: RadialGeometry,
103        layers: &[RadialLayer],
104        surface: ThermalBoundary,
105    ) -> Result<Self, SteadyConductionError> {
106        if layers.is_empty() {
107            return Err(SteadyConductionError::InvalidInput(
108                "closed form needs at least one layer".into(),
109            ));
110        }
111        let m = geometry.exponent();
112        let mf = f64::from(m);
113        let radii: Vec<f64> = layers
114            .iter()
115            .map(|l| l.outer_radius.get::<meter>())
116            .collect();
117        let k: Vec<f64> = layers
118            .iter()
119            .map(|l| l.conductivity.get::<watt_per_meter_kelvin>())
120            .collect();
121        let q: Vec<f64> = layers
122            .iter()
123            .map(|l| l.heat_source.get::<watt_per_cubic_meter>())
124            .collect();
125        let mut previous = 0.0;
126        for (j, r) in radii.iter().enumerate() {
127            if !(*r > previous) || !r.is_finite() {
128                return Err(SteadyConductionError::InvalidInput(format!(
129                    "layer {j} outer radius {r} m must exceed the previous one ({previous} m)"
130                )));
131            }
132            if !(k[j] > 0.0) || !(q[j] >= 0.0) {
133                return Err(SteadyConductionError::InvalidInput(format!(
134                    "layer {j} needs k > 0 and q''' >= 0 (got k = {}, q''' = {})",
135                    k[j], q[j]
136                )));
137            }
138            previous = *r;
139        }
140
141        // Cumulative Q at each layer's inner radius, and C_j.
142        let mut c = Vec::with_capacity(layers.len());
143        let mut q_cum = 0.0;
144        let mut r_in = 0.0_f64;
145        for j in 0..layers.len() {
146            c.push(q_cum - q[j] * r_in.r_powi(m + 1) / (mf + 1.0));
147            q_cum += q[j] * (radii[j].r_powi(m + 1) - r_in.r_powi(m + 1)) / (mf + 1.0);
148            r_in = radii[j];
149        }
150        let r_surface = radii[radii.len() - 1];
151        let surface_flux = q_cum / r_surface.r_powi(m); // W/m^2 leaving the surface
152        let t_surface = match surface {
153            ThermalBoundary::FixedTemperature(t) => t.get::<kelvin>(),
154            ThermalBoundary::Convective {
155                coefficient,
156                ambient,
157            } => {
158                let h = coefficient.get::<watt_per_square_meter_kelvin>();
159                if !(h > 0.0) {
160                    return Err(SteadyConductionError::InvalidInput(format!(
161                        "convective surface needs h > 0, got {h} W/(m^2 K)"
162                    )));
163                }
164                ambient.get::<kelvin>() + surface_flux / h
165            }
166            ThermalBoundary::Adiabatic => {
167                return Err(SteadyConductionError::InvalidInput(
168                    "an adiabatic surface has no steady state with heat generated inside".into(),
169                ))
170            }
171        };
172
173        let mut solution = Self {
174            m,
175            radii,
176            k,
177            q,
178            c,
179            t_outer: vec![0.0; layers.len()],
180        };
181        let n = layers.len();
182        solution.t_outer[n - 1] = t_surface;
183        for j in (0..n - 1).rev() {
184            // T at the outer radius of layer j = T at the inner radius of layer j+1.
185            solution.t_outer[j] =
186                solution.t_outer[j + 1] + solution.rise_in_layer(j + 1, solution.radii[j]);
187        }
188        Ok(solution)
189    }
190
191    /// `T(r) - T(r_{j+1})` inside layer `j`, K.
192    fn rise_in_layer(&self, j: usize, r: f64) -> f64 {
193        let mf = f64::from(self.m);
194        let r_out = self.radii[j];
195        let mut rise = self.q[j] * (r_out * r_out - r * r) / (2.0 * (mf + 1.0));
196        if self.c[j] != 0.0 {
197            let g = match self.m {
198                0 => r_out - r,
199                1 => (r_out / r).r_ln(),
200                _ => 1.0 / r - 1.0 / r_out,
201            };
202            rise += self.c[j] * g;
203        }
204        rise / self.k[j]
205    }
206
207    /// The exact temperature at radius `r` (distance from the mid-plane for a
208    /// slab).
209    ///
210    /// # Errors
211    /// [`SteadyConductionError::InvalidInput`] for `r` outside `[0, R]`.
212    pub fn temperature_at(
213        &self,
214        r: Length,
215    ) -> Result<ThermodynamicTemperature, SteadyConductionError> {
216        let r = r.get::<meter>();
217        let r_surface = self.radii[self.radii.len() - 1];
218        if !(0.0..=r_surface).contains(&r) {
219            return Err(SteadyConductionError::InvalidInput(format!(
220                "radius {r} m is outside the body [0, {r_surface}] m"
221            )));
222        }
223        let j = self
224            .radii
225            .iter()
226            .position(|ro| r <= *ro)
227            .unwrap_or(self.radii.len() - 1);
228        Ok(ThermodynamicTemperature::new::<kelvin>(
229            self.t_outer[j] + self.rise_in_layer(j, r),
230        ))
231    }
232
233    /// The exact surface temperature.
234    #[must_use]
235    pub fn surface_temperature(&self) -> ThermodynamicTemperature {
236        ThermodynamicTemperature::new::<kelvin>(self.t_outer[self.t_outer.len() - 1])
237    }
238
239    /// The exact temperature at the centre (`r = 0`), the peak when the heat
240    /// is generated in the innermost layer.
241    #[must_use]
242    pub fn centre_temperature(&self) -> ThermodynamicTemperature {
243        ThermodynamicTemperature::new::<kelvin>(self.t_outer[0] + self.rise_in_layer(0, 0.0))
244    }
245}