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) | |