Длительность в секундах (--time) и граничное условие Града как альтернатива Bouzidi

--time <секунды> задаёт длительность прогона прямо в физическом времени, число шагов
считается как time/δt. Мотив: шаг не есть фиксированная порция времени — δt = u_lat·δx/u_phys
привязан к размеру клетки, поэтому одно и то же число шагов на разных сетках покрывает разное
физическое время (клетка втрое мельче ⇒ вместо 6 секунд получается 2).

ГРАНИЧНОЕ УСЛОВИЕ ГРАДА (--wall grad) по Dorschner, Bösch, Chikatamarla, Boulouchos, Karlin,
J. Fluid Mech. 801 (2016), разд. 2.1 и прил. B: недостающие популяции задаются не напрямую, а
через целевые моменты — скорость (B 1), плотность (B 3) и тензор давлений (2.14)–(2.16), —
после чего собираются приближением Града (2.13). Переиспользует grad_init, уже проверенный на
эталоне Тейлора–Грина. Скорость на момент t берётся из пост-столкновительного поля: столкновение
сохраняет ρ и ρu, поэтому отдельное хранилище прошлого шага не нужно.

Добавлена также заведомо ступенчатая модель (--wall staircase) — не для счёта, а как база
сравнения, показывающая, сколько именно даёт субсеточность.

ИЗМЕРЕНО, насколько каждая модель субсеточна. Тело сдвигается внутри клетки, смотрится разброс
Cd (Re=20, D=16, стационар): staircase 1.11%, grad 0.64%, bouzidi 0.19%. Град оказывается ровно
между ступенькой и Bouzidi, и это следует из его устройства: положение стенки входит туда только
через целевую скорость — одну усреднённую по узлу величину, тогда как Bouzidi подставляет свою
долю пересечения в каждую популяцию отдельно.

Сходимость по разрешению тела (домен 15D×10D, Re=20): bouzidi 2.581/2.529/2.521 и grad
2.618/2.534/2.521 при D=8/16/32. Обе состоятельны, сходятся к одному пределу с наблюдаемым
порядком ≈2.7 и к D=32 неразличимы; на грубой сетке Град заметно хуже. Ступенчатая модель при
D=16 даёт 2.69, то есть +6.7% к пределу против +0.4% у субсеточных.

Заявленного в статье выигрыша Града по устойчивости на высоких Re в здешней канальной постановке
воспроизвести не удалось: обе модели теряют счёт на одном и том же Re, то есть ограничивает не
стенка. Поэтому умолчание остаётся bouzidi.

В шапку добавлена диагностика границы тела: сколько линков идут по интерполяционной формуле,
сколько сваливаются в ступенчатый отскок, каков разброс доли пересечения. На NACA и цилиндре
интерполяция покрывает 100% линков.

GPU-бэкенд условие Града пока не поддерживает и при таком выборе отказывается запускаться явно,
а не считает молча по Bouzidi.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 01:54:22 +03:00
co-authored by Claude Opus 5
parent f174f1c9a0
commit e3c2d417f8
6 changed files with 508 additions and 17 deletions
+231 -5
View File
@@ -8,7 +8,7 @@
use rayon::prelude::*;
use crate::math::{self, Body, Kbc, Link, LinkKind, R, CX, CY, OPP, Q};
use crate::math::{self, Body, Kbc, Link, LinkKind, WallModel, R, CX, CY, OPP, Q};
use crate::{Collision, FieldKind, Spec, StepRec};
// ─────────────────────────────────────────────────────────────────────────────
@@ -22,6 +22,8 @@ pub struct Geom {
pub solid: Vec<bool>,
/// Линки «жидкий → твёрдый» с готовой геометрией пересечения.
pub links: Vec<Link>,
/// Индекс по граничным узлам: условие Града ставится сразу на весь узел, а не полинково.
pub wall_nodes: Vec<WallNode>,
/// Плечи для момента: координаты узлов относительно центра тела.
pub body_cx: R,
pub body_cy: R,
@@ -41,7 +43,8 @@ impl Geom {
}
}
let links = build_links(nx, ny, &solid, &phi);
Geom { nx, ny, solid, links, body_cx: body.cx, body_cy: body.cy }
let wall_nodes = build_wall_nodes(&links);
Geom { nx, ny, solid, links, wall_nodes, body_cx: body.cx, body_cy: body.cy }
}
}
@@ -93,6 +96,70 @@ fn build_links(nx: usize, ny: usize, solid: &[bool], phi: &[R]) -> Vec<Link> {
links
}
/// Сводка по границе тела: действительно ли работает субсеточная интерполяция.
#[derive(Clone, Copy, Debug, Default)]
pub struct WallStats {
pub nodes: usize,
pub links: usize,
/// q < ½ и есть дальний жидкий сосед — полная интерполяционная формула.
pub near: usize,
/// q ≥ ½ — интерполяция по двум популяциям того же узла.
pub far: usize,
/// Дальнего жидкого соседа нет: откат на ПРОСТОЙ отскок, то есть ступенчатую стенку.
pub simple: usize,
pub q_min: R,
pub q_max: R,
pub q_mean: R,
/// Линки, где q упёрлась в клип [0.02, 0.98] — узел практически лежит на поверхности.
pub q_clipped: usize,
}
impl Geom {
pub fn wall_stats(&self) -> WallStats {
let mut w = WallStats { q_min: R::INFINITY, q_max: R::NEG_INFINITY, ..Default::default() };
w.nodes = self.wall_nodes.len();
w.links = self.links.len();
for l in &self.links {
match l.kind {
LinkKind::Near => w.near += 1,
LinkKind::Far => w.far += 1,
LinkKind::Simple => w.simple += 1,
}
w.q_min = w.q_min.min(l.q);
w.q_max = w.q_max.max(l.q);
w.q_mean += l.q;
if l.q <= 0.0201 || l.q >= 0.9799 {
w.q_clipped += 1;
}
}
if w.links > 0 {
w.q_mean /= w.links as R;
}
w
}
}
/// Жидкий узел, в который после переноса пришла бы хоть одна популяция из тела.
/// Линки одного узла в `Geom::links` лежат подряд (сборка идёт по y, потом x, потом
/// направлениям), поэтому достаточно смещения и количества.
#[derive(Clone, Copy)]
pub struct WallNode {
pub node: u32,
pub first: u32,
pub count: u8,
}
fn build_wall_nodes(links: &[Link]) -> Vec<WallNode> {
let mut out: Vec<WallNode> = Vec::new();
for (k, l) in links.iter().enumerate() {
match out.last_mut() {
Some(w) if w.node == l.node => w.count += 1,
_ => out.push(WallNode { node: l.node, first: k as u32, count: 1 }),
}
}
out
}
// ─────────────────────────────────────────────────────────────────────────────
// Уровень сетки
// ─────────────────────────────────────────────────────────────────────────────
@@ -119,7 +186,15 @@ impl Level {
fn new(nx: usize, ny: usize, beta: Vec<R>, geom: Geom, u0: (R, R)) -> Self {
let f0 = math::feq(1.0, u0.0, u0.1);
let n = nx * ny;
Level { nx, ny, f: vec![f0; n], post: vec![f0; n], gamma: vec![2.0; n], beta, geom }
Level {
nx,
ny,
f: vec![f0; n],
post: vec![f0; n],
gamma: vec![2.0; n],
beta,
geom,
}
}
/// Столкновение по всем жидким узлам. Внутри тела не считаем: эти популяции фиктивны
@@ -172,6 +247,14 @@ impl Level {
let _ = ny;
}
/// Простой отскок по всем линкам: q игнорируется. База сравнения для субсеточных моделей.
fn apply_staircase(&mut self) {
for l in &self.geom.links {
let node = l.node as usize;
self.f[node][l.ib as usize] = self.post[node][l.i as usize];
}
}
/// Интерполированный отскок Bouzidi по всем линкам тела.
/// `f` — поле ПОСЛЕ переноса (его правим), `post` — ПОСЛЕ столкновения (до переноса).
fn apply_bouzidi(&mut self) {
@@ -192,6 +275,109 @@ impl Level {
}
}
/// ГРАНИЧНОЕ УСЛОВИЕ ГРАДА на теле (Dorschner и др., JFM 801 (2016), прил. B).
///
/// В отличие от Bouzidi, работающего полинково, здесь условие ставится сразу на весь
/// граничный узел и не на популяции, а на моменты:
///
/// целевая скорость u_tgt = (1/n) Σ (q_i·u_f,i + u_w,i)/(1 + q_i) (B 1)
/// целевая плотность ρ_tgt = Σ_известные f_i + Σ_недостающие f_i^отскок (B 3)
/// тензор давлений Π = ρc_s²I + ρu⊗u − (ρc_s²/2β)(∇u + ∇uᵀ) (2.14)–(2.16)
///
/// после чего недостающие популяции собираются приближением Града (2.13). Скорости
/// соседей и градиенты берутся с прошлого шага — см. `u_prev`.
fn apply_grad_wall(&mut self) {
let nx = self.nx;
// стенка неподвижна; для подвижного тела сюда пойдёт её скорость на линке,
// и добавится динамическая часть плотности (B 4)
let (uwx, uwy) = (0.0, 0.0);
for wi in 0..self.geom.wall_nodes.len() {
let w = self.geom.wall_nodes[wi];
let node = w.node as usize;
let lo = w.first as usize;
let hi = lo + w.count as usize;
// ── целевая скорость (B 1) ──
let (mut ux, mut uy) = (0.0, 0.0);
for l in &self.geom.links[lo..hi] {
let far = l.far as usize;
let (fx, fy) = if self.geom.solid[far] {
// дальнего жидкого соседа нет (тело тоньше двух клеток) — остаётся стенка
(uwx, uwy)
} else {
self.u_at_t(far)
};
ux += math::grad_target_velocity_term(l.q, fx, uwx);
uy += math::grad_target_velocity_term(l.q, fy, uwy);
}
let inv = 1.0 / w.count as R;
ux *= inv;
uy *= inv;
// ── целевая плотность (B 3): известные популяции плюс отскок недостающих ──
let mut missing = [false; Q];
for l in &self.geom.links[lo..hi] {
missing[l.ib as usize] = true;
}
let mut rho = 0.0;
for i in 0..Q {
rho += if missing[i] { self.post[node][OPP[i]] } else { self.f[node][i] };
}
let (dudx, dudy, dvdx, dvdy) = self.grad_u_at_t(node);
let g = math::grad_wall(rho, ux, uy, dudx, dudy, dvdx, dvdy, self.beta[node % nx]);
for i in 0..Q {
if missing[i] {
self.f[node][i] = g[i];
}
}
}
}
/// Скорость узла на момент t — берётся из ПОСТ-СТОЛКНОВИТЕЛЬНОГО поля.
///
/// Столкновение сохраняет ρ и ρu точно, поэтому macros(post) даёт ровно ту же скорость,
/// что было в f до переноса, то есть u(x, t) — именно то, что требует прил. B. Отдельное
/// хранилище «поля предыдущего шага» при этом не нужно.
#[inline]
fn u_at_t(&self, node: usize) -> (R, R) {
let (_, a, b) = math::macros(&self.post[node]);
(a, b)
}
/// Градиент скорости на момент t: центральная разность там, где оба соседа жидкие,
/// односторонняя — где один твёрдый, ноль — если твёрдые оба.
/// Возвращает (∂u_x/∂x, ∂u_x/∂y, ∂u_y/∂x, ∂u_y/∂y).
fn grad_u_at_t(&self, node: usize) -> (R, R, R, R) {
let (nx, ny) = (self.nx, self.ny);
let (x, y) = (node % nx, node / nx);
let xm = y * nx + (x + nx - 1) % nx;
let xp = y * nx + (x + 1) % nx;
let ym = ((y + ny - 1) % ny) * nx + x;
let yp = ((y + 1) % ny) * nx + x;
let solid = &self.geom.solid;
let uc = self.u_at_t(node);
let comp = |t: (R, R), c: usize| if c == 0 { t.0 } else { t.1 };
let d = |a: usize, b: usize, c: usize| -> R {
match (!solid[a], !solid[b]) {
(true, true) => 0.5 * (comp(self.u_at_t(b), c) - comp(self.u_at_t(a), c)),
(false, true) => comp(self.u_at_t(b), c) - comp(uc, c),
(true, false) => comp(uc, c) - comp(self.u_at_t(a), c),
(false, false) => 0.0,
}
};
(d(xm, xp, 0), d(ym, yp, 0), d(xm, xp, 1), d(ym, yp, 1))
}
/// Замкнуть недостающие популяции выбранной моделью стенки.
fn apply_wall(&mut self, model: WallModel) {
match model {
WallModel::Bouzidi => self.apply_bouzidi(),
WallModel::Grad => self.apply_grad_wall(),
WallModel::Staircase => self.apply_staircase(),
}
}
/// FREE-SLIP (зеркальные) стенки канала сверху и снизу: касательный импульс сохраняется,
/// нормальный заворачивается, масса сохраняется. Применять ПОСЛЕ переноса и ПЕРЕД Zou–He.
fn free_slip_walls(&mut self) {
@@ -635,9 +821,10 @@ impl Sim {
// ── уровень 0 ──
self.pre.copy_from_slice(&self.l0.f);
let model = self.spec.kbc_model;
let wall = self.spec.wall;
let stats0 = self.l0.collide(sp_collision, model);
self.l0.stream();
self.l0.apply_bouzidi();
self.l0.apply_wall(wall);
self.l0.free_slip_walls();
self.l0.channel_bc(ux_in, uy_in, 1.0, outlet_extrap);
@@ -649,7 +836,7 @@ impl Sim {
for s in 0..p.r {
l1.collide(sp_collision, model);
l1.stream();
l1.apply_bouzidi();
l1.apply_wall(wall);
// силу снимаем на КАЖДОМ подшаге и усредняем — мгновенное значение на
// последнем подшаге даёт лишний шум в рядах при том же среднем
let (a, b, c) = l1.force();
@@ -782,6 +969,7 @@ mod tests {
ny,
solid: vec![false; nx * ny],
links: Vec::new(),
wall_nodes: Vec::new(),
body_cx: 0.0,
body_cy: 0.0,
},
@@ -843,6 +1031,44 @@ mod tests {
}
}
/// СУБСЕТОЧНОСТЬ ГРАНИЦЫ. Стенка обязана стоять не на узлах решётки: при сдвиге тела на
/// долю клетки доли пересечения q обязаны поехать, а у разрешённого тела не должно быть
/// ни одного отката на простой (ступенчатый) отскок. Если бы граница была ступенчатой,
/// средняя q не реагировала бы на смещение вовсе.
#[test]
fn wall_is_subgrid_not_staircase() {
let mut means = Vec::new();
for off in [0.0, 0.25, 0.5] {
let body = Body::new(math::ShapeKind::Cylinder, 60.0, 40.0 + off, 24.0, 0.0, 0.3);
let g = Geom::build(120, 80, &body);
let w = g.wall_stats();
assert!(w.links > 100, "смещение {off}: линков всего {}", w.links);
assert_eq!(w.simple, 0, "смещение {off}: {} ступенчатых линков", w.simple);
assert!(w.q_min > 0.0 && w.q_max < 1.0, "смещение {off}: q вышла за (0,1)");
means.push(w.q_mean);
}
let hi = means.iter().cloned().fold(R::NEG_INFINITY, R::max);
let lo = means.iter().cloned().fold(R::INFINITY, R::min);
assert!(hi - lo > 0.02, "средняя q не отреагировала на сдвиг тела: {means:?}");
}
/// Индекс граничных узлов обязан в точности разбивать список линков: без пропусков,
/// без пересечений, в том же порядке.
#[test]
fn wall_node_index_partitions_links() {
let body = Body::new(math::ShapeKind::Naca, 60.0, 40.0, 30.0, 12.0, 0.25);
let g = Geom::build(120, 80, &body);
let mut covered = 0usize;
for w in &g.wall_nodes {
assert_eq!(w.first as usize, covered, "разрыв в индексе граничных узлов");
for l in &g.links[covered..covered + w.count as usize] {
assert_eq!(l.node, w.node, "линк чужого узла в диапазоне");
}
covered += w.count as usize;
}
assert_eq!(covered, g.links.len(), "индекс покрыл не все линки");
}
/// Однородное равновесие — неподвижная точка схемы: не должно никуда уехать.
#[test]
fn uniform_equilibrium_is_fixed_point() {