solver: tie weights and constraint reduction independent of the structural mesh (#358)

The tie's mortar rows take any node numbering and degree-of-freedom kinds, and
Reduction takes a DofLayout, so heat transfer can reuse both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-15 08:27:01 -04:00
1 parent 507e8e32d2
commit 0ec1882697
4 files changed
+105 -49

No files matched your search

+8 -1
View File
@@ -343,7 +343,14 @@ pub(crate) struct Setup<'a> {
pub(crate) fn reduction(s: &Setup, values: bool, order: &[usize], budget: Budget, warnings: &mut Vec<String>) -> Result<Reduction> { pub(crate) fn reduction(s: &Setup, values: bool, order: &[usize], budget: Budget, warnings: &mut Vec<String>) -> Result<Reduction> {
let mut rows = coupling_rows(s.model, s.resolved, s.mesh)?; let mut rows = coupling_rows(s.model, s.resolved, s.mesh)?;
rows.extend(crate::tie::rows(s.model, s.resolved, s.mesh, warnings)?); rows.extend(crate::tie::rows(s.model, s.resolved, s.mesh, warnings)?);
Reduction::new(s.mesh, order, &rows, |active| prescribed(s.model, s.resolved, s.mesh, s.step, values, active, warnings), budget) Reduction::new(
&s.mesh.layout,
s.mesh.discrete.dofs(),
order,
&rows,
|active| prescribed(s.model, s.resolved, s.mesh, s.step, values, active, warnings),
budget,
)
} }
/// The reduced matrices' memory budget: the factorization's. /// The reduced matrices' memory budget: the factorization's.
+19 -15
View File
@@ -52,7 +52,7 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use fieldcraft_fem::dense::inverse3; use fieldcraft_fem::dense::inverse3;
use fieldcraft_fem::{DofKind, DofSet}; use fieldcraft_fem::{DofKind, DofLayout, DofSet};
use fieldcraft_model::{CouplingKind, Model, Resolved}; use fieldcraft_model::{CouplingKind, Model, Resolved};
use crate::sparse::Csr; use crate::sparse::Csr;
@@ -201,11 +201,11 @@ pub fn coupling_rows<'m>(model: &'m Model, resolved: &Resolved, mesh: &Mesh) ->
} }
/// The degrees of freedom that exist: every mesh node's, and the reference point directions /// The degrees of freedom that exist: every mesh node's, and the reference point directions
/// some coupling row or discrete element involves. /// some constraint row or `extra` (the discrete elements') involves.
fn active_dofs(mesh: &Mesh, rows: &[Row]) -> Vec<bool> { fn active_dofs(layout: &DofLayout, extra: impl Iterator<Item = usize>, rows: &[Row]) -> Vec<bool> {
let mut active = vec![false; mesh.dofs()]; let mut active = vec![false; layout.dofs()];
active.iter_mut().take(mesh.mesh_dofs()).for_each(|a| *a = true); active.iter_mut().take(layout.node_dofs()).for_each(|a| *a = true);
for dof in mesh.discrete.dofs() { for dof in extra {
if let Some(a) = active.get_mut(dof) { if let Some(a) = active.get_mut(dof) {
*a = true; *a = true;
} }
@@ -361,21 +361,25 @@ fn gigabytes(bytes: usize) -> String {
impl Reduction { impl Reduction {
/// Eliminate the prescribed values (`prescribed(active)` gives them) and the constraint rows. /// Eliminate the prescribed values (`prescribed(active)` gives them) and the constraint rows.
/// `order` is the mesh nodes' order for numbering the free equations; reference point /// `order` is the mesh nodes' order for numbering the free equations; reference point
/// equations follow them. /// equations follow them. `layout` numbers the degrees of freedom (mesh nodes, then reference
/// points) and `extra` lists the reference point ones that exist without a row (discrete
/// elements); a heat transfer model passes its temperatures and none.
/// ///
/// A distributing coupling whose reference point is held makes one of its nodes depend on /// A distributing coupling whose reference point is held makes one of its nodes depend on
/// all the others, which couples every coupled degree of freedom with every other: a dense /// all the others, which couples every coupled degree of freedom with every other: a dense
/// (3N)² block in the reduced matrix. Its size is checked against `budget` before anything is /// (3N)² block in the reduced matrix. Its size is checked against `budget` before anything is
/// built. /// built.
pub fn new( pub fn new(
mesh: &Mesh, layout: &DofLayout,
extra: impl Iterator<Item = usize>,
order: &[usize], order: &[usize],
rows: &[Row], rows: &[Row],
prescribed: impl FnOnce(&[bool]) -> Result<Vec<Option<f64>>>, prescribed: impl FnOnce(&[bool]) -> Result<Vec<Option<f64>>>,
budget: Budget, budget: Budget,
) -> Result<Reduction> { ) -> Result<Reduction> {
let n = mesh.dofs(); let n = layout.dofs();
let active = active_dofs(mesh, rows); let mesh_dofs = layout.node_dofs();
let active = active_dofs(layout, extra, rows);
let prescribed = prescribed(&active)?; let prescribed = prescribed(&active)?;
let mut state: Vec<State> = (0..n) let mut state: Vec<State> = (0..n)
.map(|d| match (active.get(d).copied().unwrap_or(false), prescribed.get(d).copied().flatten()) { .map(|d| match (active.get(d).copied().unwrap_or(false), prescribed.get(d).copied().flatten()) {
@@ -389,15 +393,15 @@ impl Reduction {
// reference point, one block per coupling. // reference point, one block per coupling.
let mut coupled: BTreeMap<&str, Vec<usize>> = BTreeMap::new(); let mut coupled: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
for row in rows { for row in rows {
if row.pivot >= mesh.mesh_dofs() && state.get(row.pivot) == Some(&State::Fixed) { if row.pivot >= mesh_dofs && state.get(row.pivot) == Some(&State::Fixed) {
coupled.entry(row.coupling).or_default().extend(row.terms.iter().map(|t| t.0).filter(|d| *d < mesh.mesh_dofs())); coupled.entry(row.coupling).or_default().extend(row.terms.iter().map(|t| t.0).filter(|d| *d < mesh_dofs));
} }
} }
let mut blocks: Vec<(&str, usize, usize)> = Vec::new(); let mut blocks: Vec<(&str, usize, usize)> = Vec::new();
for (name, mut dofs) in coupled { for (name, mut dofs) in coupled {
dofs.sort_unstable(); dofs.sort_unstable();
dofs.dedup(); dofs.dedup();
let mut nodes: Vec<usize> = dofs.iter().filter_map(|d| mesh.layout.locate(*d).map(|(slot, _)| slot)).collect(); let mut nodes: Vec<usize> = dofs.iter().filter_map(|d| layout.locate(*d).map(|(slot, _)| slot)).collect();
nodes.dedup(); nodes.dedup();
blocks.push((name, nodes.len(), dofs.len().saturating_mul(dofs.len()))); blocks.push((name, nodes.len(), dofs.len().saturating_mul(dofs.len())));
} }
@@ -477,8 +481,8 @@ impl Reduction {
// Equations: free mesh degrees of freedom in node order, then reference point ones. // Equations: free mesh degrees of freedom in node order, then reference point ones.
let mut eq = vec![usize::MAX; n]; let mut eq = vec![usize::MAX; n];
let mut eq_dof = Vec::new(); let mut eq_dof = Vec::new();
let mesh_order = order.iter().flat_map(|node| mesh.layout.range(*node)); let mesh_order = order.iter().flat_map(|node| layout.range(*node));
for dof in mesh_order.chain(mesh.mesh_dofs()..n) { for dof in mesh_order.chain(mesh_dofs..n) {
if state.get(dof) == Some(&State::Free) if state.get(dof) == Some(&State::Free)
&& let Some(e) = eq.get_mut(dof) && let Some(e) = eq.get_mut(dof)
&& *e == usize::MAX && *e == usize::MAX
+7 -1
View File
@@ -321,6 +321,12 @@ fn volume(kind: ElementKind, x: &[Vec3]) -> Option<f64> {
} }
} }
/// The depth of element `kind` (corners `element`) under its face or edge `x` of measure `area`:
/// the element's volume over the area, or the face's size where the volume is unknown.
pub(crate) fn thickness(kind: ElementKind, element: &[Vec3], x: &[Vec3], area: f64) -> f64 {
volume(kind, element).filter(|v| *v > 0.0 && area > 0.0).map_or(size(x), |v| v / area)
}
/// The overlap integration of a face pair (`extent`: `None`) or an edge pair of a 2D model. /// The overlap integration of a face pair (`extent`: `None`) or an edge pair of a 2D model.
fn overlap<T: Real>(extent: Option<Extent>, xs: &[[T; 3]], xm: &[[T; 3]], f: impl FnMut(&Sample<T>)) -> Option<()> { fn overlap<T: Real>(extent: Option<Extent>, xs: &[[T; 3]], xm: &[[T; 3]], f: impl FnMut(&Sample<T>)) -> Option<()> {
match extent { match extent {
@@ -420,7 +426,7 @@ fn surface_face(mesh: &Mesh, owner: &str, element: u32, labels: Vec<u32>, edge:
[a, b] if edge => norm([b[0] - a[0], b[1] - a[1], 0.0]), [a, b] if edge => norm([b[0] - a[0], b[1] - a[1], 0.0]),
_ => area_vector(&x).map_or(0.0, norm), _ => area_vector(&x).map_or(0.0, norm),
}; };
let thickness = volume(el.kind, &el.xyz).filter(|v| *v > 0.0 && area > 0.0).map_or(size(&x), |v| v / area); let thickness = thickness(el.kind, &el.xyz, &x, area);
if !(thickness > 0.0 && thickness.is_finite()) { if !(thickness > 0.0 && thickness.is_finite()) {
return Err(at(format!("the {} has no size", if edge { "edge" } else { "face" }))); return Err(at(format!("the {} has no size", if edge { "edge" } else { "face" })));
} }
+71 -32
View File
@@ -27,8 +27,14 @@
//! face under it stays free, with a warning. The constraints are small-displacement (linear) //! face under it stays free, with a warning. The constraints are small-displacement (linear)
//! constraints in nonlinear steps too. //! constraints in nonlinear steps too.
//! //!
//! **Scope.** Linear solid faces (tri3, quad4) in 3D models; heat transfer and explicit dynamics //! **Temperature.** The same weights tie each secondary node's temperature to the main nodes'
//! refuse ties by name. //! (T_A = Σ_J w_AJ T_J) in heat transfer and in the heat part of coupled temperature–displacement
//! steps: the temperature analogue of the displacement tie, so a linear temperature field crosses
//! a flat tie exactly, and since the constraint is eliminated (no interface resistance), the heat
//! leaving one side enters the other.
//!
//! **Scope.** Linear solid faces (tri3, quad4) in 3D models; explicit dynamics refuses ties by
//! name.
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -38,7 +44,7 @@ use fieldcraft_fem::mortar::integrate;
use fieldcraft_model::{Model, Resolved}; use fieldcraft_model::{Model, Resolved};
use crate::constraints::Row; use crate::constraints::Row;
use crate::contact::{Face, faces}; use crate::contact::faces;
use crate::system::Mesh; use crate::system::Mesh;
use crate::{Error, Result}; use crate::{Error, Result};
@@ -50,10 +56,6 @@ const GRID_CELLS_PER_FACE: usize = 4;
/// A secondary coefficient below this fraction of its node's total is round-off. /// A secondary coefficient below this fraction of its node's total is round-off.
const NEGLIGIBLE: f64 = 1e-12; const NEGLIGIBLE: f64 = 1e-12;
fn corners(mesh: &Mesh, f: &Face) -> Vec<Vec3> {
f.nodes.iter().map(|n| mesh.xyz.get(*n).copied().unwrap_or_default()).collect()
}
/// x = M⁻¹ b for a small symmetric positive definite M (Gauss–Jordan with partial pivoting); /// x = M⁻¹ b for a small symmetric positive definite M (Gauss–Jordan with partial pivoting);
/// `None` when it is singular. /// `None` when it is singular.
fn solve(m: &[[f64; 4]; 4], n: usize, b: [f64; 4]) -> Option<[f64; 4]> { fn solve(m: &[[f64; 4]; 4], n: usize, b: [f64; 4]) -> Option<[f64; 4]> {
@@ -105,32 +107,53 @@ fn dual(x: &[Vec3]) -> Option<[[f64; 4]; 4]> {
Some(out) Some(out)
} }
/// The constraint rows of every tie (ties by name, secondary nodes by label, directions in /// One face of a tie surface: its nodes (slots of the caller's numbering, in the face's order)
/// order), and warnings for the secondary nodes left free. /// and the depth of the element under it (the default position tolerance is half of it).
pub fn rows<'m>(model: &'m Model, resolved: &Resolved, mesh: &Mesh, warnings: &mut Vec<String>) -> Result<Vec<Row<'m>>> { pub(crate) struct TieFace {
pub nodes: Vec<usize>,
pub thickness: f64,
}
/// The nodes a tie works on: their coordinates and labels by slot, and the faces of a set
/// (`faces(owner, set)`, refusing what the analysis cannot tie).
pub(crate) struct TieNodes<'a, F> {
pub xyz: &'a [Vec3],
pub labels: &'a [u32],
pub faces: F,
}
/// The constraint rows of every tie, one per secondary node and kind in `kinds` (ties by name,
/// secondary nodes by slot, kinds in order); `dof(slot, kind)` numbers the degrees of freedom.
/// The same mortar weights tie displacements (the translations) and temperatures (one scalar
/// per node). Warnings name the secondary nodes left free.
pub(crate) fn rows_of<'m, F>(
model: &'m Model,
nodes: &TieNodes<'_, F>,
kinds: &[DofKind],
dof: impl Fn(usize, DofKind) -> Result<usize>,
warnings: &mut Vec<String>,
) -> Result<Vec<Row<'m>>>
where
F: Fn(&str, &str) -> Result<Vec<TieFace>>,
{
let mut out = Vec::new(); let mut out = Vec::new();
if model.ties().is_empty() { let corners = |f: &TieFace| -> Vec<Vec3> { f.nodes.iter().map(|n| nodes.xyz.get(*n).copied().unwrap_or_default()).collect() };
return Ok(out); let label = |n: usize| nodes.labels.get(n).copied().unwrap_or(0);
}
if mesh.theory.is_some() {
let name = model.ties().keys().next().map_or("", String::as_str);
return Err(Error::NotSupported(format!("ties (tie `{name}`) in 2D models")));
}
for (name, tie) in model.ties() { for (name, tie) in model.ties() {
let owner = format!("tie `{name}`"); let owner = format!("tie `{name}`");
let secondary = faces(model, resolved, mesh, &owner, &tie.secondary)?; let secondary = (nodes.faces)(&owner, &tie.secondary)?;
let main = faces(model, resolved, mesh, &owner, &tie.main)?; let main = (nodes.faces)(&owner, &tie.main)?;
let main_x: Vec<Vec<Vec3>> = main.iter().map(|f| corners(mesh, f)).collect(); let main_x: Vec<Vec<Vec3>> = main.iter().map(corners).collect();
let reach = |f: &Face| tie.position_tolerance.unwrap_or(0.5 * f.thickness); let reach = |f: &TieFace| tie.position_tolerance.unwrap_or(0.5 * f.thickness);
let boxes: Vec<Aabb> = main.iter().zip(&main_x).map(|(f, x)| bounds(x, reach(f))).collect(); let boxes: Vec<Aabb> = main.iter().zip(&main_x).map(|(f, x)| bounds(x, reach(f))).collect();
let grid = Grid::new(&boxes, GRID_CELLS_PER_FACE * boxes.len() + 64); let grid = Grid::new(&boxes, GRID_CELLS_PER_FACE * boxes.len() + 64);
// Per secondary node (mesh slot), its main nodes' weights M_AJ, in face order. // Per secondary node (slot), its main nodes' weights M_AJ, in face order.
let mut weights: BTreeMap<usize, BTreeMap<usize, f64>> = BTreeMap::new(); let mut weights: BTreeMap<usize, BTreeMap<usize, f64>> = BTreeMap::new();
let mut candidates = Vec::new(); let mut candidates = Vec::new();
for f in &secondary { for f in &secondary {
let xs = corners(mesh, f); let xs = corners(f);
let coefficients = let first = f.nodes.first().map_or(0, |n| label(*n));
dual(&xs).ok_or_else(|| Error::Invalid(format!("{owner}: a secondary face at node {} has no area", label(mesh, f))))?; let coefficients = dual(&xs).ok_or_else(|| Error::Invalid(format!("{owner}: a secondary face at node {first} has no area")))?;
for n in &f.nodes { for n in &f.nodes {
weights.entry(*n).or_default(); weights.entry(*n).or_default();
} }
@@ -167,16 +190,15 @@ pub fn rows<'m>(model: &'m Model, resolved: &Resolved, mesh: &Mesh, warnings: &m
for (node, row) in &weights { for (node, row) in &weights {
let total: f64 = row.values().sum(); let total: f64 = row.values().sum();
if !(total.abs() > 0.0 && total.is_finite()) { if !(total.abs() > 0.0 && total.is_finite()) {
free.push(mesh.labels.get(*node).copied().unwrap_or(0)); free.push(label(*node));
continue; continue;
} }
let terms: Vec<(usize, f64)> = row.iter().map(|(j, w)| (*j, w / total)).filter(|(_, w)| w.abs() > NEGLIGIBLE).collect(); let terms: Vec<(usize, f64)> = row.iter().map(|(j, w)| (*j, w / total)).filter(|(_, w)| w.abs() > NEGLIGIBLE).collect();
for k in 0..3 { for &kind in kinds {
let kind = DofKind::translation(k).ok_or_else(|| Error::Internal("axis".into()))?; let pivot = dof(*node, kind)?;
let pivot = mesh.dof(*node, kind)?;
let mut row = vec![(pivot, 1.0)]; let mut row = vec![(pivot, 1.0)];
for (j, w) in &terms { for (j, w) in &terms {
row.push((mesh.dof(*j, kind)?, -w)); row.push((dof(*j, kind)?, -w));
} }
out.push(Row { terms: row, pivot, coupling: name.as_str() }); out.push(Row { terms: row, pivot, coupling: name.as_str() });
} }
@@ -191,8 +213,25 @@ pub fn rows<'m>(model: &'m Model, resolved: &Resolved, mesh: &Mesh, warnings: &m
Ok(out) Ok(out)
} }
fn label(mesh: &Mesh, f: &Face) -> u32 { /// The displacement constraint rows of every tie in a structural analysis (three translations
f.nodes.first().and_then(|n| mesh.labels.get(*n)).copied().unwrap_or(0) /// per secondary node).
pub fn rows<'m>(model: &'m Model, resolved: &Resolved, mesh: &Mesh, warnings: &mut Vec<String>) -> Result<Vec<Row<'m>>> {
if model.ties().is_empty() {
return Ok(Vec::new());
}
if mesh.theory.is_some() {
let name = model.ties().keys().next().map_or("", String::as_str);
return Err(Error::NotSupported(format!("ties (tie `{name}`) in 2D models")));
}
let nodes = TieNodes {
xyz: &mesh.xyz,
labels: &mesh.labels,
faces: |owner: &str, set: &str| -> Result<Vec<TieFace>> {
Ok(faces(model, resolved, mesh, owner, set)?.into_iter().map(|f| TieFace { nodes: f.nodes, thickness: f.thickness }).collect())
},
};
let translations = [DofKind::Ux, DofKind::Uy, DofKind::Uz];
rows_of(model, &nodes, &translations, |slot, kind| mesh.dof(slot, kind), warnings)
} }
#[cfg(test)] #[cfg(test)]