From 1e5ff08c5f93ab44914accd013fadbbddaee67f6 Mon Sep 17 00:00:00 2001 From: Christian Svensson Date: Sat, 17 Oct 2026 17:29:23 +0200 Subject: [PATCH] engine: show temperature and wall heat in fluid results Route saved temperature through slices, obstacle surfaces, streamlines and both result views, with availability and model units shared by commands and the UI. Show signed obstacle heat histories per complete face area at the selected frame, leaving unrecorded times uncoloured and retaining the saved slice settings. Part of #157. --- ROADMAP.md | 7 ++ crates/engine/src/catalogue.rs | 2 +- crates/engine/src/cmd/forms.rs | 20 +++- crates/engine/src/cmd/results.rs | 115 ++++++++++++++++++-- crates/engine/src/flow.rs | 111 ++++++++++++++++++-- crates/engine/src/flow/wall.rs | 156 ++++++++++++++++++++++++++++ crates/engine/src/results.rs | 38 ++++++- crates/engine/src/view.rs | 3 +- crates/ui-egui/src/lib.rs | 3 +- crates/ui-egui/src/results.rs | 72 +++++++++++-- crates/ui-egui/src/results_panel.rs | 22 +++- docs/control-protocol.md | 25 +++++ docs/features.md | 2 +- 13 files changed, 533 insertions(+), 43 deletions(-) create mode 100644 crates/engine/src/flow/wall.rs diff --git a/ROADMAP.md b/ROADMAP.md index a55a005..f9b3f8a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,3 +123,10 @@ What exists and what is missing, by area; each gap is in a milestone above. | Fluid dynamics | LBM D3Q19 TRT, walls, inlets/outlets, obstacle forces | Turbulence with wall models, thermal and conjugate heat transfer, non-Newtonian, porous media, multiphase, GPU (M12, M13) | | Wave propagation | One-dimensional elastic waves and impact in the explicit solver (bar cases in the oracle) | A 2D/3D finite-difference ultrasound solver (velocity–stress FDTD, EFIT, k-space pseudospectral) on voxelised geometry, time–frequency diagrams (spectrogram, wavelet, Wigner–Ville) with dispersion overlays, spectral elements, absorbing boundaries, tone-burst and transducer sources, attenuation, Lamb/Rayleigh/Love guided waves and SAFE dispersion curves, ultrasound in fluids and tissue, nonlinear acoustics, A/B-scans and frequency–wavenumber analysis (M23) | | Design | — | Parametric studies on SolidCraft parameters, optimisation, submodelling (M22) | + +### Heated flow results (#157) + +Temperature is available on fluid slices, obstacle surfaces and streamline colours when the +saved frames carry it. Signed face-average wall heat follows recorded histories and the +model's power/area units. Commands, the Results panel and headless pictures share the view; +initial frames without recorded wall heat remain uncoloured. diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs index 5779a88..be02ca2 100644 --- a/crates/engine/src/catalogue.rs +++ b/crates/engine/src/catalogue.rs @@ -696,7 +696,7 @@ pub static CATALOGUE: &[Capability] = &[ "Streamlines", Results, "Plots", - "A fluid step's streamlines coloured by speed, its velocity or pressure on a slice, and the obstacles coloured by the flow", + "A fluid step's velocity, pressure or temperature on slices and obstacles, speed or temperature along streamlines, and signed wall heat per area", &["results.slice", "results.surface"], ), ready("results.export.vtu", "Export VTU", Results, "Files", "Write a solved step as VTK unstructured grids for ParaView"), diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index c6b62e8..49f3449 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -2081,6 +2081,8 @@ pub const CONTOUR: &[Field] = &[ ("displacement_z", "Displacement Z"), ("velocity", "Velocity magnitude"), ("pressure", "Pressure (fluid)"), + ("temperature", "Temperature"), + ("wall_heat_flux", "Wall heat flux (into wall)"), ("max_principal", "Max principal"), ("mid_principal", "Mid principal"), ("min_principal", "Min principal"), @@ -2122,7 +2124,7 @@ pub const FRAME: &[Field] = &[ const FLUID_FRAME: Field = int("frame", "Frame", 0, 1_000_000_000).help("The frame shown, from 0.").hint("the frame shown"); pub const SLICE: &[Field] = &[ SOLVED, - choice("field", "Field", &[("velocity", "Velocity magnitude"), ("pressure", "Pressure")]) + choice("field", "Field", &[("velocity", "Velocity magnitude"), ("pressure", "Pressure"), ("temperature", "Temperature (heated flow)")]) .help("The field coloured on the slice and the obstacles.") .hint("the field shown"), choice("axis", "Normal", &[("x", "X"), ("y", "Y"), ("z", "Z"), ("none", "None (hide)")]) @@ -2133,9 +2135,23 @@ pub const SLICE: &[Field] = &[ .hint("the position shown"), FLUID_FRAME, ]; -pub const FLOW_SURFACE: &[Field] = &[SOLVED, flag("show", "Show").help("Colour the obstacles by the field in the fluid beside them.").def("true")]; +pub const FLOW_SURFACE: &[Field] = &[ + SOLVED, + flag("show", "Show").help("Colour the obstacles by the selected field.").def("true"), + choice( + "field", + "Field", + &[("velocity", "Velocity"), ("pressure", "Pressure"), ("temperature", "Temperature"), ("wall_heat_flux", "Wall heat flux (into wall)")], + ) + .help("Colour the obstacle faces by the adjacent fluid field, or signed wall heat received per area.") + .hint("the field shown"), + FLUID_FRAME, +]; pub const STREAMLINES: &[Field] = &[ SOLVED, + choice("field", "Field", &[("velocity", "Velocity"), ("pressure", "Pressure (plain lines)"), ("temperature", "Temperature")]) + .help("Colour streamlines by speed or temperature; pressure leaves the lines in one colour.") + .hint("the field shown"), flag("show", "Show").help("Draw the streamlines.").def("true"), int("count", "Count", 1, 2000).help("Seeds on the inlet face, or points along the seed line.").def("64"), int("max_steps", "Max steps", 1, 100_000).help("Most steps of half a cell each streamline takes.").def("4000"), diff --git a/crates/engine/src/cmd/results.rs b/crates/engine/src/cmd/results.rs index f5c36d0..bfd385a 100644 --- a/crates/engine/src/cmd/results.rs +++ b/crates/engine/src/cmd/results.rs @@ -24,7 +24,7 @@ pub const DEFORMED: CommandSpec = CommandSpec { pub const CONTOUR: CommandSpec = CommandSpec { id: "results.contour", label: "Contours", - params: r#"{"step": "Load", "field": "von_mises", "bands": 12, "range"?: {"min"?: 0, "max"?: 80} | "auto"} — contours of a nodal field: displacement, displacement_x/y/z, velocity (explicit and fluid steps), pressure (fluid steps), von_mises, max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz (stresses for static steps), contact_pressure, contact_gap, contact_status (0 open, 1 closed or stick, 2 slip), contact_shear, contact_slip (friction; nonlinear static steps with contact, at the contacts' secondary surfaces); "none" turns them off; 2 to 24 bands; `range` clamps the legend (a bound left out, null or "auto" follows the field; values above `max` are drawn in the over colour), kept until the field changes"#, + params: r#"{"step": "Load", "field": "von_mises", "bands": 12, "range"?: {"min"?: 0, "max"?: 80} | "auto"} — contours of a nodal field: displacement, displacement_x/y/z, velocity (explicit and fluid steps), pressure (fluid steps), temperature (thermal frames), wall_heat_flux (signed face average on thermal fluid obstacles), von_mises, max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz (stresses for static steps), contact_pressure, contact_gap, contact_status (0 open, 1 closed or stick, 2 slip), contact_shear, contact_slip (friction; nonlinear static steps with contact, at the contacts' secondary surfaces); "none" turns them off; 2 to 24 bands; `range` clamps the legend (a bound left out, null or "auto" follows the field; values above `max` are drawn in the over colour), kept until the field changes"#, form: Some(super::forms::CONTOUR), kind: CommandKind::Session, requires: &[Requirement::Result], @@ -94,7 +94,7 @@ pub const EXPORT_CSV: CommandSpec = CommandSpec { pub const SLICE: CommandSpec = CommandSpec { id: "results.slice", label: "Flow Slice", - params: r#"{"step": "Flow", "field": "velocity" | "pressure", "axis": "x" | "y" | "z" | "none", "position": 20 | "auto", "frame": 10} — shows a fluid step's field (velocity magnitude or gauge pressure, banded with the legend as results.contour sets it) on a slice through the domain normal to `axis`, at `position` along it (inside the domain; "auto" or left out with a new axis: the domain's middle); "none" hides the slice; `frame` from 0 (left out: the one shown, at first the last); what is left out stays as shown. A fluid step is first shown with velocity on a slice through the middle, normal to the horizontal axis across the flow"#, + params: r#"{"step": "Flow", "field": "velocity" | "pressure" | "temperature", "axis": "x" | "y" | "z" | "none", "position": 20 | "auto", "frame": 10} — shows a fluid step's field (velocity magnitude, gauge pressure or temperature when its frames carry heat, banded with the legend as results.contour sets it) on a slice through the domain normal to `axis`, at `position` along it (inside the domain; "auto" or left out with a new axis: the domain's middle); "none" hides the slice; `frame` from 0 (left out: the one shown, at first the last); what is left out stays as shown. A fluid step is first shown with velocity on a slice through the middle, normal to the horizontal axis across the flow"#, form: Some(super::forms::SLICE), kind: CommandKind::Session, requires: &[Requirement::Result], @@ -104,7 +104,7 @@ pub const SLICE: CommandSpec = CommandSpec { pub const SURFACE: CommandSpec = CommandSpec { id: "results.surface", label: "Flow on Surfaces", - params: r#"{"step": "Flow", "show": true} — colours a fluid step's obstacles (the part drawn) by the field shown, read in the fluid cells beside them; `show: false` draws the part in its own colours"#, + params: r#"{"step": "Flow", "show": true, "field"?: "velocity" | "pressure" | "temperature" | "wall_heat_flux", "frame"?: 10} — colours a fluid step's obstacles (the part drawn) by the field shown, read in the fluid cells beside them; `wall_heat_flux` is signed power received by each obstacle face divided by its complete tessellated area, in model power/area units (positive into the wall); histories interpolate at the shown frame without extrapolation; recorded adiabatic faces are zero, missing histories remain uncoloured; no slice is drawn for this surface-only field. `show: false` draws the part in its own colours"#, form: Some(super::forms::FLOW_SURFACE), kind: CommandKind::Session, requires: &[Requirement::Result], @@ -114,7 +114,7 @@ pub const SURFACE: CommandSpec = CommandSpec { pub const STREAMLINES: CommandSpec = CommandSpec { id: "results.streamlines", label: "Streamlines", - params: r#"{"step": "Flow", "show": true, "count": 64, "max_steps": 4000, "seeds": [[x, y, z], …], "line": {"from": [x, y, z], "to": [x, y, z]}, "frame": 10} — streamlines of a fluid step, traced downstream through its velocity field (trilinear between the cells, midpoint rule, half-cell steps) and coloured by speed when the legend shows velocity: from `seeds` (at most 2000 points), from `count` points evenly along `line`, or else from `count` seeds (1 to 2000, default 64) on the inlet face upstream of the obstacles; each at most `max_steps` steps (1 to 100000, default 4000), 2 000 000 points in all; `show: false` hides them; `frame` as for results.slice. Replies with the view and `traced`: the streamlines, their points, the seeds and how many started none (outside the fluid)"#, + params: r#"{"step": "Flow", "show": true, "field"?: "velocity" | "pressure" | "temperature", "count": 64, "max_steps": 4000, "seeds": [[x, y, z], …], "line": {"from": [x, y, z], "to": [x, y, z]}, "frame": 10} — streamlines of a fluid step, traced downstream through its velocity field (trilinear between the cells, midpoint rule, half-cell steps) and coloured by speed or temperature when that field is selected (plain for pressure): from `seeds` (at most 2000 points), from `count` points evenly along `line`, or else from `count` seeds (1 to 2000, default 64) on the inlet face upstream of the obstacles; each at most `max_steps` steps (1 to 100000, default 4000), 2 000 000 points in all; `show: false` hides them; `frame` as for results.slice. Replies with the view and `traced`: the streamlines, their points, the seeds and how many started none (outside the fluid)"#, form: Some(super::forms::STREAMLINES), kind: CommandKind::Session, requires: &[Requirement::Result], @@ -249,12 +249,27 @@ fn contour(s: &mut Session, p: &Value) -> Result { if let (Some(f), Some((r, _))) = (field, s.latest_result(&step)) && !Field::available(r).any(|a| a == f) { + if f == Field::Temperature && matches!(r.kind(), Kind::Fluid(_)) { + return Err(EngineError::Params(format!( + "temperature is not available for fluid step `{step}`: its frames have no complete temperature field (isothermal flow)" + ))); + } + if f == Field::WallHeatFlux { + return Err(EngineError::Params(format!("wall heat flux is not available for step `{step}`: it requires thermal obstacle histories"))); + } return Err(EngineError::Params(format!("{} is not available for step `{step}` (mode shapes have no stress scale)", f.label()))); } let range = match p.get("range") { None => None, Some(r) => Some(legend_range(r)?), }; + if field == Some(Field::WallHeatFlux) + && let Some((r, _)) = s.latest_result(&step) + && let Kind::Fluid(f) = r.kind() + { + let pick = s.results_view.as_ref().filter(|v| v.step == step).map(|v| v.pick()).unwrap_or_default(); + crate::flow::wall::faces(s, f, pick)?; + } let v = view_of(s, &step)?; if v.contour != field { (v.range_min, v.range_max) = (None, None); @@ -403,12 +418,7 @@ fn seed_point(v: &Value) -> Option<[f64; 3]> { fn slice(s: &mut Session, p: &Value) -> Result { let (step, r) = fluid_step(s, p)?; let Kind::Fluid(f) = r.kind() else { return Err(EngineError::Params(format!("step `{step}` is not a fluid step"))) }; - let field = match opt::(p, "field")?.as_deref() { - None => None, - Some("velocity") => Some(Field::Velocity), - Some("pressure") => Some(Field::Pressure), - Some(_) => return Err(EngineError::invalid("field", "must be \"velocity\" or \"pressure\"")), - }; + let field = fluid_field(p, &r)?; // Some(None): hide the slice. let axis = match opt::(p, "axis")?.as_deref() { None => None, @@ -463,7 +473,27 @@ fn surface(s: &mut Session, p: &Value) -> Result { let (step, r) = fluid_step(s, p)?; let Kind::Fluid(f) = r.kind() else { return Err(EngineError::Params(format!("step `{step}` is not a fluid step"))) }; let show = opt::(p, "show")?.unwrap_or(true); + let field = if opt::(p, "field")?.as_deref() == Some("wall_heat_flux") { + if !Field::available(&r).any(|f| f == Field::WallHeatFlux) { + return Err(EngineError::invalid("field", "wall heat flux is unavailable: this step has no thermal obstacle histories")); + } + Some(Field::WallHeatFlux) + } else { + fluid_field(p, &r)? + }; + let frame = fluid_frame(f, p)?; + if field == Some(Field::WallHeatFlux) { + let mut pick = s.results_view.as_ref().filter(|v| v.step == step).map(|v| v.pick()).unwrap_or_default(); + if frame.is_some() { + pick.frame = frame; + } + crate::flow::wall::faces(s, f, pick)?; + } let v = view_of(s, &step)?; + set_fluid_field(v, field); + if frame.is_some() { + v.frame = frame; + } let mut flow = FlowView::of(v, f).into_owned(); flow.surface = show; v.flow = Some(flow); @@ -474,6 +504,7 @@ fn streamlines(s: &mut Session, p: &Value) -> Result { let (step, r) = fluid_step(s, p)?; let Kind::Fluid(f) = r.kind() else { return Err(EngineError::Params(format!("step `{step}` is not a fluid step"))) }; let show = opt::(p, "show")?.unwrap_or(true); + let field = fluid_field(p, &r)?; let count = count_param(p, "count", MAX_STREAMLINES)?; let max_steps = count_param(p, "max_steps", MAX_STREAMLINE_STEPS)?; let max = MAX_STREAMLINES as usize; @@ -502,6 +533,7 @@ fn streamlines(s: &mut Session, p: &Value) -> Result { } let frame = fluid_frame(f, p)?; let v = view_of(s, &step)?; + set_fluid_field(v, field); let mut flow = FlowView::of(v, f).into_owned(); flow.streamlines = if show { let mut sl = flow.streamlines.take().unwrap_or_default(); @@ -537,6 +569,32 @@ fn streamlines(s: &mut Session, p: &Value) -> Result { Ok(out) } +fn fluid_field(p: &Value, r: &crate::StepResult) -> Result> { + let field = match opt::(p, "field")?.as_deref() { + None => return Ok(None), + Some("velocity") => Field::Velocity, + Some("pressure") => Field::Pressure, + Some("temperature") => Field::Temperature, + Some(_) => return Err(EngineError::invalid("field", "must be velocity, pressure or temperature")), + }; + if !Field::available(r).any(|f| f == field) { + return Err(EngineError::invalid( + "field", + "temperature is unavailable: this isothermal step has no complete temperature field in its frames", + )); + } + Ok(Some(field)) +} + +fn set_fluid_field(view: &mut ResultsView, field: Option) { + if let Some(field) = field { + if view.contour != Some(field) { + (view.range_min, view.range_max) = (None, None); + } + view.contour = Some(field); + } +} + fn clear(s: &mut Session, _: &Value) -> Result { s.results_view = None; Ok(json!({"view": null})) @@ -1436,6 +1494,43 @@ mod tests { } } + #[test] + fn temperature_views_require_thermal_frames_and_follow_the_selected_frame() { + let mut r = crate::flow::tests::channel(); + r.frames[0].temperature = vec![20.0; r.grid.len()]; + r.frames[1].temperature = vec![70.0; r.grid.len()]; + let mut s = Session::default(); + s.store_result(crate::StepResult::Fluid(r)); + for command in ["results.slice", "results.surface", "results.streamlines", "results.contour"] { + let report = s.execute(command, &json!({"field": "temperature"})).unwrap(); + assert_eq!(report["contour"]["field"], "temperature", "{command}: {report}"); + assert_eq!(report["contour"]["range"]["max"]["value"], 70.0); + } + let report = s.execute("results.frame", &json!({"frame": 0})).unwrap(); + assert_eq!(report["contour"]["range"]["max"]["value"], 20.0); + assert!(Field::available(s.latest_result("Flow").unwrap().0).any(|f| f == Field::Temperature)); + let before = s.results_view.clone(); + for command in ["results.slice", "results.surface", "results.streamlines"] { + for params in [json!({"field": "temperature", "frame": u64::MAX}), json!({"field": ["temperature"]})] { + assert!(s.execute(command, ¶ms).is_err()); + assert_eq!(s.results_view, before); + } + } + let mut incomplete = crate::flow::tests::channel(); + incomplete.frames[0].temperature = vec![20.0; incomplete.grid.len()]; + incomplete.frames[1].temperature = vec![70.0; incomplete.grid.len().saturating_sub(1)]; + let incomplete = crate::StepResult::Fluid(incomplete); + assert!(!Field::available(&incomplete).any(|f| f == Field::Temperature)); + let mut cold = fluid(); + let before = cold.results_view.clone(); + for command in ["results.slice", "results.surface", "results.streamlines", "results.contour"] { + let error = cold.execute(command, &json!({"field": "temperature"})).unwrap_err().to_string(); + assert!(error.contains("isothermal"), "{command}: {error}"); + assert_eq!(cold.results_view, before); + } + assert!(!Field::available(cold.latest_result("Flow").unwrap().0).any(|f| f == Field::Temperature)); + } + #[test] fn fluid_display_hostile_params_never_panic() { let s = fluid(); diff --git a/crates/engine/src/flow.rs b/crates/engine/src/flow.rs index 3ee554f..3c177e6 100644 --- a/crates/engine/src/flow.rs +++ b/crates/engine/src/flow.rs @@ -1,5 +1,5 @@ //! The display of a fluid step's result: its fields per cell drawn as a coloured slice through the -//! domain, on the obstacles' surfaces beside the fluid, and as streamlines coloured by speed +//! domain, on the obstacles' surfaces beside the fluid, and as streamlines coloured by speed or temperature //! ([`fieldcraft_render::flow`]), as the results view's [`FlowView`] asks. The headless pictures //! ([`crate::view`]) and the desktop viewport draw the same [`FlowPlot`]; the part stays drawn //! (a fluid step does not deform it). @@ -21,6 +21,8 @@ use serde_json::{Value, json}; use crate::results::{Field, Kind, Pick, ResultsView, magnitude}; use crate::{EngineError, Result, StepResult}; +pub mod wall; + /// Streamlines traced when no count is given, and the steps (half a cell each) each may take. pub const DEFAULT_STREAMLINES: u32 = 64; pub const DEFAULT_STREAMLINE_STEPS: u32 = 4_000; @@ -90,7 +92,7 @@ impl Default for StreamlineView { } /// What a fluid step's display shows besides the field's legend (the view's contour field: -/// velocity or pressure, `None` draws no colours). +/// velocity, pressure, temperature or wall heat flux; `None` draws no colours). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct FlowView { /// The slice, `None` for none. @@ -157,14 +159,29 @@ pub fn direction(r: &FluidResult, f: &Frame) -> (Axis, bool) { (axis, sum[axis.index()] >= 0.0) } -/// A cell field's value in every cell of a frame: velocity magnitude or pressure, NaN in the +/// A cell field's value in every cell of a frame: velocity magnitude, pressure or temperature, NaN in the /// solid cells. pub fn cell_values(r: &FluidResult, field: Field, f: &Frame) -> Result> { let solid = |k: usize| r.solid.get(k).copied().unwrap_or(false); let values: Vec = match field { Field::Velocity => f.velocity.iter().map(|v| magnitude(v.map(f64::from))).collect(), Field::Pressure => f.pressure.iter().map(|p| f64::from(*p)).collect(), - _ => return Err(EngineError::Params(format!("{} is not available for fluid step `{}` (velocity or pressure)", field.label(), r.step))), + Field::Temperature if f.temperature.len() == r.grid.len() && !f.temperature.is_empty() => { + f.temperature.iter().map(|t| f64::from(*t)).collect() + } + Field::Temperature => { + return Err(EngineError::Params(format!( + "temperature is not available for fluid step `{}`: this frame has no complete temperature field (isothermal flow)", + r.step + ))); + } + _ => { + return Err(EngineError::Params(format!( + "{} is not available for fluid step `{}` (velocity, pressure or temperature)", + field.label(), + r.step + ))); + } }; Ok(values.into_iter().enumerate().map(|(k, v)| if solid(k) || !v.is_finite() { f64::NAN } else { v }).collect()) } @@ -286,6 +303,10 @@ pub struct FlowPlot { /// The bands streamlines are coloured by speed over: the legend's when it shows velocity; /// `None` draws them in one colour (the legend shows another field). pub speed: Option, + /// Face-averaged heat values paired with the displayed geometry triangles. + pub wall_values: Vec<([Vec3; 3], Option)>, + /// A point on each recorded wall face for the legend's extreme markers. + pub wall_positions: Vec>, } impl FlowPlot { @@ -301,7 +322,7 @@ pub fn plot(view: &ResultsView, result: &Arc, traced: &TraceCache) - let k = frame_index(r, view.pick())?; let f = frame(r, view.pick())?; let fv = FlowView::of(view, r).into_owned(); - let contour = match view.contour.filter(|c| matches!(c, Field::Velocity | Field::Pressure)) { + let contour = match view.contour.filter(|c| matches!(c, Field::Velocity | Field::Pressure | Field::Temperature)) { Some(field) => { let values = cell_values(r, field, f)?; cell_range(&values).map(|(min, max)| CellContour { field, bands: view.bands(min.1, max.1), min, max, values }) @@ -321,7 +342,34 @@ pub fn plot(view: &ResultsView, result: &Arc, traced: &TraceCache) - None => Arc::default(), }; let speed = contour.as_ref().filter(|c| c.field == Field::Velocity).map(|c| c.bands); - Ok(FlowPlot { grid: g, contour, slice, surface: fv.surface, streamlines, speed, view: fv }) + Ok(FlowPlot { grid: g, contour, slice, surface: fv.surface, streamlines, speed, view: fv, wall_values: Vec::new(), wall_positions: Vec::new() }) +} + +/// Add the obstacle face mapping shared by the desktop and headless views. +pub fn plot_with_surfaces( + view: &ResultsView, + result: &Arc, + traced: &TraceCache, + session: &crate::Session, + base: &fieldcraft_render::DisplayMesh, + bodies: &[&str], +) -> Result { + let mut out = plot(view, result, traced)?; + if view.contour == Some(Field::WallHeatFlux) { + let Kind::Fluid(r) = result.kind() else { return Ok(out) }; + let faces = wall::faces(session, r, view.pick())?; + let positions: std::collections::BTreeMap<_, _> = base + .surface_tris + .iter() + .filter_map(|t| bodies.get(t.body as usize).map(|name| ((*name, t.face), (t.p[0] + t.p[1] + t.p[2]) * (1.0 / 3.0)))) + .collect(); + out.wall_positions = faces.iter().map(|f| positions.get(&(f.body.as_str(), f.index)).copied()).collect(); + let values: Vec = faces.iter().map(|f| f.flux.unwrap_or(f64::NAN)).collect(); + out.contour = + cell_range(&values).map(|(min, max)| CellContour { field: Field::WallHeatFlux, bands: view.bands(min.1, max.1), min, max, values }); + out.wall_values = base.surface_tris.iter().zip(wall::triangle_values(&faces, base, bodies)).map(|(t, v)| (t.p, v)).collect(); + } + Ok(out) } /// The colours a flow plot is drawn in. @@ -330,7 +378,7 @@ pub struct FlowColors<'a> { /// The contour scale, low to high, and values above a clamped legend's maximum. pub stops: &'a [Rgb], pub over: Rgb, - /// Streamlines when the legend shows another field than velocity. + /// Streamlines when the legend shows neither velocity nor temperature. pub plain: Rgb, } @@ -339,7 +387,19 @@ pub struct FlowColors<'a> { pub fn draw(plot: &FlowPlot, scene: &mut Scene, colors: &FlowColors) { if let Some(c) = &plot.contour { let color = |band: Option| band.map_or(colors.plain, |k| bands_color(colors.stops, colors.over, &c.bands, k)); - if plot.surface { + if plot.surface && c.field == Field::WallHeatFlux { + // Only tagged geometry triangles can receive wall heat. A structural mesh or + // hidden geometry must not accidentally inherit a face's value by index. + if scene.tris.len() == plot.wall_values.len() { + for (tri, (points, value)) in scene.tris.iter_mut().zip(&plot.wall_values) { + if tri.p == *points + && let Some(v) = value + { + tri.color = color(c.bands.band(*v)); + } + } + } + } else if plot.surface { let model = std::mem::take(&mut scene.tris); for t in model { let v = t.p.map(|p| flow::sample(&plot.grid, &c.values, p)); @@ -362,7 +422,14 @@ pub fn draw(plot: &FlowPlot, scene: &mut Scene, colors: &FlowColors) { for (k, w) in l.points.windows(2).enumerate() { let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue }; let speed = 0.5 * (l.speed.get(k).copied().unwrap_or(0.0) + l.speed.get(k + 1).copied().unwrap_or(0.0)); - let color = plot.speed.as_ref().and_then(|b| b.band(speed).map(|k| bands_color(colors.stops, colors.over, b, k))).unwrap_or(colors.plain); + let temperature = plot.contour.as_ref().filter(|c| c.field == Field::Temperature); + let color = if let Some(c) = temperature { + let value = flow::sample(&plot.grid, &c.values, (a + b) * 0.5); + c.bands.band(value).map(|k| bands_color(colors.stops, colors.over, &c.bands, k)) + } else { + plot.speed.as_ref().and_then(|b| b.band(speed).map(|k| bands_color(colors.stops, colors.over, b, k))) + } + .unwrap_or(colors.plain); scene.lines.push(SceneLine { a, b, color, width: STREAMLINE_WIDTH }); } } @@ -386,7 +453,7 @@ pub fn report(view: &ResultsView, r: &FluidResult, units: fieldcraft_model::Unit } } let fv = FlowView::of(view, r); - out["slice"] = match &fv.slice { + out["slice"] = match fv.slice.as_ref().filter(|_| view.contour != Some(Field::WallHeatFlux)) { Some(s) => json!({"axis": s.axis, "position": slice_position(r, s)}), None => Value::Null, }; @@ -496,6 +563,30 @@ pub(crate) mod tests { assert!(s.iter().all(|p| p.y == 3.5), "{s:?}"); } + #[test] + fn temperature_colours_cells_and_streamlines_and_masks_solids() { + let mut r = channel(); + for f in &mut r.frames { + f.temperature = (0..r.grid.len()).map(|k| 20.0 + 2.0 * r.grid.coords(k)[0] as f32).collect(); + } + let values = cell_values(&r, Field::Temperature, &r.frames[1]).unwrap(); + assert_eq!(values.iter().filter(|v| v.is_nan()).count(), 8); + assert_eq!(cell_range(&values).map(|(a, b)| (a.1, b.1)), Some((20.0, 38.0))); + let result = Arc::new(crate::StepResult::Fluid(r)); + let mut view = ResultsView::for_result(&result); + view.contour = Some(Field::Temperature); + let p = plot(&view, &result, &TraceCache::default()).unwrap(); + let mut scene = Scene::default(); + let colors = FlowColors { stops: &[Rgb(0, 0, 255), Rgb(255, 0, 0)], over: Rgb(255, 255, 255), plain: Rgb(7, 7, 7) }; + draw(&p, &mut scene, &colors); + assert!(!scene.tris.is_empty()); + assert!(!scene.lines.is_empty()); + assert!(scene.lines.iter().all(|l| l.color != colors.plain)); + assert!(scene.lines.windows(2).any(|l| l[0].color != l[1].color)); + let cold = channel(); + assert!(cell_values(&cold, Field::Temperature, &cold.frames[1]).unwrap_err().to_string().contains("isothermal")); + } + #[test] fn cell_fields_leave_the_solid_out() { let r = channel(); diff --git a/crates/engine/src/flow/wall.rs b/crates/engine/src/flow/wall.rs new file mode 100644 index 0000000..011e624 --- /dev/null +++ b/crates/engine/src/flow/wall.rs @@ -0,0 +1,156 @@ +//! Face-averaged heat received by obstacle walls, in the model's consistent units. +//! The history is interpolated only between recorded samples: frame zero predates it. + +use std::collections::BTreeMap; + +use fieldcraft_lbm::FluidResult; +use fieldcraft_render::{DisplayMesh, Vec3}; +use serde::Serialize; + +use crate::results::Pick; +use crate::{EngineError, Result, Session}; + +#[derive(Clone, Debug, Serialize)] +pub struct FaceHeat { + pub body: String, + pub face: String, + pub area: f64, + /// Positive into the wall. None before the first recorded history sample. + pub power: Option, + pub flux: Option, + #[serde(skip)] + pub index: u32, +} + +fn interpolate(times: &[f64], values: &[f64], time: f64) -> Option { + if !time.is_finite() + || times.len() != values.len() + || times.is_empty() + || times.iter().chain(values).any(|v| !v.is_finite()) + || times.windows(2).any(|p| p[0] >= p[1]) + { + return None; + } + let k = times.partition_point(|t| *t < time); + if times.get(k) == Some(&time) { + return values.get(k).copied(); + } + let lo = k.checked_sub(1)?; + let (a, b) = (*times.get(lo)?, *times.get(k)?); + let weight = (time - a) / (b - a); + let value = (1.0 - weight) * values.get(lo)? + weight * values.get(k)?; + value.is_finite().then_some(value) +} + +/// All recorded obstacle faces, using their complete tessellated geometric area (not the +/// visible triangle area or a lattice-link count). Missing geometry is an error, never zero. +pub fn faces(s: &Session, r: &FluidResult, pick: Pick) -> Result> { + if s.latest_result(&r.step).is_some_and(|(_, current)| !current) { + return Err(EngineError::Params("wall heat requires current geometry: solve the edited model again before dividing by face area".into())); + } + let time = super::frame(r, pick)?.time; + let mut areas = BTreeMap::new(); + let mut output = Vec::new(); + for h in r.heat.iter().filter(|h| h.body.is_some()) { + let Some(name) = h.body.as_deref() else { continue }; + let body = s + .design + .iter() + .flat_map(|d| &d.bodies) + .find(|b| b.name == name) + .ok_or_else(|| EngineError::Params(format!("wall heat: obstacle `{name}` is no longer in the design")))?; + let index = body + .face_index(&h.name) + .ok_or_else(|| EngineError::Params(format!("wall heat: face `{}` of `{name}` is no longer in the design", h.name)))?; + if let std::collections::btree_map::Entry::Vacant(entry) = areas.entry(name.to_owned()) { + let mesh = body.tessellate((body.size()? * 0.001).max(1e-4))?; + let mut by_face = BTreeMap::::new(); + for (tri, face) in mesh.triangles.iter().zip(&mesh.tri_face) { + let point = |k: u32| mesh.positions.get(k as usize).copied().map(Vec3::from); + let (Some(a), Some(b), Some(c)) = (point(tri[0]), point(tri[1]), point(tri[2])) else { continue }; + *by_face.entry(*face).or_default() += 0.5 * (b - a).cross(c - a).len(); + } + entry.insert(by_face); + } + let area = areas + .get(name) + .and_then(|a| a.get(&index)) + .copied() + .filter(|a| a.is_finite() && *a > 0.0) + .ok_or_else(|| EngineError::Params(format!("wall heat: face `{}` of `{name}` has no finite positive area", h.name)))?; + let power = interpolate(&r.history_times, &h.power, time); + let flux = power.map(|q| q / area).filter(|q| q.is_finite()); + output.push(FaceHeat { body: name.into(), face: h.name.clone(), index, area, power, flux }); + } + Ok(output) +} + +/// One optional value per displayed surface triangle. Imported/unrecorded faces remain plain. +pub fn triangle_values(faces: &[FaceHeat], base: &DisplayMesh, bodies: &[&str]) -> Vec> { + let by_face: BTreeMap<_, _> = faces.iter().map(|f| ((f.body.as_str(), f.index), f.flux)).collect(); + base.surface_tris + .iter() + .map(|t| { + let body = bodies.get(t.body as usize)?; + by_face.get(&(*body, t.face)).copied().flatten() + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn wall_heat_uses_face_identity_area_sign_and_frame_in_both_views() { + let mut session = crate::flow::tests::plate_in_a_tank(); + let crate::StepResult::Fluid(mut r) = session.latest_result("Flow").unwrap().0.as_ref().clone() else { panic!() }; + let names: Vec = session.design.as_ref().unwrap().bodies[0].faces().iter().map(|f| f.name.clone()).collect(); + let end = r.frames.last().unwrap().time; + r.history_times = vec![end * 0.5, end]; + r.heat = names + .iter() + .enumerate() + .map(|(k, name)| fieldcraft_lbm::HeatHistory { + name: name.clone(), + body: Some("Body1".into()), + power: vec![0.0, if k == 0 { -120.0 } else { 0.0 }], + }) + .collect(); + for f in &mut r.frames { + f.temperature = vec![20.0; r.grid.len()]; + } + let face_values = faces(&session, &r, Pick::default()).unwrap(); + assert!(face_values.iter().all(|f| f.area > 0.0)); + assert!(face_values.iter().any(|f| (f.area - 400.0).abs() < 1e-8), "40 by 10 mm outer face"); + assert!(face_values.iter().any(|f| (f.area - 300.0).abs() < 1e-8), "30 by 10 mm outer face"); + assert!(face_values[0].flux.unwrap() < 0.0); + assert!((face_values.iter().map(|f| f.flux.unwrap() * f.area).sum::() + 120.0).abs() < 1e-10); + assert!(face_values[1..].iter().all(|f| f.flux == Some(0.0)), "adiabatic faces are zero"); + session.store_result(crate::StepResult::Fluid(r)); + let report = session.execute("results.surface", &json!({"field": "wall_heat_flux"})).unwrap(); + assert_eq!(report["wall_heat"]["positive"], "into wall"); + assert_eq!(report["wall_heat"]["faces"][0]["power"], -120.0); + assert!(report["slice"].is_null(), "wall heat is a surface-only field"); + session.execute("results.streamlines", &json!({"show": false})).unwrap(); + let (scene, _, shown) = crate::view::scene(&session, &crate::view::Palette::LIGHT); + assert!(shown.contour.as_ref().is_some_and(|c| c.0 == "wall_heat_flux")); + assert!(scene.tris.windows(2).any(|t| t[0].color != t[1].color)); + let report = session.execute("results.frame", &json!({"frame": 0})).unwrap(); + assert!(report["wall_heat"]["faces"].as_array().unwrap().iter().all(|f| f["flux"].is_null())); + assert!(session.execute("results.slice", &json!({"field": "wall_heat_flux"})).is_err()); + assert!(session.execute("results.streamlines", &json!({"field": "wall_heat_flux"})).is_err()); + } + + #[test] + fn histories_interpolate_without_fabricating_initial_heat() { + assert_eq!(interpolate(&[1.0, 3.0], &[-4.0, 8.0], 2.0), Some(2.0)); + assert_eq!(interpolate(&[1.0, 3.0], &[-4.0, 8.0], 1.0), Some(-4.0)); + assert_eq!(interpolate(&[1.0, 3.0], &[-4.0, 8.0], 0.0), None); + assert_eq!(interpolate(&[1.0, 3.0], &[-4.0, 8.0], 4.0), None); + assert_eq!(interpolate(&[1.0, 1.0], &[0.0, 1.0], 1.0), None); + assert_eq!(interpolate(&[1.0], &[f64::NAN], 1.0), None); + assert_eq!(interpolate(&[1.0], &[], 1.0), None); + } +} diff --git a/crates/engine/src/results.rs b/crates/engine/src/results.rs index 68e885a..f7d7349 100644 --- a/crates/engine/src/results.rs +++ b/crates/engine/src/results.rs @@ -91,6 +91,8 @@ pub enum Field { Temperature, /// Heat flux magnitude (heat transfer steps, at the end of the step). HeatFlux, + /// Signed fluid heat received by an obstacle face per geometric area. + WallHeatFlux, /// Contact pressure (nonlinear static steps with contact: 0 off the contacts' secondary /// surfaces). ContactPressure, @@ -106,7 +108,7 @@ pub enum Field { } impl Field { - pub const ALL: [Field; 23] = [ + pub const ALL: [Field; 24] = [ Field::Displacement, Field::DisplacementX, Field::DisplacementY, @@ -125,6 +127,7 @@ impl Field { Field::StressYz, Field::Temperature, Field::HeatFlux, + Field::WallHeatFlux, Field::ContactPressure, Field::ContactGap, Field::ContactStatus, @@ -153,6 +156,7 @@ impl Field { Field::StressYz => "stress_yz", Field::Temperature => "temperature", Field::HeatFlux => "heat_flux", + Field::WallHeatFlux => "wall_heat_flux", Field::ContactPressure => "contact_pressure", Field::ContactGap => "contact_gap", Field::ContactStatus => "contact_status", @@ -181,6 +185,7 @@ impl Field { Field::StressYz => "Stress YZ", Field::Temperature => "Temperature", Field::HeatFlux => "Heat flux magnitude", + Field::WallHeatFlux => "Wall heat flux (into wall)", Field::ContactPressure => "Contact pressure", Field::ContactGap => "Contact gap", Field::ContactStatus => "Contact status (0 open, 1 closed or stick, 2 slip)", @@ -196,7 +201,7 @@ impl Field { Field::Velocity => Quantity::Velocity, Field::Pressure => Quantity::Stress, Field::Temperature => Quantity::Temperature, - Field::HeatFlux => Quantity::HeatFlux, + Field::HeatFlux | Field::WallHeatFlux => Quantity::HeatFlux, Field::ContactGap | Field::ContactStatus => Quantity::None, Field::ContactPressure | Field::ContactShear @@ -249,6 +254,7 @@ impl Field { | Field::Pressure | Field::Temperature | Field::HeatFlux + | Field::WallHeatFlux ) && !self.is_contact() } @@ -273,7 +279,16 @@ impl Field { e.frames.last().map_or((false, false, false, false), |f| (!f.stress.is_empty(), !f.velocity.is_empty(), false, false)) } // Cell fields, drawn by `crate::flow`. - Kind::Fluid(_) => return vec![Field::Velocity, Field::Pressure].into_iter(), + Kind::Fluid(f) => { + let mut fields = vec![Field::Velocity, Field::Pressure]; + if !f.frames.is_empty() && f.frames.iter().all(|frame| !frame.temperature.is_empty() && frame.temperature.len() == f.grid.len()) { + fields.push(Field::Temperature); + if f.heat.iter().any(|h| h.body.is_some()) { + fields.push(Field::WallHeatFlux); + } + } + return fields.into_iter(); + } Kind::Heat(_) => return Field::ALL.into_iter().filter(|f| f.is_thermal()).collect::>().into_iter(), }; Field::ALL @@ -281,6 +296,7 @@ impl Field { .filter(move |f| match f { Field::Velocity => velocity, Field::Pressure => false, + Field::WallHeatFlux => false, Field::Temperature => temperature, Field::HeatFlux => flux, f if f.is_contact() => contact, @@ -536,7 +552,7 @@ pub fn field_values(model: &Model, r: &StepResult, field: Field, pick: Pick) -> Kind::Static(_) | Kind::Frequency(_) | Kind::Fluid(_) | Kind::Heat(_) => Err(unavailable()), }, // Per cell (crate::flow::cell_values), not per node. - Field::Pressure => Err(unavailable()), + Field::Pressure | Field::WallHeatFlux => Err(unavailable()), Field::Temperature => match r.kind() { Kind::Heat(h) => Ok(heat_frame(h, pick)?.1.to_vec()), Kind::Static(StaticResult { temperature: Some(t), .. }) => Ok(t.clone()), @@ -706,6 +722,18 @@ impl Session { out[k.as_str()] = x; } } + if v.contour == Some(Field::WallHeatFlux) + && let Ok(faces) = crate::flow::wall::faces(self, f, v.pick()) + { + let values: Vec = faces.iter().map(|f| f.flux.unwrap_or(f64::NAN)).collect(); + let end = |(k, value): (usize, f64)| json!({"body": faces.get(k).map(|f| &f.body), "face": faces.get(k).map(|f| &f.face), "value": value}); + let range = crate::flow::cell_range(&values).map(|(a, b)| json!({"min": end(a), "max": end(b)})); + out["contour"] = json!({"field": Field::WallHeatFlux, "label": Field::WallHeatFlux.label(), "unit": Quantity::HeatFlux.unit(self.model.units()), "range": range}); + if v.range_min.is_some() || v.range_max.is_some() { + out["contour"]["legend"] = json!({"min": v.range_min, "max": v.range_max}); + } + out["wall_heat"] = json!({"faces": faces, "positive": "into wall", "history": "linear interpolation between recorded samples; no extrapolation", "unrecorded": "uncoloured"}); + } } Kind::Heat(h) => { out["heat_flows"] = heat_flows(h); @@ -773,7 +801,7 @@ mod tests { } let (modes, _) = s.latest_result("Modes").unwrap(); assert_eq!(Field::available(modes).count(), 4); - assert_eq!(Field::available(bend).count(), Field::ALL.len() - 9, "all but velocity, pressure, the thermal and the contact fields"); + assert_eq!(Field::available(bend).count(), Field::ALL.len() - 10, "all but velocity, pressure, the thermal and the contact fields"); assert!(field_values(&s.model, modes, Field::VonMises, Pick { mode: 1, frame: None }).is_err()); assert!(field_values(&s.model, modes, Field::Displacement, Pick { mode: 9, frame: None }).is_err()); assert_eq!(field_values(&s.model, modes, Field::Displacement, Pick { mode: 2, frame: None }).unwrap().len(), modes.nodes().len()); diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index f9fb254..32cfd0a 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -511,7 +511,8 @@ fn picture(s: &Session, p: &Palette, section: Option
, toward: Vec3) -> // The part as it is, with the flow drawn through and around it. let mut scene = model_scene(&base, p); let mut bounds = base.bounds; - if let Ok(fp) = crate::flow::plot(view, result, &s.traced) { + let names: Vec<&str> = bodies.iter().map(String::as_str).collect(); + if let Ok(fp) = crate::flow::plot_with_surfaces(view, result, &s.traced, s, &base, &names) { if let Some(c) = &fp.contour { let cell = |(k, v): (usize, f64)| (u32::try_from(k).unwrap_or(u32::MAX), v); shown.contour = Some((c.field.id().to_string(), cell(c.min), cell(c.max))); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8441a7a..5e04adc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1130,7 +1130,8 @@ impl ModelApp { let still = key.as_ref().map(|k| PlotKey { frame: None, ..k.clone() }); if self.prepared.0 != still { let pick = key.as_ref().map(|k| k.pick).unwrap_or_default(); - self.prepared = (still, results::prepare(&self.session, &self.display.1, pick).unwrap_or_default()); + let bodies: Vec<&str> = self.pick.names.iter().map(|n| n.name.as_str()).collect(); + self.prepared = (still, results::prepare(&self.session, &self.display.1, pick, &bodies).unwrap_or_default()); } let frame = key.as_ref().and_then(|k| k.frame); let p = key.as_ref().and_then(|_| results::plot(&self.session, &self.display.1, frame, &self.prepared.1)); diff --git a/crates/ui-egui/src/results.rs b/crates/ui-egui/src/results.rs index d359641..59967b5 100644 --- a/crates/ui-egui/src/results.rs +++ b/crates/ui-egui/src/results.rs @@ -114,6 +114,9 @@ impl Plot { /// Where a contour extreme is: its node's position, or a fluid step's cell centre. pub fn extreme_position(&self, label: u32) -> Option { + if self.view.contour == Some(Field::WallHeatFlux) { + return self.flow.as_ref()?.wall_positions.get(label as usize).copied().flatten(); + } match self.result.kind() { Kind::Fluid(f) => Some(Vec3::from(fieldcraft_engine::flow::cell_centre(f, usize::try_from(label).ok()?))), _ => self.node_position(label), @@ -175,10 +178,12 @@ pub fn scale(view: &ResultsView, result: &StepResult, pick: Pick, base: &Display /// The part of the session's results plot that depends on what is drawn (`pick`), not on the /// animation's deformation multiplier: the deformation scale and the contour values. -pub fn prepare(session: &Session, base: &DisplayMesh, pick: Pick) -> Option { +pub fn prepare(session: &Session, base: &DisplayMesh, pick: Pick, bodies: &[&str]) -> Option { let (view, result, _) = shown(session)?; if matches!(result.kind(), Kind::Fluid(_)) { - let flow = fieldcraft_engine::flow::plot(view, result, &session.traced).ok(); + let mut view = view.clone(); + view.frame = pick.frame; + let flow = fieldcraft_engine::flow::plot_with_surfaces(&view, result, &session.traced, session, base, bodies).ok(); let cell = |(k, v): (usize, f64)| (u32::try_from(k).unwrap_or(u32::MAX), v); let contour = flow .as_ref() @@ -336,7 +341,8 @@ pub(crate) mod tests { fn plot_at(s: &Session, base: &DisplayMesh, frame: Option) -> Option { let (view, result, _) = shown(s)?; - plot(s, base, frame, &prepare(s, base, pick(view, result, frame))?) + let bodies: Vec<&str> = s.design.iter().flat_map(|d| &d.bodies).map(|b| b.name.as_str()).collect(); + plot(s, base, frame, &prepare(s, base, pick(view, result, frame), &bodies)?) } /// The sample cantilever, solved (static step `Load`, frequency step `Modes`). @@ -412,22 +418,39 @@ pub(crate) mod tests { } /// A 40 × 30 × 10 mm plate in a tank of 5 mm cells, a liquid flowing past it along x, solved. - fn plate_in_a_tank() -> Session { + fn plate_in_a_tank(thermal: bool) -> Session { use fieldcraft_geometry::sample::{Plate, plate_design}; - let dir = std::env::temp_dir().join(format!("fieldcraft-ui-flow-{}", std::process::id())); + let dir = std::env::temp_dir().join(format!("fieldcraft-ui-flow-{}-{thermal}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("plate.solidcraft"); std::fs::write(&path, plate_design(&Plate::default()).unwrap()).unwrap(); let mut s = Session::default(); s.execute("geometry.open", &json!({"path": path.to_str().unwrap()})).unwrap(); let _ = std::fs::remove_dir_all(&dir); - s.run_script(&json!([ + let mut script = json!([ {"command": "model.material.create", "params": {"name": "Water", "density": 1e-9, "fluid": {"type": "newtonian", "viscosity": 1e-7}}}, {"command": "model.fluid_domain.create", "params": {"name": "Tank", "material": "Water", "min": [-20, -20, -20], "max": [60, 50, 30], "spacing": 5, "obstacles": ["Body1"], "faces": {"x_min": {"type": "velocity", "velocity": [10, 0, 0]}, "x_max": {"type": "pressure", "pressure": 0}}}}, {"command": "model.step.create", "params": {"name": "Flow", "procedure": {"type": "fluid", "domain": "Tank", "steps": 60, "frames": 2}}}, - ])) - .unwrap(); + ]); + if thermal { + script[0]["params"]["thermal"] = json!({"conductivity": 1.0, "specific_heat": 1e8}); + let faces: Vec<_> = s.design.as_ref().unwrap().bodies[0] + .faces() + .iter() + .map(|f| { + json!({ + "face": {"body": "Body1", "face": f.name}, "condition": {"type": "temperature", "value": 60.0} + }) + }) + .collect(); + script[1]["params"]["thermal"] = + json!({"initial_temperature": 20.0, "surfaces": faces, "faces": {"x_min": {"type": "temperature", "value": 20.0}}}); + } + s.run_script(&script).unwrap(); + if thermal { + s.execute("model.units", &json!({"system": "mm-t-s-N-MPa"})).unwrap(); + } s.execute("solve.fluid", &json!({})).unwrap(); s } @@ -436,7 +459,7 @@ pub(crate) mod tests { /// slice and on the part, with its legend, and streamlines (#193). #[test] fn a_fluid_step_keeps_the_part_and_draws_the_flow() { - let mut s = plate_in_a_tank(); + let mut s = plate_in_a_tank(false); let (base, _) = fieldcraft_engine::view::display(&s); assert!(!base.surface_tris.is_empty()); let p = plot_at(&s, &base, None).unwrap(); @@ -457,6 +480,37 @@ pub(crate) mod tests { assert_eq!((p.contour.as_ref().map(|c| c.field), p.pick.frame), (Some(Field::Pressure), Some(0))); } + #[test] + fn heated_flow_fields_have_units_colours_and_face_markers() { + let mut s = plate_in_a_tank(true); + let (base, _) = fieldcraft_engine::view::display(&s); + s.execute("results.slice", &json!({"field": "temperature"})).unwrap(); + let p = plot_at(&s, &base, None).unwrap(); + assert_eq!(p.contour.as_ref().unwrap().field, Field::Temperature); + assert!(Field::Temperature.title(&p.result, p.units).contains("°C or K")); + assert!(Field::available(&p.result).any(|f| f == Field::Temperature)); + assert!(p.contour.as_ref().unwrap().max.1 > 20.0); + let last = p.frame().unwrap().2 - 1; + s.execute("results.frame", &json!({"frame": 0})).unwrap(); + assert_eq!(plot_at(&s, &base, None).unwrap().contour.as_ref().unwrap().max.1, 20.0, "the frame selector changes the temperature shown"); + s.execute("results.frame", &json!({"frame": last})).unwrap(); + s.execute("results.surface", &json!({"field": "wall_heat_flux"})).unwrap(); + let p = plot_at(&s, &base, None).unwrap(); + let c = p.contour.as_ref().unwrap(); + assert_eq!(c.field, Field::WallHeatFlux); + assert_eq!(c.field.unit(&p.result, p.units).as_deref(), Some("mW/mm²")); + assert!(c.min.1 < 0.0, "hot wall gives heat to fluid"); + assert!(p.extreme_position(c.min.0).is_some()); + assert!(p.flow.as_ref().unwrap().slice.is_none()); + for t in [Tokens::light(), Tokens::dark()] { + let scene = scene(&p, Style::ShadedEdges, crate::scene::Show::DEFAULT, &t); + assert_eq!(scene.tris.len(), base.surface_tris.len()); + assert!(scene.tris.windows(2).any(|t| t[0].color != t[1].color)); + } + s.execute("results.frame", &json!({"frame": 0})).unwrap(); + assert!(plot_at(&s, &base, None).unwrap().contour.is_none()); + } + #[test] fn modes_swing_and_static_steps_grow_when_animated() { let mut s = solved_cantilever(); diff --git a/crates/ui-egui/src/results_panel.rs b/crates/ui-egui/src/results_panel.rs index 16270cc..9a725c2 100644 --- a/crates/ui-egui/src/results_panel.rs +++ b/crates/ui-egui/src/results_panel.rs @@ -41,7 +41,8 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) { probe(app, &plot, &painter, rect, t); let mut focus = None; for (i, (line, node)) in extremes.into_iter().enumerate() { - let r = ui.interact(line, ui.id().with(("fc_legend_extreme", i)), egui::Sense::click()).on_hover_text(format!("Show and probe node {node}")); + let tip = if plot.is_fluid() { "Centre this field extreme".into() } else { format!("Show and probe node {node}") }; + let r = ui.interact(line, ui.id().with(("fc_legend_extreme", i)), egui::Sense::click()).on_hover_text(tip); if r.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } @@ -86,6 +87,7 @@ fn title(plot: &Plot) -> String { match (&plot.contour, plot.mode()) { (Some(l), _) => l.field.title(&plot.result, plot.units), (None, Some(_)) => "Mode shape (normalised)".into(), + (None, None) if plot.is_fluid() => plot.view.contour.map_or_else(|| "Flow".into(), |f| f.title(&plot.result, plot.units)), (None, None) => "Deformed shape".into(), } } @@ -474,9 +476,23 @@ fn deformation_rows(plot: &Plot, ui: &mut egui::Ui, run: &mut Vec<(&'static str, fn flow_rows(plot: &Plot, ui: &mut egui::Ui, run: &mut Vec<(&'static str, Value)>) { use fieldcraft_engine::flow::{Axis, slice_position}; let (Kind::Fluid(f), Some(flow)) = (plot.result.kind(), plot.flow.as_ref()) else { return }; + if plot.view.contour == Some(Field::WallHeatFlux) { + ui.label("Wall heat"); + ui.label("Positive into wall; face average"); + ui.end_row(); + if plot.contour.is_none() { + ui.label("History"); + ui.label("No wall heat recorded at this frame"); + ui.end_row(); + } + } let view = &flow.view; ui.label("Slice"); ui.horizontal(|ui| { + if plot.view.contour == Some(Field::WallHeatFlux) { + ui.label("Hidden for wall heat"); + return; + } let shown = view.slice.as_ref().map_or("None", |s| s.axis.id()); egui::ComboBox::from_id_salt("fc_results_slice").selected_text(format!("Normal {}", shown.to_uppercase())).width(90.0).show_ui(ui, |ui| { if ui.selectable_label(view.slice.is_none(), "None").clicked() { @@ -504,14 +520,14 @@ fn flow_rows(plot: &Plot, ui: &mut egui::Ui, run: &mut Vec<(&'static str, Value) ui.end_row(); ui.label("Obstacles"); let mut surface = view.surface; - if ui.checkbox(&mut surface, "Coloured").on_hover_text("Colour the part by the field in the fluid beside it").changed() { + if ui.checkbox(&mut surface, "Coloured").on_hover_text("Colour the part by the selected fluid field or wall heat").changed() { run.push(("results.surface", json!({"show": surface}))); } ui.end_row(); ui.label("Streamlines"); ui.horizontal(|ui| { let mut on = view.streamlines.is_some(); - if ui.checkbox(&mut on, "").on_hover_text("Streamlines from the inlet, coloured by speed when the legend shows velocity").changed() { + if ui.checkbox(&mut on, "").on_hover_text("Streamlines from the inlet, coloured by speed or temperature when selected").changed() { run.push(("results.streamlines", json!({"show": on}))); } if let Some(sl) = &view.streamlines { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index fff3170..62b6029 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -139,3 +139,28 @@ factorization's supernodes, the tet mesher's refinement and improvement passes, attempts) and stops with `cancelled`, leaving the model unchanged. One job runs at a time; another long command meanwhile is refused, naming the job. The MCP server's `solve` and `mesh` tools wait for the reply. + +## Heated flow results + +A solved fluid step with temperature in every saved frame offers `temperature` in the +Results field list and through `results.contour`, `results.slice`, `results.surface` and +`results.streamlines` (`{"field":"temperature"}`). Slices and obstacle surfaces sample +fluid-cell temperature; streamline colours sample temperature along the velocity traces. +The legend and existing `solve.probe` use the model's temperature scale, without conversion. +An isothermal step hides the field and rejects an explicit request with an explanation. +The frame slider and `results.frame` select the field's time, including the initial frame. + +`results.surface {"field":"wall_heat_flux","frame":10}` shows the signed heat received +by each obstacle face divided by that face's complete tessellated geometric area. Positive +values heat the wall; a hot wall heating the fluid is negative. This is a face average, +not a resolved local distribution. The legend uses the model's consistent power/area unit: +for mm–tonne–second it is mW/mm² (not W/mm²); other systems follow their own units. + +Wall heat uses the recorded face-group histories, linearly interpolated at the shown frame +only between recorded times. The initial frame precedes the history and remains uncoloured; +no heat is invented or extrapolated. Recorded adiabatic faces are zero. Unrecorded/imported +faces remain in their own colour. The command reply includes `wall_heat.faces` with body, +face, area, power and flux (null when unavailable), plus the sign and sampling convention. +This surface-only field suppresses the slice and leaves streamlines plain; selecting a cell +field restores the saved slice settings. `results.contour` also selects the wall field and +controls its bands/range. The desktop and headless picture use the same mapping and palette. diff --git a/docs/features.md b/docs/features.md index 2965c5f..a416b09 100644 --- a/docs/features.md +++ b/docs/features.md @@ -160,7 +160,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands. | Plots | Path Plot — Plot a quantity along a path | coming soon (M9) | | | Plots | Cut — Section cuts through the model | coming soon (M9) | | | Plots | Display Group — Show and hide parts of the model | coming soon (M9) | | -| Plots | Streamlines — A fluid step's streamlines coloured by speed, its velocity or pressure on a slice, and the obstacles coloured by the flow | ready | `results.streamlines`, `results.slice`, `results.surface` | +| Plots | Streamlines — A fluid step's velocity, pressure or temperature on slices and obstacles, speed or temperature along streamlines, and signed wall heat per area | ready | `results.streamlines`, `results.slice`, `results.surface` | | Files | Export VTU — Write a solved step as VTK unstructured grids for ParaView | ready | `results.export.vtu` | | Files | Read Results — Read results in public formats | coming soon (M9) | | | Files | Convergence — Mesh convergence studies | coming soon (M9) | |