forked from 1b-agent/fieldcraft-upstream
engine: draw an obstacle face held at a temperature at that temperature
The coloured obstacle surface samples the fluid beside each face, and the solid cells carry no temperature, so a face held at 80 °C took the mean of the fluid cells around it, which mixes in the cooler ones further out: the heated-flow sample's bracket looked 50 °C beside 79.7 °C air. A face whose thermal condition holds it at a temperature is now drawn at that value, and the legend's range reaches it (the fluid never quite does). Adiabatic and flux faces keep showing the fluid beside them. Part of #157.
This commit is contained in:
1 parent
c61a8b9a4d
commit
7e3eaa14f2
2 files changed
+131
-3
No files matched your search
+116
-1
@@ -355,6 +355,34 @@ pub fn plot_with_surfaces(
|
|||||||
bodies: &[&str],
|
bodies: &[&str],
|
||||||
) -> Result<FlowPlot> {
|
) -> Result<FlowPlot> {
|
||||||
let mut out = plot(view, result, traced)?;
|
let mut out = plot(view, result, traced)?;
|
||||||
|
if view.contour == Some(Field::Temperature)
|
||||||
|
&& let Kind::Fluid(r) = result.kind()
|
||||||
|
{
|
||||||
|
// An obstacle face held at a temperature is drawn at it: sampling the fluid beside it
|
||||||
|
// would mix in the cooler cells further out.
|
||||||
|
let held = held_temperatures(session, r, base, bodies);
|
||||||
|
if held.iter().any(Option::is_some) {
|
||||||
|
if let Some(c) = &mut out.contour {
|
||||||
|
// A held face beyond the fluid's range is the extreme: the legend, its Max/Min
|
||||||
|
// lines and the viewport's marker all name it. Its label continues past the
|
||||||
|
// cells, into `wall_positions` (see [`FlowPlot::wall_positions`]).
|
||||||
|
let cells = c.values.len();
|
||||||
|
for (t, v) in base.surface_tris.iter().zip(&held) {
|
||||||
|
let Some(v) = *v else { continue };
|
||||||
|
let at = || (t.p[0] + t.p[1] + t.p[2]) * (1.0 / 3.0);
|
||||||
|
if v > c.max.1 {
|
||||||
|
c.max = (cells + out.wall_positions.len(), v);
|
||||||
|
out.wall_positions.push(Some(at()));
|
||||||
|
} else if v < c.min.1 {
|
||||||
|
c.min = (cells + out.wall_positions.len(), v);
|
||||||
|
out.wall_positions.push(Some(at()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.bands = view.bands(c.min.1, c.max.1);
|
||||||
|
}
|
||||||
|
out.wall_values = base.surface_tris.iter().zip(held).map(|(t, v)| (t.p, v)).collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
if view.contour == Some(Field::WallHeatFlux) {
|
if view.contour == Some(Field::WallHeatFlux) {
|
||||||
let Kind::Fluid(r) = result.kind() else { return Ok(out) };
|
let Kind::Fluid(r) = result.kind() else { return Ok(out) };
|
||||||
let faces = wall::faces(session, r, view.pick())?;
|
let faces = wall::faces(session, r, view.pick())?;
|
||||||
@@ -372,6 +400,37 @@ pub fn plot_with_surfaces(
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per displayed surface triangle, the temperature its obstacle face is held at, when the step's
|
||||||
|
/// domain holds that face at one and the result is of the current model.
|
||||||
|
fn held_temperatures(session: &crate::Session, r: &FluidResult, base: &fieldcraft_render::DisplayMesh, bodies: &[&str]) -> Vec<Option<f64>> {
|
||||||
|
let none = || vec![None; base.surface_tris.len()];
|
||||||
|
if session.latest_result(&r.step).is_some_and(|(_, current)| !current) {
|
||||||
|
return none();
|
||||||
|
}
|
||||||
|
let Some(thermal) = session
|
||||||
|
.model
|
||||||
|
.step(&r.step)
|
||||||
|
.and_then(|st| st.procedure.fluid_domain())
|
||||||
|
.and_then(|d| session.model.fluid_domain(d))
|
||||||
|
.and_then(|d| d.thermal.as_ref())
|
||||||
|
else {
|
||||||
|
return none();
|
||||||
|
};
|
||||||
|
let design = session.design.as_deref();
|
||||||
|
let mut held = std::collections::BTreeMap::new();
|
||||||
|
for sb in &thermal.surfaces {
|
||||||
|
let (fieldcraft_model::ThermalCondition::Temperature { value }, fieldcraft_model::Entity::Face(face)) = (&sb.condition, &sb.face.entity)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let index = design.and_then(|d| d.bodies.iter().find(|b| b.name == sb.face.body)).and_then(|b| b.face_index(face));
|
||||||
|
if let Some(index) = index {
|
||||||
|
held.insert((sb.face.body.as_str(), index), *value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base.surface_tris.iter().map(|t| bodies.get(t.body as usize).and_then(|name| held.get(&(*name, t.face)).copied())).collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// The colours a flow plot is drawn in.
|
/// The colours a flow plot is drawn in.
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
pub struct FlowColors<'a> {
|
pub struct FlowColors<'a> {
|
||||||
@@ -401,7 +460,15 @@ pub fn draw(plot: &FlowPlot, scene: &mut Scene, colors: &FlowColors) {
|
|||||||
}
|
}
|
||||||
} else if plot.surface {
|
} else if plot.surface {
|
||||||
let model = std::mem::take(&mut scene.tris);
|
let model = std::mem::take(&mut scene.tris);
|
||||||
for t in model {
|
// Faces held at a temperature (`wall_values`, matched by position as for wall heat).
|
||||||
|
let held = (c.field == Field::Temperature && model.len() == plot.wall_values.len()).then_some(&plot.wall_values);
|
||||||
|
for (k, t) in model.into_iter().enumerate() {
|
||||||
|
if let Some((points, Some(v))) = held.and_then(|h| h.get(k))
|
||||||
|
&& t.p == *points
|
||||||
|
{
|
||||||
|
banded(t.p, [*v; 3], t.normal, &c.bands, &color, &mut scene.tris);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let v = t.p.map(|p| flow::sample(&plot.grid, &c.values, p));
|
let v = t.p.map(|p| flow::sample(&plot.grid, &c.values, p));
|
||||||
let (sum, known) = v.iter().filter(|x| !x.is_nan()).fold((0.0, 0u32), |(s, n), x| (s + x, n + 1));
|
let (sum, known) = v.iter().filter(|x| !x.is_nan()).fold((0.0, 0u32), |(s, n), x| (s + x, n + 1));
|
||||||
if known == 0 {
|
if known == 0 {
|
||||||
@@ -542,6 +609,54 @@ pub(crate) mod tests {
|
|||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A face held at a temperature is drawn at that temperature, and the legend reaches it,
|
||||||
|
/// rather than at the mean of the fluid cells around it (which mixes in the cooler ones
|
||||||
|
/// further out: an 80 °C bracket looked 50 °C).
|
||||||
|
#[test]
|
||||||
|
fn a_face_held_at_a_temperature_is_drawn_at_it() {
|
||||||
|
let mut s = plate_in_a_tank();
|
||||||
|
let face = s.design.as_ref().unwrap().bodies[0].faces()[0].name.clone();
|
||||||
|
s.execute(
|
||||||
|
"model.material.edit",
|
||||||
|
&json!({"name": "Water", "density": 1e-9, "fluid": {"type": "newtonian", "viscosity": 1e-7}, "thermal": {"conductivity": 400, "specific_heat": 4e9}}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
s.execute(
|
||||||
|
"model.fluid_domain.edit",
|
||||||
|
&json!({"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}},
|
||||||
|
"thermal": {"initial_temperature": 20}}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
s.execute(
|
||||||
|
"model.fluid_domain.thermal_boundary",
|
||||||
|
&json!({"domain": "Tank", "at": {"body": "Body1", "face": face}, "condition": {"type": "temperature", "value": 80}}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
s.execute("solve.fluid", &json!({})).unwrap();
|
||||||
|
s.execute("results.surface", &json!({"field": "temperature", "show": true})).unwrap();
|
||||||
|
let (result, current) = s.latest_result("Flow").unwrap();
|
||||||
|
assert!(current);
|
||||||
|
let view = s.results_view.clone().unwrap();
|
||||||
|
let base = crate::view::display(&s).0;
|
||||||
|
let names: Vec<&str> = s.design.as_ref().unwrap().bodies.iter().map(|b| b.name.as_str()).collect();
|
||||||
|
let fp = plot_with_surfaces(&view, result, &s.traced, &s, &base, &names).unwrap();
|
||||||
|
let c = fp.contour.as_ref().unwrap();
|
||||||
|
// The fluid never quite reaches the wall's 80 °C; the legend does, and its maximum is the
|
||||||
|
// held face (a label past the cells, at a point on that face).
|
||||||
|
assert!(c.values.iter().all(|t| t.is_nan() || *t < 80.0));
|
||||||
|
assert_eq!((c.max.1, c.bands.max), (80.0, 80.0), "{:?}", c.bands);
|
||||||
|
let at = c.max.0.checked_sub(c.values.len()).and_then(|k| fp.wall_positions.get(k).copied().flatten());
|
||||||
|
assert!(at.is_some(), "{:?}", c.max);
|
||||||
|
let held: Vec<f64> = fp.wall_values.iter().filter_map(|(_, v)| *v).collect();
|
||||||
|
assert!(!held.is_empty() && held.iter().all(|t| *t == 80.0), "{held:?}");
|
||||||
|
// The headless picture draws the held face in the top band's colour.
|
||||||
|
let p = crate::view::Palette::LIGHT;
|
||||||
|
let top = bands_color(&p.contour, p.contour_over, &c.bands, c.bands.band(80.0).unwrap());
|
||||||
|
let (scene, ..) = crate::view::scene(&s, &p);
|
||||||
|
assert!(scene.tris.iter().any(|t| t.color == top));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_fluid_result_is_first_shown_across_the_flow() {
|
fn a_fluid_result_is_first_shown_across_the_flow() {
|
||||||
let r = channel();
|
let r = channel();
|
||||||
|
|||||||
@@ -118,7 +118,14 @@ impl Plot {
|
|||||||
return self.flow.as_ref()?.wall_positions.get(label as usize).copied().flatten();
|
return self.flow.as_ref()?.wall_positions.get(label as usize).copied().flatten();
|
||||||
}
|
}
|
||||||
match self.result.kind() {
|
match self.result.kind() {
|
||||||
Kind::Fluid(f) => Some(Vec3::from(fieldcraft_engine::flow::cell_centre(f, usize::try_from(label).ok()?))),
|
Kind::Fluid(f) => {
|
||||||
|
let label = usize::try_from(label).ok()?;
|
||||||
|
// Past the cells: a face held at a temperature that is the field's extreme.
|
||||||
|
match label.checked_sub(f.grid.len()) {
|
||||||
|
Some(k) => self.flow.as_ref()?.wall_positions.get(k).copied().flatten(),
|
||||||
|
None => Some(Vec3::from(fieldcraft_engine::flow::cell_centre(f, label))),
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => self.node_position(label),
|
_ => self.node_position(label),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -492,7 +499,13 @@ pub(crate) mod tests {
|
|||||||
assert!(p.contour.as_ref().unwrap().max.1 > 20.0);
|
assert!(p.contour.as_ref().unwrap().max.1 > 20.0);
|
||||||
let last = p.frame().unwrap().2 - 1;
|
let last = p.frame().unwrap().2 - 1;
|
||||||
s.execute("results.frame", &json!({"frame": 0})).unwrap();
|
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");
|
let at_rest = plot_at(&s, &base, None).unwrap();
|
||||||
|
let c = at_rest.contour.as_ref().unwrap();
|
||||||
|
let cells = &at_rest.flow.as_ref().unwrap().contour.as_ref().unwrap().values;
|
||||||
|
assert!(cells.iter().filter(|t| t.is_finite()).all(|t| *t == 20.0), "the frame selector changes the temperature shown");
|
||||||
|
// The faces are held at 60 °C from the start: the legend's maximum is a face, at a point on it.
|
||||||
|
assert_eq!(c.max.1, 60.0);
|
||||||
|
assert!(at_rest.extreme_position(c.max.0).is_some());
|
||||||
s.execute("results.frame", &json!({"frame": last})).unwrap();
|
s.execute("results.frame", &json!({"frame": last})).unwrap();
|
||||||
s.execute("results.surface", &json!({"field": "wall_heat_flux"})).unwrap();
|
s.execute("results.surface", &json!({"field": "wall_heat_flux"})).unwrap();
|
||||||
let p = plot_at(&s, &base, None).unwrap();
|
let p = plot_at(&s, &base, None).unwrap();
|
||||||
|
|||||||
Reference in new issue
Block a user