use std::sync::Arc;
use uom::si::f64::{
HeatTransfer, Power, TemperatureInterval, ThermalConductivity, ThermodynamicTemperature,
VolumetricPowerDensity,
};
use uom::si::heat_transfer::watt_per_square_meter_kelvin;
use uom::si::power::watt;
use uom::si::temperature_interval::kelvin as delta_kelvin;
use uom::si::thermal_conductivity::watt_per_meter_kelvin;
use uom::si::thermodynamic_temperature::kelvin;
use uom::si::volumetric_power_density::watt_per_cubic_meter;
use super::mesh::ConductionMesh;
use super::SteadyConductionError;
use crate::fields::boundary::bc::{BoundaryCondition, PatchField};
use crate::fields::field::Field;
use crate::fields::surface_field::SurfaceScalarField;
use crate::fields::vol_field::VolScalarField;
use crate::fv_operators::fvm;
use crate::fv_operators::fvm::DeltaCoeff;
use crate::ldu_matrix::fv_matrix::{SolverPerformance, SolverSettings};
use crate::mesh::fv_mesh::FvMesh;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ThermalBoundary {
Adiabatic,
FixedTemperature(ThermodynamicTemperature),
Convective {
coefficient: HeatTransfer,
ambient: ThermodynamicTemperature,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct PatchCondition {
pub boundary: ThermalBoundary,
pub film_area_fraction: Option<Vec<f64>>,
}
impl PatchCondition {
#[must_use]
pub fn uniform(boundary: ThermalBoundary) -> Self {
Self {
boundary,
film_area_fraction: None,
}
}
#[must_use]
pub fn with_film_area_fraction(boundary: ThermalBoundary, fraction: Vec<f64>) -> Self {
Self {
boundary,
film_area_fraction: Some(fraction),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct SteadyConductionSettings {
pub picard_tolerance: TemperatureInterval,
pub max_picard_passes: usize,
pub linear: SolverSettings,
}
impl Default for SteadyConductionSettings {
fn default() -> Self {
Self {
picard_tolerance: TemperatureInterval::new::<delta_kelvin>(1e-6),
max_picard_passes: 200,
linear: SolverSettings {
tolerance: 1e-13,
max_iter: 100_000,
},
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct EnergyBalance {
pub generated: Power,
pub removed: Power,
pub removed_per_patch: Vec<Power>,
}
impl EnergyBalance {
#[must_use]
pub fn imbalance(&self) -> Power {
self.generated - self.removed
}
#[must_use]
pub fn relative_imbalance(&self) -> f64 {
let g = self.generated.get::<watt>().abs();
let d = self.imbalance().get::<watt>().abs();
if g > 0.0 {
d / g
} else {
d
}
}
}
#[derive(Debug, Clone)]
pub struct SteadyConductionProblem {
mesh: ConductionMesh,
zone_source: Vec<f64>,
patches: Vec<PatchCondition>,
}
impl SteadyConductionProblem {
pub fn new(
mesh: ConductionMesh,
zone_heat_source: Vec<VolumetricPowerDensity>,
patches: Vec<PatchCondition>,
) -> Result<Self, SteadyConductionError> {
if zone_heat_source.len() != mesh.n_zones {
return Err(SteadyConductionError::InvalidInput(format!(
"{} zone heat sources for {} zones",
zone_heat_source.len(),
mesh.n_zones
)));
}
if patches.len() != mesh.mesh.patches.len() {
return Err(SteadyConductionError::InvalidInput(format!(
"{} patch conditions for {} mesh patches",
patches.len(),
mesh.mesh.patches.len()
)));
}
let zone_source: Vec<f64> = zone_heat_source
.iter()
.map(|q| q.get::<watt_per_cubic_meter>())
.collect();
if let Some(bad) = zone_source.iter().find(|q| !(**q >= 0.0) || !q.is_finite()) {
return Err(SteadyConductionError::InvalidInput(format!(
"heat sources must be finite and non-negative, got {bad} W/m^3"
)));
}
let mut removes_heat = false;
for (pi, (cond, patch)) in patches.iter().zip(mesh.mesh.patches.iter()).enumerate() {
match cond.boundary {
ThermalBoundary::Adiabatic => {}
ThermalBoundary::FixedTemperature(_) => removes_heat |= patch.size > 0,
ThermalBoundary::Convective { coefficient, .. } => {
let h = coefficient.get::<watt_per_square_meter_kelvin>();
if !(h > 0.0) || !h.is_finite() {
return Err(SteadyConductionError::InvalidInput(format!(
"patch {pi}: h must be positive and finite, got {h} W/(m^2 K)"
)));
}
removes_heat |= patch.size > 0;
}
}
if let Some(frac) = &cond.film_area_fraction {
if frac.len() != patch.size {
return Err(SteadyConductionError::InvalidInput(format!(
"patch {pi}: {} film-area fractions for {} faces",
frac.len(),
patch.size
)));
}
if let Some(f) = frac.iter().find(|f| !(0.0..=1.0).contains(*f)) {
return Err(SteadyConductionError::InvalidInput(format!(
"patch {pi}: film-area fraction {f} is outside [0, 1]"
)));
}
}
}
let heated = mesh
.cell_zone
.iter()
.any(|z| zone_source.get(*z).is_some_and(|q| *q > 0.0));
if heated && !removes_heat {
return Err(SteadyConductionError::InvalidInput(
"heat is generated but no patch removes it: no steady state exists".into(),
));
}
Ok(Self {
mesh,
zone_source,
patches,
})
}
#[must_use]
pub fn mesh(&self) -> &ConductionMesh {
&self.mesh
}
pub fn solve<K>(
&self,
conductivity: K,
settings: &SteadyConductionSettings,
) -> Result<SteadyConductionSolution, SteadyConductionError>
where
K: Fn(usize, ThermodynamicTemperature) -> ThermalConductivity,
{
let mesh = self.mesh.mesh.clone();
let n = mesh.n_cells;
let tol = settings.picard_tolerance.get::<delta_kelvin>();
let mut t = vec![self.initial_guess_kelvin(); n];
let mut k = vec![0.0; n];
let q_field = VolScalarField::new(
"q",
mesh.clone(),
Field::new(
self.mesh
.cell_zone
.iter()
.map(|z| self.zone_source[*z])
.collect(),
),
mesh.patches
.iter()
.map(|p| PatchField::zero_gradient(p.size))
.collect(),
);
let mut passes = 0;
let mut change = f64::INFINITY;
let mut linear = SolverPerformance {
n_iterations: 0,
final_residual: 0.0,
converged: true,
};
while change >= tol {
if passes == settings.max_picard_passes {
return Err(SteadyConductionError::PicardNotConverged {
last_change_kelvin: change,
iterations: passes,
});
}
passes += 1;
for c in 0..n {
let zone = self.mesh.cell_zone[c];
let kc = conductivity(zone, ThermodynamicTemperature::new::<kelvin>(t[c]))
.get::<watt_per_meter_kelvin>();
if !(kc > 0.0) || !kc.is_finite() {
return Err(SteadyConductionError::BadConductivity {
zone,
temperature_kelvin: t[c],
value: kc,
});
}
k[c] = kc;
}
let gamma = face_conductivity(&mesh, &k);
let t_field = VolScalarField::new(
"T",
mesh.clone(),
Field::new(t.clone()),
self.boundary_fields(&mesh, &k),
);
let eqn = fvm::laplacian(&gamma, &t_field) - fvm::su(&q_field, &t_field);
let (solved, perf) = eqn.solve_cg_with_guess("T", &t_field, settings.linear);
if !perf.converged {
return Err(SteadyConductionError::LinearSolveFailed {
residual: perf.final_residual,
iterations: perf.n_iterations,
});
}
linear = perf;
change = solved
.internal
.iter()
.zip(t.iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0_f64, f64::max);
t.copy_from_slice(solved.internal.as_slice());
}
let (balance, face_t, face_weight) = self.energy_balance(&mesh, &k, &t);
Ok(SteadyConductionSolution {
mesh,
cell_zone: self.mesh.cell_zone.clone(),
cell_temperature: t,
cell_conductivity: k,
boundary_face_temperature: face_t,
boundary_face_weight: face_weight,
energy_balance: balance,
picard_passes: passes,
last_picard_change: TemperatureInterval::new::<delta_kelvin>(change),
linear,
})
}
fn initial_guess_kelvin(&self) -> f64 {
self.patches
.iter()
.find_map(|p| match p.boundary {
ThermalBoundary::FixedTemperature(t) => Some(t.get::<kelvin>()),
ThermalBoundary::Convective { ambient, .. } => Some(ambient.get::<kelvin>()),
ThermalBoundary::Adiabatic => None,
})
.unwrap_or(300.0)
}
fn face_h(&self, pi: usize, local: usize) -> Option<(f64, f64)> {
let cond = &self.patches[pi];
match cond.boundary {
ThermalBoundary::Convective {
coefficient,
ambient,
} => {
let frac = cond.film_area_fraction.as_ref().map_or(1.0, |f| f[local]);
Some((
coefficient.get::<watt_per_square_meter_kelvin>() * frac,
ambient.get::<kelvin>(),
))
}
_ => None,
}
}
fn boundary_fields(&self, mesh: &FvMesh, k: &[f64]) -> Vec<PatchField<f64>> {
mesh.patches
.iter()
.enumerate()
.map(|(pi, patch)| {
let bc = match self.patches[pi].boundary {
ThermalBoundary::Adiabatic => BoundaryCondition::ZeroGradient,
ThermalBoundary::FixedTemperature(tf) => {
BoundaryCondition::FixedValue(tf.get::<kelvin>())
}
ThermalBoundary::Convective { .. } => {
let mut w = Vec::with_capacity(patch.size);
let mut t_ref = Vec::with_capacity(patch.size);
for local in 0..patch.size {
let gf = patch.start + local;
let d = DeltaCoeff::Orthogonal.boundary_delta(mesh, gf);
let (h, ta) = self.face_h(pi, local).unwrap_or((0.0, 0.0));
w.push(h * d / (h * d + k[mesh.owner[gf]]));
t_ref.push(ta);
}
BoundaryCondition::MixedField {
value_fraction: Field::new(w),
ref_value: Field::new(t_ref),
ref_grad: Field::zeros(patch.size),
}
}
};
PatchField {
bc,
values: Field::zeros(patch.size),
}
})
.collect()
}
fn energy_balance(
&self,
mesh: &FvMesh,
k: &[f64],
t: &[f64],
) -> (EnergyBalance, Vec<f64>, Vec<f64>) {
let generated: f64 = (0..mesh.n_cells)
.map(|c| self.zone_source[self.mesh.cell_zone[c]] * mesh.cell_volumes[c])
.sum();
let n_b = mesh.n_faces - mesh.n_internal_faces;
let mut face_t = vec![0.0; n_b];
let mut face_w = vec![0.0; n_b];
let mut per_patch = Vec::with_capacity(mesh.patches.len());
for (pi, patch) in mesh.patches.iter().enumerate() {
let mut removed = 0.0;
for local in 0..patch.size {
let gf = patch.start + local;
let c = mesh.owner[gf];
let area = mesh.face_areas[gf];
let d = DeltaCoeff::Orthogonal.boundary_delta(mesh, gf);
let (flux, tf, weight) = match self.patches[pi].boundary {
ThermalBoundary::Adiabatic => (0.0, t[c], area),
ThermalBoundary::FixedTemperature(ts) => {
let ts = ts.get::<kelvin>();
(k[c] * area / d * (t[c] - ts), ts, area)
}
ThermalBoundary::Convective { .. } => {
let (h, ta) = self.face_h(pi, local).unwrap_or((0.0, 0.0));
if h > 0.0 {
let flux = area * (t[c] - ta) / (1.0 / h + d / k[c]);
(flux, ta + flux / (h * area), area * h / self.raw_h(pi))
} else {
(0.0, t[c], 0.0)
}
}
};
removed += flux;
face_t[gf - mesh.n_internal_faces] = tf;
face_w[gf - mesh.n_internal_faces] = weight;
}
per_patch.push(Power::new::<watt>(removed));
}
let removed = per_patch.iter().map(|p| p.get::<watt>()).sum();
(
EnergyBalance {
generated: Power::new::<watt>(generated),
removed: Power::new::<watt>(removed),
removed_per_patch: per_patch,
},
face_t,
face_w,
)
}
fn raw_h(&self, pi: usize) -> f64 {
match self.patches[pi].boundary {
ThermalBoundary::Convective { coefficient, .. } => {
coefficient.get::<watt_per_square_meter_kelvin>()
}
_ => 1.0,
}
}
}
fn face_conductivity(mesh: &Arc<FvMesh>, k: &[f64]) -> SurfaceScalarField {
let internal: Vec<f64> = (0..mesh.n_internal_faces)
.map(|f| {
let o = mesh.owner[f];
let nb = mesh.neighbour[f];
let d = DeltaCoeff::Orthogonal.interior_delta(mesh, f);
let d_p = (mesh.face_centres[f] - mesh.cell_centres[o]).mag();
let d_n = (mesh.cell_centres[nb] - mesh.face_centres[f]).mag();
d / (d_p / k[o] + d_n / k[nb])
})
.collect();
let boundary = mesh
.patches
.iter()
.map(|p| PatchField {
bc: BoundaryCondition::ZeroGradient,
values: Field::new((0..p.size).map(|i| k[mesh.owner[p.start + i]]).collect()),
})
.collect();
SurfaceScalarField::new("k", mesh.clone(), Field::new(internal), boundary)
}
#[derive(Debug, Clone)]
pub struct SteadyConductionSolution {
mesh: Arc<FvMesh>,
cell_zone: Vec<usize>,
cell_temperature: Vec<f64>,
cell_conductivity: Vec<f64>,
boundary_face_temperature: Vec<f64>,
boundary_face_weight: Vec<f64>,
pub energy_balance: EnergyBalance,
pub picard_passes: usize,
pub last_picard_change: TemperatureInterval,
pub linear: SolverPerformance,
}
impl SteadyConductionSolution {
#[must_use]
pub fn n_cells(&self) -> usize {
self.cell_temperature.len()
}
#[must_use]
pub fn temperature(&self, c: usize) -> ThermodynamicTemperature {
ThermodynamicTemperature::new::<kelvin>(self.cell_temperature[c])
}
#[must_use]
pub fn conductivity(&self, c: usize) -> ThermalConductivity {
ThermalConductivity::new::<watt_per_meter_kelvin>(self.cell_conductivity[c])
}
#[must_use]
pub fn peak_temperature(&self) -> (usize, ThermodynamicTemperature) {
let (c, t) =
self.cell_temperature
.iter()
.enumerate()
.fold((0, f64::NEG_INFINITY), |acc, (c, t)| {
if *t > acc.1 {
(c, *t)
} else {
acc
}
});
(c, ThermodynamicTemperature::new::<kelvin>(t))
}
#[must_use]
pub fn volume_mean_temperature(&self, zones: &[usize]) -> Option<ThermodynamicTemperature> {
let (mut sum, mut vol) = (0.0, 0.0);
for c in 0..self.n_cells() {
if zones.is_empty() || zones.contains(&self.cell_zone[c]) {
sum += self.cell_temperature[c] * self.mesh.cell_volumes[c];
vol += self.mesh.cell_volumes[c];
}
}
(vol > 0.0).then(|| ThermodynamicTemperature::new::<kelvin>(sum / vol))
}
#[must_use]
pub fn boundary_face_temperature(&self, face: usize) -> Option<ThermodynamicTemperature> {
face.checked_sub(self.mesh.n_internal_faces)
.and_then(|i| self.boundary_face_temperature.get(i))
.map(|t| ThermodynamicTemperature::new::<kelvin>(*t))
}
#[must_use]
pub fn surface_mean_temperature(&self, patch: usize) -> Option<ThermodynamicTemperature> {
let p = self.mesh.patches.get(patch)?;
let (mut sum, mut w) = (0.0, 0.0);
for local in 0..p.size {
let i = p.start + local - self.mesh.n_internal_faces;
sum += self.boundary_face_temperature[i] * self.boundary_face_weight[i];
w += self.boundary_face_weight[i];
}
(w > 0.0).then(|| ThermodynamicTemperature::new::<kelvin>(sum / w))
}
}