diff --git a/crates/engine/src/cmd/solve.rs b/crates/engine/src/cmd/solve.rs index f871c98..b29fdbf 100644 --- a/crates/engine/src/cmd/solve.rs +++ b/crates/engine/src/cmd/solve.rs @@ -303,6 +303,7 @@ fn run_heat(s: &mut Session, p: &Value) -> Result { "max_temperature": largest(r.nodes.iter().copied().zip(t.iter().copied())), "heat_supplied": r.applied_total, "heat_removed": r.reaction_total, + "heat_flows": crate::results::heat_flows(&r), "warnings": r.warnings, }); s.store_result(StepResult::Implicit(ImplicitResult::Heat(r))); @@ -1477,6 +1478,30 @@ pub(crate) mod tests { } } + /// A heat balance per load: the convection `Air` carries away what the heater's prescribed + /// temperature supplies (#362). + #[test] + fn heat_flows_balance_per_load() { + let mut s = Session::default(); + cantilever(&mut s); + let script = json!([ + {"command": "model.material.edit", "params": {"name": "Mat", "elastic": {"type": "isotropic", "young": 1000, "poisson": 0}, "density": 1, "thermal": {"conductivity": 2}}}, + {"command": "model.boundary_condition.create", "params": {"name": "Heater", "type": "temperature", "target": {"set": "root"}, "value": 100}}, + {"command": "model.set.create", "params": {"name": "skin", "faces": (1..=6).map(|f| json!({"element": 10, "face": f})).collect::>()}}, + {"command": "model.load.create", "params": {"name": "Air", "type": "convection", "set": "skin", "film": 0.5, "sink": 20}}, + {"command": "model.step.create", "params": {"name": "Therm", "procedure": {"type": "heat_transfer"}, "boundary_conditions": ["Heater"], "loads": ["Air"]}}, + ]); + s.run_script(&script).unwrap(); + let r = s.execute("solve.heat", &json!({"step": "Therm"})).unwrap(); + let flows = &r["heat_flows"]; + let air = flows["loads"].as_array().unwrap().iter().find(|l| l["name"] == "Air").unwrap(); + assert_eq!(air["type"], "convection"); + let (out, supplied) = (air["heat"].as_f64().unwrap(), flows["reactions"].as_f64().unwrap()); + assert!(out < 0.0 && supplied > 0.0, "{flows}"); + assert!(flows["imbalance"].as_f64().unwrap().abs() < 1e-6 * supplied, "{flows}"); + assert_eq!(s.results_report()["heat_flows"], *flows); + } + #[test] fn solve_heat_and_probe_temperatures() { // The cantilever bar as a conductor: 0 at the root, 100 at the tip, linear between. diff --git a/crates/engine/src/results.rs b/crates/engine/src/results.rs index 6f8738a..8274a5b 100644 --- a/crates/engine/src/results.rs +++ b/crates/engine/src/results.rs @@ -497,6 +497,7 @@ impl Session { out["frames"] = f.frames.iter().map(|f| f.time).collect(); } Kind::Heat(h) => { + out["heat_flows"] = heat_flows(h); out["frames"] = json!(h.times); if let Ok((t, _)) = heat_frame(h, v.pick()) { out["time"] = json!(t); @@ -519,6 +520,16 @@ impl Session { } } +/// The heat balance of a heat transfer result: each heat load's flow into the body (`loads`: +/// `{name, type, heat}`, negative out of it), the prescribed temperatures' total (`reactions`), +/// and `imbalance`, their sum: about 0 in a steady state, the heat stored per time in a +/// transient one. +pub fn heat_flows(h: &HeatResult) -> Value { + let loads: Vec = h.loads.iter().map(|l| json!({"name": l.name, "type": l.kind, "heat": l.heat})).collect(); + let applied: f64 = h.loads.iter().map(|l| l.heat).sum(); + json!({"loads": loads, "reactions": h.reaction_total, "imbalance": applied + h.reaction_total}) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/solver/src/heat.rs b/crates/solver/src/heat.rs index 5dbdbc1..0558423 100644 --- a/crates/solver/src/heat.rs +++ b/crates/solver/src/heat.rs @@ -126,6 +126,16 @@ pub struct ElementFlux { pub points: Vec, } +/// The heat flow through one heat load. +#[derive(Debug, Clone, PartialEq)] +pub struct LoadFlow { + pub name: String, + /// `surface_flux`, `convection` or `heat_source`. + pub kind: &'static str, + /// Heat per time into the body (negative: out of it). + pub heat: f64, +} + /// The result of a heat transfer step. #[derive(Debug, Clone, PartialEq)] pub struct HeatResult { @@ -143,6 +153,9 @@ pub struct HeatResult { /// The heat each prescribed temperature supplies to the body at the end of the step /// (negative where it takes heat away; zero at free nodes). pub reaction: Vec, + /// The heat each heat load carries into the body at the end of the step, in the step's + /// order (negative where it takes heat away, as a convection to a colder sink does). + pub loads: Vec, /// Heat supplied by fluxes, convection and sources (at the end temperatures), and the total /// reaction heat flow; in a steady state they cancel. pub applied_total: f64, @@ -189,24 +202,7 @@ fn assemble(model: &Model, resolved: &Resolved, mesh: &HeatMesh, graph: &NodeGra }; for name in &step.loads { let load = model.load(name).ok_or_else(|| Error::Invalid(format!("load `{name}` is missing")))?; - let faces = || -> Result, Vec<[f64; 3]>)>> { - let set = load.set().unwrap_or_default(); - resolved - .sets - .get(set) - .map(|s| s.faces.as_slice()) - .unwrap_or_default() - .iter() - .map(|fc| { - let labels = model - .face_nodes(*fc) - .ok_or_else(|| Error::Invalid(format!("load `{name}`: element {} face {} is missing", fc.element, fc.face)))?; - let idx: Vec = labels.iter().map(|l| mesh.node_of(&format!("load `{name}`"), *l)).collect::>()?; - let xyz = idx.iter().filter_map(|i| mesh.xyz.get(*i).copied()).collect(); - Ok((fc.element, idx, xyz)) - }) - .collect() - }; + let faces = || load_faces(model, resolved, mesh, name, load); match load { Load::SurfaceFlux { flux, .. } => { for (label, idx, xyz) in faces()? { @@ -236,6 +232,60 @@ fn assemble(model: &Model, resolved: &Resolved, mesh: &HeatMesh, graph: &NodeGra Ok(HeatSystem { k, c, f }) } +/// The element faces of heat load `name`'s set: each face's element, node indices and +/// coordinates. +fn load_faces(model: &Model, resolved: &Resolved, mesh: &HeatMesh, name: &str, load: &Load) -> Result, Vec<[f64; 3]>)>> { + let set = load.set().unwrap_or_default(); + resolved + .sets + .get(set) + .map(|s| s.faces.as_slice()) + .unwrap_or_default() + .iter() + .map(|fc| { + let labels = + model.face_nodes(*fc).ok_or_else(|| Error::Invalid(format!("load `{name}`: element {} face {} is missing", fc.element, fc.face)))?; + let idx: Vec = labels.iter().map(|l| mesh.node_of(&format!("load `{name}`"), *l)).collect::>()?; + let xyz = idx.iter().filter_map(|i| mesh.xyz.get(*i).copied()).collect(); + Ok((fc.element, idx, xyz)) + }) + .collect() +} + +/// The heat each of the step's heat loads carries into the body at temperatures `t`: a flux +/// ∫q dA, a source ∫Q dV, a convection h ∫(T∞ − T) dA (negative where it carries heat away). +fn load_flows(model: &Model, resolved: &Resolved, mesh: &HeatMesh, step: &Step, t: &[f64]) -> Result> { + let mut out = Vec::new(); + for name in &step.loads { + let Some(load) = model.load(name).filter(|l| l.is_thermal()) else { continue }; + let mut heat = 0.0; + match load { + Load::SurfaceFlux { flux, .. } => { + for (label, _, xyz) in load_faces(model, resolved, mesh, name, load)? { + heat += flux * face_weights(&xyz).map_err(|e| Error::element(label, e))?.iter().sum::(); + } + } + // h Σ w_j (T∞ − T_j): the film matrix's columns sum to h w_j (Σ N = 1). + Load::Convection { film, sink, .. } => { + for (label, idx, xyz) in load_faces(model, resolved, mesh, name, load)? { + let w = face_weights(&xyz).map_err(|e| Error::element(label, e))?; + heat += film * w.iter().zip(&idx).map(|(w, i)| w * (sink - t.get(*i).copied().unwrap_or(*sink))).sum::(); + } + } + Load::HeatSource { set, power } => { + for label in resolved.sets.get(set).map(|s| s.elements.as_slice()).unwrap_or_default() { + if let Some(e) = mesh.element(*label) { + heat += e.kernel()?.source(*power).map_err(|err| Error::element(e.label, err))?.iter().sum::(); + } + } + } + _ => continue, + } + out.push(LoadFlow { name: name.clone(), kind: load.kind(), heat }); + } + Ok(out) +} + /// The prescribed temperature of every node (`None` = free) under the step's boundary /// conditions. fn prescribed(model: &Model, resolved: &Resolved, mesh: &HeatMesh, step: &Step) -> Result>> { @@ -445,6 +495,7 @@ pub fn solve_heat(model: &Model, step: Option<&str>, limits: &Limits) -> Result< let applied_total: f64 = kt.iter().zip(&s.f).map(|(a, b)| b - a).sum(); let reaction_total: f64 = reaction.iter().sum(); let (flux, elements) = fluxes(&s.mesh, t)?; + let loads = load_flows(model, &resolved, &s.mesh, step, t)?; // Measured against the gross heat flows, which cancel in the net sums. let scale = reaction.iter().chain(&s.f).map(|v| v.abs()).sum::(); if s.c.is_none() && scale > 0.0 && (applied_total + reaction_total).abs() > 1e-6 * scale { @@ -459,6 +510,7 @@ pub fn solve_heat(model: &Model, step: Option<&str>, limits: &Limits) -> Result< flux, elements, reaction, + loads, applied_total, reaction_total, stats, diff --git a/crates/ui-egui/src/results_panel.rs b/crates/ui-egui/src/results_panel.rs index bc6d2ce..4fa574b 100644 --- a/crates/ui-egui/src/results_panel.rs +++ b/crates/ui-egui/src/results_panel.rs @@ -212,6 +212,9 @@ pub fn controls(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens) } } }); + } else if let Kind::Heat(h) = plot.result.kind() { + ui.separator(); + heat_flows(ui, &fieldcraft_engine::results::heat_flows(h), t); } else { ui.separator(); if ui.button("Reaction sums").on_hover_text("Reaction force and moment per boundary-condition set (results.reactions)").clicked() { @@ -287,6 +290,24 @@ fn explicit(ui: &mut egui::Ui, e: &ExplicitResult, plot: &Plot, t: &Tokens, run: }); } +/// A heat step's balance: the heat each heat load carries in (negative: out), the prescribed +/// temperatures' total, and what is left over (about 0 when steady). +fn heat_flows(ui: &mut egui::Ui, flows: &Value, t: &Tokens) { + ui.label(RichText::new("Heat flow into the body").color(t.text_dim)); + egui::Grid::new("fc_results_heat").num_columns(2).spacing(vec2(8.0, 2.0)).show(ui, |ui| { + let row = |ui: &mut egui::Ui, name: &str, v: Option| { + ui.label(name); + ui.label(v.map_or("—".into(), number)); + ui.end_row(); + }; + for l in flows["loads"].as_array().into_iter().flatten() { + row(ui, &format!("{} ({})", l["name"].as_str().unwrap_or("?"), l["type"].as_str().unwrap_or("").replace('_', " ")), l["heat"].as_f64()); + } + row(ui, "Prescribed temperatures", flows["reactions"].as_f64()); + row(ui, "Imbalance", flows["imbalance"].as_f64()); + }); +} + fn reactions_table(ui: &mut egui::Ui, r: &Value, t: &Tokens) { let row = |ui: &mut egui::Ui, name: &str, v: &Value| { let f = |k: usize| v.pointer(&format!("/force/{k}")).and_then(Value::as_f64).map_or("—".into(), number);