diff --git a/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/cutwall.rs b/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/cutwall.rs new file mode 100644 index 0000000..db420a7 --- /dev/null +++ b/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/cutwall.rs @@ -0,0 +1,400 @@ +//! The apertured cut-cell wall (`WallScheme::CutCell`, item 10 — the +//! "AM-wall"): the cut geometry classifies the grid (a cell is fluid where +//! its fluid volume is positive; an interior face is an unknown where its +//! aperture is positive, prescribed the surface velocity otherwise — no +//! ghost faces), and the wall enters the operators through the apertures: +//! continuity `Σ_f A_f u_f·n_f + U_b·W_c = 0` per cell, the projection +//! coefficient `dt·A_f/δ`, the momentum control volume of an unknown face +//! `V_u = A_f h` with its own faces' apertures averaged from the two +//! adjacent cells (mass fluxes averaged, so the momentum volume conserves +//! mass exactly when the cells do), the wall closing it (`W = −Σ A n`), +//! an implicit wall shear `μ A_w (u_f − U_b)/d_f`, and an inertia floor of +//! 0.1 in the time derivative only. Every prescribed value is the limit of +//! the computed one as the aperture closes (the shear coefficient grows as +//! `1/A_f`), which is what makes the wall smooth in the interface position. + +use super::Grid; +use super::body::Body; +use super::cut::CutGeometry; +use super::field::Field; +use super::step::{Boundaries, Side}; +use super::wall::{FaceKind, Mask}; + +/// The inertia floor: the momentum volume's fraction in the time +/// derivative is at least this. +pub(super) const INERTIA_FLOOR: f64 = 0.1; +/// The wall-distance floor of a face, in units of the smallest spacing. +pub(super) const DISTANCE_FLOOR: f64 = 0.05; + +/// Lattice addressing of faces and cells with the periodic wrap in z as +/// data: a face of component `c` at `p = [i, j, k]` (its own coordinate is +/// the face index, the others the cell's), a cell at `[i, j, k]`. +#[derive(Debug, Clone, Copy)] +pub(super) struct Lattice { + pub(super) g: Grid, + pub(super) periodic_z: bool, +} + +impl Lattice { + fn wrap_z(&self, k: i64, planes: i64) -> Option { + if self.periodic_z { + Some(k.rem_euclid(self.g.nz as i64) as usize) + } else if (0..planes).contains(&k) { + Some(k as usize) + } else { + None + } + } + + /// The index of the face of component `c` at `p`, `None` outside. + pub(super) fn face(&self, c: usize, p: [i64; 3]) -> Option { + let (nx, ny, nz) = (self.g.nx as i64, self.g.ny as i64, self.g.nz as i64); + let (i, j) = (p[0], p[1]); + let (ni, nj, nk) = match c { + 0 => (nx + 1, ny, nz), + 1 => (nx, ny + 1, nz), + _ => (nx, ny, nz + 1), + }; + if !(0..ni).contains(&i) || !(0..nj).contains(&j) { + return None; + } + let k = self.wrap_z(p[2], nk)?; + let (i, j) = (i as usize, j as usize); + Some(match c { + 0 => self.g.uface(k, j, i), + 1 => self.g.vface(k, j, i), + _ => self.g.wface(k, j, i), + }) + } + + /// The index of the cell at `p`, `None` outside. + pub(super) fn cell(&self, p: [i64; 3]) -> Option { + let (nx, ny, nz) = (self.g.nx as i64, self.g.ny as i64, self.g.nz as i64); + if !(0..nx).contains(&p[0]) || !(0..ny).contains(&p[1]) { + return None; + } + let k = self.wrap_z(p[2], nz)?; + Some(self.g.cell(k, p[1] as usize, p[0] as usize)) + } + + /// The centre of the face of component `c` at `p`. + pub(super) fn face_position(&self, c: usize, p: [i64; 3]) -> [f64; 3] { + let h = [self.g.dx, self.g.dy, self.g.dz]; + let mut x = [0.0; 3]; + for d in 0..3 { + let off = if d == c { 0.0 } else { 0.5 }; + x[d] = (p[d] as f64 + off) * h[d]; + } + x + } +} + +/// The geometry of an unknown face's momentum control volume. +#[derive(Debug, Clone, Copy)] +pub(super) struct CvGeometry { + /// The face's own aperture. + pub(super) alpha: f64, + /// The control volume's face apertures `[direction][minus, plus]`. + pub(super) ap: [[f64; 2]; 3], + /// The wall's vector area closing the control volume (into the body). + pub(super) wall: [f64; 3], + /// The wall distance of the face (floored). + pub(super) distance: f64, +} + +impl Mask { + /// Classify the grid against `body` at `t` by its cut geometry. + pub fn build_cut(body: &Body, g: Grid, t: f64, b: Boundaries) -> Result { + let cut = CutGeometry::build(body, g, t); + let (nx, ny, nz) = (g.nx, g.ny, g.nz); + let periodic = b.z0 == Side::Periodic; + let allowed = |side: Side| matches!(side, Side::Velocity | Side::Periodic | Side::SlipWall); + let mut cell_fluid = vec![true; g.cells()]; + let mut fluid_cells = 0; + let mut anchor = None; + for k in 0..nz { + for j in 0..ny { + for i in 0..nx { + let idx = g.cell(k, j, i); + let fluid = cut.vol[idx] > 0.0; + cell_fluid[idx] = fluid; + if fluid { + fluid_cells += 1; + if anchor.is_none() { + anchor = Some(idx); + } + } else { + let touches = (i == 0 && !allowed(b.x0)) + || (i + 1 == nx && !allowed(b.x1)) + || (j == 0 && !allowed(b.y0)) + || (j + 1 == ny && !allowed(b.y1)) + || (k == 0 && !allowed(b.z0)) + || (k + 1 == nz && !allowed(b.z1)); + if touches { + return Err(format!( + "embedded body reaches a domain side that is not a Velocity/Periodic side at cell ({k}, {j}, {i})" + )); + } + } + } + } + } + let Some(anchor) = anchor else { + return Err("embedded body covers the whole domain".into()); + }; + let kind = |a: f64| { + if a > 0.0 { + FaceKind::Fluid + } else { + FaceKind::Solid + } + }; + let mut u_kind = vec![FaceKind::Fluid; g.n_ufaces()]; + let mut v_kind = vec![FaceKind::Fluid; g.n_vfaces()]; + let mut w_kind = vec![FaceKind::Fluid; g.n_wfaces()]; + for k in 0..nz { + for j in 0..ny { + for i in 1..nx { + let f = g.uface(k, j, i); + u_kind[f] = kind(cut.a_u[f]); + } + } + for j in 1..ny { + for i in 0..nx { + let f = g.vface(k, j, i); + v_kind[f] = kind(cut.a_v[f]); + } + } + } + let w_range = if periodic { 0..nz + 1 } else { 1..nz }; + for k in w_range { + for j in 0..ny { + for i in 0..nx { + let f = g.wface(k, j, i); + w_kind[f] = kind(cut.a_w[f]); + } + } + } + Ok(Self { + grid: g, + periodic_z: periodic, + cell_fluid, + u_kind, + v_kind, + w_kind, + u_ghosts: Vec::new(), + v_ghosts: Vec::new(), + w_ghosts: Vec::new(), + anchor, + fluid_cells, + cut: Some(cut), + }) + } + + pub(super) fn lattice(&self) -> Lattice { + Lattice { + g: self.grid, + periodic_z: self.periodic_z, + } + } + + /// Aperture of the face of component `c` at lattice `p` (1 without a + /// cut geometry), `None` outside the grid. + pub(super) fn aperture(&self, c: usize, p: [i64; 3]) -> Option { + let f = self.lattice().face(c, p)?; + Some(match c { + 0 => self.a_u(f), + 1 => self.a_v(f), + _ => self.a_w(f), + }) + } + + /// The momentum control volume of the unknown face of component `c` at + /// `p`: its face apertures are the averages of the two adjacent cells' + /// (the own-direction faces at the cell centres average the face's and + /// its own-direction neighbours' apertures), its wall closes them, its + /// wall distance is the face centre's signed distance moved to the + /// fluid part's centre, `φ + ½h(1 − α)`, floored. + pub(super) fn cv_geometry(&self, c: usize, p: [i64; 3]) -> CvGeometry { + let g = self.grid; + let h = [g.dx, g.dy, g.dz]; + let area = [g.dy * g.dz, g.dx * g.dz, g.dx * g.dy]; + let mut e = |d: usize| { + let mut v = [0i64; 3]; + v[d] = 1; + v + }; + let add = + |a: [i64; 3], b: [i64; 3], s: i64| [a[0] + s * b[0], a[1] + s * b[1], a[2] + s * b[2]]; + let ec = e(c); + let cell_minus = add(p, ec, -1); + let cell_plus = p; + let alpha = self.aperture(c, p).unwrap_or(1.0); + let mut ap = [[1.0; 2]; 3]; + for d in 0..3 { + let ed = e(d); + if d == c { + let am = self.aperture(c, add(p, ec, -1)).unwrap_or(alpha); + let apl = self.aperture(c, add(p, ec, 1)).unwrap_or(alpha); + ap[d] = [0.5 * (am + alpha), 0.5 * (alpha + apl)]; + } else { + let minus = 0.5 + * (self.aperture(d, cell_minus).unwrap_or(1.0) + + self.aperture(d, cell_plus).unwrap_or(1.0)); + let plus = 0.5 + * (self.aperture(d, add(cell_minus, ed, 1)).unwrap_or(1.0) + + self.aperture(d, add(cell_plus, ed, 1)).unwrap_or(1.0)); + ap[d] = [minus, plus]; + } + } + let mut wall = [0.0; 3]; + for d in 0..3 { + wall[d] = -(ap[d][1] - ap[d][0]) * area[d]; + } + let h_min = g.dx.min(g.dy).min(g.dz); + let phi_face = self.cut.as_ref().map_or(h_min, |cut| { + let f = self + .lattice() + .face(c, p) + .expect("unknown face inside the grid"); + match c { + 0 => cut.d_u[f], + 1 => cut.d_v[f], + _ => cut.d_w[f], + } + }); + let distance = (phi_face + 0.5 * h[c] * (1.0 - alpha)).max(DISTANCE_FLOOR * h_min); + CvGeometry { + alpha, + ap, + wall, + distance, + } + } + + /// The surface velocity component `c` at the foot of the normal from + /// the face centre. + pub fn surface_velocity_at(&self, body: &Body, x: [f64; 3], c: usize, t: f64) -> f64 { + let g = self.grid; + let eps = 1e-6 * g.dx.min(g.dy).min(g.dz); + let s = body.phi(x[0], x[1], x[2], t); + let (n1, n2, n3) = body.normal(x[0], x[1], x[2], t, eps); + let v = body.surface_velocity(x[0] - s * n1, x[1] - s * n2, x[2] - s * n3, t); + [v.0, v.1, v.2][c] + } + + /// The volume fluxes of the surface velocity through every cell's wall + /// into the body, `U_b·W_c` (zero for a body at rest; the porous + /// manufactured surface's flux otherwise), made compatible: the net + /// flux (the quadrature's defect on a closed surface — a rigid + /// translation's is zero by closure) is redistributed over the wall + /// cells by wall area, as the binary wall's ghost fluxes are. Returns + /// the table and the correction (flux per unit wall area). + pub fn wall_flux_table(&self, body: &Body, t: f64) -> (Vec, f64) { + let mut table = vec![0.0; self.grid.cells()]; + let Some(cut) = self.cut.as_ref() else { + return (table, 0.0); + }; + let (mut net, mut area) = (0.0, 0.0); + for idx in 0..table.len() { + if !self.cell_fluid[idx] { + continue; + } + let w = cut.wall[idx]; + let a = (w[0] * w[0] + w[1] * w[1] + w[2] * w[2]).sqrt(); + if a == 0.0 { + continue; + } + table[idx] = self.wall_flux(body, idx, t); + net += table[idx]; + area += a; + } + let correction = if area > 0.0 { net / area } else { 0.0 }; + if correction != 0.0 { + for (idx, w) in cut.wall.iter().enumerate() { + let a = (w[0] * w[0] + w[1] * w[1] + w[2] * w[2]).sqrt(); + table[idx] -= correction * a; + } + } + (table, correction) + } + + /// The volume flux of the surface velocity through a cell's wall into + /// the body, `U_b·W_c`, uncorrected. Zero without a cut geometry. + pub fn wall_flux(&self, body: &Body, idx: usize, t: f64) -> f64 { + let Some(cut) = self.cut.as_ref() else { + return 0.0; + }; + let w = cut.wall[idx]; + if w == [0.0; 3] { + return 0.0; + } + let g = self.grid; + let (k, j, i) = g.kji(idx); + let x = [ + (i as f64 + 0.5) * g.dx, + (j as f64 + 0.5) * g.dy, + (k as f64 + 0.5) * g.dz, + ]; + let mut flux = 0.0; + for (c, wc) in w.iter().enumerate() { + flux += self.surface_velocity_at(body, x, c, t) * wc; + } + flux + } + + /// The cut-cell load route: the force on the body from the operators + /// themselves — `Σ_c p_c W_c` over the cells plus the implicit wall + /// shear `Σ_f μ A_w (u_f − U_b)/d_f` over the unknown faces. `None` + /// without a cut geometry. + pub fn cut_wall_force(&self, body: &Body, f: &Field, mu: f64, t: f64) -> Option<[f64; 3]> { + let cut = self.cut.as_ref()?; + let g = self.grid; + let (nx, ny, nz) = (g.nx, g.ny, g.nz); + let mut force = [0.0; 3]; + for (idx, w) in cut.wall.iter().enumerate() { + if self.cell_fluid[idx] { + for c in 0..3 { + force[c] += f.p[idx] * w[c]; + } + } + } + let lat = self.lattice(); + let values: [&[f64]; 3] = [&f.u, &f.v, &f.w]; + let w_range = if self.periodic_z { 0..nz } else { 1..nz }; + for c in 0..3 { + let (ir, jr, kr) = match c { + 0 => (1..nx, 0..ny, 0..nz), + 1 => (0..nx, 1..ny, 0..nz), + _ => (0..nx, 0..ny, w_range.clone()), + }; + for k in kr { + for j in jr.clone() { + for i in ir.clone() { + let p = [i as i64, j as i64, k as i64]; + let idx = lat.face(c, p).expect("face"); + let kind = match c { + 0 => self.u_kind[idx], + 1 => self.v_kind[idx], + _ => self.w_kind[idx], + }; + if kind != FaceKind::Fluid { + continue; + } + let cv = self.cv_geometry(c, p); + let a_w = (cv.wall[0] * cv.wall[0] + + cv.wall[1] * cv.wall[1] + + cv.wall[2] * cv.wall[2]) + .sqrt(); + if a_w == 0.0 { + continue; + } + let ub = self.surface_velocity_at(body, lat.face_position(c, p), c, t); + force[c] += mu * a_w * (values[c][idx] - ub) / cv.distance; + } + } + } + } + Some(force) + } +} diff --git a/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/loads.rs b/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/loads.rs index 4f4a473..ce99962 100644 --- a/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/loads.rs +++ b/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/loads.rs @@ -379,6 +379,7 @@ impl Mask { if !self.is_fluid_cell(idx) { continue; } + let dv = dv * self.vol(idx); let (fu0, fu1) = (g.uface(k, j, i), g.uface(k, j, i + 1)); let (fv0, fv1) = (g.vface(k, j, i), g.vface(k, j + 1, i)); let (fw0, fw1) = (g.wface(k, j, i), g.wface(k + 1, j, i)); diff --git a/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/mod.rs b/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/mod.rs index 0b1227f..d117e76 100644 --- a/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/mod.rs +++ b/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/mod.rs @@ -8,6 +8,7 @@ pub mod body; pub mod cut; +pub mod cutwall; pub mod field; pub mod grid; pub mod loads; diff --git a/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/step/cut_predictor.rs b/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/step/cut_predictor.rs new file mode 100644 index 0000000..746be09 --- /dev/null +++ b/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/step/cut_predictor.rs @@ -0,0 +1,215 @@ +//! The predictor on the apertured cut-cell wall (`cutwall.rs`): one +//! routine for the three components, addressed on the face lattice. The +//! momentum control volume of an unknown face is `V_u = α h A` closed by +//! the wall; its faces carry the mass fluxes averaged from the two adjacent +//! cells (the 2D face velocities when every aperture is 1), upwind plus the +//! TVD correction as the 2D predictor, apertured diffusion, the pressure +//! force `−(p₊ − p₋) α A` (the projection's gradient), the wall's momentum +//! flux `m_w U_b` with `m_w = −Σ m_f` (so a uniform field stays uniform), +//! and the implicit wall shear `μ A_w (u − U_b)/d_f`; the time derivative +//! carries the inertia floor. + +use super::{Side, Solver}; +use crate::solvers::incompressible::embedded3::cutwall::INERTIA_FLOOR; +use crate::solvers::incompressible::embedded3::field::Field; +use crate::solvers::incompressible::simple::ConvectionScheme; + +impl Solver { + /// The three components' predictors on the unknown faces; the + /// prescribed faces keep their imposed values. + pub(super) fn cut_predictor(&self, field: &mut Field, dt: f64, t_old: f64) { + let mask = self.mask.as_ref().expect("cut mask"); + let g = field.grid; + let (nx, ny, nz) = (g.nx, g.ny, g.nz); + let periodic = self.params.boundaries.periodic_z(); + let lat = mask.lattice(); + let w_range = if periodic { 0..nz } else { 1..nz }; + for c in 0..3 { + let (ir, jr, kr) = match c { + 0 => (1..nx, 0..ny, 0..nz), + 1 => (0..nx, 1..ny, 0..nz), + _ => (0..nx, 0..ny, w_range.clone()), + }; + let mut updates = Vec::new(); + for k in kr { + for j in jr.clone() { + for i in ir.clone() { + let fluid = match c { + 0 => self.u_is_fluid(k, j, i), + 1 => self.v_is_fluid(k, j, i), + _ => self.w_is_fluid(k, j, i), + }; + if !fluid { + continue; + } + let p = [i as i64, j as i64, k as i64]; + let idx = lat.face(c, p).expect("face"); + updates.push((idx, self.cut_face_update(field, c, p, dt, t_old))); + } + } + } + let out: &mut Vec = match c { + 0 => &mut field.u, + 1 => &mut field.v, + _ => &mut field.w, + }; + for (idx, val) in updates { + out[idx] = val; + } + } + if periodic { + for j in 0..ny { + for i in 0..nx { + field.w[g.wface(nz, j, i)] = field.w[g.wface(0, j, i)]; + } + } + } + } + + /// The predicted value of the unknown face of component `c` at lattice + /// `p` from the old field. + #[allow(clippy::too_many_lines)] + fn cut_face_update(&self, field: &Field, c: usize, p: [i64; 3], dt: f64, t_old: f64) -> f64 { + let mask = self.mask.as_ref().expect("cut mask"); + let body = self.body.as_ref().expect("body"); + let g = field.grid; + let h = [g.dx, g.dy, g.dz]; + let n = [g.nx as f64, g.ny as f64, g.nz as f64]; + let area = [g.dy * g.dz, g.dx * g.dz, g.dx * g.dy]; + let rho = self.fluid.density; + let mu = self.fluid.viscosity; + let b = self.params.boundaries; + let sides = [[b.x0, b.x1], [b.y0, b.y1], [b.z0, b.z1]]; + let scheme = self.params.convection_scheme; + let lat = mask.lattice(); + let old: [&[f64]; 3] = [&field.u_old, &field.v_old, &field.w_old]; + let e = |d: usize| { + let mut v = [0i64; 3]; + v[d] = 1; + v + }; + let add = + |a: [i64; 3], b: [i64; 3], s: i64| [a[0] + s * b[0], a[1] + s * b[1], a[2] + s * b[2]]; + // The old value of a face of component `cc` at `q`, `None` outside. + let val = |cc: usize, q: [i64; 3]| lat.face(cc, q).map(|f| old[cc][f]); + let ap = |cc: usize, q: [i64; 3]| mask.aperture(cc, q); + let cv = mask.cv_geometry(c, p); + let x = lat.face_position(c, p); + let u0 = val(c, p).expect("the face"); + let ec = e(c); + let cell_minus = add(p, ec, -1); + let cell_plus = p; + let ub = mask.surface_velocity_at(body, x, c, t_old); + + let mut mass_out = 0.0; + let mut conv = 0.0; + let mut diff = 0.0; + for d in 0..3 { + let ed = e(d); + let a_d = area[d]; + // Neighbouring faces of this component along d (None beyond a wall). + let up1 = val(c, add(p, ed, 1)); + let up2 = val(c, add(p, ed, 2)); + let dn1 = val(c, add(p, ed, -1)); + let dn2 = val(c, add(p, ed, -2)); + // The control volume's mass fluxes through its plus / minus faces + // along d: the averages of the two adjacent cells' face fluxes + // (own direction: the face's and its neighbours' fluxes). + let (m_plus, m_minus) = if d == c { + let f_up = ap(c, add(p, ec, 1)).unwrap_or(cv.alpha) * up1.unwrap_or(u0); + let f_dn = ap(c, add(p, ec, -1)).unwrap_or(cv.alpha) * dn1.unwrap_or(u0); + let f0 = cv.alpha * u0; + (0.5 * (f0 + f_up) * a_d, 0.5 * (f_dn + f0) * a_d) + } else { + let flux = |q: [i64; 3]| ap(d, q).unwrap_or(1.0) * val(d, q).unwrap_or(0.0); + ( + 0.5 * (flux(add(cell_minus, ed, 1)) + flux(add(cell_plus, ed, 1))) * a_d, + 0.5 * (flux(cell_minus) + flux(cell_plus)) * a_d, + ) + }; + mass_out += m_plus - m_minus; + // Beyond a domain side along d: the boundary value on a Velocity + // side, the face's own value (mirror) otherwise. + let beyond = |plus: bool| { + let side = sides[d][usize::from(plus)]; + if side == Side::Velocity { + let mut xb = x; + xb[d] = if plus { n[d] * h[d] } else { 0.0 }; + [ + self.boundary(xb[0], xb[1], xb[2], t_old).0, + self.boundary(xb[0], xb[1], xb[2], t_old).1, + self.boundary(xb[0], xb[1], xb[2], t_old).2, + ][c] + } else { + u0 + } + }; + // Convection through the plus face. + let (u_plus, delta_plus) = match up1 { + Some(un) => { + let delta = if scheme == ConvectionScheme::Upwind { + 0.0 + } else if m_plus >= 0.0 { + scheme.face_correction(dn1, u0, un) + } else { + scheme.face_correction(up2, un, u0) + }; + (Self::upwind(m_plus, u0, un), delta) + } + None => (Self::upwind(m_plus, u0, beyond(true)), 0.0), + }; + let (u_minus, delta_minus) = match dn1 { + Some(ud) => { + let delta = if scheme == ConvectionScheme::Upwind { + 0.0 + } else if m_minus >= 0.0 { + scheme.face_correction(dn2, ud, u0) + } else { + scheme.face_correction(up1, u0, ud) + }; + (Self::upwind(m_minus, ud, u0), delta) + } + None => (Self::upwind(m_minus, beyond(false), u0), 0.0), + }; + conv += m_plus * (u_plus + delta_plus) - m_minus * (u_minus + delta_minus); + // Diffusion through the plus / minus faces. + let (g_minus, g_plus) = (cv.ap[d][0], cv.ap[d][1]); + diff += match up1 { + Some(un) => mu * g_plus * a_d * (un - u0) / h[d], + None => { + if sides[d][1] == Side::Velocity { + mu * g_plus * a_d * (beyond(true) - u0) / (0.5 * h[d]) + } else { + 0.0 + } + } + }; + diff -= match dn1 { + Some(ud) => mu * g_minus * a_d * (u0 - ud) / h[d], + None => { + if sides[d][0] == Side::Velocity { + mu * g_minus * a_d * (u0 - beyond(false)) / (0.5 * h[d]) + } else { + 0.0 + } + } + }; + } + // The wall's momentum flux closes the mass balance exactly. + conv -= mass_out * ub; + let p_plus = lat.cell(cell_plus).map_or(0.0, |ci| field.p[ci]); + let p_minus = lat.cell(cell_minus).map_or(0.0, |ci| field.p[ci]); + let pressure = -(p_plus - p_minus) * cv.alpha * area[c]; + let v_u = cv.alpha * h[c] * area[c]; + let source = self.momentum_source.as_ref().map_or(0.0, |f| { + let s = f(x[0], x[1], x[2], t_old); + [s.0, s.1, s.2][c] * v_u + }); + let a_w = + (cv.wall[0] * cv.wall[0] + cv.wall[1] * cv.wall[1] + cv.wall[2] * cv.wall[2]).sqrt(); + let shear = mu * a_w / cv.distance; + let v_eff = cv.alpha.max(INERTIA_FLOOR) * h[c] * area[c]; + let inertia = rho * v_eff / dt; + (inertia * u0 - conv + diff + pressure + source + shear * ub) / (inertia + shear) + } +} diff --git a/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/step/mod.rs b/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/step/mod.rs index 1c1e5ca..c3e5e31 100644 --- a/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/step/mod.rs +++ b/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/step/mod.rs @@ -4,6 +4,7 @@ //! `dz = 1`) every number is the 2D solver's. The fluid predicates are the //! wall's hooks (item 9). +mod cut_predictor; #[cfg(feature = "cuda")] pub mod device; mod predictor; @@ -110,6 +111,7 @@ pub struct Solver { body: Option, mask: Option, last_ghost_correction: f64, + wall_fluxes: Vec, pcg_cache: PcgCache, time: f64, initialized: bool, @@ -137,6 +139,7 @@ impl Solver { body: None, mask: None, last_ghost_correction: 0.0, + wall_fluxes: Vec::new(), pcg_cache: PcgCache::default(), time: 0.0, initialized: false, @@ -174,7 +177,8 @@ impl Solver { self.mask.as_ref() } - /// The last step's ghost compatibility correction. + /// The last step's compatibility correction: the binary wall's shared + /// ghost flux correction, or the cut wall's wall-flux correction. #[must_use] pub fn ghost_correction(&self) -> f64 { self.last_ghost_correction @@ -226,6 +230,36 @@ impl Solver { .is_none_or(|m| m.is_fluid_cell(m.grid().cell(k, j, i))) } + // The apertures (1 without a cut geometry). + #[inline] + pub(super) fn au(&self, k: usize, j: usize, i: usize) -> f64 { + self.mask + .as_ref() + .map_or(1.0, |m| m.a_u(m.grid().uface(k, j, i))) + } + #[inline] + pub(super) fn av(&self, k: usize, j: usize, i: usize) -> f64 { + self.mask + .as_ref() + .map_or(1.0, |m| m.a_v(m.grid().vface(k, j, i))) + } + #[inline] + pub(super) fn aw(&self, k: usize, j: usize, i: usize) -> f64 { + self.mask + .as_ref() + .map_or(1.0, |m| m.a_w(m.grid().wface(k, j, i))) + } + /// The surface velocity's compatible flux through the cell's wall at + /// the step's new time (cut wall only; the table is rebuilt per step). + #[inline] + pub(super) fn wall_flux(&self, idx: usize) -> f64 { + self.wall_fluxes[idx] + } + #[inline] + pub(super) fn has_cut(&self) -> bool { + self.mask.as_ref().is_some_and(|m| m.cut().is_some()) + } + pub(super) fn upwind(face_velocity: f64, upstream: f64, downstream: f64) -> f64 { if face_velocity >= 0.0 { upstream @@ -283,7 +317,10 @@ impl Solver { let (nx, ny, nz) = (g.nx, g.ny, g.nz); let b = self.params.boundaries; let periodic = b.periodic_z(); - for k in 0..nz { + if self.has_cut() { + self.cut_predictor(field, dt, t_old); + } + for k in (0..nz).filter(|_| !self.has_cut()) { for j in 0..ny { for i in 1..nx { if !self.u_is_fluid(k, j, i) { @@ -295,7 +332,7 @@ impl Solver { } } } - for k in 0..nz { + for k in (0..nz).filter(|_| !self.has_cut()) { for j in 1..ny { for i in 0..nx { if !self.v_is_fluid(k, j, i) { @@ -308,7 +345,7 @@ impl Solver { } } let k_range = if periodic { 0..nz } else { 1..nz }; - for k in k_range { + for k in k_range.filter(|_| !self.has_cut()) { for j in 0..ny { for i in 0..nx { if !self.w_is_fluid(k, j, i) { @@ -365,15 +402,15 @@ impl Solver { let t = self.time; if let Some(body) = &self.body { if self.mask.is_none() { - assert_eq!( - self.params.wall_scheme, - WallScheme::GhostBinary, - "item 10 brings CutCell" - ); - self.mask = Some( - Mask::build(body, field.grid, t, self.params.boundaries) - .expect("embedded mask"), - ); + let mask = match self.params.wall_scheme { + WallScheme::GhostBinary => { + Mask::build(body, field.grid, t, self.params.boundaries) + } + WallScheme::CutCell => { + Mask::build_cut(body, field.grid, t, self.params.boundaries) + } + }; + self.mask = Some(mask.expect("embedded mask")); } } self.apply_boundary_normals(field, t); @@ -398,6 +435,14 @@ impl Solver { self.momentum_predictor(field, dt, t_old); self.apply_boundary_normals(field, t_new); field.copy_to_starred(); + let mut cut_correction = None; + if let (Some(body), Some(mask)) = (&self.body, &self.mask) { + if mask.cut().is_some() { + let (table, correction) = mask.wall_flux_table(body, t_new); + self.wall_fluxes = table; + cut_correction = Some(correction); + } + } let mut total = 0; let mut final_residual = f64::INFINITY; let mut poisson_iterations = 0; @@ -414,8 +459,8 @@ impl Solver { } // Ghost faces follow the corrected field (the next step's stencil data). if let (Some(body), Some(mask)) = (&self.body, &self.mask) { - self.last_ghost_correction = - mask.impose(body, &mut field.u, &mut field.v, &mut field.w, t_new); + let imposed = mask.impose(body, &mut field.u, &mut field.v, &mut field.w, t_new); + self.last_ghost_correction = cut_correction.unwrap_or(imposed); } self.time = t_new; StepResult { diff --git a/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/step/projection.rs b/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/step/projection.rs index 860c8c2..7785281 100644 --- a/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/step/projection.rs +++ b/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/step/projection.rs @@ -39,42 +39,42 @@ impl Solver { extra += ae_outlet; } } else if self.u_is_fluid(k, j, i + 1) { - problem.ae[idx] = ae_interior; + problem.ae[idx] = ae_interior * self.au(k, j, i + 1); } if i == 0 { if b.x0 == outlet { extra += ae_outlet; } } else if self.u_is_fluid(k, j, i) { - problem.aw[idx] = ae_interior; + problem.aw[idx] = ae_interior * self.au(k, j, i); } if j + 1 == ny { if b.y1 == outlet { extra += an_outlet; } } else if self.v_is_fluid(k, j + 1, i) { - problem.an[idx] = an_interior; + problem.an[idx] = an_interior * self.av(k, j + 1, i); } if j == 0 { if b.y0 == outlet { extra += an_outlet; } } else if self.v_is_fluid(k, j, i) { - problem.as_[idx] = an_interior; + problem.as_[idx] = an_interior * self.av(k, j, i); } if k + 1 == nz && !periodic { if b.z1 == outlet { extra += at_outlet; } } else if self.w_is_fluid((k + 1) % nz, j, i) { - problem.at[idx] = at_interior; + problem.at[idx] = at_interior * self.aw((k + 1) % nz, j, i); } if k == 0 && !periodic { if b.z0 == outlet { extra += at_outlet; } } else if self.w_is_fluid(k, j, i) { - problem.ab[idx] = at_interior; + problem.ab[idx] = at_interior * self.aw(k, j, i); } problem.extra_diag[idx] = extra; } @@ -246,6 +246,7 @@ impl Solver { let g = field.grid; let (nx, ny, nz, dx, dy, dz) = (g.nx, g.ny, g.nz, g.dx, g.dy, g.dz); let rho = self.fluid.density; + let cut = self.has_cut(); let mut source_scale = 0.0; for k in 0..nz { for j in 0..ny { @@ -255,15 +256,19 @@ impl Solver { field.sp[idx] = 0.0; continue; } - let divergence_flux = rho - * ((field.u_star[g.uface(k, j, i + 1)] - field.u_star[g.uface(k, j, i)]) + let mut divergence_flux = rho + * ((self.au(k, j, i + 1) * field.u_star[g.uface(k, j, i + 1)] + - self.au(k, j, i) * field.u_star[g.uface(k, j, i)]) * (dy * dz) - + (field.v_star[g.vface(k, j + 1, i)] - - field.v_star[g.vface(k, j, i)]) + + (self.av(k, j + 1, i) * field.v_star[g.vface(k, j + 1, i)] + - self.av(k, j, i) * field.v_star[g.vface(k, j, i)]) * (dx * dz) - + (field.w_star[g.wface(k + 1, j, i)] - - field.w_star[g.wface(k, j, i)]) + + (self.aw(k + 1, j, i) * field.w_star[g.wface(k + 1, j, i)] + - self.aw(k, j, i) * field.w_star[g.wface(k, j, i)]) * (dx * dy)); + if cut { + divergence_flux += rho * self.wall_flux(idx); + } field.sp[idx] = -divergence_flux; source_scale += divergence_flux.abs(); } @@ -315,6 +320,7 @@ impl Solver { let g = field.grid; let (nx, ny, nz, dx, dy, dz) = (g.nx, g.ny, g.nz, g.dx, g.dy, g.dz); let rho = self.fluid.density; + let cut = self.has_cut(); let b = self.params.boundaries; let outlet = Side::PressureOutlet; let periodic = b.periodic_z(); @@ -402,12 +408,20 @@ impl Solver { if !self.cell_is_fluid(k, j, i) { continue; } - let divergence_flux = rho - * ((field.u[g.uface(k, j, i + 1)] - field.u[g.uface(k, j, i)]) * (dy * dz) - + (field.v[g.vface(k, j + 1, i)] - field.v[g.vface(k, j, i)]) + let idx = g.cell(k, j, i); + let mut divergence_flux = rho + * ((self.au(k, j, i + 1) * field.u[g.uface(k, j, i + 1)] + - self.au(k, j, i) * field.u[g.uface(k, j, i)]) + * (dy * dz) + + (self.av(k, j + 1, i) * field.v[g.vface(k, j + 1, i)] + - self.av(k, j, i) * field.v[g.vface(k, j, i)]) * (dx * dz) - + (field.w[g.wface(k + 1, j, i)] - field.w[g.wface(k, j, i)]) + + (self.aw(k + 1, j, i) * field.w[g.wface(k + 1, j, i)] + - self.aw(k, j, i) * field.w[g.wface(k, j, i)]) * (dx * dy)); + if cut { + divergence_flux += rho * self.wall_flux(idx); + } mass_imbalance += divergence_flux.abs(); } } diff --git a/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/wall.rs b/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/wall.rs index fbeda60..9ecd473 100644 --- a/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/wall.rs +++ b/crates/specialized/rtx-cfd/src/solvers/incompressible/embedded3/wall.rs @@ -10,6 +10,7 @@ use super::Grid; use super::body::Body; +use super::cut::CutGeometry; use super::step::{Boundaries, Side}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -45,7 +46,7 @@ pub(crate) struct StencilNode { } #[derive(Debug, Clone)] -struct Ghost { +pub(super) struct Ghost { idx: usize, x: f64, y: f64, @@ -62,17 +63,20 @@ struct Ghost { #[derive(Clone)] pub struct Mask { - grid: Grid, - periodic_z: bool, - cell_fluid: Vec, - u_kind: Vec, - v_kind: Vec, - w_kind: Vec, - u_ghosts: Vec, - v_ghosts: Vec, - w_ghosts: Vec, - anchor: usize, - fluid_cells: usize, + pub(super) grid: Grid, + pub(super) periodic_z: bool, + pub(super) cell_fluid: Vec, + pub(super) u_kind: Vec, + pub(super) v_kind: Vec, + pub(super) w_kind: Vec, + pub(super) u_ghosts: Vec, + pub(super) v_ghosts: Vec, + pub(super) w_ghosts: Vec, + pub(super) anchor: usize, + pub(super) fluid_cells: usize, + /// The cut geometry of the apertured wall (`WallScheme::CutCell`, + /// `cutwall.rs`); `None` on the binary ghost wall. + pub(super) cut: Option, } /// The z lattice position of a query: the lower plane index, the upper @@ -479,9 +483,38 @@ impl Mask { w_ghosts, anchor, fluid_cells, + cut: None, }) } + /// The cut geometry (apertured wall only). + #[must_use] + pub fn cut(&self) -> Option<&CutGeometry> { + self.cut.as_ref() + } + /// Fluid area fraction of a u / v / w face (1 on the binary wall). + #[inline] + #[must_use] + pub fn a_u(&self, idx: usize) -> f64 { + self.cut.as_ref().map_or(1.0, |c| c.a_u[idx]) + } + #[inline] + #[must_use] + pub fn a_v(&self, idx: usize) -> f64 { + self.cut.as_ref().map_or(1.0, |c| c.a_v[idx]) + } + #[inline] + #[must_use] + pub fn a_w(&self, idx: usize) -> f64 { + self.cut.as_ref().map_or(1.0, |c| c.a_w[idx]) + } + /// Fluid volume fraction of a cell (1 on the binary wall). + #[inline] + #[must_use] + pub fn vol(&self, idx: usize) -> f64 { + self.cut.as_ref().map_or(1.0, |c| c.vol[idx]) + } + #[inline] #[must_use] pub fn is_fluid_cell(&self, idx: usize) -> bool { diff --git a/crates/specialized/rtx-cfd/tests/embedded3_embedded_mms.rs b/crates/specialized/rtx-cfd/tests/embedded3_embedded_mms.rs index 6582a9e..b21fc95 100644 --- a/crates/specialized/rtx-cfd/tests/embedded3_embedded_mms.rs +++ b/crates/specialized/rtx-cfd/tests/embedded3_embedded_mms.rs @@ -1,14 +1,20 @@ -//! embedded3 gate 9a: the manufactured solution with an embedded sphere -//! (centre (0.6, 0.45, 0.5), r 0.2, off-centre so the exact force is not -//! zero by symmetry) carrying the exact field as its surface velocity, on -//! the binary ghost wall. The velocity error falls at the scheme's order, -//! every fluid cell is divergence-free, the compatibility correction -//! shrinks, and both load routes converge to the exact surface integral of -//! the manufactured stress (the control-volume route measures F − M with M -//! the momentum flux through the porous manufactured surface). +//! embedded3 gates 9a and 10: the manufactured solution with an embedded +//! sphere (centre (0.6, 0.45, 0.5), r 0.2, off-centre so the exact force is +//! not zero by symmetry) carrying the exact field as its surface velocity, +//! on the binary ghost wall (item 9) and the apertured cut-cell wall (item +//! 10). The velocity error falls at the scheme's order, every fluid cell +//! is divergence-free (apertured, with the porous surface's flux, on the +//! cut wall), the compatibility correction shrinks, and both load routes +//! converge to the exact surface integral of the manufactured stress (the +//! control-volume route measures F − M with M the momentum flux through +//! the porous manufactured surface). Item 10's gate: the cut wall's errors +//! are at most the binary wall's at every n, its loads within 10 % at the +//! finest rung. use rtx_cfd::solvers::incompressible::ConvectionScheme; -use rtx_cfd::solvers::incompressible::embedded3::{Body, Field, Fluid, Grid, Parameters, Solver}; +use rtx_cfd::solvers::incompressible::embedded3::{ + Body, FaceKind, Field, Fluid, Grid, Parameters, Solver, WallScheme, +}; use std::f64::consts::PI; const RHO: f64 = 1.0; @@ -114,7 +120,7 @@ struct Measurement { force_cv: [f64; 3], } -fn measure(n: usize) -> Measurement { +fn measure(n: usize, scheme: WallScheme) -> Measurement { let h = 1.0 / n as f64; let dt = 0.4 * (h * h / (4.0 * MU / RHO)).min(h); let mut solver = Solver::new( @@ -128,6 +134,7 @@ fn measure(n: usize) -> Measurement { corrector_steps: 2, tolerance: 1e-8, convection_scheme: ConvectionScheme::Upwind, + wall_scheme: scheme, ..Parameters::default() }, ); @@ -140,9 +147,10 @@ fn measure(n: usize) -> Measurement { let g = Grid::cubic(n, n, n, h); let mut f = Field::new(g); solver.initialize(&mut f); + let mut last = solver.advance(&mut f, dt); for _ in 0..200_000 { let (bu, bv, bw) = (f.u.clone(), f.v.clone(), f.w.clone()); - solver.advance(&mut f, dt); + last = solver.advance(&mut f, dt); let mut change = 0.0_f64; for (a, b) in f.u.iter() @@ -157,7 +165,6 @@ fn measure(n: usize) -> Measurement { } } let mask = solver.mask().expect("mask"); - use rtx_cfd::solvers::incompressible::embedded3::FaceKind; let (mut sq, mut vol) = (0.0, 0.0); let dv = h * h * h; for k in 0..n { @@ -194,21 +201,53 @@ fn measure(n: usize) -> Measurement { } } } + let body = solver.body().expect("body"); + let t = solver.time(); + // The apertured divergence per unit volume, the porous surface's flux + // through the wall included (the plain divergence on the binary wall). let mut max_div = 0.0_f64; + let mut at_vol = 1.0; + let mut sum_flux = 0.0; + let (wall_fluxes, _) = mask.wall_flux_table(body, t); for k in 0..n { for j in 0..n { for i in 0..n { - if mask.is_fluid_cell(g.cell(k, j, i)) { - let div = (f.u[g.uface(k, j, i + 1)] - f.u[g.uface(k, j, i)]) / h - + (f.v[g.vface(k, j + 1, i)] - f.v[g.vface(k, j, i)]) / h - + (f.w[g.wface(k + 1, j, i)] - f.w[g.wface(k, j, i)]) / h; - max_div = max_div.max(div.abs()); + let idx = g.cell(k, j, i); + if mask.is_fluid_cell(idx) { + let flux = (mask.a_u(g.uface(k, j, i + 1)) * f.u[g.uface(k, j, i + 1)] + - mask.a_u(g.uface(k, j, i)) * f.u[g.uface(k, j, i)]) + * h + * h + + (mask.a_v(g.vface(k, j + 1, i)) * f.v[g.vface(k, j + 1, i)] + - mask.a_v(g.vface(k, j, i)) * f.v[g.vface(k, j, i)]) + * h + * h + + (mask.a_w(g.wface(k + 1, j, i)) * f.w[g.wface(k + 1, j, i)] + - mask.a_w(g.wface(k, j, i)) * f.w[g.wface(k, j, i)]) + * h + * h + + wall_fluxes[idx]; + sum_flux += flux.abs(); + if (flux / (h * h * h)).abs() > max_div { + max_div = (flux / (h * h * h)).abs(); + at_vol = mask.vol(idx); + } } } } } - let body = solver.body().expect("body"); - let surface = mask.surface_force(body, &f, MU, solver.time(), 0.5 * h); + println!( + " [{scheme:?} n {n}] max div {max_div:.2e} in a cell of fluid fraction {at_vol:.3e}; Σ|flux| {sum_flux:.2e}; last step residual {:.2e}", + last.final_residual + ); + let surface = match scheme { + WallScheme::GhostBinary => mask.surface_force(body, &f, MU, t, 0.5 * h), + WallScheme::CutCell => rtx_cfd::solvers::incompressible::embedded3::SurfaceForce { + f: mask.cut_wall_force(body, &f, MU, t).expect("cut wall"), + samples: 0, + skipped: 0, + }, + }; let (i0, i1) = (n / 8, n - n / 8); let src = |x: f64, y: f64, z: f64| source3(x, y, z); let force_cv = mask.control_volume_force(&f, dt, RHO, MU, Some(&src), (i0, i1, i0, i1, i0, i1)); @@ -226,14 +265,21 @@ fn norm(a: [f64; 3]) -> f64 { (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt() } -fn ladder(resolutions: &[usize]) { +/// The velocity errors and the two routes' relative force errors per rung. +struct Ladder { + errors: Vec, + surface: Vec, + cv: Vec, +} + +fn ladder(resolutions: &[usize], scheme: WallScheme) -> Ladder { let (fe, m) = exact_force_and_flux(); let f_scale = norm(fe); let fcv = [fe[0] - m[0], fe[1] - m[1], fe[2] - m[2]]; println!( - " exact force {fe:.5?}; momentum flux {m:.5?}; the control-volume route measures {fcv:.5?}" + " {scheme:?}: exact force {fe:.5?}; momentum flux {m:.5?}; the control-volume route measures {fcv:.5?}" ); - let ms: Vec = resolutions.iter().map(|&n| measure(n)).collect(); + let ms: Vec = resolutions.iter().map(|&n| measure(n, scheme)).collect(); let errors: Vec = ms.iter().map(|x| x.l2_velocity).collect(); let mut se = Vec::new(); let mut ce = Vec::new(); @@ -287,15 +333,57 @@ fn ladder(resolutions: &[usize]) { ce.windows(2).all(|w| w[1] < w[0]), "control-volume-route error not falling {ce:?}" ); + Ladder { + errors, + surface: se, + cv: ce, + } +} + +/// Item 10's comparison: the cut wall's velocity error at most the binary +/// wall's at every rung; both routes within `load_bound` at the finest. +fn compare(resolutions: &[usize], load_bound: f64) { + let ghost = ladder(resolutions, WallScheme::GhostBinary); + let cut = ladder(resolutions, WallScheme::CutCell); + for (k, &n) in resolutions.iter().enumerate() { + println!( + " n = {n:3} L2 u ghost {:.4e} cut {:.4e} (ratio {:.3})", + ghost.errors[k], + cut.errors[k], + cut.errors[k] / ghost.errors[k] + ); + assert!( + cut.errors[k] <= ghost.errors[k], + "cut-cell error above the binary wall's at n = {n}" + ); + } + let last = resolutions.len() - 1; + assert!( + cut.surface[last] < load_bound && cut.cv[last] < load_bound, + "cut-cell loads at the finest rung: surface {:.3e}, control volume {:.3e} (bound {load_bound})", + cut.surface[last], + cut.cv[last] + ); } #[test] fn embedded_sphere_recovers_the_manufactured_solution() { - ladder(&[12, 24]); + ladder(&[12, 24], WallScheme::GhostBinary); +} + +#[test] +fn cut_cell_wall_recovers_the_manufactured_solution() { + compare(&[12, 24], 0.2); } #[test] #[ignore = "the three-rung ladder to n = 48 (minutes on the host)"] fn embedded_sphere_three_rungs() { - ladder(&[12, 24, 48]); + ladder(&[12, 24, 48], WallScheme::GhostBinary); +} + +#[test] +#[ignore = "item 10's finest rung: the cut wall's loads within 10 % at n = 48"] +fn cut_cell_three_rungs() { + compare(&[12, 24, 48], 0.1); }