//! embedded3 item 11a: the fresh-cell falsifier of the 2D track //! (`embedded_fresh_cell_falsifier.rs`, omni-cortex //! `docs/fresh_cell_gcl_campaign.md`) on the 3D solver, per unit span — //! the rigid Turek–Hron flag (0.35 × 0.02 m) extruded across a periodic //! slab, oscillating transversely in still fluid at the flag's tip speed //! (1 m/s peak, 80 mm amplitude) on h = 1/152 at dt = 3.24e-4. Per step: //! the load per unit span (the ghost wall's traction route over the //! plate's samples; the cut wall's operator route), a far-field pressure //! probe, the fluid's kinetic energy, the fresh-cell count. //! //! Registered gates (`docs/embedded3_campaign.md` item 11): //! - GhostBinary reproduces the 2D wall's impulse: energy per flipped //! column within 30 % of the 2D 0.048 J/m per flipped cell, spike RMS //! exponent in dt ≈ −1 (published −0.8 for the raw volume source); //! - CutCell: energy per fresh column ≥ 20× lower, max force spike < 5 % //! of ½ρU²L, exponent ∈ [−0.3, 0.3]. //! //! Default run: dt only, both schemes (minutes on the host); //! `RTX_E3_FALSIFIER_LADDER=1` runs dt, dt/2, dt/4 and fits the exponent //! (the gated variant is `#[ignore]`); `RTX_E3_FALSIFIER_NZ` sets the //! span in cells (default 4); `RTX_E3_FALSIFIER_CSV=` dumps records. use rtx_cfd::solvers::incompressible::ConvectionScheme; use rtx_cfd::solvers::incompressible::embedded3::{ Body, Boundaries, Field, Fluid, Grid, Parameters, Side, Solver, WallScheme, }; use std::io::Write as _; const RHO: f64 = 1000.0; const MU: f64 = 1.0; const N: usize = 152; const DT_FSI2: f64 = 3.24e-4; const HX: f64 = 0.175; const HY: f64 = 0.01; const AMP: f64 = 0.08; const U_PEAK: f64 = 1.0; const CX: f64 = 0.5; const CY0: f64 = 0.5; /// The 2D wall's measured energy per flipped cell (J/m at U = 1, h = 1/152). const ENERGY_2D: f64 = 0.048; fn span_cells() -> usize { std::env::var("RTX_E3_FALSIFIER_NZ") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(4) } fn center_y(t: f64) -> f64 { CY0 + AMP * (U_PEAK / AMP * t).sin() } fn center_v(t: f64) -> f64 { U_PEAK * (U_PEAK / AMP * t).cos() } fn plate_sdf(x: f64, y: f64, yc: f64) -> f64 { let qx = (x - CX).abs() - HX; let qy = (y - yc).abs() - HY; let outside = (qx.max(0.0).powi(2) + qy.max(0.0).powi(2)).sqrt(); outside + qx.max(qy).min(0.0) } const R_CIRCLE: f64 = 0.05; /// `RTX_E3_FALSIFIER_BODY=circle`: the 2D falsifier's smooth body (a /// cylinder of radius 0.05 across the span) instead of the plate. fn circle_body() -> bool { std::env::var("RTX_E3_FALSIFIER_BODY").is_ok_and(|v| v == "circle") } /// `RTX_E3_FALSIFIER_BODY=stadium`: the plate with semicircular ends /// (radius `HY`): the same length and thickness, a smooth interface for /// the cut geometry's linear interpolant. fn stadium_body() -> bool { std::env::var("RTX_E3_FALSIFIER_BODY").is_ok_and(|v| v == "stadium") } fn stadium_sdf(x: f64, y: f64, yc: f64) -> f64 { let half = HX - HY; let qx = (x - CX).abs().max(half) - half; (qx * qx + (y - yc).powi(2)).sqrt() - HY } fn plate(moving: bool) -> Body { let yc = move |t: f64| if moving { center_y(t) } else { CY0 }; let vc = move |t: f64| if moving { center_v(t) } else { 0.0 }; if circle_body() { return Body::from_sdf(move |x, y, _z, t| { ((x - CX).powi(2) + (y - yc(t)).powi(2)).sqrt() - R_CIRCLE }) .with_surface_velocity(move |_, _, _, t| (0.0, vc(t), 0.0)); } if stadium_body() { return Body::from_sdf(move |x, y, _z, t| stadium_sdf(x, y, yc(t))) .with_surface_velocity(move |_, _, _, t| (0.0, vc(t), 0.0)); } Body::from_sdf(move |x, y, _z, t| plate_sdf(x, y, yc(t))) .with_surface_velocity(move |_, _, _, t| (0.0, vc(t), 0.0)) } /// The load scale `½ρU²L` of the body (its length across the motion). fn load_scale() -> f64 { let l = if circle_body() { 2.0 * R_CIRCLE } else { 2.0 * HX }; 0.5 * RHO * U_PEAK * U_PEAK * l } /// Surface samples of the plate at `t`: `(x, y, z, nx, ny, area)` over the /// four edges at spacing `ds` and `nz` z levels. fn samples( t: f64, moving: bool, ds: f64, nz: usize, dz: f64, ) -> Vec<(f64, f64, f64, f64, f64, f64)> { let yc = if moving { center_y(t) } else { CY0 }; let mut out = Vec::new(); if circle_body() { let n = ((2.0 * std::f64::consts::PI * R_CIRCLE / ds).ceil() as usize).max(8); let dth = 2.0 * std::f64::consts::PI / n as f64; for k in 0..n { let th = (k as f64 + 0.5) * dth; let (sn, cs) = th.sin_cos(); for kz in 0..nz { out.push(( CX + R_CIRCLE * cs, yc + R_CIRCLE * sn, (kz as f64 + 0.5) * dz, cs, sn, R_CIRCLE * dth * dz, )); } } return out; } if stadium_body() { let half = HX - HY; let n_flat = ((2.0 * half / ds).ceil() as usize).max(1); for k in 0..n_flat { let x = CX - half + (k as f64 + 0.5) / n_flat as f64 * 2.0 * half; for kz in 0..nz { let z = (kz as f64 + 0.5) * dz; let a = 2.0 * half / n_flat as f64 * dz; out.push((x, yc + HY, z, 0.0, 1.0, a)); out.push((x, yc - HY, z, 0.0, -1.0, a)); } } let n_arc = ((std::f64::consts::PI * HY / ds).ceil() as usize).max(4); for (cx, sign) in [(CX + half, 1.0), (CX - half, -1.0)] { for k in 0..n_arc { let th = -std::f64::consts::FRAC_PI_2 + (k as f64 + 0.5) / n_arc as f64 * std::f64::consts::PI; let (sn, cs) = th.sin_cos(); let (nx, ny) = (sign * cs, sn); for kz in 0..nz { out.push(( cx + HY * nx, yc + HY * ny, (kz as f64 + 0.5) * dz, nx, ny, std::f64::consts::PI * HY / n_arc as f64 * dz, )); } } } return out; } let (x0, x1, y0, y1) = (CX - HX, CX + HX, yc - HY, yc + HY); let mut edge = |ax: f64, ay: f64, bx: f64, by: f64, nx: f64, ny: f64| { let len = ((bx - ax).powi(2) + (by - ay).powi(2)).sqrt(); let n = ((len / ds).ceil() as usize).max(1); for k in 0..n { let s = (k as f64 + 0.5) / n as f64; for kz in 0..nz { out.push(( ax + s * (bx - ax), ay + s * (by - ay), (kz as f64 + 0.5) * dz, nx, ny, len / n as f64 * dz, )); } } }; edge(x0, y0, x1, y0, 0.0, -1.0); edge(x1, y0, x1, y1, 1.0, 0.0); edge(x1, y1, x0, y1, 0.0, 1.0); edge(x0, y1, x0, y0, -1.0, 0.0); out } struct Record { t: f64, /// Load per unit span (the scheme's wall route). fy: f64, /// Load per unit span by the control-volume route (a box of whole /// cells around the body, reading no near-wall value). fy_cv: f64, /// The inertia floor's momentum source per unit span (cut wall). fy_floor: f64, /// The pressure corrections' closure lag per unit span (cut wall). fy_lag: f64, /// The force the momentum equation applied (shear on u*, exchange on uⁿ). fy_applied: f64, fresh: usize, skipped: usize, p_far: f64, /// Kinetic energy per unit span over the fluid cells. ke: f64, } struct Run { records: Vec, /// The largest kinetic-energy change per step at a step with fresh /// cells (after the impulsive start) over that step's flipped columns /// (J/m) — the 2D falsifier's 2.604 J/m over 54 cells = 0.048. energy_per_flip: f64, seconds: f64, } fn run(scheme: WallScheme, moving: bool, dt: f64, t_end: f64) -> Run { let nz = span_cells(); let h = 1.0 / N as f64; let lz = nz as f64 * h; let mut solver = Solver::new( Fluid { density: RHO, viscosity: MU, reference_velocity: 1.0, reference_length: 2.0 * HY, }, Parameters { corrector_steps: 2, tolerance: 1e-8, convection_scheme: ConvectionScheme::Upwind, wall_scheme: scheme, boundaries: Boundaries { z0: Side::Periodic, z1: Side::Periodic, ..Boundaries::default() }, max_surface_speed: Some(U_PEAK), // `RTX_E3_SUBSTEPS`: intermediate geometries for the space-time apertures. aperture_substeps: std::env::var("RTX_E3_SUBSTEPS") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(0), // `RTX_E3_CELL_MEAN=1`: the momentum volume from the two cells' fractions. momentum_volume_cell_mean: std::env::var("RTX_E3_CELL_MEAN").is_ok_and(|v| v == "1"), ..Parameters::default() }, ); solver.set_boundary_velocity(|_, _, _, _| (0.0, 0.0, 0.0)); if moving { solver.set_moving_body(plate(true)); } else { solver.set_body(plate(false)); } let g = Grid::cubic(N, N, nz, h); let mut field = Field::new(g); solver.initialize(&mut field); let steps = (t_end / dt).round() as usize; let mut records = Vec::with_capacity(steps); // The 2D definition: the largest |ΔKE| step's energy over that // step's flipped columns. let mut largest_jump = 0.0_f64; let mut energy_per_flip = 0.0_f64; let mut ke_prev: Option = None; let start = std::time::Instant::now(); let (jp, ip, kp) = ( (0.92 * N as f64) as usize, (0.5 * N as f64) as usize, nz / 2, ); for step in 0..steps { let result = solver.advance(&mut field, dt); let t = (step + 1) as f64 * dt; let mask = solver.mask().expect("mask"); let body = solver.body().expect("body"); let (mut fy, mut skipped) = (0.0, 0usize); match scheme { WallScheme::GhostBinary => { for (x, y, z, nx, ny, area) in samples(t, moving, 0.5 * h, nz, h) { match mask.traction_at(body, &field, MU, t, [x, y, z], [nx, ny, 0.0]) { Some(tr) => fy += tr[1] * area, None => skipped += 1, } } } WallScheme::CutCell => { fy = mask.cut_wall_force(body, &field, MU, t).expect("cut wall")[1]; } } fy /= lz; let fy_floor = solver.floor_source()[1] / lz; let fy_lag = solver.pressure_lag()[1] / lz; let fy_applied = if scheme == WallScheme::CutCell { mask.cut_wall_force_applied(body, &field, MU, RHO, t) .expect("applied")[1] / lz } else { 0.0 }; let margin = 8; let fy_cv = mask.control_volume_force_moving( &field, dt, RHO, MU, None, (margin, N - margin, margin, N - margin, 0, nz), false, solver.previous_volumes(), )[1] / lz; let p_far = field.p[g.cell(kp, jp, ip)]; let mut ke = 0.0; for k in 0..nz { for j in 0..N { for i in 0..N { let idx = g.cell(k, j, i); if mask.is_fluid_cell(idx) { let uc = 0.5 * (field.u[g.uface(k, j, i)] + field.u[g.uface(k, j, i + 1)]); let vc = 0.5 * (field.v[g.vface(k, j, i)] + field.v[g.vface(k, j + 1, i)]); let wc = 0.5 * (field.w[g.wface(k, j, i)] + field.w[g.wface(k + 1, j, i)]); ke += 0.5 * RHO * (uc * uc + vc * vc + wc * wc) * h * h * h * mask.vol(idx); } } } } ke /= lz; if let Some(prev) = ke_prev { if step > 30 && result.fresh_cells > 0 && (ke - prev).abs() > largest_jump { largest_jump = (ke - prev).abs(); // The plate's event is its row (the 2D divided by the row's // 54 cells); the circle's is the step's fresh columns. let columns = if circle_body() { result.fresh_cells as f64 / nz as f64 } else { (2.0 * HX / h).round() }; energy_per_flip = largest_jump / columns; } } ke_prev = Some(ke); records.push(Record { t, fy, fy_cv, fy_floor, fy_lag, fy_applied, fresh: result.fresh_cells, skipped, p_far, ke, }); } if scheme == WallScheme::CutCell { let n = records.len().max(1) as f64; let rms = |f: &dyn Fn(&Record) -> f64| { (records.iter().map(|r| f(r) * f(r)).sum::() / n).sqrt() }; let mean = |f: &dyn Fn(&Record) -> f64| records.iter().map(f).sum::() / n; println!( " route residual (wall − box) RMS {:.3e} → with the floor source {:.3e}, with the closure lag {:.3e}, both {:.3e}, APPLIED form {:.3e} (mean {:+.3e}); means wall {:+.3e} box {:+.3e} floor {:+.3e} lag {:+.3e}; box RMS {:.3e}", rms(&|r| r.fy - r.fy_cv), rms(&|r| r.fy + r.fy_floor - r.fy_cv), rms(&|r| r.fy + r.fy_lag - r.fy_cv), rms(&|r| r.fy + r.fy_floor + r.fy_lag - r.fy_cv), rms(&|r| r.fy_applied - r.fy_cv), mean(&|r| r.fy_applied), mean(&|r| r.fy), mean(&|r| r.fy_cv), mean(&|r| r.fy_floor), mean(&|r| r.fy_lag), rms(&|r| r.fy_cv) ); } Run { records, energy_per_flip, seconds: start.elapsed().as_secs_f64(), } } /// Spike series: the load minus its 21-step running median. fn spikes(f: &[f64]) -> Vec { let w = 10usize; (0..f.len()) .map(|k| { let lo = k.saturating_sub(w); let hi = (k + w + 1).min(f.len()); let mut win: Vec = f[lo..hi].to_vec(); win.sort_by(|a, b| a.partial_cmp(b).unwrap()); f[k] - win[win.len() / 2] }) .collect() } struct Stats { rms_force: f64, rms_spike: f64, max_spike: f64, rms_spike_cv: f64, max_spike_cv: f64, rms_pfar_spike: f64, max_pfar_spike: f64, max_ke_jump: f64, fresh_total: usize, skipped_max: usize, } fn stats(records: &[Record], t_lo: f64, t_hi: f64) -> Stats { let fy: Vec = records.iter().map(|r| r.fy).collect(); let sp = spikes(&fy); let fcv: Vec = records.iter().map(|r| r.fy_cv).collect(); let spc = spikes(&fcv); let pf: Vec = records.iter().map(|r| r.p_far).collect(); let spf = spikes(&pf); let idx: Vec = (0..records.len()) .filter(|&k| records[k].t >= t_lo && records[k].t <= t_hi) .collect(); let rms = |v: &dyn Fn(usize) -> f64| { (idx.iter().map(|&k| v(k) * v(k)).sum::() / idx.len().max(1) as f64).sqrt() }; Stats { rms_force: rms(&|k| fy[k]), rms_spike: rms(&|k| sp[k]), max_spike: idx.iter().map(|&k| sp[k].abs()).fold(0.0, f64::max), rms_spike_cv: rms(&|k| spc[k]), max_spike_cv: idx.iter().map(|&k| spc[k].abs()).fold(0.0, f64::max), rms_pfar_spike: rms(&|k| spf[k]), max_pfar_spike: idx.iter().map(|&k| spf[k].abs()).fold(0.0, f64::max), max_ke_jump: idx .iter() .filter(|&&k| k > 0) .map(|&k| (records[k].ke - records[k - 1].ke).abs()) .fold(0.0, f64::max), fresh_total: idx.iter().map(|&k| records[k].fresh).sum(), skipped_max: idx.iter().map(|&k| records[k].skipped).max().unwrap_or(0), } } fn dump(dir: &str, name: &str, records: &[Record]) { let path = std::path::Path::new(dir).join(format!("{name}.csv")); let mut f = std::fs::File::create(path).expect("csv"); writeln!(f, "t,fy,fy_cv,fresh,skipped,p_far,ke").unwrap(); for r in records { writeln!( f, "{:.6},{:.6e},{:.6e},{},{},{:.6e},{:.6e}", r.t, r.fy, r.fy_cv, r.fresh, r.skipped, r.p_far, r.ke ) .unwrap(); } } struct Verdict { energy_per_flip: f64, max_spike: f64, exponent: Option, } fn falsify(scheme: WallScheme, ladder: bool) -> Verdict { let csv_dir = std::env::var("RTX_E3_FALSIFIER_CSV").ok(); let period = 2.0 * std::f64::consts::PI * AMP / U_PEAK; let t_end = 0.3 * period; let (t_lo, t_hi) = (0.02 * period, 0.28 * period); let rest = run(scheme, false, DT_FSI2, t_end); let s0 = stats(&rest.records, t_lo, t_hi); println!( " {scheme:?} plate AT REST, dt {DT_FSI2:.2e} ({:.0} s): rms force {:.3e}, rms spike {:.3e}, max spike {:.3e}, fresh {}, skipped max {}", rest.seconds, s0.rms_force, s0.rms_spike, s0.max_spike, s0.fresh_total, s0.skipped_max ); if let Some(d) = &csv_dir { dump(d, &format!("{scheme:?}_rest"), &rest.records); } let dts: Vec = if ladder { vec![DT_FSI2, DT_FSI2 / 2.0, DT_FSI2 / 4.0] } else { vec![DT_FSI2] }; let mut points = Vec::new(); let mut energy = 0.0_f64; let mut max_spike = 0.0_f64; for &dt in &dts { let r = run(scheme, true, dt, t_end); let s = stats(&r.records, t_lo, t_hi); println!( " {scheme:?} plate MOVING, dt {dt:.3e} ({} steps, {:.0} s): rms force {:.3e}, rms spike {:.3e} ({:.1}x rest), max spike {:.3e} N/m ({:.2e} of ½ρU²L), fresh cells {} ({:.2}/step), skipped max {}", r.records.len(), r.seconds, s.rms_force, s.rms_spike, s.rms_spike / s0.rms_spike.max(1e-300), s.max_spike, s.max_spike / load_scale(), s.fresh_total, s.fresh_total as f64 / r.records.len() as f64, s.skipped_max ); println!( " control-volume route: rms spike {:.3e}, max spike {:.3e} N/m ({:.2e} of ½ρU²L)", s.rms_spike_cv, s.max_spike_cv, s.max_spike_cv / load_scale() ); println!( " far probe p(0.5, 0.92): rms spike {:.3e}, max spike {:.3e}; max |ΔKE| per step {:.3e} J/m; energy per flipped column {:.3e} J/m ({:.2} of the 2D wall's {ENERGY_2D})", s.rms_pfar_spike, s.max_pfar_spike, s.max_ke_jump, r.energy_per_flip, r.energy_per_flip / ENERGY_2D ); if let Some(d) = &csv_dir { dump(d, &format!("{scheme:?}_moving_dt{dt:.3e}"), &r.records); } assert!(s.rms_force.is_finite() && s.rms_spike.is_finite()); if dt == DT_FSI2 { energy = r.energy_per_flip; max_spike = s.max_spike; } points.push((dt, s.rms_spike)); } let exponent = (points.len() >= 2).then(|| { let xs: Vec = points.iter().map(|p| p.0.ln()).collect(); let ys: Vec = points.iter().map(|p| p.1.ln()).collect(); let mx = xs.iter().sum::() / xs.len() as f64; let my = ys.iter().sum::() / ys.len() as f64; let num: f64 = xs.iter().zip(&ys).map(|(x, y)| (x - mx) * (y - my)).sum(); let den: f64 = xs.iter().map(|x| (x - mx).powi(2)).sum(); let e = num / den; println!( " {scheme:?}: spike RMS ~ (dt)^{e:.2} across {} time steps", points.len() ); e }); Verdict { energy_per_flip: energy, max_spike, exponent, } } #[test] fn oscillating_plate_both_walls() { let ladder = std::env::var("RTX_E3_FALSIFIER_LADDER").is_ok(); println!( " body: {}", if circle_body() { "circle R 0.05" } else if stadium_body() { "stadium 0.35 x 0.02 (semicircular ends)" } else { "plate 0.35 x 0.02" } ); let ghost = falsify(WallScheme::GhostBinary, ladder); let cut = falsify(WallScheme::CutCell, ladder); println!( " energy per flipped column: ghost {:.3e}, cut {:.3e} (ratio {:.1}x); max spike: ghost {:.3e}, cut {:.3e} N/m", ghost.energy_per_flip, cut.energy_per_flip, ghost.energy_per_flip / cut.energy_per_flip.max(1e-300), ghost.max_spike, cut.max_spike ); assert!( ghost.energy_per_flip > 0.0, "the binary wall must flip cells" ); } /// The registered gates on the dt ladder. #[test] #[ignore = "item 11's gated ladder (dt, dt/2, dt/4 on both walls; tens of minutes on the host)"] fn oscillating_plate_gates() { let ghost = falsify(WallScheme::GhostBinary, true); let cut = falsify(WallScheme::CutCell, true); let ratio = ghost.energy_per_flip / cut.energy_per_flip.max(1e-300); println!( " GATES: ghost energy per flipped column {:.3e} ({:.2} of 2D), exponent {:.2}; cut energy {:.3e} ({:.1}x lower), max spike {:.3e} N/m ({:.2e} of ½ρU²L), exponent {:.2}", ghost.energy_per_flip, ghost.energy_per_flip / ENERGY_2D, ghost.exponent.unwrap(), cut.energy_per_flip, ratio, cut.max_spike, cut.max_spike / load_scale(), cut.exponent.unwrap() ); let g2d = ghost.energy_per_flip / ENERGY_2D; assert!( (0.7..=1.3).contains(&g2d), "ghost energy per flip {g2d:.2} of 2D" ); assert!(ratio >= 20.0, "cut energy only {ratio:.1}x lower"); assert!( cut.max_spike < 0.05 * load_scale(), "cut max spike {:.3e}", cut.max_spike ); let e = cut.exponent.unwrap(); assert!((-0.3..=0.3).contains(&e), "cut exponent {e:.2}"); }