//! embedded3 gate 8: the cut geometry. A sphere and a z-cylinder on //! n = 16 / 32 / 64: the fluid volume and the wall area converge at second //! order; the wall closure Σ_c A_w n_w vanishes to rounding for a body //! inside the box; a continuity sweep of the sphere's centre across one //! cell (200 positions): bounded difference quotient of every aperture and //! volume, no jump > 1e-3 between neighbouring positions; and the //! translating sphere's discrete volume change per step, MEASURED (the //! plan's "to rounding" clause is checked, not assumed). use rtx_cfd::solvers::incompressible::embedded3::{Body, CutGeometry, Grid}; use std::f64::consts::PI; fn cube(n: usize) -> Grid { let h = 1.0 / n as f64; Grid { nx: n, ny: n, nz: n, dx: h, dy: h, dz: h, } } #[test] fn volume_and_area_converge_at_second_order_and_the_wall_closes() { let r: f64 = 0.3; let sphere_v = 4.0 / 3.0 * PI * r.powi(3); let sphere_a = 4.0 * PI * r * r; let cyl_v = PI * r * r * 1.0; let cyl_a = 2.0 * PI * r * 1.0; for (name, exact_v, exact_a, body) in [ ( "sphere", 1.0 - sphere_v, sphere_a, Body::sphere(|_t| (0.5, 0.5, 0.5), r), ), ( "z-cylinder", 1.0 - cyl_v, cyl_a, Body::cylinder_z(0.5, 0.5, r), ), ] { let mut ev = Vec::new(); let mut ea = Vec::new(); for n in [16usize, 32, 64] { let g = CutGeometry::build(&body, cube(n), 0.0); let v = g.fluid_volume(); let (a, closure) = g.wall_area_and_closure(); let closure_norm = (closure[0].powi(2) + closure[1].powi(2) + closure[2].powi(2)).sqrt(); ev.push((v - exact_v).abs()); ea.push((a - exact_a).abs()); println!( " {name} n {n}: fluid volume {v:.8} (exact {exact_v:.8}, err {:.2e}); wall area {a:.6} (exact {exact_a:.6}, err {:.2e}); closure |Σ A_w n_w| {closure_norm:.2e}", ev.last().unwrap(), ea.last().unwrap() ); // The z-cylinder touches the z walls: its closure includes the // end caps' missing area only through the cell walls, so the // closure holds for the sphere; for the cylinder the z component // is the two caps (equal and opposite) and x, y close. if name == "sphere" { assert!( closure_norm < 1e-12, "{name} n {n}: closure {closure_norm:.3e}" ); } else { assert!( closure[0].abs() < 1e-12 && closure[1].abs() < 1e-12, "{name} n {n}: closure x/y {closure:?}" ); } } for (label, e) in [("volume", &ev), ("area", &ea)] { for w in e.windows(2) { let order = (w[0] / w[1]).log2(); println!(" {name} {label} order {order:.2}"); assert!(order > 1.5, "{name} {label}: order {order:.2} below second"); } } } } /// Continuity in the body position. A face aperture where the interface is /// tangent to the face changes at a rate of order `r / h` per cell width of /// shift (the cap's area grows linearly in the shift, on a face of area /// h²), so an O(1) Lipschitz bound is the wrong premise; the discriminating /// test is that the largest change between neighbouring positions falls in /// proportion when the sweep is refined tenfold — a discontinuity would not. fn sweep(positions: usize) -> f64 { let n = 16; let g = cube(n); let h = g.dx; let r: f64 = 0.3; let mut prev: Option = None; let mut worst_jump = 0.0_f64; for s in 0..=positions { let shift = h * s as f64 / positions as f64; let body = Body::sphere( move |_t| (0.5 + shift, 0.5 + 0.37 * shift, 0.5 + 0.11 * shift), r, ); let cut = CutGeometry::build(&body, g, 0.0); if let Some(p) = &prev { let jump = |a: &[f64], b: &[f64]| { a.iter() .zip(b) .fold(0.0_f64, |m, (x, y)| m.max((x - y).abs())) }; let j = jump(&cut.a_u, &p.a_u) .max(jump(&cut.a_v, &p.a_v)) .max(jump(&cut.a_w, &p.a_w)) .max(jump(&cut.vol, &p.vol)); worst_jump = worst_jump.max(j); } prev = Some(cut); } worst_jump } #[test] fn apertures_and_volumes_are_continuous_in_the_body_position() { let j200 = sweep(200); let j2000 = sweep(2000); let ratio = j2000 / j200; println!( " sphere over one cell: largest neighbour change {j200:.3e} at 200 positions, {j2000:.3e} at 2000 (ratio {ratio:.3}; 0.1 = Lipschitz, 1 = a jump)" ); assert!( ratio < 0.2, "the largest change does not fall with the sweep resolution (ratio {ratio:.3}): a discontinuity" ); assert!( ratio > 0.05, "ratio {ratio:.3} below the Lipschitz expectation — check the sweep" ); } #[test] fn the_translating_sphere_volume_change_is_measured() { let n = 32; let g = cube(n); let r: f64 = 0.3; let dt = 1e-3; let speed = 1.0; // one cell width in n·dt... 0.03125 m per 31 steps let mut worst_rel = 0.0_f64; let mut prev = CutGeometry::build( &Body::sphere(move |t| (0.5 + speed * t, 0.5, 0.5), r), g, 0.0, ); let swept_per_step = PI * r * r * speed * dt; // the sphere's cross-section swept for s in 1..=31 { let t = s as f64 * dt; let cut = CutGeometry::build(&Body::sphere(move |t| (0.5 + speed * t, 0.5, 0.5), r), g, t); let dv = cut.fluid_volume() - prev.fluid_volume(); worst_rel = worst_rel.max(dv.abs() / swept_per_step); prev = cut; } let body_v = 4.0 / 3.0 * PI * r.powi(3); println!( " translating sphere n {n}: largest |Σ ΔV_c| per step = {worst_rel:.3e} of the swept cross-section volume per step ({:.2e} of the body volume)", worst_rel * swept_per_step / body_v ); // Recorded; the compatibility of the moving-body projection is decided // on this number (the plan's clause "to rounding" is not true for the // piecewise-linear interpolant — the cut cells' volume error moves with // the body). }