test(rtx-cfd): fresh-cell falsifier extended (circle body, far-field probe, kinetic energy, speed knob) + print-only divergence trace; two candidate fixes REFUTED on it (swept-volume source 40x worse at either sign; fresh-face field extension no effect), both kept default-off with their verdicts
Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Format Check (push) Canceled after 0s
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (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
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
Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Format Check (push) Canceled after 0s
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (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
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
Phase 1 of omni-cortex docs/fresh_cell_gcl_campaign.md. The mechanism of the moving-body force spikes is measured from four directions: per-flip force amplitude ∝ 1/dt, kinetic energy injected per flipped cell 0.048 J/m independent of dt and body shape (plate row vs circle), felt at a far-field pressure probe, and ∝ U^2 (2.60 / 0.64 / 0.15 J/m at U = 1 / 0.5 / 0.25). A binary mask's wall position jumps by one cell at every flip and the fluid answers with a fixed impulse. Neither the swept-volume source (the wall faces already carry the swept volume — the source double-counts it) nor the fresh-face velocity is where it lives. Next: the virtual cut cell in the projection (apertures + the wall-relative divergence), registered in the campaign doc. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01X2GmJXeQ2njUecEKiJZ1G2
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
23eb996a9f
commit
c9492d3e1e
@@ -93,6 +93,7 @@ pub struct EmbeddedSolverState {
|
|||||||
mask: Option<EmbeddedMask>,
|
mask: Option<EmbeddedMask>,
|
||||||
time: f64,
|
time: f64,
|
||||||
initialized: bool,
|
initialized: bool,
|
||||||
|
alpha: Option<Vec<f64>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of one embedded PISO step.
|
/// Result of one embedded PISO step.
|
||||||
@@ -115,6 +116,31 @@ pub struct EmbeddedPisoSolver {
|
|||||||
config: CfdConfig,
|
config: CfdConfig,
|
||||||
parameters: EmbeddedParameters,
|
parameters: EmbeddedParameters,
|
||||||
momentum_source: Option<SourceFn>,
|
momentum_source: Option<SourceFn>,
|
||||||
|
/// Reporting-only: the cells that turned fluid on the current step,
|
||||||
|
/// kept for the `RTX_EMBEDDED_TRACE_SP` divergence trace (empty
|
||||||
|
/// unless the env var is set).
|
||||||
|
fresh_trace: Vec<(usize, usize)>,
|
||||||
|
/// REFUTED on the falsifier (2026-09-03): 40× larger spikes at either
|
||||||
|
/// sign — the wall faces already carry the swept volume; kept as the
|
||||||
|
/// record of that measurement, never to be enabled.
|
||||||
|
/// Swept-volume source strength (0 = off, bit-identical; ±1 = on,
|
||||||
|
/// sign as registered by the falsifier): the fluid area fraction of
|
||||||
|
/// every interface cell, α = clamp(½ + φ/h, 0, 1) from the body's
|
||||||
|
/// signed distance at the cell centre, enters the continuity
|
||||||
|
/// constraint as a source ρ (α^{n+1} − α^n) dx dy / dt, so a cell's
|
||||||
|
/// fluid volume enters continuously as the wall sweeps instead of as
|
||||||
|
/// a whole-cell jump at the mask flip — the fixed impulse per flip
|
||||||
|
/// the fresh-cell falsifier measured (omni-cortex
|
||||||
|
/// `docs/fresh_cell_gcl_campaign.md`).
|
||||||
|
swept_volume: f64,
|
||||||
|
/// Field extension for fresh faces (knob, default off = bit-identical;
|
||||||
|
/// measured NO EFFECT on the falsifier 2026-09-03 — kept as the record):
|
||||||
|
/// see `EmbeddedMask::extend_fresh_faces`.
|
||||||
|
field_extension: bool,
|
||||||
|
/// The previous step's fluid area fractions (moving path, knob on).
|
||||||
|
alpha_old: Option<Vec<f64>>,
|
||||||
|
/// This step's fractions, computed at the mask rebuild.
|
||||||
|
alpha_new: Option<Vec<f64>>,
|
||||||
boundary_velocity: Option<VelocityFn>,
|
boundary_velocity: Option<VelocityFn>,
|
||||||
body: Option<EmbeddedBody>,
|
body: Option<EmbeddedBody>,
|
||||||
mask: Option<EmbeddedMask>,
|
mask: Option<EmbeddedMask>,
|
||||||
@@ -133,6 +159,11 @@ impl EmbeddedPisoSolver {
|
|||||||
config,
|
config,
|
||||||
parameters,
|
parameters,
|
||||||
momentum_source: None,
|
momentum_source: None,
|
||||||
|
fresh_trace: Vec::new(),
|
||||||
|
swept_volume: 0.0,
|
||||||
|
field_extension: false,
|
||||||
|
alpha_old: None,
|
||||||
|
alpha_new: None,
|
||||||
boundary_velocity: None,
|
boundary_velocity: None,
|
||||||
body: None,
|
body: None,
|
||||||
mask: None,
|
mask: None,
|
||||||
@@ -201,6 +232,16 @@ impl EmbeddedPisoSolver {
|
|||||||
self.moving = true;
|
self.moving = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Field extension for faces that turn fluid (moving-body path).
|
||||||
|
pub fn set_field_extension(&mut self, on: bool) {
|
||||||
|
self.field_extension = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Swept-volume source strength for the moving-body path (0 = off).
|
||||||
|
pub fn set_swept_volume_source(&mut self, strength: f64) {
|
||||||
|
self.swept_volume = strength;
|
||||||
|
}
|
||||||
|
|
||||||
/// The body, if any.
|
/// The body, if any.
|
||||||
pub fn body(&self) -> Option<&EmbeddedBody> {
|
pub fn body(&self) -> Option<&EmbeddedBody> {
|
||||||
self.body.as_ref()
|
self.body.as_ref()
|
||||||
@@ -228,6 +269,7 @@ impl EmbeddedPisoSolver {
|
|||||||
mask: self.mask.clone(),
|
mask: self.mask.clone(),
|
||||||
time: self.time,
|
time: self.time,
|
||||||
initialized: self.initialized,
|
initialized: self.initialized,
|
||||||
|
alpha: self.alpha_old.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,6 +279,7 @@ impl EmbeddedPisoSolver {
|
|||||||
self.mask = state.mask.clone();
|
self.mask = state.mask.clone();
|
||||||
self.time = state.time;
|
self.time = state.time;
|
||||||
self.initialized = state.initialized;
|
self.initialized = state.initialized;
|
||||||
|
self.alpha_old = state.alpha.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reset the accumulated time.
|
/// Reset the accumulated time.
|
||||||
@@ -722,6 +765,108 @@ impl EmbeddedPisoSolver {
|
|||||||
source_scale += divergence_flux.abs();
|
source_scale += divergence_flux.abs();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Swept-volume source (knob; see `swept_volume`): the corrected
|
||||||
|
// field must satisfy Σ u·n A = −dV_f/dt in every interface cell.
|
||||||
|
if let (true, Some(a_new), Some(a_old)) =
|
||||||
|
(self.swept_volume != 0.0, &self.alpha_new, &self.alpha_old)
|
||||||
|
{
|
||||||
|
for j in 0..ny {
|
||||||
|
for i in 0..nx {
|
||||||
|
if !self.cell_is_fluid(j, i) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let k = j * nx + i;
|
||||||
|
let da = a_new[k] - a_old[k];
|
||||||
|
if da != 0.0 {
|
||||||
|
field.sp[(j, i)] -= self.swept_volume * rho * da * dx * dy / dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reporting-only divergence trace (RTX_EMBEDDED_TRACE_SP): where the
|
||||||
|
// projection's source sits relative to the step's fresh cells, in
|
||||||
|
// units of one whole cell volume per step (rho dx dy / dt).
|
||||||
|
if warm_start && !self.fresh_trace.is_empty() {
|
||||||
|
let unit = rho * dx * dy / dt;
|
||||||
|
let is_fresh =
|
||||||
|
|j: usize, i: usize| self.fresh_trace.iter().any(|&(a, b)| a == j && b == i);
|
||||||
|
let is_nbr = |j: usize, i: usize| {
|
||||||
|
self.fresh_trace.iter().any(|&(a, b)| {
|
||||||
|
(a == j && (b + 1 == i || i + 1 == b)) || (b == i && (a + 1 == j || j + 1 == a))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let (mut mf, mut mn, mut mo) = (0.0f64, 0.0f64, 0.0f64);
|
||||||
|
let (mut arg, mut argv) = ((0usize, 0usize), 0.0f64);
|
||||||
|
let mut sum_fresh = 0.0f64;
|
||||||
|
for j in 0..ny {
|
||||||
|
for i in 0..nx {
|
||||||
|
if !self.cell_is_fluid(j, i) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let v = field.sp[(j, i)] / unit;
|
||||||
|
if is_fresh(j, i) {
|
||||||
|
mf = mf.max(v.abs());
|
||||||
|
sum_fresh += v;
|
||||||
|
} else if is_nbr(j, i) {
|
||||||
|
mn = mn.max(v.abs());
|
||||||
|
} else {
|
||||||
|
mo = mo.max(v.abs());
|
||||||
|
}
|
||||||
|
if v.abs() > argv.abs() {
|
||||||
|
argv = v;
|
||||||
|
arg = (j, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let class = if is_fresh(arg.0, arg.1) {
|
||||||
|
"FRESH"
|
||||||
|
} else if is_nbr(arg.0, arg.1) {
|
||||||
|
"NEIGHBOUR"
|
||||||
|
} else {
|
||||||
|
"other"
|
||||||
|
};
|
||||||
|
// The argmax cell's 3x3 neighbourhood: F = fluid, S = solid,
|
||||||
|
// * = fresh this step (row above first).
|
||||||
|
let mut hood = String::new();
|
||||||
|
for dj in [1i64, 0, -1] {
|
||||||
|
for di in [-1i64, 0, 1] {
|
||||||
|
let (jj, ii) = (arg.0 as i64 + dj, arg.1 as i64 + di);
|
||||||
|
let c = if jj < 0 || ii < 0 || jj >= ny as i64 || ii >= nx as i64 {
|
||||||
|
'#'
|
||||||
|
} else if is_fresh(jj as usize, ii as usize) {
|
||||||
|
'*'
|
||||||
|
} else if self.cell_is_fluid(jj as usize, ii as usize) {
|
||||||
|
'F'
|
||||||
|
} else {
|
||||||
|
'S'
|
||||||
|
};
|
||||||
|
hood.push(c);
|
||||||
|
}
|
||||||
|
hood.push('/');
|
||||||
|
}
|
||||||
|
let fj = self.fresh_trace.iter().map(|c| c.0);
|
||||||
|
let fi = self.fresh_trace.iter().map(|c| c.1);
|
||||||
|
println!(
|
||||||
|
" SP-TRACE fresh rows {:?}..{:?} cols {:?}..{:?}; argmax hood {hood}",
|
||||||
|
fj.clone().min(),
|
||||||
|
fj.max(),
|
||||||
|
fi.clone().min(),
|
||||||
|
fi.max()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" SP-TRACE t = {:.6}: {} fresh cells; max |sp| {:+.3} cell-volumes/step at ({}, {}) [{class}]; \
|
||||||
|
max over fresh {:.3}, neighbours {:.3}, others {:.3}; sum over fresh {:+.3}",
|
||||||
|
self.time + dt,
|
||||||
|
self.fresh_trace.len(),
|
||||||
|
argv,
|
||||||
|
arg.0,
|
||||||
|
arg.1,
|
||||||
|
mf,
|
||||||
|
mn,
|
||||||
|
mo,
|
||||||
|
sum_fresh
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let ae_interior = dt * dy / dx;
|
let ae_interior = dt * dy / dx;
|
||||||
let an_interior = dt * dx / dy;
|
let an_interior = dt * dx / dy;
|
||||||
@@ -950,6 +1095,8 @@ impl EmbeddedPisoSolver {
|
|||||||
// would read its gradient), and impose the new mask's ghost values
|
// would read its gradient), and impose the new mask's ghost values
|
||||||
// from the previous corrected field.
|
// from the previous corrected field.
|
||||||
let mut fresh_cells = 0usize;
|
let mut fresh_cells = 0usize;
|
||||||
|
let trace_sp = std::env::var("RTX_EMBEDDED_TRACE_SP").is_ok();
|
||||||
|
self.fresh_trace.clear();
|
||||||
if self.moving {
|
if self.moving {
|
||||||
if let Some(body) = &self.body {
|
if let Some(body) = &self.body {
|
||||||
let (nx, ny, dx, dy) = field.grid_info();
|
let (nx, ny, dx, dy) = field.grid_info();
|
||||||
@@ -968,6 +1115,9 @@ impl EmbeddedPisoSolver {
|
|||||||
for i in 0..nx {
|
for i in 0..nx {
|
||||||
if new_mask.is_fluid_cell(j, i) && !old_mask.is_fluid_cell(j, i) {
|
if new_mask.is_fluid_cell(j, i) && !old_mask.is_fluid_cell(j, i) {
|
||||||
fresh_cells += 1;
|
fresh_cells += 1;
|
||||||
|
if trace_sp {
|
||||||
|
self.fresh_trace.push((j, i));
|
||||||
|
}
|
||||||
let mut sum = 0.0;
|
let mut sum = 0.0;
|
||||||
let mut count = 0usize;
|
let mut count = 0usize;
|
||||||
let mut visit = |jj: usize, ii: usize| {
|
let mut visit = |jj: usize, ii: usize| {
|
||||||
@@ -997,6 +1147,20 @@ impl EmbeddedPisoSolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if self.swept_volume != 0.0 {
|
||||||
|
let h = dx.min(dy);
|
||||||
|
let mut alpha = vec![1.0f64; nx * ny];
|
||||||
|
for j in 0..ny {
|
||||||
|
for i in 0..nx {
|
||||||
|
let phi = body.phi((i as f64 + 0.5) * dx, (j as f64 + 0.5) * dy, t_new);
|
||||||
|
alpha[j * nx + i] = (0.5 + phi / h).clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.alpha_old.is_none() {
|
||||||
|
self.alpha_old = Some(alpha.clone());
|
||||||
|
}
|
||||||
|
self.alpha_new = Some(alpha);
|
||||||
|
}
|
||||||
let u_history = field.u_old.clone();
|
let u_history = field.u_old.clone();
|
||||||
let v_history = field.v_old.clone();
|
let v_history = field.v_old.clone();
|
||||||
new_mask.impose_from(
|
new_mask.impose_from(
|
||||||
@@ -1007,6 +1171,21 @@ impl EmbeddedPisoSolver {
|
|||||||
&mut field.v,
|
&mut field.v,
|
||||||
t_new,
|
t_new,
|
||||||
);
|
);
|
||||||
|
if self.field_extension {
|
||||||
|
if let Some(old_mask) = &self.mask {
|
||||||
|
old_mask.extend_fresh_faces(
|
||||||
|
&new_mask,
|
||||||
|
body,
|
||||||
|
t_new,
|
||||||
|
&u_history,
|
||||||
|
&v_history,
|
||||||
|
&mut field.u,
|
||||||
|
&mut field.v,
|
||||||
|
&mut field.u_old,
|
||||||
|
&mut field.v_old,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
self.mask = Some(new_mask);
|
self.mask = Some(new_mask);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1034,6 +1213,9 @@ impl EmbeddedPisoSolver {
|
|||||||
};
|
};
|
||||||
|
|
||||||
self.time = t_new;
|
self.time = t_new;
|
||||||
|
if let Some(a) = self.alpha_new.take() {
|
||||||
|
self.alpha_old = Some(a);
|
||||||
|
}
|
||||||
Ok(EmbeddedResult {
|
Ok(EmbeddedResult {
|
||||||
solver_result: SolverResult {
|
solver_result: SolverResult {
|
||||||
converged: final_residual < self.parameters.tolerance,
|
converged: final_residual < self.parameters.tolerance,
|
||||||
|
|||||||
@@ -696,6 +696,47 @@ impl EmbeddedMask {
|
|||||||
self.impose_from(body, &u_source, &v_source, u, v, t)
|
self.impose_from(body, &u_source, &v_source, u, v, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Field extension for the faces that were ghosts in this (old) mask
|
||||||
|
/// and are fluid in `new_mask`: overwrite their velocity AND history
|
||||||
|
/// with the fluid-side reconstruction at their new distance from the
|
||||||
|
/// wall ([`Ghost::extend`]), instead of the inherited ghost value (a
|
||||||
|
/// plane fit extrapolated through the wall, or — where the fit has too
|
||||||
|
/// few fluid nodes, at corners — the mirror formula with the
|
||||||
|
/// deviation's sign flipped). Returns the number of faces extended.
|
||||||
|
/// Knob-gated by the solver; off, nothing here runs.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn extend_fresh_faces(
|
||||||
|
&self,
|
||||||
|
new_mask: &EmbeddedMask,
|
||||||
|
body: &EmbeddedBody,
|
||||||
|
t: f64,
|
||||||
|
u_source: &DMatrix<f64>,
|
||||||
|
v_source: &DMatrix<f64>,
|
||||||
|
u: &mut DMatrix<f64>,
|
||||||
|
v: &mut DMatrix<f64>,
|
||||||
|
u_old: &mut DMatrix<f64>,
|
||||||
|
v_old: &mut DMatrix<f64>,
|
||||||
|
) -> usize {
|
||||||
|
let mut extended = 0usize;
|
||||||
|
for g in &self.u_ghosts {
|
||||||
|
if new_mask.u_kind(g.j, g.i) == FaceKind::Fluid {
|
||||||
|
let val = g.extend(u_source, body.phi(g.x, g.y, t));
|
||||||
|
u[(g.j, g.i)] = val;
|
||||||
|
u_old[(g.j, g.i)] = val;
|
||||||
|
extended += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for g in &self.v_ghosts {
|
||||||
|
if new_mask.v_kind(g.j, g.i) == FaceKind::Fluid {
|
||||||
|
let val = g.extend(v_source, body.phi(g.x, g.y, t));
|
||||||
|
v[(g.j, g.i)] = val;
|
||||||
|
v_old[(g.j, g.i)] = val;
|
||||||
|
extended += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extended
|
||||||
|
}
|
||||||
|
|
||||||
/// [`Self::impose`] with the fluid values read from a *different* field
|
/// [`Self::impose`] with the fluid values read from a *different* field
|
||||||
/// than the one written: the moving-body step reconstructs the new
|
/// than the one written: the moving-body step reconstructs the new
|
||||||
/// mask's ghost values from the previous step's corrected field (the
|
/// mask's ghost values from the previous step's corrected field (the
|
||||||
@@ -1057,6 +1098,20 @@ impl Ghost {
|
|||||||
/// collinear), fall back to the linear profile along the normal with the
|
/// collinear), fall back to the linear profile along the normal with the
|
||||||
/// non-fluid nodes replaced by the surface velocity at their own
|
/// non-fluid nodes replaced by the surface velocity at their own
|
||||||
/// projections.
|
/// projections.
|
||||||
|
/// The fluid-side value at signed distance `s_new >= 0` from the wall
|
||||||
|
/// along this ghost's normal: the wall value plus the probe's
|
||||||
|
/// deviation scaled by `s_new / s_probe` — never the mirror. This is
|
||||||
|
/// the field extension for a face that has just turned fluid
|
||||||
|
/// (Yang & Balaras 2006; Lee, Kim, Choi & Yang 2011's temporal
|
||||||
|
/// velocity discontinuity is what it removes).
|
||||||
|
fn extend(&self, values: &DMatrix<f64>, s_new: f64) -> f64 {
|
||||||
|
let mut probe = 0.0;
|
||||||
|
for n in &self.nodes {
|
||||||
|
probe += n.weight * n.fallback.unwrap_or_else(|| values[(n.j, n.i)]);
|
||||||
|
}
|
||||||
|
self.u_surface + (probe - self.u_surface) * (s_new.max(0.0) / self.s_probe)
|
||||||
|
}
|
||||||
|
|
||||||
fn reconstruct(&self, values: &DMatrix<f64>) -> f64 {
|
fn reconstruct(&self, values: &DMatrix<f64>) -> f64 {
|
||||||
let mut pts: Vec<(f64, f64, f64)> = self
|
let mut pts: Vec<(f64, f64, f64)> = self
|
||||||
.nodes
|
.nodes
|
||||||
|
|||||||
@@ -34,19 +34,39 @@ const DT_FSI2: f64 = 3.24e-4;
|
|||||||
const HX: f64 = 0.175; // plate half-length (0.35 m)
|
const HX: f64 = 0.175; // plate half-length (0.35 m)
|
||||||
const HY: f64 = 0.01; // plate half-thickness (0.02 m)
|
const HY: f64 = 0.01; // plate half-thickness (0.02 m)
|
||||||
const AMP: f64 = 0.08; // tip amplitude
|
const AMP: f64 = 0.08; // tip amplitude
|
||||||
const OMEGA: f64 = 1.0 / AMP; // peak speed A·ω = 1 m/s
|
/// Peak speed (m/s); `RTX_FRESHCELL_U` overrides (the amplitude stays).
|
||||||
|
fn peak_speed() -> f64 {
|
||||||
|
std::env::var("RTX_FRESHCELL_U")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(1.0)
|
||||||
|
}
|
||||||
const CX: f64 = 0.5;
|
const CX: f64 = 0.5;
|
||||||
const CY0: f64 = 0.5;
|
const CY0: f64 = 0.5;
|
||||||
|
|
||||||
fn center_y(t: f64) -> f64 {
|
fn center_y(t: f64) -> f64 {
|
||||||
CY0 + AMP * (OMEGA * t).sin()
|
CY0 + AMP * (peak_speed() / AMP * t).sin()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn center_v(t: f64) -> f64 {
|
fn center_v(t: f64) -> f64 {
|
||||||
AMP * OMEGA * (OMEGA * t).cos()
|
peak_speed() * (peak_speed() / AMP * t).cos()
|
||||||
|
}
|
||||||
|
|
||||||
|
const R_CIRCLE: f64 = 0.05;
|
||||||
|
|
||||||
|
fn circle_body() -> bool {
|
||||||
|
std::env::var("RTX_FRESHCELL_BODY").is_ok_and(|v| v == "circle")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn plate(moving: bool) -> EmbeddedBody {
|
fn plate(moving: bool) -> EmbeddedBody {
|
||||||
|
if circle_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 };
|
||||||
|
return EmbeddedBody::from_sdf(move |x, y, t| {
|
||||||
|
((x - CX).powi(2) + (y - yc(t)).powi(2)).sqrt() - R_CIRCLE
|
||||||
|
})
|
||||||
|
.with_surface_velocity(move |_, _, t| (0.0, vc(t)));
|
||||||
|
}
|
||||||
let yc = move |t: f64| if moving { center_y(t) } else { CY0 };
|
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 };
|
let vc = move |t: f64| if moving { center_v(t) } else { 0.0 };
|
||||||
EmbeddedBody::from_sdf(move |x, y, t| {
|
EmbeddedBody::from_sdf(move |x, y, t| {
|
||||||
@@ -61,6 +81,17 @@ fn plate(moving: bool) -> EmbeddedBody {
|
|||||||
/// Surface samples of the plate at time `t` (outward normals), spacing `ds`.
|
/// Surface samples of the plate at time `t` (outward normals), spacing `ds`.
|
||||||
fn samples(t: f64, moving: bool, ds: f64) -> Vec<(f64, f64, f64, f64, f64)> {
|
fn samples(t: f64, moving: bool, ds: f64) -> Vec<(f64, f64, f64, f64, f64)> {
|
||||||
let yc = if moving { center_y(t) } else { CY0 };
|
let yc = if moving { center_y(t) } else { CY0 };
|
||||||
|
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;
|
||||||
|
return (0..n)
|
||||||
|
.map(|k| {
|
||||||
|
let th = (k as f64 + 0.5) * dth;
|
||||||
|
let (s, c) = th.sin_cos();
|
||||||
|
(CX + R_CIRCLE * c, yc + R_CIRCLE * s, c, s, R_CIRCLE * dth)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
let (x0, x1, y0, y1) = (CX - HX, CX + HX, yc - HY, yc + HY);
|
let (x0, x1, y0, y1) = (CX - HX, CX + HX, yc - HY, yc + HY);
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let mut edge = |ax: f64, ay: f64, bx: f64, by: f64, nx: f64, ny: f64| {
|
let mut edge = |ax: f64, ay: f64, bx: f64, by: f64, nx: f64, ny: f64| {
|
||||||
@@ -90,6 +121,11 @@ struct Record {
|
|||||||
fy: f64,
|
fy: f64,
|
||||||
fresh: usize,
|
fresh: usize,
|
||||||
skipped: usize,
|
skipped: usize,
|
||||||
|
/// Pressure at a far-field probe (0.5, 0.92) — the fluid's own
|
||||||
|
/// account of the impulse, independent of the traction sampler.
|
||||||
|
p_far: f64,
|
||||||
|
/// Kinetic energy over the fluid cells.
|
||||||
|
ke: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run(moving: bool, dt: f64, t_end: f64) -> CfdResult<Vec<Record>> {
|
async fn run(moving: bool, dt: f64, t_end: f64) -> CfdResult<Vec<Record>> {
|
||||||
@@ -108,6 +144,12 @@ async fn run(moving: bool, dt: f64, t_end: f64) -> CfdResult<Vec<Record>> {
|
|||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
solver.set_boundary_velocity(|_, _, _| (0.0, 0.0));
|
solver.set_boundary_velocity(|_, _, _| (0.0, 0.0));
|
||||||
|
if std::env::var("RTX_EMBEDDED_EXTEND").is_ok() {
|
||||||
|
solver.set_field_extension(true);
|
||||||
|
}
|
||||||
|
if let Ok(v) = std::env::var("RTX_EMBEDDED_SWEPT") {
|
||||||
|
solver.set_swept_volume_source(v.parse().expect("RTX_EMBEDDED_SWEPT"));
|
||||||
|
}
|
||||||
if moving {
|
if moving {
|
||||||
solver.set_moving_body(plate(true));
|
solver.set_moving_body(plate(true));
|
||||||
} else {
|
} else {
|
||||||
@@ -134,12 +176,27 @@ async fn run(moving: bool, dt: f64, t_end: f64) -> CfdResult<Vec<Record>> {
|
|||||||
None => skipped += 1,
|
None => skipped += 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let jp = (0.92 * N as f64) as usize;
|
||||||
|
let ip = (0.5 * N as f64) as usize;
|
||||||
|
let p_far = field.p[(jp, ip)];
|
||||||
|
let mut ke = 0.0;
|
||||||
|
for j in 0..N {
|
||||||
|
for i in 0..N {
|
||||||
|
if mask.is_fluid_cell(j, i) {
|
||||||
|
let uc = 0.5 * (field.u[(j, i)] + field.u[(j, i + 1)]);
|
||||||
|
let vc = 0.5 * (field.v[(j, i)] + field.v[(j + 1, i)]);
|
||||||
|
ke += 0.5 * RHO * (uc * uc + vc * vc) * h * h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
records.push(Record {
|
records.push(Record {
|
||||||
t,
|
t,
|
||||||
fx,
|
fx,
|
||||||
fy,
|
fy,
|
||||||
fresh: result.fresh_cells,
|
fresh: result.fresh_cells,
|
||||||
skipped,
|
skipped,
|
||||||
|
p_far,
|
||||||
|
ke,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(records)
|
Ok(records)
|
||||||
@@ -160,6 +217,9 @@ fn spikes(f: &[f64]) -> Vec<f64> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct Stats {
|
struct Stats {
|
||||||
|
rms_pfar_spike: f64,
|
||||||
|
max_pfar_spike: f64,
|
||||||
|
max_ke_jump: f64,
|
||||||
rms_spike: f64,
|
rms_spike: f64,
|
||||||
max_spike: f64,
|
max_spike: f64,
|
||||||
rms_force: f64,
|
rms_force: f64,
|
||||||
@@ -193,7 +253,19 @@ fn stats(records: &[Record], t_lo: f64, t_hi: f64) -> Stats {
|
|||||||
.count();
|
.count();
|
||||||
let top_spikes_with_fresh = with_fresh as f64 / top.len().max(1) as f64;
|
let top_spikes_with_fresh = with_fresh as f64 / top.len().max(1) as f64;
|
||||||
let skipped_max = idx.iter().map(|&k| records[k].skipped).max().unwrap_or(0);
|
let skipped_max = idx.iter().map(|&k| records[k].skipped).max().unwrap_or(0);
|
||||||
|
let pf: Vec<f64> = records.iter().map(|r| r.p_far).collect();
|
||||||
|
let spf = spikes(&pf);
|
||||||
|
let rms_pfar_spike = rms(&|k| spf[k]);
|
||||||
|
let max_pfar_spike = idx.iter().map(|&k| spf[k].abs()).fold(0.0, f64::max);
|
||||||
|
let max_ke_jump = idx
|
||||||
|
.iter()
|
||||||
|
.filter(|&&k| k > 0)
|
||||||
|
.map(|&k| (records[k].ke - records[k - 1].ke).abs())
|
||||||
|
.fold(0.0, f64::max);
|
||||||
Stats {
|
Stats {
|
||||||
|
rms_pfar_spike,
|
||||||
|
max_pfar_spike,
|
||||||
|
max_ke_jump,
|
||||||
rms_spike,
|
rms_spike,
|
||||||
max_spike,
|
max_spike,
|
||||||
rms_force,
|
rms_force,
|
||||||
@@ -206,12 +278,12 @@ fn stats(records: &[Record], t_lo: f64, t_hi: f64) -> Stats {
|
|||||||
fn dump(dir: &str, name: &str, records: &[Record]) {
|
fn dump(dir: &str, name: &str, records: &[Record]) {
|
||||||
let path = std::path::Path::new(dir).join(format!("{name}.csv"));
|
let path = std::path::Path::new(dir).join(format!("{name}.csv"));
|
||||||
let mut f = std::fs::File::create(path).expect("csv");
|
let mut f = std::fs::File::create(path).expect("csv");
|
||||||
writeln!(f, "t,fx,fy,fresh,skipped").unwrap();
|
writeln!(f, "t,fx,fy,fresh,skipped,p_far,ke").unwrap();
|
||||||
for r in records {
|
for r in records {
|
||||||
writeln!(
|
writeln!(
|
||||||
f,
|
f,
|
||||||
"{:.6},{:.6e},{:.6e},{},{}",
|
"{:.6},{:.6e},{:.6e},{},{},{:.6e},{:.6e}",
|
||||||
r.t, r.fx, r.fy, r.fresh, r.skipped
|
r.t, r.fx, r.fy, r.fresh, r.skipped, r.p_far, r.ke
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
@@ -221,7 +293,7 @@ fn dump(dir: &str, name: &str, records: &[Record]) {
|
|||||||
async fn oscillating_plate_force_spikes_track_fresh_cells() -> CfdResult<()> {
|
async fn oscillating_plate_force_spikes_track_fresh_cells() -> CfdResult<()> {
|
||||||
let ladder = std::env::var("RTX_FRESHCELL_LADDER").is_ok();
|
let ladder = std::env::var("RTX_FRESHCELL_LADDER").is_ok();
|
||||||
let csv_dir = std::env::var("RTX_FRESHCELL_CSV").ok();
|
let csv_dir = std::env::var("RTX_FRESHCELL_CSV").ok();
|
||||||
let period = 2.0 * std::f64::consts::PI / OMEGA;
|
let period = 2.0 * std::f64::consts::PI * AMP / peak_speed();
|
||||||
// Through the first max-velocity crossing (t = 0, the plate starts at
|
// Through the first max-velocity crossing (t = 0, the plate starts at
|
||||||
// peak speed) and up to the turning point at T/4, plus a little.
|
// peak speed) and up to the turning point at T/4, plus a little.
|
||||||
let t_end = 0.3 * period;
|
let t_end = 0.3 * period;
|
||||||
@@ -263,6 +335,10 @@ async fn oscillating_plate_force_spikes_track_fresh_cells() -> CfdResult<()> {
|
|||||||
100.0 * s.top_spikes_with_fresh,
|
100.0 * s.top_spikes_with_fresh,
|
||||||
s.skipped_max
|
s.skipped_max
|
||||||
);
|
);
|
||||||
|
println!(
|
||||||
|
" far probe p(0.5,0.92): rms spike {:.3e}, max spike {:.3e}; max |ΔKE| per step {:.3e} J/m",
|
||||||
|
s.rms_pfar_spike, s.max_pfar_spike, s.max_ke_jump
|
||||||
|
);
|
||||||
if let Some(d) = &csv_dir {
|
if let Some(d) = &csv_dir {
|
||||||
dump(d, &format!("moving_dt{dt:.3e}"), &rec);
|
dump(d, &format!("moving_dt{dt:.3e}"), &rec);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user