CI / Test (macos-latest) (push) Blocked by required conditions
CI / Test (ubuntu-latest) (push) Blocked by required conditions
CI / Python Bindings (maturin) (macos-latest) (push) Blocked by required conditions
CI / Build (macos-latest) (push) Waiting to run
CI / Python Bindings (maturin) (ubuntu-latest) (push) Blocked by required conditions
CI / WASM Build + Size Check (push) Blocked by required conditions
CI / Distributed Training Tests (push) Blocked by required conditions
CI / CI Success (push) Blocked by required conditions
CI / Build (ubuntu-latest) (push) Failing after 3s
Performance Benchmarks / Run Benchmarks (push) Failing after 4s
CI / Format Check (push) Failing after 5s
CI / Clippy Check (push) Failing after 4s
Documentation / Build API Documentation (push) Failing after 3s
Documentation / Build User Guide (push) Successful in 3s
CI / Build CPU-Only (Explicit) (push) Failing after 1m7s
Co-Authored-By: Claude Fable 5.1 <[email protected]>
173 lines
6.4 KiB
Rust
173 lines
6.4 KiB
Rust
//! 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<CutGeometry> = 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).
|
|
}
|