//! Prescribed-motion piston channel: the moving-boundary, slip-wall and //! pressure-outlet machinery against an exact solution. //! //! A channel `[s(t), L] x [0, H]` whose left wall (the piston) moves with //! prescribed `s(t) = s0 sin(Omega t)`, slip walls top and bottom, and a //! pressure outlet at `x = L`. For an incompressible fluid the exact //! solution is a rigid column: //! //! ```text //! u(x, y, t) = s'(t) (spatially uniform) //! v = 0 //! p(x, t) = rho s''(t) (L - x) (linear, zero at the outlet) //! ``` //! //! and the discretisation reproduces it **exactly** — in space and in //! time. Continuity forces the discrete `u` to equal the wall's discrete //! velocity `(s(t1) - s(t0))/dt` in every cell, and the projected pressure //! is exactly the linear field whose gradient produces the wall's discrete //! *acceleration* (the backward difference of its discrete velocity). So //! the assertions here are at rounding/solver-tolerance level against the //! discrete references, not truncation level against the analytic ones: //! the analytic comparison would only measure the O(dt) difference between //! `s'(t)` and its finite differences, which says nothing about the //! solver. First measured: `|u - wall|` ~8e-12, `|p - exact_disc|` at //! solver-tolerance, `|v|` ~2e-12. //! //! This isolates the fluid half of the added-mass FSI benchmark //! (`rtx-fsi/tests/piston_added_mass.rs`): if that test misbehaves and //! this one is green, the coupling is at fault, not the fluid. use rtx_cfd::solvers::incompressible::ale::{ AleBoundaries, AleField, AleParameters, AlePisoSolver, SideBoundary, }; use rtx_cfd::{CfdConfig, CfdResult}; const RHO: f64 = 1.0; const L: f64 = 1.0; const H: f64 = 0.25; const NX: usize = 32; const NY: usize = 4; const S0: f64 = 0.02; const OMEGA: f64 = 5.0; fn s_of(t: f64) -> f64 { S0 * (OMEGA * t).sin() } fn s_dot(t: f64) -> f64 { S0 * OMEGA * (OMEGA * t).cos() } fn s_ddot(t: f64) -> f64 { -S0 * OMEGA * OMEGA * (OMEGA * t).sin() } /// Node lines for piston position `s`: linear stretch of the reference /// spacing onto `[s, L]`. fn lines_x(s: f64) -> Vec { (0..=NX) .map(|i| s + (L - s) * i as f64 / NX as f64) .collect() } fn lines_y() -> Vec { (0..=NY).map(|j| H * j as f64 / NY as f64).collect() } struct Errors { max_u: f64, max_v: f64, max_p: f64, } /// March to `t_end` and return worst-case deviations from the closed form, /// measured over the second half of the run (past the start-up transient /// of the impulsively consistent but discretely fresh initial state). async fn measure(dt: f64, t_end: f64) -> CfdResult { let config = CfdConfig::new() .with_density(RHO) .with_viscosity(1e-3) .with_reference_velocity(S0 * OMEGA) .with_reference_length(L); let params = AleParameters { corrector_steps: 30, tolerance: 1e-10, boundaries: AleBoundaries { left: SideBoundary::Velocity, right: SideBoundary::PressureOutlet, bottom: SideBoundary::SlipWall, top: SideBoundary::SlipWall, }, ..AleParameters::default() }; let mut solver = AlePisoSolver::new(config, params)?; let mut field = AleField::new(lines_x(0.0), lines_y())?; // Consistent initial state: u = s'(0) everywhere, p = rho s''(0) (L-x) // = 0 at t = 0. field.u.fill(s_dot(0.0)); let steps = (t_end / dt).round() as usize; let mut worst = Errors { max_u: 0.0, max_v: 0.0, max_p: 0.0, }; let mut previous_wall = s_dot(0.0); for step in 0..steps { let t0 = step as f64 * dt; let t1 = (step + 1) as f64 * dt; // A material wall: the prescribed velocity must be the mesh line's // own motion over the step, not the analytic s'(t). let wall = (s_of(t1) - s_of(t0)) / dt; solver .set_boundary_velocity(move |x, _y, _t| if x < 0.5 { (wall, 0.0) } else { (0.0, 0.0) }); let result = solver .advance(&mut field, &lines_x(s_of(t1)), &lines_y(), dt) .await?; assert!( result.solver_result.converged, "step {step}: mass residual {:.3e}", result.solver_result.final_residual ); if t1 >= 0.5 * t_end { // The discrete references: continuity makes every u equal the // wall's discrete velocity, and the projected pressure gradient // produces the wall's discrete acceleration. let a_disc = (wall - previous_wall) / dt; for value in field.u.iter() { worst.max_u = worst.max_u.max((value - wall).abs()); } for value in field.v.iter() { worst.max_v = worst.max_v.max(value.abs()); } let xc: Vec = field.x.windows(2).map(|w| 0.5 * (w[0] + w[1])).collect(); for j in 0..NY { for i in 0..NX { let p_ref = RHO * a_disc * (L - xc[i]); worst.max_p = worst.max_p.max((field.p[(j, i)] - p_ref).abs()); } } } previous_wall = wall; } Ok(worst) } #[tokio::test] async fn prescribed_piston_drives_the_exact_rigid_column() -> CfdResult<()> { let t_end = 2.0; let coarse = measure(2e-3, t_end).await?; let fine = measure(1e-3, t_end).await?; let u_scale = S0 * OMEGA; let p_scale = RHO * S0 * OMEGA * OMEGA * L; println!( " dt 2e-3: |u - s'| {:.3e} |v| {:.3e} |p - exact| {:.3e}", coarse.max_u, coarse.max_v, coarse.max_p ); println!( " dt 1e-3: |u - s'| {:.3e} |v| {:.3e} |p - exact| {:.3e}", fine.max_u, fine.max_v, fine.max_p ); // The rigid column is exact discretely: rounding level, at both step // sizes, with no truncation term to refine away. for errors in [&coarse, &fine] { assert!( errors.max_u < 1e-9 * u_scale, "u deviates from the discrete wall velocity by {:.3e} \ (scale {u_scale:.3e}) — the rigid column should be exact", errors.max_u ); // Nothing drives v: it stays at rounding level. assert!( errors.max_v < 1e-9 * u_scale, "v should be identically zero, got {:.3e}", errors.max_v ); // The pressure is the fluid's added-mass reaction — the quantity // the FSI benchmark feeds back to the structure. Exactly linear, // zero at the outlet, gradient rho times the wall's discrete // acceleration; the residual here is the projection's inner-solve // tolerance, not truncation. assert!( errors.max_p < 1e-5 * p_scale, "p deviates from rho a_disc (L - x) by {:.3e} (scale {p_scale:.3e})", errors.max_p ); } Ok(()) }