forked from 1b-agent/fieldcraft-upstream
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1fe5def0f | ||
|
|
732e57ebf1 |
No files matched your search
@@ -26,6 +26,8 @@ is committed and listed here with its exports.
|
|||||||
| docs/media/examples/crank-mirror.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved crank-mirror sample (docs/examples.md) | MIT OR Apache-2.0 |
|
| docs/media/examples/crank-mirror.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved crank-mirror sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||||
| docs/meshing/skill/analytic-cylinder.png, docs/meshing/skill/analytic-pipe.png, docs/meshing/skill/analytic-plate-hole.png, docs/meshing/skill/analytic-sphere.png, docs/meshing/skill/analytic-torus.png, docs/meshing/skill/bent-tube.png, docs/meshing/skill/bolted-assembly.png, docs/meshing/skill/cantilever-i-beam.png, docs/meshing/skill/crank-arm.png, docs/meshing/skill/cup.png, docs/meshing/skill/filleted-bracket-blocks.png, docs/meshing/skill/filleted-bracket.png, docs/meshing/skill/i-beam.png, docs/meshing/skill/loft-transition.png, docs/meshing/skill/perforated-plate.png, docs/meshing/skill/pi5-base.png, docs/meshing/skill/pi5-lid.png, docs/meshing/skill/pinhole-block.png, docs/meshing/skill/plain-cup.png, docs/meshing/skill/rounded-box.png, docs/meshing/skill/shallow-groove.png, docs/meshing/skill/sharp-wedge.png, docs/meshing/skill/sphere-on-plate.png, docs/meshing/skill/spline-vase.png, docs/meshing/skill/stepped-block.png, docs/meshing/skill/thin-ribs.png, docs/meshing/skill/thin-wall-enclosure.png, docs/meshing/skill/upright-bracket.png | FieldCraft contributors | Original: section pictures of the corpus parts and samples meshed by the meshing skill, drawn by `fieldcraft-cli snapshot` (`cargo xtask skill-results`, docs/meshing/skill-results.md) | MIT OR Apache-2.0 |
|
| docs/meshing/skill/analytic-cylinder.png, docs/meshing/skill/analytic-pipe.png, docs/meshing/skill/analytic-plate-hole.png, docs/meshing/skill/analytic-sphere.png, docs/meshing/skill/analytic-torus.png, docs/meshing/skill/bent-tube.png, docs/meshing/skill/bolted-assembly.png, docs/meshing/skill/cantilever-i-beam.png, docs/meshing/skill/crank-arm.png, docs/meshing/skill/cup.png, docs/meshing/skill/filleted-bracket-blocks.png, docs/meshing/skill/filleted-bracket.png, docs/meshing/skill/i-beam.png, docs/meshing/skill/loft-transition.png, docs/meshing/skill/perforated-plate.png, docs/meshing/skill/pi5-base.png, docs/meshing/skill/pi5-lid.png, docs/meshing/skill/pinhole-block.png, docs/meshing/skill/plain-cup.png, docs/meshing/skill/rounded-box.png, docs/meshing/skill/shallow-groove.png, docs/meshing/skill/sharp-wedge.png, docs/meshing/skill/sphere-on-plate.png, docs/meshing/skill/spline-vase.png, docs/meshing/skill/stepped-block.png, docs/meshing/skill/thin-ribs.png, docs/meshing/skill/thin-wall-enclosure.png, docs/meshing/skill/upright-bracket.png | FieldCraft contributors | Original: section pictures of the corpus parts and samples meshed by the meshing skill, drawn by `fieldcraft-cli snapshot` (`cargo xtask skill-results`, docs/meshing/skill-results.md) | MIT OR Apache-2.0 |
|
||||||
| docs/media/examples/flow-past.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the flow-past sample before solving (its bracket as the sample opens) (docs/examples.md) | MIT OR Apache-2.0 |
|
| docs/media/examples/flow-past.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the flow-past sample before solving (its bracket as the sample opens) (docs/examples.md) | MIT OR Apache-2.0 |
|
||||||
|
| docs/media/examples/cooling-enclosure.png | FieldCraft contributors | Original: the natural-convection sealed-enclosure cooling study rendered by FieldCraft (`ui.render`, light theme), generated by `tools/cooling/make.py`; geometry is the committed `examples/cooling/enclosure.solidcraft.json` recipe | MIT OR Apache-2.0 |
|
||||||
|
| docs/media/examples/cooling-heat-sink.png | FieldCraft contributors | Original: the forced-convection heat-sink cooling study rendered by FieldCraft (`ui.render`, light theme), generated by `tools/cooling/make.py`; geometry is the committed `examples/cooling/heatsink.solidcraft.json` recipe | MIT OR Apache-2.0 |
|
||||||
| docs/media/examples/flow-past-flow.png | FieldCraft contributors | Original: the solved flow-past sample rendered by FieldCraft (`ui.render`, light theme), velocity on the mid-slice with the bracket and upstream streamlines, generated by `tools/flow-clip/make.py` | MIT OR Apache-2.0 |
|
| docs/media/examples/flow-past-flow.png | FieldCraft contributors | Original: the solved flow-past sample rendered by FieldCraft (`ui.render`, light theme), velocity on the mid-slice with the bracket and upstream streamlines, generated by `tools/flow-clip/make.py` | MIT OR Apache-2.0 |
|
||||||
| docs/media/examples/flow-past-transient.mp4 | FieldCraft contributors | Original: the flow-past start-up transient rendered frame by frame by FieldCraft (`ui.render`), generated by `tools/flow-clip/make.py` (captions drawn by `tools/demo-video/caption.py` in Inter, OFL, crates/ui-egui/fonts) | MIT OR Apache-2.0 |
|
| docs/media/examples/flow-past-transient.mp4 | FieldCraft contributors | Original: the flow-past start-up transient rendered frame by frame by FieldCraft (`ui.render`), generated by `tools/flow-clip/make.py` (captions drawn by `tools/demo-video/caption.py` in Inter, OFL, crates/ui-egui/fonts) | MIT OR Apache-2.0 |
|
||||||
| docs/media/examples/cantilever.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved cantilever sample (docs/examples.md) | MIT OR Apache-2.0 |
|
| docs/media/examples/cantilever.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved cantilever sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
//! Compile a SolidCraft recipe script (`{"commands": [...]}`) into a `.solidcraft` design that
|
||||||
|
//! `geometry.open` (and so `fieldcraft-cli run`/`snapshot`) can open. Used by the cooling-examples
|
||||||
|
//! runner (`tools/cooling/make.py`) to build the committed example geometry from its recipe.
|
||||||
|
//!
|
||||||
|
//! `cargo run --example compile_recipe -p fieldcraft-geometry --features scripts -- recipe.json out.solidcraft`
|
||||||
|
fn main() -> Result<(), String> {
|
||||||
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
let (recipe, out) = match args.as_slice() {
|
||||||
|
[recipe, out] => (recipe, out),
|
||||||
|
_ => return Err("usage: compile_recipe <recipe.json> <out.solidcraft>".into()),
|
||||||
|
};
|
||||||
|
let script = std::fs::read_to_string(recipe).map_err(|e| format!("read {recipe}: {e}"))?;
|
||||||
|
let bytes = fieldcraft_geometry::script_design(&script).map_err(|e| format!("compile: {e}"))?;
|
||||||
|
std::fs::write(out, &bytes).map_err(|e| format!("write {out}: {e}"))?;
|
||||||
|
eprintln!("compile_recipe: wrote {out} ({} bytes)", bytes.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ use crate::cholesky::{Factor, FactorError, UpperColumns};
|
|||||||
use crate::constraints::{Budget, Reduction, coupling_rows, symmetry_rows};
|
use crate::constraints::{Budget, Reduction, coupling_rows, symmetry_rows};
|
||||||
use crate::eigen::{Operator, Wanted, lanczos};
|
use crate::eigen::{Operator, Wanted, lanczos};
|
||||||
use crate::ldlt::{Kind, Symmetric};
|
use crate::ldlt::{Kind, Symmetric};
|
||||||
use crate::ordering::fill_reducing_order;
|
use crate::ordering::nested_dissection;
|
||||||
use crate::sparse::{Csr, NodeGraph, add_block, add_diagonal, add_scalar};
|
use crate::sparse::{Csr, NodeGraph, add_block, add_diagonal, add_scalar};
|
||||||
use crate::system::{
|
use crate::system::{
|
||||||
Mesh, Structural, StructuralKernel, add_thermal_loads, inelastic_warnings, loads, prescribed, structural_body_forces, temperature_loads,
|
Mesh, Structural, StructuralKernel, add_thermal_loads, inelastic_warnings, loads, prescribed, structural_body_forces, temperature_loads,
|
||||||
@@ -311,10 +311,10 @@ pub(crate) fn pattern(mesh: &Mesh) -> (NodeGraph, Vec<usize>) {
|
|||||||
// The order covers the mesh nodes; reference point equations follow them.
|
// The order covers the mesh nodes; reference point equations follow them.
|
||||||
let n = mesh.labels.len();
|
let n = mesh.labels.len();
|
||||||
let order = if graph.adjacency.len() == n {
|
let order = if graph.adjacency.len() == n {
|
||||||
fill_reducing_order(&mesh.xyz, &graph.adjacency)
|
nested_dissection(&mesh.xyz, &graph.adjacency)
|
||||||
} else {
|
} else {
|
||||||
let mesh_part: Vec<Vec<usize>> = graph.adjacency.iter().take(n).map(|row| row.iter().copied().filter(|b| *b < n).collect()).collect();
|
let mesh_part: Vec<Vec<usize>> = graph.adjacency.iter().take(n).map(|row| row.iter().copied().filter(|b| *b < n).collect()).collect();
|
||||||
fill_reducing_order(&mesh.xyz, &mesh_part)
|
nested_dissection(&mesh.xyz, &mesh_part)
|
||||||
};
|
};
|
||||||
(graph, order)
|
(graph, order)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,11 +151,6 @@ fn ereach(a: &UpperColumns, k: usize, parent: &[usize], stack: &mut [usize], mar
|
|||||||
top
|
top
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The entries of the Cholesky factor of `a`'s pattern, in its own order, without values.
|
|
||||||
pub(crate) fn factor_entries(a: &UpperColumns) -> usize {
|
|
||||||
symbolic(a).1.iter().sum()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The elimination tree and the entry count of each column of L, without computing values.
|
/// The elimination tree and the entry count of each column of L, without computing values.
|
||||||
fn symbolic(a: &UpperColumns) -> (Vec<usize>, Vec<usize>) {
|
fn symbolic(a: &UpperColumns) -> (Vec<usize>, Vec<usize>) {
|
||||||
let parent = etree(a);
|
let parent = etree(a);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ use fieldcraft_model::{ElementKind, Load, Model, Procedure, Resolved, SectionKin
|
|||||||
use crate::cholesky::{Factor, FactorError, UpperColumns};
|
use crate::cholesky::{Factor, FactorError, UpperColumns};
|
||||||
use crate::constraints::{Budget, Reduction, Row};
|
use crate::constraints::{Budget, Reduction, Row};
|
||||||
use crate::contact::thickness;
|
use crate::contact::thickness;
|
||||||
use crate::ordering::fill_reducing_order;
|
use crate::ordering::nested_dissection;
|
||||||
use crate::sparse::{Csr, NodeGraph, add_block};
|
use crate::sparse::{Csr, NodeGraph, add_block};
|
||||||
use crate::system::hold;
|
use crate::system::hold;
|
||||||
use crate::tie::{TieFace, TieNodes, rows_of};
|
use crate::tie::{TieFace, TieNodes, rows_of};
|
||||||
@@ -398,7 +398,7 @@ fn run(model: &Model, resolved: &Resolved, step: &Step, limits: &Limits) -> Resu
|
|||||||
let mut warnings = Vec::new();
|
let mut warnings = Vec::new();
|
||||||
let rows = tie_rows(model, resolved, &mesh, &mut warnings)?;
|
let rows = tie_rows(model, resolved, &mesh, &mut warnings)?;
|
||||||
let fixed = prescribed(model, resolved, &mesh, step)?;
|
let fixed = prescribed(model, resolved, &mesh, step)?;
|
||||||
let order = fill_reducing_order(&mesh.xyz, &graph.adjacency);
|
let order = nested_dissection(&mesh.xyz, &graph.adjacency);
|
||||||
let budget = Budget { max_bytes: limits.max_factor_entries.saturating_mul(8), pattern_entries: graph.entries(&mesh.layout), matrices: 1 };
|
let budget = Budget { max_bytes: limits.max_factor_entries.saturating_mul(8), pattern_entries: graph.entries(&mesh.layout), matrices: 1 };
|
||||||
let eqs = Reduction::new(&mesh.layout, std::iter::empty(), &order, &rows, |_| Ok(fixed), budget)?;
|
let eqs = Reduction::new(&mesh.layout, std::iter::empty(), &order, &rows, |_| Ok(fixed), budget)?;
|
||||||
if eqs.neq() == 0 {
|
if eqs.neq() == 0 {
|
||||||
|
|||||||
@@ -9,17 +9,6 @@
|
|||||||
//! unstructured problems for parallel processing*, Computing Systems in Engineering 2:135–148,
|
//! unstructured problems for parallel processing*, Computing Systems in Engineering 2:135–148,
|
||||||
//! 1991, with vertex separators). Ties are broken by node index, so the ordering is
|
//! 1991, with vertex separators). Ties are broken by node index, so the ordering is
|
||||||
//! deterministic.
|
//! deterministic.
|
||||||
//!
|
|
||||||
//! A plane cuts a thin curved wall (a shell meshed with solids, a bellows, a revolved part) many
|
|
||||||
//! times, so its separators are large there. [`graph_dissection`] takes its separators from the
|
|
||||||
//! graph instead: a middle level of the breadth-first level structure rooted at a
|
|
||||||
//! pseudo-peripheral node (A. George, J. W. H. Liu, *An automatic nested dissection algorithm for
|
|
||||||
//! irregular finite element problems*, SIAM J. Numer. Anal. 15(5):1053–1069, 1978; the
|
|
||||||
//! pseudo-peripheral node of N. E. Gibbs, W. G. Poole, P. K. Stockmeyer, *An algorithm for
|
|
||||||
//! reducing the bandwidth and profile of a sparse matrix*, SIAM J. Numer. Anal. 13(2):236–250,
|
|
||||||
//! 1976), which cuts such a wall across its surface. [`fill_reducing_order`] computes both and
|
|
||||||
//! keeps the one whose Cholesky factor has fewer entries; the symbolic count costs O(|L|) time
|
|
||||||
//! and O(n + |A|) memory, small next to the numeric factorization.
|
|
||||||
|
|
||||||
/// Parts this small are ordered as they come.
|
/// Parts this small are ordered as they come.
|
||||||
const LEAF: usize = 16;
|
const LEAF: usize = 16;
|
||||||
@@ -97,179 +86,6 @@ pub fn nested_dissection(xyz: &[[f64; 3]], adjacency: &[Vec<usize>]) -> Vec<usiz
|
|||||||
order
|
order
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The nested dissection order of the nodes that fills the factor less: by coordinate planes or
|
|
||||||
/// by the graph's level structures. Ties keep the coordinate order.
|
|
||||||
pub fn fill_reducing_order(xyz: &[[f64; 3]], adjacency: &[Vec<usize>]) -> Vec<usize> {
|
|
||||||
let by_planes = nested_dissection(xyz, adjacency);
|
|
||||||
let by_graph = graph_dissection(adjacency);
|
|
||||||
if fill(adjacency, &by_graph) < fill(adjacency, &by_planes) { by_graph } else { by_planes }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Searches for a pseudo-peripheral node per part.
|
|
||||||
const PERIPHERAL_SEARCHES: usize = 8;
|
|
||||||
|
|
||||||
/// The breadth-first level structure of the nodes with `owner == token` reached from `root`.
|
|
||||||
/// Marks each reached node's level in `level` (which must be `usize::MAX` there on entry).
|
|
||||||
fn level_structure(adjacency: &[Vec<usize>], root: usize, token: usize, owner: &[usize], level: &mut [usize]) -> Vec<Vec<usize>> {
|
|
||||||
let mut out = vec![vec![root]];
|
|
||||||
if let Some(l) = level.get_mut(root) {
|
|
||||||
*l = 0;
|
|
||||||
}
|
|
||||||
loop {
|
|
||||||
let depth = out.len();
|
|
||||||
let mut next = Vec::new();
|
|
||||||
for &v in out.last().into_iter().flatten() {
|
|
||||||
for &w in adjacency.get(v).into_iter().flatten() {
|
|
||||||
if owner.get(w) == Some(&token)
|
|
||||||
&& let Some(l) = level.get_mut(w)
|
|
||||||
&& *l == usize::MAX
|
|
||||||
{
|
|
||||||
*l = depth;
|
|
||||||
next.push(w);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if next.is_empty() {
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
out.push(next);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clears the levels of `structure`'s nodes.
|
|
||||||
fn clear(level: &mut [usize], structure: &[Vec<usize>]) {
|
|
||||||
for &v in structure.iter().flatten() {
|
|
||||||
if let Some(l) = level.get_mut(v) {
|
|
||||||
*l = usize::MAX;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A permutation of the nodes by nested dissection on the graph alone (George & Liu 1978, see
|
|
||||||
/// the module notes): each part's separator is the nodes of its middle level that touch the
|
|
||||||
/// next level. Disconnected parts are ordered one after the other. Ties go by node index, so
|
|
||||||
/// the ordering is deterministic.
|
|
||||||
pub fn graph_dissection(adjacency: &[Vec<usize>]) -> Vec<usize> {
|
|
||||||
let n = adjacency.len();
|
|
||||||
let mut order = Vec::with_capacity(n);
|
|
||||||
// The part each node is in (a token per part), and its level in the current search.
|
|
||||||
let mut owner = vec![0usize; n];
|
|
||||||
let mut level = vec![usize::MAX; n];
|
|
||||||
let mut token = 0usize;
|
|
||||||
enum Task {
|
|
||||||
Split(Vec<usize>),
|
|
||||||
Emit(Vec<usize>),
|
|
||||||
}
|
|
||||||
let mut stack = vec![Task::Split((0..n).collect())];
|
|
||||||
while let Some(task) = stack.pop() {
|
|
||||||
let part = match task {
|
|
||||||
Task::Emit(sep) => {
|
|
||||||
order.extend(sep);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Task::Split(p) => p,
|
|
||||||
};
|
|
||||||
let Some(&first) = part.first() else { continue };
|
|
||||||
if part.len() <= LEAF {
|
|
||||||
order.extend(part);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
token += 1;
|
|
||||||
for &v in &part {
|
|
||||||
if let Some(o) = owner.get_mut(v) {
|
|
||||||
*o = token;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let degree = |v: usize, owner: &[usize]| adjacency.get(v).map_or(0, |row| row.iter().filter(|w| owner.get(**w) == Some(&token)).count());
|
|
||||||
// A pseudo-peripheral root: from the first node, move to the least connected node of the
|
|
||||||
// last level while that deepens the structure.
|
|
||||||
let mut structure = level_structure(adjacency, first, token, &owner, &mut level);
|
|
||||||
for _ in 0..PERIPHERAL_SEARCHES {
|
|
||||||
let Some(candidate) = structure.last().and_then(|last| last.iter().copied().min_by_key(|&v| (degree(v, &owner), v))) else { break };
|
|
||||||
clear(&mut level, &structure);
|
|
||||||
let deeper = level_structure(adjacency, candidate, token, &owner, &mut level);
|
|
||||||
if deeper.len() <= structure.len() {
|
|
||||||
clear(&mut level, &deeper);
|
|
||||||
structure =
|
|
||||||
level_structure(adjacency, structure.first().and_then(|l| l.first()).copied().unwrap_or(first), token, &owner, &mut level);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
structure = deeper;
|
|
||||||
}
|
|
||||||
let reached: usize = structure.iter().map(Vec::len).sum();
|
|
||||||
if reached < part.len() {
|
|
||||||
// Disconnected: the reached component, then the rest.
|
|
||||||
let rest: Vec<usize> = part.iter().copied().filter(|&v| level.get(v) == Some(&usize::MAX)).collect();
|
|
||||||
clear(&mut level, &structure);
|
|
||||||
let mut component: Vec<usize> = structure.into_iter().flatten().collect();
|
|
||||||
component.sort_unstable();
|
|
||||||
stack.push(Task::Split(rest));
|
|
||||||
stack.push(Task::Split(component));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if structure.len() < 3 {
|
|
||||||
clear(&mut level, &structure);
|
|
||||||
order.extend(part);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// The middle level: the first at which the levels up to it hold half the part, kept
|
|
||||||
// off the last level so both sides are non-empty.
|
|
||||||
let mut count = 0;
|
|
||||||
let mut middle = 1;
|
|
||||||
for (i, l) in structure.iter().enumerate().skip(1) {
|
|
||||||
count += l.len();
|
|
||||||
if 2 * count >= part.len() {
|
|
||||||
middle = i.min(structure.len() - 2);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let touches_next =
|
|
||||||
|v: usize| adjacency.get(v).is_some_and(|row| row.iter().any(|&w| owner.get(w) == Some(&token) && level.get(w) == Some(&(middle + 1))));
|
|
||||||
let mut separator = Vec::new();
|
|
||||||
let mut before: Vec<usize> = structure.iter().take(middle).flatten().copied().collect();
|
|
||||||
for &v in structure.get(middle).into_iter().flatten() {
|
|
||||||
if touches_next(v) { separator.push(v) } else { before.push(v) }
|
|
||||||
}
|
|
||||||
let mut after: Vec<usize> = structure.iter().skip(middle + 1).flatten().copied().collect();
|
|
||||||
clear(&mut level, &structure);
|
|
||||||
before.sort_unstable();
|
|
||||||
separator.sort_unstable();
|
|
||||||
after.sort_unstable();
|
|
||||||
// Order: before, then after, then the separator (the stack runs in reverse).
|
|
||||||
stack.push(Task::Emit(separator));
|
|
||||||
stack.push(Task::Split(after));
|
|
||||||
stack.push(Task::Split(before));
|
|
||||||
}
|
|
||||||
order
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The entries of the Cholesky factor of the node graph `adjacency` eliminated in `order` (one
|
|
||||||
/// per node pair): the fill an ordering gives. `order` must be a permutation of the nodes.
|
|
||||||
pub fn fill(adjacency: &[Vec<usize>], order: &[usize]) -> usize {
|
|
||||||
let n = adjacency.len();
|
|
||||||
let mut position = vec![usize::MAX; n];
|
|
||||||
for (k, &v) in order.iter().enumerate() {
|
|
||||||
if let Some(p) = position.get_mut(v) {
|
|
||||||
*p = k;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut ptr = Vec::with_capacity(n + 1);
|
|
||||||
ptr.push(0);
|
|
||||||
let mut idx = Vec::new();
|
|
||||||
let mut rows = Vec::new();
|
|
||||||
for (k, &v) in order.iter().enumerate() {
|
|
||||||
rows.clear();
|
|
||||||
rows.extend(adjacency.get(v).into_iter().flatten().filter_map(|w| position.get(*w).copied()).filter(|&r| r < k));
|
|
||||||
rows.push(k);
|
|
||||||
rows.sort_unstable();
|
|
||||||
rows.dedup();
|
|
||||||
idx.extend_from_slice(&rows);
|
|
||||||
ptr.push(idx.len());
|
|
||||||
}
|
|
||||||
let val = vec![0.0; idx.len()];
|
|
||||||
crate::cholesky::factor_entries(&crate::cholesky::UpperColumns { n: order.len(), ptr, idx, val })
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -300,170 +116,4 @@ mod tests {
|
|||||||
assert!(last.iter().all(|v| v / m == last[0] / m) || last.iter().all(|v| v % m == last[0] % m), "{last:?}");
|
assert!(last.iter().all(|v| v / m == last[0] / m) || last.iter().all(|v| v % m == last[0] % m), "{last:?}");
|
||||||
assert_eq!(order, nested_dissection(&xyz, &adj), "deterministic");
|
assert_eq!(order, nested_dissection(&xyz, &adj), "deterministic");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The node graph of a structured hex8 grid of `dims` cells (27-point stencil), with the
|
|
||||||
/// node coordinates from `at(i, j, k)`; `wrap` closes the first index around.
|
|
||||||
fn hex_grid(dims: [usize; 3], wrap: bool, at: impl Fn(usize, usize, usize) -> [f64; 3]) -> (Vec<[f64; 3]>, Vec<Vec<usize>>) {
|
|
||||||
let ni = if wrap { dims[0] } else { dims[0] + 1 };
|
|
||||||
let (nj, nk) = (dims[1] + 1, dims[2] + 1);
|
|
||||||
let id = |i: usize, j: usize, k: usize| (i % ni) + ni * (j + nj * k);
|
|
||||||
let mut xyz = vec![[0.0; 3]; ni * nj * nk];
|
|
||||||
let mut adj = vec![Vec::new(); ni * nj * nk];
|
|
||||||
for k in 0..nk {
|
|
||||||
for j in 0..nj {
|
|
||||||
for i in 0..ni {
|
|
||||||
xyz[id(i, j, k)] = at(i, j, k);
|
|
||||||
for t in 0..27 {
|
|
||||||
let (di, dj, dk) = (t % 3, (t / 3) % 3, t / 9);
|
|
||||||
let (a, b, c) = (i + ni + di - 1, j + dj, k + dk);
|
|
||||||
let a = if wrap {
|
|
||||||
a % ni
|
|
||||||
} else if a < ni || a >= 2 * ni {
|
|
||||||
continue;
|
|
||||||
} else {
|
|
||||||
a - ni
|
|
||||||
};
|
|
||||||
if (1..=nj).contains(&b) && (1..=nk).contains(&c) {
|
|
||||||
adj[id(i, j, k)].push(id(a, b - 1, c - 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for row in &mut adj {
|
|
||||||
row.sort_unstable();
|
|
||||||
row.dedup();
|
|
||||||
}
|
|
||||||
(xyz, adj)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_permutation(order: &[usize], n: usize) -> bool {
|
|
||||||
let mut sorted = order.to_vec();
|
|
||||||
sorted.sort_unstable();
|
|
||||||
sorted == (0..n).collect::<Vec<_>>()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A thin cylinder wall, two solid elements thick, 160 around and 40 high.
|
|
||||||
fn cylinder_wall() -> (Vec<[f64; 3]>, Vec<Vec<usize>>) {
|
|
||||||
hex_grid([160, 2, 40], true, |i, j, k| {
|
|
||||||
let t = i as f64 / 160.0 * std::f64::consts::TAU;
|
|
||||||
let r = 50.0 + j as f64 * 0.5;
|
|
||||||
[r * t.cos(), r * t.sin(), k as f64 * 2.0]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn graph_dissection_is_a_deterministic_permutation() {
|
|
||||||
let (_, adj) = cylinder_wall();
|
|
||||||
let order = graph_dissection(&adj);
|
|
||||||
assert!(is_permutation(&order, adj.len()));
|
|
||||||
assert_eq!(order, graph_dissection(&adj));
|
|
||||||
// Two disconnected blocks and an isolated node.
|
|
||||||
let (_, mut two) = hex_grid([6, 6, 6], false, |i, j, k| [i as f64, j as f64, k as f64]);
|
|
||||||
let m = two.len();
|
|
||||||
two.extend(two.clone().into_iter().map(|row| row.into_iter().map(|v| v + m).collect()));
|
|
||||||
two.push(Vec::new());
|
|
||||||
assert!(is_permutation(&graph_dissection(&two), 2 * m + 1));
|
|
||||||
assert!(graph_dissection(&[]).is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_graph_cuts_a_thin_curved_wall_with_less_fill() {
|
|
||||||
let (xyz, adj) = cylinder_wall();
|
|
||||||
let planes = fill(&adj, &nested_dissection(&xyz, &adj));
|
|
||||||
let graph = fill(&adj, &graph_dissection(&adj));
|
|
||||||
assert!(graph < planes, "graph {graph} planes {planes}");
|
|
||||||
assert_eq!(fill(&adj, &fill_reducing_order(&xyz, &adj)), graph);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_block_keeps_the_better_ordering() {
|
|
||||||
let (xyz, adj) = hex_grid([16, 16, 16], false, |i, j, k| [i as f64, j as f64, k as f64]);
|
|
||||||
let planes = fill(&adj, &nested_dissection(&xyz, &adj));
|
|
||||||
let chosen = fill(&adj, &fill_reducing_order(&xyz, &adj));
|
|
||||||
assert!(chosen <= planes, "chosen {chosen} planes {planes}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Factor entries and factor time (s) of a diagonally dominant 3-unknowns-per-node matrix on
|
|
||||||
/// the graph `adj`, eliminated in `order`.
|
|
||||||
fn factor_3dof(adj: &[Vec<usize>], order: &[usize]) -> (usize, f64) {
|
|
||||||
let mut place = vec![0; adj.len()];
|
|
||||||
for (p, &v) in order.iter().enumerate() {
|
|
||||||
place[v] = p;
|
|
||||||
}
|
|
||||||
let neq = 3 * adj.len();
|
|
||||||
let mut cols: Vec<Vec<(usize, f64)>> = vec![Vec::new(); neq];
|
|
||||||
for (v, row) in adj.iter().enumerate() {
|
|
||||||
for &w in row {
|
|
||||||
for a in 0..3 {
|
|
||||||
for b in 0..3 {
|
|
||||||
let (r, c) = (3 * place[w] + b, 3 * place[v] + a);
|
|
||||||
if r > c {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let value = if v == w {
|
|
||||||
if a == b { 30.0 } else { 0.5 }
|
|
||||||
} else if a == b {
|
|
||||||
-1.0
|
|
||||||
} else {
|
|
||||||
0.01 * ((v + w) % 3) as f64
|
|
||||||
};
|
|
||||||
cols[c].push((r, value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut ptr = vec![0];
|
|
||||||
let (mut idx, mut val) = (Vec::new(), Vec::new());
|
|
||||||
for mut col in cols {
|
|
||||||
col.sort_by_key(|e| e.0);
|
|
||||||
idx.extend(col.iter().map(|e| e.0));
|
|
||||||
val.extend(col.iter().map(|e| e.1));
|
|
||||||
ptr.push(idx.len());
|
|
||||||
}
|
|
||||||
let upper = crate::cholesky::UpperColumns { n: neq, ptr, idx, val };
|
|
||||||
let t = std::time::Instant::now();
|
|
||||||
let factor = crate::cholesky::Factor::new(&upper, usize::MAX).unwrap();
|
|
||||||
(factor.entries(), t.elapsed().as_secs_f64())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The measurement table of the ordering choice (fill and time per ordering).
|
|
||||||
#[test]
|
|
||||||
#[ignore = "measurement; run with --ignored --nocapture"]
|
|
||||||
fn measure_orderings() {
|
|
||||||
let cases = [
|
|
||||||
("cylinder wall 160x2x40", cylinder_wall()),
|
|
||||||
(
|
|
||||||
"bellows 120x2x120",
|
|
||||||
hex_grid([120, 2, 120], true, |i, j, k| {
|
|
||||||
let t = i as f64 / 120.0 * std::f64::consts::TAU;
|
|
||||||
let z = k as f64;
|
|
||||||
// Convolutions of the wall in z, the wall thickness along the normal.
|
|
||||||
let r = 40.0 + 12.0 * (z / 120.0 * 8.0 * std::f64::consts::TAU).sin() + j as f64 * 0.5;
|
|
||||||
[r * t.cos(), r * t.sin(), z * 1.0]
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
("block 24^3", hex_grid([24, 24, 24], false, |i, j, k| [i as f64, j as f64, k as f64])),
|
|
||||||
];
|
|
||||||
for (name, (xyz, adj)) in cases {
|
|
||||||
let t = std::time::Instant::now();
|
|
||||||
let planes = nested_dissection(&xyz, &adj);
|
|
||||||
let tp = t.elapsed();
|
|
||||||
let t = std::time::Instant::now();
|
|
||||||
let graph = graph_dissection(&adj);
|
|
||||||
let tg = t.elapsed();
|
|
||||||
let t = std::time::Instant::now();
|
|
||||||
let (fp, fg) = (fill(&adj, &planes), fill(&adj, &graph));
|
|
||||||
let tf = t.elapsed();
|
|
||||||
let (ep, sp) = factor_3dof(&adj, &planes);
|
|
||||||
let (eg, sg) = factor_3dof(&adj, &graph);
|
|
||||||
println!(
|
|
||||||
"| {name} | {} | {fp} / {fg} | {ep} / {eg} | {sp:.3} / {sg:.3} | {:.1} / {:.1} / {:.1} |",
|
|
||||||
3 * adj.len(),
|
|
||||||
tp.as_secs_f64() * 1e3,
|
|
||||||
tg.as_secs_f64() * 1e3,
|
|
||||||
tf.as_secs_f64() * 1e3
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -227,3 +227,82 @@ temperature (with a small numerical tolerance); recorded hot walls give heat to
|
|||||||
This coarse, short transient is a quick display demonstration, not a converged convection
|
This coarse, short transient is a quick display demonstration, not a converged convection
|
||||||
benchmark. It has neither buoyancy nor solid heat conduction. The sample is isolated in
|
benchmark. It has neither buoyancy nor solid heat conduction. The sample is isolated in
|
||||||
`crates/engine/src/sample/heated_flow.rs` and its gallery registration.
|
`crates/engine/src/sample/heated_flow.rs` and its gallery registration.
|
||||||
|
|
||||||
|
# Electronics cooling studies
|
||||||
|
|
||||||
|
These are larger CFD studies, not one-click samples: each sets up a thermal lattice-Boltzmann
|
||||||
|
model from a committed SolidCraft recipe ([`examples/cooling`](../examples/cooling)) and solves it
|
||||||
|
on the CPU in a few minutes. `tools/cooling/make.py` (see its
|
||||||
|
[README](../tools/cooling/README.md)) rebuilds their numbers and pictures. They show what the
|
||||||
|
fluid solver can do for electronics cooling today, and where it stands in for what it cannot yet
|
||||||
|
do — approximations made honestly:
|
||||||
|
|
||||||
|
- **No conjugate heat transfer into solids yet**
|
||||||
|
([#157](https://github.com/tangency-labs/fieldcraft/issues/157)): a high-conductivity heat sink
|
||||||
|
or cold plate is modelled as an **isothermal surface**, and a component as a **held-temperature
|
||||||
|
or heat-flux face**. Real junction temperatures need conduction through the solid.
|
||||||
|
- **No fan curve**: a fan is a **uniform inlet velocity**. The current two-relaxation-time
|
||||||
|
collision is resolution-bound near its stability limit
|
||||||
|
([#557](https://github.com/tangency-labs/fieldcraft/issues/557)), so the forced cases use a
|
||||||
|
**modest duct velocity** standing in for a low-speed fan; higher fan speeds need the finer-grid
|
||||||
|
and turbulence work tracked there.
|
||||||
|
- **No surface-to-surface radiation**: the sealed-enclosure case is **convection only** (radiation
|
||||||
|
carries a real share of the heat in a sealed box).
|
||||||
|
|
||||||
|
Background for the planned electronics-cooling release is in
|
||||||
|
[discussion #584](https://github.com/tangency-labs/fieldcraft/discussions/584).
|
||||||
|
|
||||||
|
## Heat sink in forced air (`examples/cooling/heatsink`)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
A 40 × 40 mm aluminium heat sink with six 18 mm fins, held isothermal at 80 °C (a high-conductivity
|
||||||
|
sink with fin efficiency near 1 — see the note above), in a 120 × 44 × 30 mm duct. Air at 20 °C
|
||||||
|
enters at 100 mm/s (0.1 m/s, a gentle fan), the duct walls no-slip and the outlet at zero pressure.
|
||||||
|
D3Q19 TRT with the D3Q7 thermal lattice on 158,400 cells of 1 mm, solved to 4 s (16,000 steps at a
|
||||||
|
halved time step, so the weakly-compressible density error stays near 1 %). About four minutes on
|
||||||
|
one CPU.
|
||||||
|
|
||||||
|
**Numbers:** the sink sheds **4.4 W** to the air — a sink-to-inlet **thermal resistance of
|
||||||
|
13.5 K/W** over the 60 K rise. Halving the time step again moves the heat by under 3 %, so the
|
||||||
|
figure is converged; it is in the range expected for a small sink in gentle airflow.
|
||||||
|
|
||||||
|
**Look at:** the air warming on the mid-plane temperature slice and the streamlines coloured by
|
||||||
|
temperature (`results.slice` / `results.streamlines` with `field: "temperature"`), with the sink
|
||||||
|
coloured by the flow beside it (`results.surface`). The heat each fin face gives the air is in the
|
||||||
|
`solve.fluid` report (`heat`, negative into the fluid).
|
||||||
|
|
||||||
|
**Caveat:** the reported minimum air temperature dips a few kelvin below the inlet near the sharp
|
||||||
|
fin corners — a local artifact of the D3Q7 anti-bounce-back wall at convex edges, not a physical
|
||||||
|
undershoot; the legend is clamped to the physical range.
|
||||||
|
|
||||||
|
**Regenerate:** `python3 tools/cooling/make.py heat-sink` (needs a display, or `xvfb-run`).
|
||||||
|
|
||||||
|
## Sealed enclosure with a PCB (`examples/cooling/enclosure`)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
A small package held at 40 °C on a vertical PCB inside a sealed 60 × 40 × 60 mm air box whose walls
|
||||||
|
sit at 25 °C ambient — no fan. Buoyancy (Boussinesq, β = 3.4 × 10⁻³ K⁻¹, g along −z) drives a plume
|
||||||
|
up off the chip; the air stratifies warm at the ceiling over cool air below, and recirculates down
|
||||||
|
the walls. D3Q19 TRT with the D3Q7 thermal lattice and buoyancy on 144,000 cells of 1 mm, stepped
|
||||||
|
at a fixed 0.2 ms to 3 s (15,000 steps). About four minutes on one CPU.
|
||||||
|
|
||||||
|
**Numbers:** the chip sheds **63 mW** by natural convection — a natural-convection coefficient of
|
||||||
|
about **5 W/m²K** over its surface, which is in the textbook range for a small body in still air,
|
||||||
|
and a chip-to-ambient resistance of about **237 K/W**. That the bare chip's resistance is ~18×
|
||||||
|
the finned, fan-cooled sink's (13.5 K/W) is the point: natural convection alone carries little, so
|
||||||
|
real parts need a heat sink or airflow. The air stays between the wall and chip temperatures
|
||||||
|
(24.9–39.5 °C) with no numerical undershoot.
|
||||||
|
|
||||||
|
**Look at:** the temperature on the vertical mid-plane slice (the stratification), the streamlines
|
||||||
|
of the buoyant recirculation, and the heat the chip sheds in the `solve.fluid` report.
|
||||||
|
|
||||||
|
**Caveats:** the buoyant **start-up** needs a small fixed time step — the solver's automatic step
|
||||||
|
over-accelerates the first instant and goes unstable (lattice speed 0.505 at step 38); the steady
|
||||||
|
plume itself is gentle (73 mm/s, cell Reynolds ≈ 5), so a steady or accelerated solve would avoid
|
||||||
|
resolving the transient (see the Gaps note on the pull request). The rise is capped at 15 K for the
|
||||||
|
same reason. This is **convection only**: a sealed enclosure also loses a real share of the heat by
|
||||||
|
**surface-to-surface radiation**, which the solver does not model yet.
|
||||||
|
|
||||||
|
**Regenerate:** `python3 tools/cooling/make.py enclosure` (needs a display, or `xvfb-run`).
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"command": "solid.box",
|
||||||
|
"params": {
|
||||||
|
"corner": [
|
||||||
|
10,
|
||||||
|
19,
|
||||||
|
6
|
||||||
|
],
|
||||||
|
"length": 40,
|
||||||
|
"width": 2,
|
||||||
|
"height": 44,
|
||||||
|
"body_name": "PCB"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "solid.box",
|
||||||
|
"params": {
|
||||||
|
"corner": [
|
||||||
|
27,
|
||||||
|
21,
|
||||||
|
26
|
||||||
|
],
|
||||||
|
"length": 16,
|
||||||
|
"width": 6,
|
||||||
|
"height": 12,
|
||||||
|
"body_name": "Chip"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
{
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"command": "solid.box",
|
||||||
|
"params": {
|
||||||
|
"corner": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"length": 40,
|
||||||
|
"width": 40,
|
||||||
|
"height": 3,
|
||||||
|
"body_name": "HeatSink"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "solid.box",
|
||||||
|
"params": {
|
||||||
|
"corner": [
|
||||||
|
0,
|
||||||
|
4.25,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
"length": 40,
|
||||||
|
"width": 1.5,
|
||||||
|
"height": 18,
|
||||||
|
"operation": "join",
|
||||||
|
"targets": [
|
||||||
|
"HeatSink"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "solid.box",
|
||||||
|
"params": {
|
||||||
|
"corner": [
|
||||||
|
0,
|
||||||
|
10.25,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
"length": 40,
|
||||||
|
"width": 1.5,
|
||||||
|
"height": 18,
|
||||||
|
"operation": "join",
|
||||||
|
"targets": [
|
||||||
|
"HeatSink"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "solid.box",
|
||||||
|
"params": {
|
||||||
|
"corner": [
|
||||||
|
0,
|
||||||
|
16.25,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
"length": 40,
|
||||||
|
"width": 1.5,
|
||||||
|
"height": 18,
|
||||||
|
"operation": "join",
|
||||||
|
"targets": [
|
||||||
|
"HeatSink"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "solid.box",
|
||||||
|
"params": {
|
||||||
|
"corner": [
|
||||||
|
0,
|
||||||
|
22.25,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
"length": 40,
|
||||||
|
"width": 1.5,
|
||||||
|
"height": 18,
|
||||||
|
"operation": "join",
|
||||||
|
"targets": [
|
||||||
|
"HeatSink"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "solid.box",
|
||||||
|
"params": {
|
||||||
|
"corner": [
|
||||||
|
0,
|
||||||
|
28.25,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
"length": 40,
|
||||||
|
"width": 1.5,
|
||||||
|
"height": 18,
|
||||||
|
"operation": "join",
|
||||||
|
"targets": [
|
||||||
|
"HeatSink"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "solid.box",
|
||||||
|
"params": {
|
||||||
|
"corner": [
|
||||||
|
0,
|
||||||
|
34.25,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
"length": 40,
|
||||||
|
"width": 1.5,
|
||||||
|
"height": 18,
|
||||||
|
"operation": "join",
|
||||||
|
"targets": [
|
||||||
|
"HeatSink"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"values": {
|
"values": {
|
||||||
"lambda": {
|
"lambda": {
|
||||||
"band": 3.6152397073587395e-10,
|
"band": 3.6152397073587395e-10,
|
||||||
"value": 0.36012816702934053
|
"value": 0.3601281681211783
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
"note": "FieldCraft's accepted results for this case, written by `cargo xtask oracle --bless`. A result outside its band fails CI.",
|
"note": "FieldCraft's accepted results for this case, written by `cargo xtask oracle --bless`. A result outside its band fails CI.",
|
||||||
"values": {
|
"values": {
|
||||||
"collapse_force": {
|
"collapse_force": {
|
||||||
"band": 3.226068671283815e-7,
|
"band": 3.2260685847425373e-7,
|
||||||
"value": -322.6068671283815
|
"value": -322.6068584742537
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Electronics cooling examples
|
||||||
|
|
||||||
|
`make.py` runs the electronics-cooling studies in [docs/examples.md](../../docs/examples.md) and
|
||||||
|
regenerates their numbers and pictures in `docs/media/examples/`. Each case compiles its committed
|
||||||
|
SolidCraft recipe ([`examples/cooling`](../../examples/cooling)) with the `compile_recipe` example,
|
||||||
|
sets up a thermal lattice-Boltzmann model (the setup is the per-case function in `make.py`), solves
|
||||||
|
it on the CPU, prints the headline numbers, and renders a still (and, for the transient cases, a
|
||||||
|
short MP4).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/cooling/make.py heat-sink # build, run, render into docs/media/examples
|
||||||
|
python3 tools/cooling/make.py heat-sink --no-build # with the release binaries already built
|
||||||
|
python3 tools/cooling/make.py heat-sink --no-clip # still only, skip the MP4
|
||||||
|
```
|
||||||
|
|
||||||
|
Needs Python 3 (standard library only), a display for the FieldCraft window (`xvfb-run` on a
|
||||||
|
headless machine), and `ffmpeg` for the MP4s. Honours `CARGO_TARGET_DIR`; set
|
||||||
|
`FIELDCRAFT_GPU=software` for a software renderer. The solves take a few minutes each.
|
||||||
|
|
||||||
|
What the solver does today, and the stand-ins these cases use for what it does not (conjugate heat
|
||||||
|
transfer into solids #157, fan curves, surface-to-surface radiation), are described in
|
||||||
|
docs/examples.md under *Electronics cooling studies*. The geometry is our own SolidCraft recipes;
|
||||||
|
nothing comes from a commercial product.
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Run the electronics-cooling examples and regenerate their gallery numbers and pictures.
|
||||||
|
|
||||||
|
Each case sets up a thermal lattice-Boltzmann model from a committed SolidCraft recipe
|
||||||
|
(`examples/cooling/*.solidcraft.json`, compiled with the `compile_recipe` example), solves it on
|
||||||
|
the CPU, prints the headline numbers (heat removed, temperatures, flow, thermal resistance), and
|
||||||
|
renders a still (and, for the transient cases, a short MP4) into `docs/media/examples/`.
|
||||||
|
|
||||||
|
What the solver does today and how these cases stand in for what it does not (see docs/examples.md):
|
||||||
|
* No conjugate heat transfer into solids yet (#157): a high-conductivity heat sink or cold plate
|
||||||
|
is modelled as an isothermal surface, and a component as a held-temperature or heat-flux face.
|
||||||
|
* No fan curve: a fan is a uniform inlet velocity. The current TRT collision is resolution-bound
|
||||||
|
near its stability limit (#557), so the forced cases use a modest duct velocity.
|
||||||
|
* No surface-to-surface radiation: the sealed-enclosure case is convection only (radiation, which
|
||||||
|
carries a real share in a sealed box, is noted as missing).
|
||||||
|
|
||||||
|
Python standard library only; ffmpeg encodes the MP4s. Needs a display for the app window (run
|
||||||
|
under xvfb-run on a headless machine) and the release binaries (built unless --no-build).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT / "tools" / "demo-video"))
|
||||||
|
import caption # noqa: E402
|
||||||
|
|
||||||
|
AIR = {
|
||||||
|
"name": "Air",
|
||||||
|
"density": 1.2e-12,
|
||||||
|
"fluid": {"type": "newtonian", "viscosity": 1.8e-11},
|
||||||
|
"thermal": {"conductivity": 0.026, "specific_heat": 1.005e9},
|
||||||
|
}
|
||||||
|
WIDTH, HEIGHT = 1280, 720
|
||||||
|
CAPTION_FONT = ROOT / "crates" / "ui-egui" / "fonts" / "Inter-SemiBold.ttf"
|
||||||
|
CAPTION_HEIGHT = 44
|
||||||
|
FPS = 12
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
print(f"cooling: {msg}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def target_dir():
|
||||||
|
return Path(os.environ.get("CARGO_TARGET_DIR", ROOT / "target"))
|
||||||
|
|
||||||
|
|
||||||
|
def build():
|
||||||
|
cargo = os.environ.get("CARGO", "cargo")
|
||||||
|
subprocess.run([cargo, "build", "--release", "-p", "fieldcraft", "-p", "fieldcraft-cli"], cwd=ROOT, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def compile_recipe(recipe, out):
|
||||||
|
cargo = os.environ.get("CARGO", "cargo")
|
||||||
|
subprocess.run(
|
||||||
|
[cargo, "run", "-q", "--release", "--example", "compile_recipe", "-p", "fieldcraft-geometry",
|
||||||
|
"--features", "scripts", "--", str(recipe), str(out)],
|
||||||
|
cwd=ROOT, check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def free_port():
|
||||||
|
with socket.socket() as s:
|
||||||
|
s.bind(("127.0.0.1", 0))
|
||||||
|
return s.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
class Control:
|
||||||
|
def __init__(self, port, timeout=180.0):
|
||||||
|
end = time.monotonic() + timeout
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
self.sock = socket.create_connection(("127.0.0.1", port), timeout=5.0)
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
if time.monotonic() > end:
|
||||||
|
raise SystemExit(f"control channel on port {port} did not open")
|
||||||
|
time.sleep(0.5)
|
||||||
|
self.sock.settimeout(1200.0)
|
||||||
|
self.file = self.sock.makefile("rwb")
|
||||||
|
self.id = 0
|
||||||
|
|
||||||
|
def send(self, method, params):
|
||||||
|
self.id += 1
|
||||||
|
self.file.write((json.dumps({"id": self.id, "method": method, "params": params}) + "\n").encode())
|
||||||
|
self.file.flush()
|
||||||
|
reply = json.loads(self.file.readline())
|
||||||
|
if not reply.get("ok"):
|
||||||
|
raise SystemExit(f"{method} {json.dumps(params)[:120]}: {reply.get('error')}")
|
||||||
|
return reply.get("result")
|
||||||
|
|
||||||
|
def execute(self, command, params=None):
|
||||||
|
return self.send("engine.execute", {"command": command, "params": params or {}})
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
try:
|
||||||
|
self.send("app.quit", {})
|
||||||
|
except (OSError, SystemExit):
|
||||||
|
pass
|
||||||
|
self.file.close()
|
||||||
|
self.sock.close()
|
||||||
|
|
||||||
|
|
||||||
|
def hold_all_faces(ctl, body, value):
|
||||||
|
"""Every face of `body` held at a temperature (an isothermal high-k solid, or a held component)."""
|
||||||
|
bodies = ctl.execute("geometry.bodies", {})
|
||||||
|
b = next(x for x in bodies if x["name"] == body)
|
||||||
|
return [{"face": {"body": body, "face": f["name"]}, "condition": {"type": "temperature", "value": value}}
|
||||||
|
for f in b["faces"]]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------- heat sink + fan
|
||||||
|
|
||||||
|
def heat_sink(ctl, design):
|
||||||
|
"""Forced convection: air pushed along a 6-fin aluminium sink held isothermal at 80 C, inlet
|
||||||
|
air at 20 C. Reports the heat removed and the sink-to-air thermal resistance."""
|
||||||
|
V, Tsink, Tin = 100.0, 80.0, 20.0 # mm/s, C, C
|
||||||
|
ctl.execute("geometry.open", {"path": str(design)})
|
||||||
|
surfaces = hold_all_faces(ctl, "HeatSink", Tsink)
|
||||||
|
ctl.execute("model.units", {"system": "mm-t-s-N-MPa"})
|
||||||
|
ctl.execute("model.material.create", AIR)
|
||||||
|
ctl.execute("model.fluid_domain.create", {
|
||||||
|
"name": "Duct", "material": "Air", "min": [-30, -2, 0], "max": [90, 42, 30], "spacing": 1.0,
|
||||||
|
"faces": {"x_min": {"type": "velocity", "velocity": [V, 0, 0]}, "x_max": {"type": "pressure", "pressure": 0},
|
||||||
|
"y_min": {"type": "wall"}, "y_max": {"type": "wall"}, "z_min": {"type": "wall"}, "z_max": {"type": "wall"}},
|
||||||
|
"obstacles": ["HeatSink"], "initial_velocity": [V, 0, 0],
|
||||||
|
"thermal": {"initial_temperature": Tin, "faces": {"x_min": {"type": "temperature", "value": Tin}}, "surfaces": surfaces},
|
||||||
|
})
|
||||||
|
ctl.execute("model.step.create", {"name": "Cool",
|
||||||
|
"procedure": {"type": "fluid", "domain": "Duct", "end_time": 4.0, "frames": 40, "time_step": 0.00025}})
|
||||||
|
log("solving the heat sink (forced convection) ...")
|
||||||
|
report = ctl.execute("solve.fluid", {"backend": "cpu"})
|
||||||
|
numbers = heat_sink_numbers(report, Tsink, Tin, V)
|
||||||
|
render = {"field": "temperature", "clamp": [Tin, Tsink], "streamlines": True,
|
||||||
|
"camera": {"yaw": -40.0, "pitch": 22.0, "target": {"center_of": "bbox"}, "fit": True},
|
||||||
|
"caption": "heat sink · forced air 0.1 m/s", "mp4": False}
|
||||||
|
return report, numbers, render
|
||||||
|
|
||||||
|
|
||||||
|
def heat_sink_numbers(report, Tsink, Tin, V):
|
||||||
|
heat = report.get("heat", [])
|
||||||
|
Q = -sum(h["final"] for h in heat if h.get("body") == "HeatSink") # mW removed from the sink
|
||||||
|
R = (Tsink - Tin) / (Q / 1000.0) if Q else None # K/W
|
||||||
|
return {
|
||||||
|
"inlet_velocity_mm_s": V,
|
||||||
|
"sink_temperature_C": Tsink, "inlet_air_C": Tin,
|
||||||
|
"heat_removed_W": round(Q / 1000.0, 3),
|
||||||
|
"thermal_resistance_K_per_W": round(R, 2) if R else None,
|
||||||
|
"air_temperature_C": report.get("temperature"),
|
||||||
|
"cells": report.get("cells"), "steps": report.get("steps"),
|
||||||
|
"density_change": report.get("mass", {}).get("relative_change"),
|
||||||
|
"warnings": report.get("warnings"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------- sealed enclosure (PCB)
|
||||||
|
|
||||||
|
def enclosure(ctl, design):
|
||||||
|
"""Natural convection: a chip on a vertical PCB in a sealed 60x40x60 mm box, no fan. The chip is
|
||||||
|
held at 40 C, the enclosure walls at 25 C ambient; buoyancy (Boussinesq) drives the plume. The
|
||||||
|
buoyant start-up needs a small, fixed time step — the auto step over-accelerates and goes
|
||||||
|
unstable (see the Gaps note in docs/examples.md). Convection only; no radiation."""
|
||||||
|
Tchip, Tamb = 40.0, 25.0 # C
|
||||||
|
ctl.execute("geometry.open", {"path": str(design)})
|
||||||
|
surfaces = hold_all_faces(ctl, "Chip", Tchip)
|
||||||
|
ctl.execute("model.units", {"system": "mm-t-s-N-MPa"})
|
||||||
|
ctl.execute("model.material.create", AIR)
|
||||||
|
wall = {"type": "temperature", "value": Tamb}
|
||||||
|
ctl.execute("model.fluid_domain.create", {
|
||||||
|
"name": "Box", "material": "Air", "min": [0, 0, 0], "max": [60, 40, 60], "spacing": 1.0,
|
||||||
|
"faces": {f: {"type": "wall"} for f in ("x_min", "x_max", "y_min", "y_max", "z_min", "z_max")},
|
||||||
|
"obstacles": ["PCB", "Chip"], "initial_velocity": [0, 0, 0],
|
||||||
|
"thermal": {"initial_temperature": Tamb, "gravity": [0, 0, -9810], "expansion": 3.4e-3,
|
||||||
|
"faces": {f: wall for f in ("x_min", "x_max", "y_min", "y_max", "z_min", "z_max")},
|
||||||
|
"surfaces": surfaces},
|
||||||
|
})
|
||||||
|
ctl.execute("model.step.create", {"name": "Natural",
|
||||||
|
"procedure": {"type": "fluid", "domain": "Box", "end_time": 3.0, "frames": 40, "time_step": 0.0002}})
|
||||||
|
log("solving the enclosure (natural convection) ...")
|
||||||
|
report = ctl.execute("solve.fluid", {"backend": "cpu"})
|
||||||
|
heat = report.get("heat", [])
|
||||||
|
Q = -sum(h["final"] for h in heat if h.get("body") == "Chip") # mW shed by the chip
|
||||||
|
numbers = {
|
||||||
|
"chip_temperature_C": Tchip, "ambient_C": Tamb,
|
||||||
|
"heat_shed_W": round(Q / 1000.0, 4),
|
||||||
|
"chip_to_ambient_K_per_W": round((Tchip - Tamb) / (Q / 1000.0), 1) if Q else None,
|
||||||
|
"air_temperature_C": report.get("temperature"),
|
||||||
|
"max_speed_mm_s": round(report.get("max_speed", 0.0), 1),
|
||||||
|
"cells": report.get("cells"), "steps": report.get("steps"),
|
||||||
|
"warnings": report.get("warnings"),
|
||||||
|
}
|
||||||
|
render = {"field": "temperature", "clamp": [Tamb, Tchip],
|
||||||
|
"slice": {"axis": "y", "position": 24},
|
||||||
|
"streamlines": {"count": 14, "max_steps": 1400, "line": {"from": [27, 24, 39], "to": [43, 24, 39]}},
|
||||||
|
"camera": {"yaw": -14.0, "pitch": 8.0, "target": {"center_of": "bbox"}, "fit": True},
|
||||||
|
"caption": "sealed enclosure · natural convection", "mp4": False}
|
||||||
|
return report, numbers, render
|
||||||
|
|
||||||
|
|
||||||
|
CASES = {
|
||||||
|
"heat-sink": {"recipe": "heatsink.solidcraft.json", "run": heat_sink, "out": "cooling-heat-sink"},
|
||||||
|
"enclosure": {"recipe": "enclosure.solidcraft.json", "run": enclosure, "out": "cooling-enclosure"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------- render + encode
|
||||||
|
|
||||||
|
def apply_view(ctl, render):
|
||||||
|
ctl.send("ui.set", {"dark": False, "style": "shaded_edges", "show_tree": False})
|
||||||
|
ctl.execute("results.contour", {"field": render["field"], "range": {"min": render["clamp"][0], "max": render["clamp"][1]}})
|
||||||
|
ctl.execute("results.surface", {"show": True})
|
||||||
|
if render.get("slice"):
|
||||||
|
ctl.execute("results.slice", {"field": render["field"], **render["slice"]})
|
||||||
|
sl = render.get("streamlines")
|
||||||
|
if sl:
|
||||||
|
ctl.execute("results.streamlines", dict({"show": True}, **(sl if isinstance(sl, dict) else {})))
|
||||||
|
ctl.send("ui.camera", render["camera"])
|
||||||
|
|
||||||
|
|
||||||
|
def render_still(ctl, render, out_png):
|
||||||
|
apply_view(ctl, render)
|
||||||
|
ctl.send("ui.render", {"path": str(out_png), "width": WIDTH, "height": HEIGHT})
|
||||||
|
log(f"wrote still {out_png}")
|
||||||
|
|
||||||
|
|
||||||
|
def render_clip(ctl, render, frames_dir, mp4):
|
||||||
|
frames_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
font = caption.Font.load(CAPTION_FONT)
|
||||||
|
# Discover the frame count from a results reply.
|
||||||
|
info = ctl.execute("results.slice", {"field": render["field"]})
|
||||||
|
nframes = info.get("frames", [])
|
||||||
|
nframes = len(nframes) if isinstance(nframes, list) else 0
|
||||||
|
if nframes <= 1:
|
||||||
|
return None
|
||||||
|
shots = []
|
||||||
|
for k in range(nframes):
|
||||||
|
ctl.execute("results.frame", {"frame": k})
|
||||||
|
shot = frames_dir / f"f{k:04d}.png"
|
||||||
|
ctl.send("ui.render", {"path": str(shot), "width": WIDTH, "height": HEIGHT})
|
||||||
|
bar = frames_dir / f"c{k:04d}.png"
|
||||||
|
bar.write_bytes(caption.caption_bar(font, (WIDTH, CAPTION_HEIGHT), render["caption"], f"frame {k + 1}/{nframes}"))
|
||||||
|
shots.append((shot, bar))
|
||||||
|
seqdir = frames_dir / "seq"
|
||||||
|
shutil.rmtree(seqdir, ignore_errors=True)
|
||||||
|
(seqdir / "caps").mkdir(parents=True)
|
||||||
|
i = 0
|
||||||
|
held = [(s, b, 1) for s, b in shots]
|
||||||
|
held[-1] = (held[-1][0], held[-1][1], 16)
|
||||||
|
for s, b, c in held:
|
||||||
|
for _ in range(c):
|
||||||
|
os.link(s, seqdir / f"{i:05d}.png")
|
||||||
|
os.link(b, seqdir / "caps" / f"{i:05d}.png")
|
||||||
|
i += 1
|
||||||
|
ffmpeg = os.environ.get("FFMPEG", shutil.which("ffmpeg") or "ffmpeg")
|
||||||
|
src = ["-framerate", str(FPS), "-i", str(seqdir / "%05d.png"), "-framerate", str(FPS), "-i", str(seqdir / "caps" / "%05d.png")]
|
||||||
|
cap = f"[0:v]pad={WIDTH}:{HEIGHT + CAPTION_HEIGHT}:0:0[w];[w][1:v]overlay=0:{HEIGHT}"
|
||||||
|
mp4.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
subprocess.run([ffmpeg, "-hide_banner", "-loglevel", "error", "-y", *src, "-filter_complex", cap,
|
||||||
|
"-c:v", "libx264", "-preset", "veryslow", "-crf", "26", "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(mp4)], check=True)
|
||||||
|
log(f"wrote clip {mp4} ({i} frames)")
|
||||||
|
return mp4
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("case", choices=sorted(CASES), help="which example to run")
|
||||||
|
ap.add_argument("--out", type=Path, default=ROOT / "docs" / "media" / "examples")
|
||||||
|
ap.add_argument("--no-build", action="store_true")
|
||||||
|
ap.add_argument("--no-clip", action="store_true", help="still only, skip the MP4")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
case = CASES[args.case]
|
||||||
|
if not args.no_build:
|
||||||
|
build()
|
||||||
|
bin_dir = target_dir() / "release"
|
||||||
|
work = Path(tempfile.mkdtemp(prefix="fieldcraft-cooling-"))
|
||||||
|
design = work / "part.solidcraft"
|
||||||
|
compile_recipe(ROOT / "examples" / "cooling" / case["recipe"], design)
|
||||||
|
args.out.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
port = free_port()
|
||||||
|
env = {**os.environ, "FIELDCRAFT_GPU": os.environ.get("FIELDCRAFT_GPU", "software")}
|
||||||
|
app = subprocess.Popen([str(bin_dir / "fieldcraft"), "--control", str(port)],
|
||||||
|
stdout=open(work / "app.log", "w"), stderr=subprocess.STDOUT, env=env)
|
||||||
|
ctl = None
|
||||||
|
try:
|
||||||
|
ctl = Control(port)
|
||||||
|
report, numbers, render = case["run"](ctl, design)
|
||||||
|
print(json.dumps(numbers, indent=2))
|
||||||
|
render_still(ctl, render, args.out / f"{case['out']}.png")
|
||||||
|
if render.get("mp4") and not args.no_clip:
|
||||||
|
render_clip(ctl, render, work / "frames", args.out / f"{case['out']}-transient.mp4")
|
||||||
|
finally:
|
||||||
|
if ctl:
|
||||||
|
ctl.close()
|
||||||
|
app.terminate()
|
||||||
|
try:
|
||||||
|
app.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
app.kill()
|
||||||
|
shutil.rmtree(work, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in new issue
Block a user