Files
rustytorch/crates/specialized/rtx-cfd/examples/flow_past_cylinder.rs
T
Omar SobhandClaude Fable 5 327da7ff47
Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Build CPU-Only (Explicit) (push) Canceled after 0s
CI / Python Bindings (maturin) (macos-latest) (push) Canceled after 0s
CI / Python Bindings (maturin) (ubuntu-latest) (push) Canceled after 0s
CI / WASM Build + Size Check (push) Canceled after 0s
CI / Distributed Training Tests (push) Canceled after 0s
CI / CI Success (push) Canceled after 0s
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s
CI / Format Check (push) Canceled after 0s
CI / Clippy Check (push) Canceled after 0s
CI / Build (macos-latest) (push) Canceled after 0s
CI / Build (ubuntu-latest) (push) Canceled after 0s
CI / Test (macos-latest) (push) Canceled after 0s
rtx-cfd: multigrid-PCG projection — 30x faster, same answers — and the CFD1 refinement study
Falsifier 4 of the Turek–Hron geometry decision fired (the SOR projection
cost 0.09 s/step at 250x41 and an hour per run at 5 mm); this answers it.

solvers::incompressible::poisson: PoissonProblem (cell-centred five-point
SPD operator as per-cell face coefficients + Dirichlet diagonal extra +
active mask) and solve_multigrid_pcg — conjugate gradient preconditioned
by one V-cycle of geometric multigrid: aggregation by 2 per direction (odd
sizes absorbed, coarse cell active iff any child is), the Galerkin coarse
operator for piecewise-constant prolongation / summation restriction,
symmetric Gauss–Seidel smoothing, coarse correction scaled by 2 (Braess's
under-correction of unsmoothed aggregation; scalar, so the preconditioner
stays symmetric and positive on range(A)), L1 TRUE-residual stop with a
stagnation guard. Singular systems are handled per connected component of
the active cells (mean projection and level per pure-Neumann component;
the anchor's component to p[anchor] = 0). PoissonSolverKind::{Sor,
Multigrid} on PisoParameters / EmbeddedParameters; Sor is the default and
its code is byte-for-byte untouched; an unconverged multigrid solve falls
back to the SOR sweeps for that projection.

Verified (poisson/tests.rs, tests/poisson_equivalence.rs):
- PCG iterations to cut the residual 1e-8 on the closed Neumann box at
  32^2..256^2: 4, 4, 4, 4; ragged masked domains 8/8/8;
- manufactured recoveries to ~1e-14; Galerkin identity A_c v = R A P v to
  7e-15 on every level (masked, outlet column, non-uniform conductances);
  V-cycle symmetric to 1e-14; NaN-poisoned inactive cells untouched;
- two Neumann components with opposite imbalances, and a Dirichlet
  component beside an imbalanced Neumann one (review scenarios): converge,
  each component right up to its own constant;
- speed vs plain SOR at the same stop: 22.7x (128^2), 41x (256^2);
- same answers as SOR: PISO MMS 4.6e-8 relative, Taylor–Green divergence
  1.4e-9 every step, embedded-circle MMS 7e-8, no-body bit-identity with MG
  on both solvers, channel+outlet+circle 1.4e-10; CFD1 loads identical to
  four digits at 0.003 s/step vs 0.094 (30x).

CFD1 refinement study (tests/turek_hron_cfd.rs, three grids, 257 s):
h = 10 / 6.6 / 5 mm -> control-volume drag 15.6156 / 15.2829 / 15.0988 vs
14.2929 (+9.25 / +6.93 / +5.64%), apparent order 0.71, Richardson
extrapolate 14.04; surface route and lift not monotone (flag 2/3/4 cells
thick) — the test asserts the measured band at the finest grid.

Built with a 4-agent workflow (core, integration, refinement study,
adversarial review); the review found no defects and four risks, three
fixed here (per-component projection, one symmetric smoother-sweep
parameter, acting on `converged` with an SOR fallback) and one recorded
(isotropic aggregation loses grid-independence on anisotropic cells).

rtx-cfd 301 -> 318 green.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-20 10:20:25 -07:00

640 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Flow past circular cylinder simulation
//!
//! Classic external flow benchmark demonstrating:
//! - Flow separation and wake formation
//! - Vortex shedding (Kármán vortex street)
//! - Drag and lift coefficient calculation
//! - Transition from steady to unsteady flow
//!
//! Reynolds number regimes:
//! - Re < 5: Steady flow, no separation
//! - 5 < Re < 40: Steady separated flow with wake
//! - Re > 40: Unsteady vortex shedding
//! - Re > 200: Turbulent wake
use nalgebra::{DVector, Vector3};
use rtx_cfd::{
CfdConfig, CfdResult,
discretization::{FiniteVolumeMethod, FluxScheme, SpatialOrder},
mesh::{MeshGenerator, UnstructuredMesh},
solvers::incompressible::{
BoundaryConditions, BoundaryLocation, BoundaryType, FlowField, PisoParameters, PisoSolver,
},
turbulence::{SmagorinskyConstants, SmagorinskyModel, TurbulenceModel, TurbulenceState},
};
use std::f64::consts::PI;
use std::time::Instant;
/// Cylinder flow simulation parameters
#[derive(Debug)]
pub struct CylinderConfig {
/// Cylinder diameter
pub diameter: f64,
/// Inlet velocity
pub inlet_velocity: f64,
/// Reynolds number
pub reynolds_number: f64,
/// Domain dimensions [length, width, height]
pub domain_size: [f64; 3],
/// Cylinder center position
pub cylinder_center: [f64; 2],
/// Characteristic mesh size near cylinder
pub mesh_size_cylinder: f64,
/// Characteristic mesh size at boundaries
pub mesh_size_boundary: f64,
/// Maximum iterations per time step
pub max_iterations: usize,
/// Convergence tolerance
pub tolerance: f64,
/// Time step
pub time_step: f64,
/// Simulation time
pub total_time: f64,
/// Use LES turbulence model
pub use_les: bool,
/// Output frequency (time steps)
pub output_frequency: usize,
}
impl CylinderConfig {
/// Create configuration for Re = 100 case (steady flow)
pub fn re_100() -> Self {
Self {
diameter: 1.0,
inlet_velocity: 1.0,
reynolds_number: 100.0,
domain_size: [20.0, 10.0, 1.0], // Long domain to capture wake
cylinder_center: [5.0, 5.0], // Offset from inlet
mesh_size_cylinder: 0.05,
mesh_size_boundary: 0.5,
max_iterations: 100,
tolerance: 1e-6,
time_step: 0.01,
total_time: 50.0,
use_les: false,
output_frequency: 100,
}
}
/// Create configuration for Re = 200 case (vortex shedding)
pub fn re_200() -> Self {
Self {
diameter: 1.0,
inlet_velocity: 1.0,
reynolds_number: 200.0,
domain_size: [25.0, 12.0, 1.0],
cylinder_center: [6.0, 6.0],
mesh_size_cylinder: 0.03,
mesh_size_boundary: 0.4,
max_iterations: 150,
tolerance: 1e-6,
time_step: 0.005,
total_time: 100.0,
use_les: false,
output_frequency: 200,
}
}
/// Create configuration for high Re case with LES
pub fn high_re_les() -> Self {
Self {
diameter: 1.0,
inlet_velocity: 1.0,
reynolds_number: 3900.0,
domain_size: [30.0, 15.0, 1.0],
cylinder_center: [8.0, 7.5],
mesh_size_cylinder: 0.02,
mesh_size_boundary: 0.3,
max_iterations: 200,
tolerance: 1e-5,
time_step: 0.001,
total_time: 200.0,
use_les: true,
output_frequency: 1000,
}
}
/// Calculate viscosity from Reynolds number
pub fn viscosity(&self, density: f64) -> f64 {
density * self.inlet_velocity * self.diameter / self.reynolds_number
}
/// Calculate Strouhal number (for vortex shedding frequency)
pub fn strouhal_number(&self) -> f64 {
// Empirical correlation for circular cylinder
if self.reynolds_number < 50.0 {
0.0 // No shedding
} else if self.reynolds_number < 200.0 {
0.2 - 0.0002 * self.reynolds_number
} else {
0.2 // Approximately constant for Re > 200
}
}
/// Calculate expected shedding frequency
pub fn shedding_frequency(&self) -> f64 {
let st = self.strouhal_number();
st * self.inlet_velocity / self.diameter
}
}
/// Force coefficients
#[derive(Debug, Clone)]
pub struct ForceCoefficients {
/// Drag coefficient
pub cd: f64,
/// Lift coefficient
pub cl: f64,
/// Pressure drag coefficient
pub cd_pressure: f64,
/// Viscous drag coefficient
pub cd_viscous: f64,
/// Time stamp
pub time: f64,
}
impl ForceCoefficients {
/// New zero coefficients
pub fn zero(time: f64) -> Self {
Self {
cd: 0.0,
cl: 0.0,
cd_pressure: 0.0,
cd_viscous: 0.0,
time,
}
}
}
/// Flow past cylinder simulation
pub struct FlowPastCylinder {
/// Configuration
config: CylinderConfig,
/// CFD configuration
cfd_config: CfdConfig,
/// Mesh
mesh: UnstructuredMesh,
/// Flow field
flow_field: FlowField,
/// Solver
solver: PisoSolver,
/// Boundary conditions
boundary_conditions: BoundaryConditions,
/// Discretization
discretization: FiniteVolumeMethod,
/// LES model (optional)
les_model: Option<SmagorinskyModel>,
/// Turbulence state
turbulence_state: Option<TurbulenceState>,
/// Force history
force_history: Vec<ForceCoefficients>,
/// Cylinder surface cell indices
cylinder_cells: Vec<usize>,
}
impl FlowPastCylinder {
/// Create new cylinder flow simulation
pub fn new(config: CylinderConfig) -> CfdResult<Self> {
// Set up CFD configuration
let density = 1.0;
let viscosity = config.viscosity(density);
let cfd_config = CfdConfig::new()
.with_density(density)
.with_viscosity(viscosity)
.with_reference_velocity(config.inlet_velocity)
.with_reference_length(config.diameter);
cfd_config.validate()?;
println!("Setting up flow past cylinder simulation:");
println!(" Reynolds number: {}", config.reynolds_number);
println!(
" Domain size: {:.1}×{:.1}",
config.domain_size[0], config.domain_size[1]
);
println!(" Cylinder diameter: {}", config.diameter);
println!(" Viscosity: {:.2e}", viscosity);
if config.reynolds_number > 40.0 {
let freq = config.shedding_frequency();
println!(" Expected vortex shedding frequency: {:.3} Hz", freq);
println!(" Strouhal number: {:.3}", config.strouhal_number());
}
// Create mesh (simplified - in practice would use proper mesh generation)
let mesh = UnstructuredMesh::new();
let n_cells = Self::estimate_cell_count(&config);
// Approximate grid dimensions for flow field
let nx = (config.domain_size[0] / config.mesh_size_boundary) as usize;
let ny = (config.domain_size[1] / config.mesh_size_boundary) as usize;
let dx = config.domain_size[0] / nx as f64;
let dy = config.domain_size[1] / ny as f64;
// Initialize flow field
let mut flow_field = FlowField::new(nx, ny, dx, dy)?;
// Set initial conditions
Self::set_initial_conditions(&mut flow_field, &config, nx, ny)?;
// Set up boundary conditions
let (boundary_conditions, cylinder_cells) =
Self::setup_boundary_conditions(&config, nx, ny)?;
// Create discretization with upwind scheme for stability
let discretization = FiniteVolumeMethod::new(SpatialOrder::Second, FluxScheme::Upwind)
.with_diffusion_coefficient(viscosity);
// Create PISO solver parameters
let parameters = PisoParameters {
corrector_steps: 2,
time_step: config.time_step,
tolerance: config.tolerance,
..PisoParameters::default()
};
// Create PISO solver for unsteady flow
let solver = PisoSolver::new(cfd_config.clone(), parameters)?;
// Set up LES model if requested
let (les_model, turbulence_state) = if config.use_les {
let mut model =
SmagorinskyModel::new(n_cells).with_constants(SmagorinskyConstants::standard());
// Calculate filter width from mesh size
let filter_width = DVector::from_element(n_cells, config.mesh_size_cylinder * 2.0);
model.set_filter_width(filter_width)?;
let mut state = TurbulenceState::new(n_cells);
state.density = density;
state.molecular_viscosity = viscosity;
println!(" LES model: Smagorinsky");
println!(" Filter width: {:.3}", config.mesh_size_cylinder * 2.0);
(Some(model), Some(state))
} else {
(None, None)
};
Ok(Self {
config,
cfd_config,
mesh,
flow_field,
solver,
boundary_conditions,
discretization,
les_model,
turbulence_state,
force_history: Vec::new(),
cylinder_cells,
})
}
/// Estimate number of cells for mesh generation
fn estimate_cell_count(config: &CylinderConfig) -> usize {
// Rough estimate based on domain size and mesh resolution
let domain_area = config.domain_size[0] * config.domain_size[1];
let avg_cell_size = (config.mesh_size_cylinder + config.mesh_size_boundary) / 2.0;
let avg_cell_area = avg_cell_size * avg_cell_size;
(domain_area / avg_cell_area) as usize
}
/// Set initial conditions
fn set_initial_conditions(
flow_field: &mut FlowField,
config: &CylinderConfig,
nx: usize,
ny: usize,
) -> CfdResult<()> {
// Initialize with uniform flow
for j in 0..ny {
for i in 0..nx {
flow_field.set_velocity(i, j, config.inlet_velocity, 0.0)?;
flow_field.set_pressure(i, j, 0.0)?;
}
}
Ok(())
}
/// Set up boundary conditions
fn setup_boundary_conditions(
config: &CylinderConfig,
_nx: usize,
_ny: usize,
) -> CfdResult<(BoundaryConditions, Vec<usize>)> {
let mut boundary_conditions = BoundaryConditions::new();
// Inlet boundary (left side)
boundary_conditions.add_boundary_condition(
BoundaryLocation::Left,
BoundaryType::VelocityInlet {
u: config.inlet_velocity,
v: 0.0,
},
);
// Outlet boundary (right side)
boundary_conditions.add_boundary_condition(
BoundaryLocation::Right,
BoundaryType::PressureOutlet { pressure: 0.0 },
);
// Slip walls (top and bottom)
boundary_conditions
.add_boundary_condition(BoundaryLocation::Top, BoundaryType::FreeSlipWall);
boundary_conditions
.add_boundary_condition(BoundaryLocation::Bottom, BoundaryType::FreeSlipWall);
// Note: Cylinder surface boundary would require special handling in unstructured mesh
let cylinder_cells = Vec::new(); // Simplified for now
println!(" Cylinder surface cells: {}", cylinder_cells.len());
Ok((boundary_conditions, cylinder_cells))
}
/// Run time-dependent simulation
pub fn run_simulation(&mut self) -> CfdResult<CylinderResults> {
println!("\nStarting transient simulation...");
println!(" Time step: {:.2e}", self.config.time_step);
println!(" Total time: {:.2}", self.config.total_time);
let start_time = Instant::now();
let mut time = 0.0;
let mut time_step = 0;
let mut residuals = Vec::new();
while time < self.config.total_time {
// Note: PisoSolver methods are async, placeholder here
let residual = 1e-6; // Placeholder - would need async runtime
// Update LES model if present (placeholder - model doesn't have update method)
if self.turbulence_state.is_some() {
// Note: Would call update methods here in real implementation
// self.update_turbulence_state(&mut turbulence_state)?;
}
// Calculate force coefficients
let forces = self.calculate_force_coefficients(time)?;
self.force_history.push(forces);
time += self.config.time_step;
time_step += 1;
residuals.push(residual);
// Print progress
if time_step % self.config.output_frequency == 0 {
let latest_forces = self.force_history.last().unwrap();
println!(
" Time: {:.3}, Step: {}, Residual: {:.2e}, Cd: {:.3}, Cl: {:.3}",
time, time_step, residual, latest_forces.cd, latest_forces.cl
);
}
}
let elapsed = start_time.elapsed();
println!(
"Simulation completed in {:.2} seconds",
elapsed.as_secs_f64()
);
Ok(CylinderResults {
time_steps: time_step,
residuals,
force_history: self.force_history.clone(),
final_time: time,
elapsed_time: elapsed,
flow_field: self.flow_field.clone(),
})
}
/// Update turbulence state from flow field
fn update_turbulence_state(&mut self, state: &mut TurbulenceState) -> CfdResult<()> {
let nx = self.flow_field.nx;
let ny = self.flow_field.ny;
// Update velocity field
for j in 0..ny {
for i in 0..nx {
let cell_idx = i + j * nx;
if cell_idx < state.velocity.len() {
let (u, v) = self.flow_field.get_velocity_at(i, j)?;
state.velocity[cell_idx] = Vector3::new(u, v, 0.0);
}
}
}
// Update pressure field
for j in 0..ny {
for i in 0..nx {
let cell_idx = i + j * nx;
if cell_idx < state.pressure.len() {
let p = self.flow_field.get_pressure_at(i, j)?;
state.pressure[cell_idx] = p;
}
}
}
// Calculate velocity gradients (simplified)
for j in 1..ny - 1 {
for i in 1..nx - 1 {
let cell_idx = i + j * nx;
if cell_idx < state.velocity_gradients.len() {
let dx = self.flow_field.dx;
let (u_right, _) = self.flow_field.get_velocity_at(i + 1, j)?;
let (u_left, _) = self.flow_field.get_velocity_at(i - 1, j)?;
state.velocity_gradients[cell_idx][0][0] = (u_right - u_left) / (2.0 * dx);
}
}
}
Ok(())
}
/// Calculate force coefficients on cylinder
fn calculate_force_coefficients(&self, time: f64) -> CfdResult<ForceCoefficients> {
// Simplified force calculation
// In a real implementation, would integrate pressure and shear stress over cylinder surface
// For now, return placeholder values since cylinder_cells is empty
Ok(ForceCoefficients {
cd: 1.0, // Placeholder drag coefficient
cl: 0.0, // Placeholder lift coefficient
cd_pressure: 0.8, // Placeholder pressure drag
cd_viscous: 0.2, // Placeholder viscous drag
time,
})
}
/// Get force history
pub fn force_history(&self) -> &Vec<ForceCoefficients> {
&self.force_history
}
/// Get configuration
pub fn config(&self) -> &CylinderConfig {
&self.config
}
}
/// Cylinder simulation results
#[derive(Debug, Clone)]
pub struct CylinderResults {
/// Number of time steps
pub time_steps: usize,
/// Residual history
pub residuals: Vec<f64>,
/// Force coefficient history
pub force_history: Vec<ForceCoefficients>,
/// Final simulation time
pub final_time: f64,
/// Elapsed wall time
pub elapsed_time: std::time::Duration,
/// Final flow field
pub flow_field: FlowField,
}
impl CylinderResults {
/// Print summary
pub fn print_summary(&self) {
println!("\n=== Cylinder Flow Results ===");
println!("Time steps: {}", self.time_steps);
println!("Final time: {:.2}", self.final_time);
println!(
"Elapsed time: {:.2} seconds",
self.elapsed_time.as_secs_f64()
);
if let (Some(first), Some(last)) = (self.force_history.first(), self.force_history.last()) {
println!("Initial Cd: {:.3}, Final Cd: {:.3}", first.cd, last.cd);
println!("Initial Cl: {:.3}, Final Cl: {:.3}", first.cl, last.cl);
}
// Calculate mean and RMS values
if !self.force_history.is_empty() {
let mean_cd = self.force_history.iter().map(|f| f.cd).sum::<f64>()
/ self.force_history.len() as f64;
let mean_cl = self.force_history.iter().map(|f| f.cl).sum::<f64>()
/ self.force_history.len() as f64;
let rms_cl = (self
.force_history
.iter()
.map(|f| (f.cl - mean_cl).powi(2))
.sum::<f64>()
/ self.force_history.len() as f64)
.sqrt();
println!("Mean Cd: {:.3}", mean_cd);
println!("Mean Cl: {:.3}", mean_cl);
println!("RMS Cl: {:.3}", rms_cl);
}
}
/// Detect vortex shedding frequency
pub fn detect_shedding_frequency(&self, dt: f64) -> Option<f64> {
if self.force_history.len() < 100 {
return None;
}
// Simple frequency detection using zero crossings of Cl
let cl_values: Vec<f64> = self.force_history.iter().map(|f| f.cl).collect();
let mean_cl = cl_values.iter().sum::<f64>() / cl_values.len() as f64;
let mut zero_crossings = 0;
for i in 1..cl_values.len() {
if (cl_values[i] - mean_cl) * (cl_values[i - 1] - mean_cl) < 0.0 {
zero_crossings += 1;
}
}
if zero_crossings > 4 {
let frequency = zero_crossings as f64 / (2.0 * self.final_time);
Some(frequency)
} else {
None
}
}
}
fn main() -> CfdResult<()> {
println!("=== Flow Past Circular Cylinder Simulation ===");
// Run Re = 100 case
println!("\n--- Re = 100 Case ---");
let mut cylinder_100 = FlowPastCylinder::new(CylinderConfig::re_100())?;
let results_100 = cylinder_100.run_simulation()?;
results_100.print_summary();
// Run Re = 200 case with vortex shedding
println!("\n--- Re = 200 Case (Vortex Shedding) ---");
let mut cylinder_200 = FlowPastCylinder::new(CylinderConfig::re_200())?;
let results_200 = cylinder_200.run_simulation()?;
results_200.print_summary();
if let Some(freq) = results_200.detect_shedding_frequency(cylinder_200.config().time_step) {
println!("Detected shedding frequency: {:.3} Hz", freq);
let strouhal = freq * cylinder_200.config().diameter / cylinder_200.config().inlet_velocity;
println!("Strouhal number: {:.3}", strouhal);
}
// Run high Re case with LES
println!("\n--- High Re Case with LES (Re = 3900) ---");
let mut cylinder_les = FlowPastCylinder::new(CylinderConfig::high_re_les())?;
let results_les = cylinder_les.run_simulation()?;
results_les.print_summary();
println!("\n=== All cylinder simulations completed successfully! ===");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cylinder_config_creation() {
let config = CylinderConfig::re_100();
assert_eq!(config.reynolds_number, 100.0);
assert_eq!(config.diameter, 1.0);
assert!(!config.use_les);
}
#[test]
fn test_strouhal_number() {
let config = CylinderConfig::re_200();
let st = config.strouhal_number();
assert!(st > 0.0);
assert!(st < 0.3);
}
#[test]
fn test_viscosity_calculation() {
let config = CylinderConfig::re_100();
let density = 1.0;
let viscosity = config.viscosity(density);
assert!((viscosity - 0.01).abs() < 1e-10);
}
#[test]
fn test_force_coefficients() {
let forces = ForceCoefficients::zero(1.0);
assert_eq!(forces.cd, 0.0);
assert_eq!(forces.cl, 0.0);
assert_eq!(forces.time, 1.0);
}
#[test]
fn test_les_config() {
let config = CylinderConfig::high_re_les();
assert!(config.use_les);
assert_eq!(config.reynolds_number, 3900.0);
}
}