1use 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#[derive(Debug, Clone, Copy, PartialEq)]
58pub enum ThermalBoundary {
59 Adiabatic,
61 FixedTemperature(ThermodynamicTemperature),
63 Convective {
65 coefficient: HeatTransfer,
67 ambient: ThermodynamicTemperature,
69 },
70}
71
72#[derive(Debug, Clone, PartialEq)]
75pub struct PatchCondition {
76 pub boundary: ThermalBoundary,
78 pub film_area_fraction: Option<Vec<f64>>,
85}
86
87impl PatchCondition {
88 #[must_use]
90 pub fn uniform(boundary: ThermalBoundary) -> Self {
91 Self {
92 boundary,
93 film_area_fraction: None,
94 }
95 }
96
97 #[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#[derive(Debug, Clone, Copy)]
110pub struct SteadyConductionSettings {
111 pub picard_tolerance: TemperatureInterval,
113 pub max_picard_passes: usize,
115 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#[derive(Debug, Clone, PartialEq)]
134pub struct EnergyBalance {
135 pub generated: Power,
137 pub removed: Power,
140 pub removed_per_patch: Vec<Power>,
142}
143
144impl EnergyBalance {
145 #[must_use]
147 pub fn imbalance(&self) -> Power {
148 self.generated - self.removed
149 }
150
151 #[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#[derive(Debug, Clone)]
168pub struct SteadyConductionProblem {
169 mesh: ConductionMesh,
170 zone_source: Vec<f64>,
172 patches: Vec<PatchCondition>,
173}
174
175impl SteadyConductionProblem {
176 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 #[must_use]
262 pub fn mesh(&self) -> &ConductionMesh {
263 &self.mesh
264 }
265
266 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 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 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 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 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 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 (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 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
509fn 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#[derive(Debug, Clone)]
538pub struct SteadyConductionSolution {
539 mesh: Arc<FvMesh>,
540 cell_zone: Vec<usize>,
541 cell_temperature: Vec<f64>,
543 cell_conductivity: Vec<f64>,
545 boundary_face_temperature: Vec<f64>,
547 boundary_face_weight: Vec<f64>,
549 pub energy_balance: EnergyBalance,
551 pub picard_passes: usize,
553 pub last_picard_change: TemperatureInterval,
555 pub linear: SolverPerformance,
557}
558
559impl SteadyConductionSolution {
560 #[must_use]
562 pub fn n_cells(&self) -> usize {
563 self.cell_temperature.len()
564 }
565
566 #[must_use]
568 pub fn temperature(&self, c: usize) -> ThermodynamicTemperature {
569 ThermodynamicTemperature::new::<kelvin>(self.cell_temperature[c])
570 }
571
572 #[must_use]
574 pub fn conductivity(&self, c: usize) -> ThermalConductivity {
575 ThermalConductivity::new::<watt_per_meter_kelvin>(self.cell_conductivity[c])
576 }
577
578 #[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 #[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 #[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 #[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}