Skip to main content

outram_foam_basic_lib/steady_conduction/
solve.rs

1// SPDX-License-Identifier: GPL-3.0-only
2// Copyright (C) 2026 OUTRAM PARK contributors
3// Derived from OpenFOAM (www.openfoam.com)
4//   applications/solvers/basic/laplacianFoam/laplacianFoam.C (TEqn, steady)
5//     Copyright (C) 2011-2017 OpenFOAM Foundation
6//     Copyright (C) 2019 OpenCFD Ltd.
7//   src/thermoTools/derivedFvPatchFields/externalWallHeatFluxTemperature/
8//     externalWallHeatFluxTemperatureFvPatchScalarField.C (convective mixed
9//     condition: valueFraction = hp/(hp + kappa deltaCoeffs), refValue = Ta)
10//   src/finiteVolume/interpolation/surfaceInterpolation/schemes/harmonic/
11//     harmonic.H (face conductivity 1/reverseLinear(1/k))
12//   Upstream licence: GPL-3.0-or-later.
13//
14// This file is part of OUTRAM PARK.
15//
16// OUTRAM PARK is free software: you can redistribute it and/or modify it
17// under the terms of the GNU General Public License as published by the
18// Free Software Foundation, either version 3 of the License, or (at your
19// option) any later version.
20//
21// OUTRAM PARK is distributed in the hope that it will be useful, but
22// WITHOUT ANY WARRANTY; without even the implied warranty of
23// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
24// General Public License for more details.
25//
26// You should have received a copy of the GNU General Public License along
27// with OUTRAM PARK.  If not, see <https://www.gnu.org/licenses/>.
28
29//! The steady conduction solve and its result (see the module docs of
30//! [`super`]).
31
32use std::sync::Arc;
33
34use uom::si::f64::{
35    HeatTransfer, Power, TemperatureInterval, ThermalConductivity, ThermodynamicTemperature,
36    VolumetricPowerDensity,
37};
38use uom::si::heat_transfer::watt_per_square_meter_kelvin;
39use uom::si::power::watt;
40use uom::si::temperature_interval::kelvin as delta_kelvin;
41use uom::si::thermal_conductivity::watt_per_meter_kelvin;
42use uom::si::thermodynamic_temperature::kelvin;
43use uom::si::volumetric_power_density::watt_per_cubic_meter;
44
45use super::mesh::ConductionMesh;
46use super::SteadyConductionError;
47use crate::fields::boundary::bc::{BoundaryCondition, PatchField};
48use crate::fields::field::Field;
49use crate::fields::surface_field::SurfaceScalarField;
50use crate::fields::vol_field::VolScalarField;
51use crate::fv_operators::fvm;
52use crate::fv_operators::fvm::DeltaCoeff;
53use crate::ldu_matrix::fv_matrix::{SolverPerformance, SolverSettings};
54use crate::mesh::fv_mesh::FvMesh;
55
56/// The thermal condition on one boundary patch.
57#[derive(Debug, Clone, Copy, PartialEq)]
58pub enum ThermalBoundary {
59    /// No heat crosses the patch (a symmetry plane, or perfect insulation).
60    Adiabatic,
61    /// The surface is held at this temperature.
62    FixedTemperature(ThermodynamicTemperature),
63    /// The surface loses heat to a fluid: `-k dT/dn = h (T_surface - T_ambient)`.
64    Convective {
65        /// Heat-transfer coefficient `h`.
66        coefficient: HeatTransfer,
67        /// Bulk fluid (ambient) temperature `T_ambient`.
68        ambient: ThermodynamicTemperature,
69    },
70}
71
72/// A [`ThermalBoundary`] applied to one mesh patch, with an optional per-face
73/// film-area fraction.
74#[derive(Debug, Clone, PartialEq)]
75pub struct PatchCondition {
76    /// The condition.
77    pub boundary: ThermalBoundary,
78    /// For [`ThermalBoundary::Convective`] only: the fraction of each face's
79    /// meshed area that is true wetted surface (one value per face, in
80    /// `[0, 1]`). `None` means 1 everywhere. On a voxel staircase this is
81    /// `|n_axis . n_true|`, which makes the film's total area converge to the
82    /// true surface area (see [`super::mesh::voxel_mesh`]). Ignored by the
83    /// other conditions.
84    pub film_area_fraction: Option<Vec<f64>>,
85}
86
87impl PatchCondition {
88    /// The condition with no film-area correction.
89    #[must_use]
90    pub fn uniform(boundary: ThermalBoundary) -> Self {
91        Self {
92            boundary,
93            film_area_fraction: None,
94        }
95    }
96
97    /// The condition with a per-face film-area fraction (see the field doc).
98    #[must_use]
99    pub fn with_film_area_fraction(boundary: ThermalBoundary, fraction: Vec<f64>) -> Self {
100        Self {
101            boundary,
102            film_area_fraction: Some(fraction),
103        }
104    }
105}
106
107/// Iteration controls. The defaults converge the energy balance to about
108/// `1e-10` of the generated power on the meshes in this module's tests.
109#[derive(Debug, Clone, Copy)]
110pub struct SteadyConductionSettings {
111    /// Stop the Picard passes on `k(T)` when no cell moves more than this.
112    pub picard_tolerance: TemperatureInterval,
113    /// Give up after this many passes.
114    pub max_picard_passes: usize,
115    /// Conjugate-gradient settings for each linear solve.
116    pub linear: SolverSettings,
117}
118
119impl Default for SteadyConductionSettings {
120    fn default() -> Self {
121        Self {
122            picard_tolerance: TemperatureInterval::new::<delta_kelvin>(1e-6),
123            max_picard_passes: 200,
124            linear: SolverSettings {
125                tolerance: 1e-13,
126                max_iter: 100_000,
127            },
128        }
129    }
130}
131
132/// Heat generated in the body against heat leaving through its boundary.
133#[derive(Debug, Clone, PartialEq)]
134pub struct EnergyBalance {
135    /// `sum(q''' V)` over the cells.
136    pub generated: Power,
137    /// Heat leaving through all patches, each face's flux recomputed from its
138    /// own boundary condition and the solved cell temperature.
139    pub removed: Power,
140    /// [`Self::removed`] split by patch (mesh patch order).
141    pub removed_per_patch: Vec<Power>,
142}
143
144impl EnergyBalance {
145    /// `generated - removed`.
146    #[must_use]
147    pub fn imbalance(&self) -> Power {
148        self.generated - self.removed
149    }
150
151    /// `|generated - removed| / generated` (0 when nothing is generated and
152    /// nothing leaves).
153    #[must_use]
154    pub fn relative_imbalance(&self) -> f64 {
155        let g = self.generated.get::<watt>().abs();
156        let d = self.imbalance().get::<watt>().abs();
157        if g > 0.0 {
158            d / g
159        } else {
160            d
161        }
162    }
163}
164
165/// A steady conduction problem: a mesh, the heat source of each zone and the
166/// condition on each patch.
167#[derive(Debug, Clone)]
168pub struct SteadyConductionProblem {
169    mesh: ConductionMesh,
170    /// W/m^3 per zone.
171    zone_source: Vec<f64>,
172    patches: Vec<PatchCondition>,
173}
174
175impl SteadyConductionProblem {
176    /// Set up a problem.
177    ///
178    /// `zone_heat_source` has one entry per zone (`mesh.n_zones`);
179    /// `patches` one entry per mesh patch, in patch order.
180    ///
181    /// # Errors
182    /// [`SteadyConductionError::InvalidInput`] for wrong lengths, a negative or
183    /// non-finite source, a non-positive `h`, a film-area fraction of the
184    /// wrong length or outside `[0, 1]`, or a heated body with no patch that
185    /// removes heat (no steady state exists).
186    pub fn new(
187        mesh: ConductionMesh,
188        zone_heat_source: Vec<VolumetricPowerDensity>,
189        patches: Vec<PatchCondition>,
190    ) -> Result<Self, SteadyConductionError> {
191        if zone_heat_source.len() != mesh.n_zones {
192            return Err(SteadyConductionError::InvalidInput(format!(
193                "{} zone heat sources for {} zones",
194                zone_heat_source.len(),
195                mesh.n_zones
196            )));
197        }
198        if patches.len() != mesh.mesh.patches.len() {
199            return Err(SteadyConductionError::InvalidInput(format!(
200                "{} patch conditions for {} mesh patches",
201                patches.len(),
202                mesh.mesh.patches.len()
203            )));
204        }
205        let zone_source: Vec<f64> = zone_heat_source
206            .iter()
207            .map(|q| q.get::<watt_per_cubic_meter>())
208            .collect();
209        if let Some(bad) = zone_source.iter().find(|q| !(**q >= 0.0) || !q.is_finite()) {
210            return Err(SteadyConductionError::InvalidInput(format!(
211                "heat sources must be finite and non-negative, got {bad} W/m^3"
212            )));
213        }
214        let mut removes_heat = false;
215        for (pi, (cond, patch)) in patches.iter().zip(mesh.mesh.patches.iter()).enumerate() {
216            match cond.boundary {
217                ThermalBoundary::Adiabatic => {}
218                ThermalBoundary::FixedTemperature(_) => removes_heat |= patch.size > 0,
219                ThermalBoundary::Convective { coefficient, .. } => {
220                    let h = coefficient.get::<watt_per_square_meter_kelvin>();
221                    if !(h > 0.0) || !h.is_finite() {
222                        return Err(SteadyConductionError::InvalidInput(format!(
223                            "patch {pi}: h must be positive and finite, got {h} W/(m^2 K)"
224                        )));
225                    }
226                    removes_heat |= patch.size > 0;
227                }
228            }
229            if let Some(frac) = &cond.film_area_fraction {
230                if frac.len() != patch.size {
231                    return Err(SteadyConductionError::InvalidInput(format!(
232                        "patch {pi}: {} film-area fractions for {} faces",
233                        frac.len(),
234                        patch.size
235                    )));
236                }
237                if let Some(f) = frac.iter().find(|f| !(0.0..=1.0).contains(*f)) {
238                    return Err(SteadyConductionError::InvalidInput(format!(
239                        "patch {pi}: film-area fraction {f} is outside [0, 1]"
240                    )));
241                }
242            }
243        }
244        let heated = mesh
245            .cell_zone
246            .iter()
247            .any(|z| zone_source.get(*z).is_some_and(|q| *q > 0.0));
248        if heated && !removes_heat {
249            return Err(SteadyConductionError::InvalidInput(
250                "heat is generated but no patch removes it: no steady state exists".into(),
251            ));
252        }
253        Ok(Self {
254            mesh,
255            zone_source,
256            patches,
257        })
258    }
259
260    /// The mesh the problem is posed on.
261    #[must_use]
262    pub fn mesh(&self) -> &ConductionMesh {
263        &self.mesh
264    }
265
266    /// Solve for the steady temperature.
267    ///
268    /// `conductivity(zone, T)` gives `k` of a zone at temperature `T`; it is
269    /// re-evaluated at every cell's temperature between passes (Picard) until
270    /// no cell moves more than `settings.picard_tolerance`. A constant `k`
271    /// converges on the second pass.
272    ///
273    /// # Errors
274    /// [`SteadyConductionError::BadConductivity`] for a non-positive or
275    /// non-finite `k`; [`SteadyConductionError::LinearSolveFailed`];
276    /// [`SteadyConductionError::PicardNotConverged`].
277    pub fn solve<K>(
278        &self,
279        conductivity: K,
280        settings: &SteadyConductionSettings,
281    ) -> Result<SteadyConductionSolution, SteadyConductionError>
282    where
283        K: Fn(usize, ThermodynamicTemperature) -> ThermalConductivity,
284    {
285        let mesh = self.mesh.mesh.clone();
286        let n = mesh.n_cells;
287        let tol = settings.picard_tolerance.get::<delta_kelvin>();
288        let mut t = vec![self.initial_guess_kelvin(); n];
289        let mut k = vec![0.0; n];
290        let q_field = VolScalarField::new(
291            "q",
292            mesh.clone(),
293            Field::new(
294                self.mesh
295                    .cell_zone
296                    .iter()
297                    .map(|z| self.zone_source[*z])
298                    .collect(),
299            ),
300            mesh.patches
301                .iter()
302                .map(|p| PatchField::zero_gradient(p.size))
303                .collect(),
304        );
305
306        let mut passes = 0;
307        let mut change = f64::INFINITY;
308        let mut linear = SolverPerformance {
309            n_iterations: 0,
310            final_residual: 0.0,
311            converged: true,
312        };
313        while change >= tol {
314            if passes == settings.max_picard_passes {
315                return Err(SteadyConductionError::PicardNotConverged {
316                    last_change_kelvin: change,
317                    iterations: passes,
318                });
319            }
320            passes += 1;
321            for c in 0..n {
322                let zone = self.mesh.cell_zone[c];
323                let kc = conductivity(zone, ThermodynamicTemperature::new::<kelvin>(t[c]))
324                    .get::<watt_per_meter_kelvin>();
325                if !(kc > 0.0) || !kc.is_finite() {
326                    return Err(SteadyConductionError::BadConductivity {
327                        zone,
328                        temperature_kelvin: t[c],
329                        value: kc,
330                    });
331                }
332                k[c] = kc;
333            }
334            let gamma = face_conductivity(&mesh, &k);
335            let t_field = VolScalarField::new(
336                "T",
337                mesh.clone(),
338                Field::new(t.clone()),
339                self.boundary_fields(&mesh, &k),
340            );
341            let eqn = fvm::laplacian(&gamma, &t_field) - fvm::su(&q_field, &t_field);
342            let (solved, perf) = eqn.solve_cg_with_guess("T", &t_field, settings.linear);
343            if !perf.converged {
344                return Err(SteadyConductionError::LinearSolveFailed {
345                    residual: perf.final_residual,
346                    iterations: perf.n_iterations,
347                });
348            }
349            linear = perf;
350            change = solved
351                .internal
352                .iter()
353                .zip(t.iter())
354                .map(|(a, b)| (a - b).abs())
355                .fold(0.0_f64, f64::max);
356            t.copy_from_slice(solved.internal.as_slice());
357        }
358
359        let (balance, face_t, face_weight) = self.energy_balance(&mesh, &k, &t);
360        Ok(SteadyConductionSolution {
361            mesh,
362            cell_zone: self.mesh.cell_zone.clone(),
363            cell_temperature: t,
364            cell_conductivity: k,
365            boundary_face_temperature: face_t,
366            boundary_face_weight: face_weight,
367            energy_balance: balance,
368            picard_passes: passes,
369            last_picard_change: TemperatureInterval::new::<delta_kelvin>(change),
370            linear,
371        })
372    }
373
374    /// The starting temperature: the first fixed or ambient temperature found.
375    fn initial_guess_kelvin(&self) -> f64 {
376        self.patches
377            .iter()
378            .find_map(|p| match p.boundary {
379                ThermalBoundary::FixedTemperature(t) => Some(t.get::<kelvin>()),
380                ThermalBoundary::Convective { ambient, .. } => Some(ambient.get::<kelvin>()),
381                ThermalBoundary::Adiabatic => None,
382            })
383            .unwrap_or(300.0)
384    }
385
386    /// Film coefficient times film-area fraction on one boundary face, W/(m^2 K).
387    fn face_h(&self, pi: usize, local: usize) -> Option<(f64, f64)> {
388        let cond = &self.patches[pi];
389        match cond.boundary {
390            ThermalBoundary::Convective {
391                coefficient,
392                ambient,
393            } => {
394                let frac = cond.film_area_fraction.as_ref().map_or(1.0, |f| f[local]);
395                Some((
396                    coefficient.get::<watt_per_square_meter_kelvin>() * frac,
397                    ambient.get::<kelvin>(),
398                ))
399            }
400            _ => None,
401        }
402    }
403
404    /// The temperature field's boundary conditions for the current `k`.
405    fn boundary_fields(&self, mesh: &FvMesh, k: &[f64]) -> Vec<PatchField<f64>> {
406        mesh.patches
407            .iter()
408            .enumerate()
409            .map(|(pi, patch)| {
410                let bc = match self.patches[pi].boundary {
411                    ThermalBoundary::Adiabatic => BoundaryCondition::ZeroGradient,
412                    ThermalBoundary::FixedTemperature(tf) => {
413                        BoundaryCondition::FixedValue(tf.get::<kelvin>())
414                    }
415                    ThermalBoundary::Convective { .. } => {
416                        let mut w = Vec::with_capacity(patch.size);
417                        let mut t_ref = Vec::with_capacity(patch.size);
418                        for local in 0..patch.size {
419                            let gf = patch.start + local;
420                            let d = DeltaCoeff::Orthogonal.boundary_delta(mesh, gf);
421                            let (h, ta) = self.face_h(pi, local).unwrap_or((0.0, 0.0));
422                            // externalWallHeatFluxTemperature: hp/(hp + k deltaCoeffs).
423                            w.push(h * d / (h * d + k[mesh.owner[gf]]));
424                            t_ref.push(ta);
425                        }
426                        BoundaryCondition::MixedField {
427                            value_fraction: Field::new(w),
428                            ref_value: Field::new(t_ref),
429                            ref_grad: Field::zeros(patch.size),
430                        }
431                    }
432                };
433                PatchField {
434                    bc,
435                    values: Field::zeros(patch.size),
436                }
437            })
438            .collect()
439    }
440
441    /// Generated and removed heat, the boundary-face temperatures, and the
442    /// true-area weight of each boundary face (area times film fraction).
443    fn energy_balance(
444        &self,
445        mesh: &FvMesh,
446        k: &[f64],
447        t: &[f64],
448    ) -> (EnergyBalance, Vec<f64>, Vec<f64>) {
449        let generated: f64 = (0..mesh.n_cells)
450            .map(|c| self.zone_source[self.mesh.cell_zone[c]] * mesh.cell_volumes[c])
451            .sum();
452        let n_b = mesh.n_faces - mesh.n_internal_faces;
453        let mut face_t = vec![0.0; n_b];
454        let mut face_w = vec![0.0; n_b];
455        let mut per_patch = Vec::with_capacity(mesh.patches.len());
456        for (pi, patch) in mesh.patches.iter().enumerate() {
457            let mut removed = 0.0;
458            for local in 0..patch.size {
459                let gf = patch.start + local;
460                let c = mesh.owner[gf];
461                let area = mesh.face_areas[gf];
462                let d = DeltaCoeff::Orthogonal.boundary_delta(mesh, gf);
463                let (flux, tf, weight) = match self.patches[pi].boundary {
464                    ThermalBoundary::Adiabatic => (0.0, t[c], area),
465                    ThermalBoundary::FixedTemperature(ts) => {
466                        let ts = ts.get::<kelvin>();
467                        (k[c] * area / d * (t[c] - ts), ts, area)
468                    }
469                    ThermalBoundary::Convective { .. } => {
470                        let (h, ta) = self.face_h(pi, local).unwrap_or((0.0, 0.0));
471                        if h > 0.0 {
472                            let flux = area * (t[c] - ta) / (1.0 / h + d / k[c]);
473                            // Surface temperature from the film: q'' = h (T_s - T_a).
474                            (flux, ta + flux / (h * area), area * h / self.raw_h(pi))
475                        } else {
476                            (0.0, t[c], 0.0)
477                        }
478                    }
479                };
480                removed += flux;
481                face_t[gf - mesh.n_internal_faces] = tf;
482                face_w[gf - mesh.n_internal_faces] = weight;
483            }
484            per_patch.push(Power::new::<watt>(removed));
485        }
486        let removed = per_patch.iter().map(|p| p.get::<watt>()).sum();
487        (
488            EnergyBalance {
489                generated: Power::new::<watt>(generated),
490                removed: Power::new::<watt>(removed),
491                removed_per_patch: per_patch,
492            },
493            face_t,
494            face_w,
495        )
496    }
497
498    /// The patch's convective `h` without film fraction, W/(m^2 K) (1 if not convective).
499    fn raw_h(&self, pi: usize) -> f64 {
500        match self.patches[pi].boundary {
501            ThermalBoundary::Convective { coefficient, .. } => {
502                coefficient.get::<watt_per_square_meter_kelvin>()
503            }
504            _ => 1.0,
505        }
506    }
507}
508
509/// Face conductivity for `fvm::laplacian` so that each internal face carries
510/// the series conductance `A / (d_P/k_P + d_N/k_N)` (OpenFOAM `harmonic`).
511///
512/// `fvm::laplacian` multiplies the face value by `A/|d|`, so the face value is
513/// `|d| / (d_P/k_P + d_N/k_N)`. Boundary faces carry the owner cell's `k`.
514fn face_conductivity(mesh: &Arc<FvMesh>, k: &[f64]) -> SurfaceScalarField {
515    let internal: Vec<f64> = (0..mesh.n_internal_faces)
516        .map(|f| {
517            let o = mesh.owner[f];
518            let nb = mesh.neighbour[f];
519            let d = DeltaCoeff::Orthogonal.interior_delta(mesh, f);
520            let d_p = (mesh.face_centres[f] - mesh.cell_centres[o]).mag();
521            let d_n = (mesh.cell_centres[nb] - mesh.face_centres[f]).mag();
522            d / (d_p / k[o] + d_n / k[nb])
523        })
524        .collect();
525    let boundary = mesh
526        .patches
527        .iter()
528        .map(|p| PatchField {
529            bc: BoundaryCondition::ZeroGradient,
530            values: Field::new((0..p.size).map(|i| k[mesh.owner[p.start + i]]).collect()),
531        })
532        .collect();
533    SurfaceScalarField::new("k", mesh.clone(), Field::new(internal), boundary)
534}
535
536/// The solved steady temperature and its energy balance.
537#[derive(Debug, Clone)]
538pub struct SteadyConductionSolution {
539    mesh: Arc<FvMesh>,
540    cell_zone: Vec<usize>,
541    /// K per cell.
542    cell_temperature: Vec<f64>,
543    /// W/(m K) per cell, as used in the final solve.
544    cell_conductivity: Vec<f64>,
545    /// K per boundary face (global face index minus `n_internal_faces`).
546    boundary_face_temperature: Vec<f64>,
547    /// Area weight of each boundary face for surface means, m^2.
548    boundary_face_weight: Vec<f64>,
549    /// Heat generated against heat removed.
550    pub energy_balance: EnergyBalance,
551    /// Picard passes performed (a constant `k` takes 2).
552    pub picard_passes: usize,
553    /// Largest cell change of the final pass.
554    pub last_picard_change: TemperatureInterval,
555    /// The last linear solve's record.
556    pub linear: SolverPerformance,
557}
558
559impl SteadyConductionSolution {
560    /// Number of cells.
561    #[must_use]
562    pub fn n_cells(&self) -> usize {
563        self.cell_temperature.len()
564    }
565
566    /// Temperature of cell `c` (panics if `c` is out of range, like indexing).
567    #[must_use]
568    pub fn temperature(&self, c: usize) -> ThermodynamicTemperature {
569        ThermodynamicTemperature::new::<kelvin>(self.cell_temperature[c])
570    }
571
572    /// Conductivity of cell `c` as used in the final solve.
573    #[must_use]
574    pub fn conductivity(&self, c: usize) -> ThermalConductivity {
575        ThermalConductivity::new::<watt_per_meter_kelvin>(self.cell_conductivity[c])
576    }
577
578    /// The hottest cell and its temperature (a cell-centre value; the true
579    /// peak of a smooth profile lies slightly above it on a coarse mesh).
580    #[must_use]
581    pub fn peak_temperature(&self) -> (usize, ThermodynamicTemperature) {
582        let (c, t) =
583            self.cell_temperature
584                .iter()
585                .enumerate()
586                .fold((0, f64::NEG_INFINITY), |acc, (c, t)| {
587                    if *t > acc.1 {
588                        (c, *t)
589                    } else {
590                        acc
591                    }
592                });
593        (c, ThermodynamicTemperature::new::<kelvin>(t))
594    }
595
596    /// Volume-weighted mean temperature over the cells of `zones` (all cells
597    /// when `zones` is empty). `None` if no cell matches.
598    #[must_use]
599    pub fn volume_mean_temperature(&self, zones: &[usize]) -> Option<ThermodynamicTemperature> {
600        let (mut sum, mut vol) = (0.0, 0.0);
601        for c in 0..self.n_cells() {
602            if zones.is_empty() || zones.contains(&self.cell_zone[c]) {
603                sum += self.cell_temperature[c] * self.mesh.cell_volumes[c];
604                vol += self.mesh.cell_volumes[c];
605            }
606        }
607        (vol > 0.0).then(|| ThermodynamicTemperature::new::<kelvin>(sum / vol))
608    }
609
610    /// Temperature of boundary face `face` (a global face index).
611    ///
612    /// Fixed: the imposed value. Convective: `T_a + q''/h`, from the face's
613    /// own film. Adiabatic: the owner cell's temperature. `None` for an
614    /// internal face.
615    #[must_use]
616    pub fn boundary_face_temperature(&self, face: usize) -> Option<ThermodynamicTemperature> {
617        face.checked_sub(self.mesh.n_internal_faces)
618            .and_then(|i| self.boundary_face_temperature.get(i))
619            .map(|t| ThermodynamicTemperature::new::<kelvin>(*t))
620    }
621
622    /// Area-weighted mean surface temperature of patch `patch`.
623    ///
624    /// On a convective patch the weight of each face is its area times its
625    /// film-area fraction, i.e. the true wetted area it stands for. `None`
626    /// for an empty patch or one with zero total weight.
627    #[must_use]
628    pub fn surface_mean_temperature(&self, patch: usize) -> Option<ThermodynamicTemperature> {
629        let p = self.mesh.patches.get(patch)?;
630        let (mut sum, mut w) = (0.0, 0.0);
631        for local in 0..p.size {
632            let i = p.start + local - self.mesh.n_internal_faces;
633            sum += self.boundary_face_temperature[i] * self.boundary_face_weight[i];
634            w += self.boundary_face_weight[i];
635        }
636        (w > 0.0).then(|| ThermodynamicTemperature::new::<kelvin>(sum / w))
637    }
638}