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}