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

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:
Omar Sobh
2026-09-03 11:40:28 -07:00
co-authored by Claude Fable 5.1
parent 23eb996a9f
commit c9492d3e1e
3 changed files with 320 additions and 7 deletions
@@ -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);
} }