forked from 1b-agent/fieldcraft-upstream
solver: heat flow through each heat load, and the imbalance (#362)
solve.heat's reply and the results report give heat_flows: each convection, flux and source load's heat into the body, the prescribed temperatures' total and their sum; the results panel lists them for a heat step. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
0e0d8bd114
commit
464a4d50c1
4 files changed
+127
-18
No files matched your search
@@ -303,6 +303,7 @@ fn run_heat(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"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::<Vec<_>>()}},
|
||||
{"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.
|
||||
|
||||
@@ -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<Value> = 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::*;
|
||||
|
||||
+70
-18
@@ -126,6 +126,16 @@ pub struct ElementFlux {
|
||||
pub points: Vec<PointFlux>,
|
||||
}
|
||||
|
||||
/// 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<f64>,
|
||||
/// 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<LoadFlow>,
|
||||
/// 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<(u32, Vec<usize>, 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<usize> = labels.iter().map(|l| mesh.node_of(&format!("load `{name}`"), *l)).collect::<Result<_>>()?;
|
||||
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<(u32, Vec<usize>, 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<usize> = labels.iter().map(|l| mesh.node_of(&format!("load `{name}`"), *l)).collect::<Result<_>>()?;
|
||||
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<Vec<LoadFlow>> {
|
||||
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::<f64>();
|
||||
}
|
||||
}
|
||||
// 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::<f64>();
|
||||
}
|
||||
}
|
||||
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::<f64>();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => 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<Vec<Option<f64>>> {
|
||||
@@ -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::<f64>();
|
||||
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,
|
||||
|
||||
@@ -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<f64>| {
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user