Длительность в секундах (--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:
Binary file not shown.
@@ -83,6 +83,19 @@ cargo test --release -- --include-ignored # плюс эталоны стат
|
||||
|
||||
`--gif-speed 0.1` даёт замедление в 10 раз (тоже точно, через тот же накопитель).
|
||||
|
||||
## Шаг — не фиксированная порция времени
|
||||
|
||||
Физическая длительность шага привязана к размеру клетки: δt = u_lat·δx/u_phys. Уменьшили клетку
|
||||
вдвое — вдвое уменьшился и δt, и то же число шагов покроет вдвое меньше физического времени.
|
||||
Отсюда `--time`: задаёте длительность в секундах, число шагов считается само.
|
||||
|
||||
Измельчение стоит дважды: клеток становится (1/δx)², а шагов на ту же секунду — 1/δx, итого
|
||||
работы ~(1/δx)³ в двумерии. И помните, что `--size` задаётся В КЛЕТКАХ: уменьшив клетку и не
|
||||
тронув `--size`, вы уменьшите тело физически.
|
||||
|
||||
Судить о длительности удобнее всего по **конвективным временам D/U** — их печатает шапка. Это
|
||||
единственная мера, не зависящая ни от сетки, ни от выбора u_lat.
|
||||
|
||||
## Что параметризовано
|
||||
|
||||
- **поток**: скорость (м/с), направление, число Рейнольдса, решёточная скорость (число Маха);
|
||||
@@ -90,10 +103,12 @@ cargo test --release -- --include-ignored # плюс эталоны стат
|
||||
вложенного патча и его границы;
|
||||
- **тело**: семь форм на выбор — `cylinder`, `square`, `diamond`, `ellipse`, `naca`, `triangle`,
|
||||
`plate` — плюс характерный размер, относительная толщина, угол атаки и положение;
|
||||
- **время**: число шагов, начальное поле (однородный поток либо покой с разгоном), длина
|
||||
разгона, амплитуда и длительность стартового возмущения;
|
||||
- **схема**: оператор столкновения (`kbc`/`bgk`), состав сдвиговой части (`n1`/`n2`), режим
|
||||
выхода, поглощающая губка, бэкенд, число потоков;
|
||||
- **время**: длительность прогона — либо числом шагов (`--steps`), либо прямо в СЕКУНДАХ
|
||||
физического времени (`--time`, число шагов считается как time/δt); начальное поле (однородный
|
||||
поток либо покой с разгоном), длина разгона, амплитуда и длительность стартового возмущения;
|
||||
- **схема**: оператор столкновения (`kbc`/`bgk`), состав сдвиговой части (`n1`/`n2`), модель
|
||||
стенки на теле (`bouzidi`/`grad`/`staircase`), режим выхода, поглощающая губка, бэкенд,
|
||||
число потоков;
|
||||
- **анимация**: файл, поле (`speed`/`vorticity`/`density`/`gamma`), палитра, масштаб, шаг кадра,
|
||||
частота, скорость воспроизведения, диапазон нормировки;
|
||||
- **вывод**: период живых строк, три уровня подробности, число окон в отчёте о сходимости, CSV.
|
||||
@@ -210,6 +225,62 @@ c_s²(1/(γβ) − ½) и, поскольку измеренная ⟨γ⟩ ≈
|
||||
постановке (старт из покоя, губка выключена) `n1` доживает до конца с пульсацией 75% от U,
|
||||
а `n2` разваливается. Поэтому умолчание — `n1`.
|
||||
|
||||
### Модель стенки на теле: насколько она субсеточная
|
||||
|
||||
Ключ `--wall`:
|
||||
|
||||
- `bouzidi` (умолчание) — интерполированный отскок: доля пересечения q входит в КАЖДУЮ
|
||||
восстанавливаемую популяцию, полинково;
|
||||
- `grad` — условие Града (Dorschner, Bösch, Chikatamarla, Boulouchos, Karlin, JFM 801 (2016),
|
||||
разд. 2.1 и прил. B): задаются не популяции, а целевые моменты — ρ, u и тензор давлений, —
|
||||
после чего недостающие популяции собираются приближением Града (2.13);
|
||||
- `staircase` — простой отскок, q игнорируется. Не для счёта: это база сравнения, показывающая,
|
||||
сколько именно даёт субсеточность.
|
||||
|
||||
**Субсеточность — измеренная.** Прямой тест: сдвигаем тело внутри клетки и смотрим, насколько
|
||||
поедет Cd. У по-настоящему субсеточной границы ответ не должен зависеть от того, где тело стоит
|
||||
относительно узлов (Re = 20, D = 16, стационар, сдвиги 0…½ клетки):
|
||||
|
||||
| модель | разброс Cd | Cd |
|
||||
|---|---|---|
|
||||
| `staircase` | 1.11% | 2.68–2.71 |
|
||||
| `grad` | 0.64% | 2.64–2.66 |
|
||||
| `bouzidi` | **0.19%** | 2.653–2.658 |
|
||||
|
||||
Град оказывается ровно между ступенькой и Bouzidi, и это следует из его устройства: положение
|
||||
стенки входит туда ТОЛЬКО через целевую скорость (B 1) — одну усреднённую по узлу величину.
|
||||
Целевая плотность (B 3) — обычная сумма отскочивших и известных популяций, без q вовсе; тензор
|
||||
давлений — конечные разности по решётке, тоже без q. Плюс все недостающие популяции узла
|
||||
собираются из ОДНОГО набора моментов, так что полинковая направленность теряется. Bouzidi же
|
||||
подставляет свою q в каждую популяцию отдельно. Ступенчатой поверхность у Града не становится,
|
||||
но геометрия у него разрешена заметно грубее.
|
||||
|
||||
**Сходимость по разрешению тела.** Физическая постановка фиксирована (домен 15D × 10D,
|
||||
блокировка 0.1, Re = 20), меняется только число клеток на диаметр:
|
||||
|
||||
| D | `bouzidi` | `grad` |
|
||||
|---|---|---|
|
||||
| 8 | 2.581 | 2.618 |
|
||||
| 16 | 2.529 | 2.534 |
|
||||
| 32 | **2.521** | **2.521** |
|
||||
|
||||
Обе модели состоятельны и сходятся к одному пределу с наблюдаемым порядком ≈2.7; к D = 32 они
|
||||
неразличимы. Но на грубой сетке Град заметно хуже: ошибка при D = 8 равна 0.097 против 0.060.
|
||||
Для сравнения, `staircase` при D = 16 даёт 2.69 — то есть +6.7% к пределу, тогда как обе
|
||||
субсеточные модели держатся в пределах +0.4%.
|
||||
|
||||
**Зачем тогда Град.** Его преимущество в статье — не геометрическая точность, а устойчивость
|
||||
на турбулентных режимах (авторы пишут, что интерполяционные схемы «ограничены низкими числами
|
||||
Рейнольдса, поскольку на границе возникают паразитные скачки») и естественная форма для
|
||||
подвижных стенок: скорость стенки входит в целевые значения, а не отдельной поправкой.
|
||||
В здешней канальной постановке преимущества по устойчивости воспроизвести не удалось: при росте
|
||||
Re обе модели теряют счёт на одном и том же значении (Re ≈ 5·10⁴ при теле в 16 клеток), то есть
|
||||
ограничивает не стенка, а что-то другое — вероятнее всего Zou–He при τ → ½. Поэтому умолчание —
|
||||
`bouzidi`, а `grad` стоит держать в виду для будущих подвижных тел.
|
||||
|
||||
Ограничение реализации: `--wall grad` пока только на процессорном бэкенде; GPU при таком выборе
|
||||
отказывается запускаться явно, а не считает молча по Bouzidi.
|
||||
|
||||
### Паритет бэкендов и согласованность уровней
|
||||
|
||||
CPU (f64) и GPU (f32) на одной постановке совпадают до 4–5 значащих цифр шаг в шаг: ⟨ρ⟩
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -784,6 +784,13 @@ impl Sim {
|
||||
))
|
||||
.map_err(|e| format!("не удалось получить устройство: {e}"))?;
|
||||
|
||||
// Условие Града на GPU пока не перенесено. Молча считать по Bouzidi нельзя — это
|
||||
// была бы другая физика под тем же ключом, поэтому отказываемся явно.
|
||||
if spec.wall == math::WallModel::Grad {
|
||||
return Err("--wall grad на GPU не реализован (перенесён только Bouzidi). Запусти с --backend cpu либо оставь --wall bouzidi"
|
||||
.into());
|
||||
}
|
||||
|
||||
// ── топология берётся из процессорного бэкенда, а не строится заново ──
|
||||
let geom0 = cpu::Geom::build(spec.nx, spec.ny, &spec.body);
|
||||
let fluid_count = geom0.solid.iter().filter(|s| !**s).count() as R;
|
||||
|
||||
@@ -95,6 +95,8 @@ pub struct Spec {
|
||||
pub collision: Collision,
|
||||
/// Что входит в сдвиговую часть s (см. `math::KbcModel`).
|
||||
pub kbc_model: math::KbcModel,
|
||||
/// Чем замыкаются недостающие популяции на теле (см. `math::WallModel`).
|
||||
pub wall: math::WallModel,
|
||||
/// Узел зонда следа в координатах L0.
|
||||
pub probe: (usize, usize),
|
||||
}
|
||||
@@ -167,9 +169,14 @@ struct Cli {
|
||||
body_y: Option<R>,
|
||||
|
||||
// ── время ──
|
||||
/// Число шагов симуляции
|
||||
#[arg(long, default_value_t = 20000, help_heading = "Время")]
|
||||
steps: u64,
|
||||
/// Число шагов симуляции. Взаимоисключающе с --time.
|
||||
#[arg(long, conflicts_with = "time", help_heading = "Время")]
|
||||
steps: Option<u64>,
|
||||
/// Длительность прогона в СЕКУНДАХ физического времени. Число шагов считается как
|
||||
/// time/δt, где δt = u_lat·dx/u_phys — то есть зависит и от размера клетки, и от
|
||||
/// решёточной скорости. Взаимоисключающе с --steps.
|
||||
#[arg(long, help_heading = "Время")]
|
||||
time: Option<R>,
|
||||
/// Длина smoothstep-разгона входа, шагов
|
||||
#[arg(long, default_value_t = 1000, help_heading = "Время")]
|
||||
ramp: u64,
|
||||
@@ -190,6 +197,13 @@ struct Cli {
|
||||
/// Оператор столкновения
|
||||
#[arg(long, default_value = "kbc", value_parser = ["kbc", "bgk"], help_heading = "Схема")]
|
||||
collision: String,
|
||||
/// Модель стенки на теле: bouzidi — интерполированный отскок по линкам; grad — условие
|
||||
/// Града (Dorschner и др., JFM 801 (2016)), где задаются не популяции, а целевые моменты
|
||||
/// ρ, u и тензор давлений. Авторы метода предпочитают grad: интерполяционные схемы, по их
|
||||
/// словам, «ограничены низкими Re, поскольку на границе возникают паразитные скачки».
|
||||
#[arg(long, default_value = "bouzidi", value_parser = math::WallModel::ALL,
|
||||
help_heading = "Схема")]
|
||||
wall: String,
|
||||
/// Состав сдвиговой части KBC (табл. I 2D-статьи): n1 — только девиатор {N, Π_xy}
|
||||
/// (KBC D), объёмная вязкость гуляет вместе с γ и может стать отрицательной; n2 —
|
||||
/// девиатор со следом {N, Π_xy, T} (KBC C), объёмная вязкость фиксирована ξ = ν.
|
||||
@@ -375,6 +389,33 @@ fn build_spec(cli: &Cli) -> Result<Spec, String> {
|
||||
(cy.round() as usize).min(cli.ny - 2),
|
||||
);
|
||||
|
||||
// Длительность задаётся либо в шагах, либо в секундах. Шаг не есть фиксированная порция
|
||||
// времени: δt привязан к размеру клетки, поэтому одно и то же число шагов на разных
|
||||
// сетках покрывает разное физическое время.
|
||||
let steps = match (cli.steps, cli.time) {
|
||||
(Some(s), _) => s,
|
||||
(None, Some(t)) => {
|
||||
if !(t > 0.0) {
|
||||
return Err("--time обязан быть положительным".into());
|
||||
}
|
||||
let n = (t / units.dt).round();
|
||||
if n < 1.0 {
|
||||
return Err(format!(
|
||||
"--time {t} с при шаге δt = {:.4e} с даёт меньше одного шага",
|
||||
units.dt
|
||||
));
|
||||
}
|
||||
if n > 2e9 {
|
||||
return Err(format!(
|
||||
"--time {t} с при шаге δt = {:.4e} с требует {n:.3e} шагов — это заведомо неподъёмно. Подними --u-lat или --dx, либо задай --steps явно",
|
||||
units.dt
|
||||
));
|
||||
}
|
||||
n as u64
|
||||
}
|
||||
(None, None) => 20_000,
|
||||
};
|
||||
|
||||
Ok(Spec {
|
||||
nx: cli.nx,
|
||||
ny: cli.ny,
|
||||
@@ -383,7 +424,7 @@ fn build_spec(cli: &Cli) -> Result<Spec, String> {
|
||||
patch,
|
||||
units,
|
||||
beta0,
|
||||
steps: cli.steps,
|
||||
steps,
|
||||
ramp: cli.ramp.max(1),
|
||||
flow_angle: cli.flow_angle * std::f64::consts::PI / 180.0,
|
||||
pert_amp: cli.pert_amp,
|
||||
@@ -394,6 +435,7 @@ fn build_spec(cli: &Cli) -> Result<Spec, String> {
|
||||
sponge_mult: cli.sponge_mult,
|
||||
collision: if cli.collision == "bgk" { Collision::Bgk } else { Collision::Kbc },
|
||||
kbc_model: math::KbcModel::from_str(&cli.kbc_model).ok_or("неизвестная модель KBC")?,
|
||||
wall: math::WallModel::from_str(&cli.wall).ok_or("неизвестная модель стенки")?,
|
||||
probe,
|
||||
})
|
||||
}
|
||||
@@ -666,6 +708,29 @@ fn print_header(spec: &Spec, cli: &Cli, plan: &gif::GifPlan, field: FieldKind) {
|
||||
} else {
|
||||
"LBGK"
|
||||
});
|
||||
// Действительно ли субсеточная граница работает: если большинство линков сваливается в
|
||||
// простой отскок, стенка де-факто ступенчатая, как её ни называй.
|
||||
{
|
||||
let g = cpu::Geom::build(spec.nx, spec.ny, &spec.body);
|
||||
let w = g.wall_stats();
|
||||
let pc = |k: usize| 100.0 * k as R / w.links.max(1) as R;
|
||||
println!("
|
||||
── Граница тела ────────────────────────────────────────────────────────────");
|
||||
println!(" граничных узлов {:>12} линков {}", w.nodes, w.links);
|
||||
println!(" интерполяция {:>11.1}% (ближняя {:.1}% + дальняя {:.1}%)",
|
||||
pc(w.near) + pc(w.far), pc(w.near), pc(w.far));
|
||||
println!(" простой отскок {:>11.1}% {}", pc(w.simple),
|
||||
if w.simple == 0 { "— ступенчатых линков нет" } else { "← ЭТИ ЛИНКИ СТУПЕНЧАТЫЕ" });
|
||||
println!(" доля пересечения q {:>12} мин {:.3}, средн {:.3}, макс {:.3}; зажато {:.1}%",
|
||||
"", w.q_min, w.q_mean, w.q_max, pc(w.q_clipped));
|
||||
}
|
||||
|
||||
println!(" стенка тела {:>12}",
|
||||
match spec.wall {
|
||||
math::WallModel::Bouzidi => "Bouzidi",
|
||||
math::WallModel::Grad => "Град (моменты)",
|
||||
math::WallModel::Staircase => "простой отскок",
|
||||
});
|
||||
println!(" выход по u_y {:>12} губка {} столбцов ×{:.0}",
|
||||
if spec.outlet_extrapolate { "extrapolate" } else { "zero" },
|
||||
spec.sponge_len, spec.sponge_mult);
|
||||
|
||||
@@ -97,10 +97,6 @@ pub fn feq(rho: R, ux: R, uy: R) -> [R; Q] {
|
||||
///
|
||||
/// По построению сохраняет ρ и ρu точно (третий момент весов обнуляется по симметрии).
|
||||
///
|
||||
/// В самом решателе пока не используется: канальная постановка стартует с однородного потока,
|
||||
/// где Π⁽¹⁾ = 0 и приближение Града вырождается в равновесие. Нужна для эталонных течений,
|
||||
/// у которых стартовое поле имеет ненулевые градиенты.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
#[inline]
|
||||
pub fn grad_init(rho: R, ux: R, uy: R, pxx: R, pxy: R, pyy: R) -> [R; Q] {
|
||||
let axx = pxx - rho * CS2;
|
||||
@@ -662,6 +658,86 @@ pub fn zou_he_outlet(f: &mut [R; Q], rho_out: R, uy_out: R) {
|
||||
f[7] = f[5] + d - (1.0 / 6.0) * rho_out * ux - 0.5 * rho_out * uy_out;
|
||||
}
|
||||
|
||||
/// Чем замыкаются недостающие популяции на теле — те, что после переноса должны были прийти
|
||||
/// из твёрдого узла.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum WallModel {
|
||||
/// Интерполированный отскок Bouzidi: формула применяется к каждому линку по отдельности,
|
||||
/// стенка стоит на доле q вдоль линка. Второй порядок по положению стенки, дёшево.
|
||||
Bouzidi,
|
||||
/// Граничное условие Града (Dorschner, Bösch, Chikatamarla, Boulouchos, Karlin,
|
||||
/// J. Fluid Mech. 801 (2016) 623–651, разд. 2.1 и прил. B).
|
||||
///
|
||||
/// Условие ставится не на сами популяции, а на МОМЕНТЫ — целевые ρ, u и тензор давлений, —
|
||||
/// после чего недостающие популяции собираются приближением Града (ур. 2.13). Авторы
|
||||
/// метода предпочитают его интерполяционным схемам: те «ограничены низкими числами
|
||||
/// Рейнольдса, поскольку на границе возникают паразитные скачки» (разд. 2.1).
|
||||
/// Заодно это естественная форма для подвижных стенок: скорость стенки входит в целевые
|
||||
/// значения, а не в отдельную поправку.
|
||||
///
|
||||
/// ВАЖНО про субсеточность: положение стенки входит сюда ТОЛЬКО через целевую скорость
|
||||
/// (B 1) — одну усреднённую по узлу величину. Целевая плотность (B 3) и тензор давлений
|
||||
/// строятся вообще без q, а все недостающие популяции узла собираются из одного набора
|
||||
/// моментов, то есть полинковая направленность теряется. Bouzidi, наоборот, применяет
|
||||
/// свою q к каждой популяции отдельно. Поэтому геометрия у Града разрешена грубее — это
|
||||
/// видно и по чувствительности к положению тела внутри клетки, и по точности на грубых
|
||||
/// сетках (см. README).
|
||||
Grad,
|
||||
/// ПРОСТОЙ ОТСКОК: q игнорируется, стенка по построению лежит ровно посередине между
|
||||
/// узлами — ступенчатая поверхность. Первый порядок. Держится не для счёта, а как база
|
||||
/// сравнения: показывает, сколько именно даёт субсеточность.
|
||||
Staircase,
|
||||
}
|
||||
|
||||
impl WallModel {
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
Some(match s.to_ascii_lowercase().as_str() {
|
||||
"bouzidi" | "bb" => WallModel::Bouzidi,
|
||||
"grad" => WallModel::Grad,
|
||||
"staircase" | "step" => WallModel::Staircase,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
pub const ALL: [&'static str; 3] = ["bouzidi", "grad", "staircase"];
|
||||
}
|
||||
|
||||
/// Недостающие популяции по целевым моментам — ур. (2.13) вместе с (2.14)–(2.16):
|
||||
///
|
||||
/// Π = Π^eq + Π^neq, Π^eq = ρc_s²I + ρu⊗u, Π^neq = −(ρc_s²/2β)(∇u + ∇uᵀ)
|
||||
///
|
||||
/// Возвращает ПОЛНЫЙ набор из девяти популяций; вызывающий берёт из него только недостающие.
|
||||
/// Градиенты берутся с поля предыдущего шага: в момент применения условия скорость в самом
|
||||
/// граничном узле ещё не определена — недостающие популяции как раз и вычисляются.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[inline]
|
||||
pub fn grad_wall(
|
||||
rho: R,
|
||||
ux: R,
|
||||
uy: R,
|
||||
dudx: R,
|
||||
dudy: R,
|
||||
dvdx: R,
|
||||
dvdy: R,
|
||||
beta: R,
|
||||
) -> [R; Q] {
|
||||
let pref = rho * CS2 / (2.0 * beta);
|
||||
let pxx = rho * CS2 + rho * ux * ux - pref * 2.0 * dudx;
|
||||
let pyy = rho * CS2 + rho * uy * uy - pref * 2.0 * dvdy;
|
||||
let pxy = rho * ux * uy - pref * (dudy + dvdx);
|
||||
grad_init(rho, ux, uy, pxx, pxy, pyy)
|
||||
}
|
||||
|
||||
/// Целевая скорость граничного узла — ур. (B 1): линейная интерполяция между стенкой и
|
||||
/// «дальним» жидким соседом по каждому недостающему направлению, усреднённая по ним.
|
||||
///
|
||||
/// u_tgt = (1/n) Σ (q_i·u_f,i + u_w,i)/(1 + q_i)
|
||||
///
|
||||
/// Три точки лежат на одной прямой: стенка на −q от узла, сам узел в нуле, дальний сосед на +1.
|
||||
#[inline]
|
||||
pub fn grad_target_velocity_term(q: R, u_far: R, u_wall: R) -> R {
|
||||
(q * u_far + u_wall) / (1.0 + q)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Сила на теле
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -1025,6 +1101,52 @@ mod tests {
|
||||
assert!((rho - 1.0).abs() < 1e-12, "ρ={rho}");
|
||||
}
|
||||
|
||||
/// Приближение Града обязано ТОЧНО воспроизводить моменты, из которых собрано:
|
||||
/// плотность, импульс и полный тензор давлений. На этом и держится идея граничного
|
||||
/// условия — задаём моменты, а не популяции.
|
||||
#[test]
|
||||
fn grad_reproduces_target_moments() {
|
||||
let (rho, ux, uy) = (1.02, 0.03, -0.01);
|
||||
let (pxx, pxy, pyy) = (0.35, 0.004, 0.33);
|
||||
let g = grad_init(rho, ux, uy, pxx, pxy, pyy);
|
||||
let (r, x, y) = macros(&g);
|
||||
assert!((r - rho).abs() < 1e-13, "ρ: {r}");
|
||||
assert!((x - ux).abs() < 1e-13, "ux: {x}");
|
||||
assert!((y - uy).abs() < 1e-13, "uy: {y}");
|
||||
let mxx: R = (0..Q).map(|i| (CX[i] * CX[i]) as R * g[i]).sum();
|
||||
let myy: R = (0..Q).map(|i| (CY[i] * CY[i]) as R * g[i]).sum();
|
||||
let mxy: R = (0..Q).map(|i| (CX[i] * CY[i]) as R * g[i]).sum();
|
||||
assert!((mxx - pxx).abs() < 1e-13, "Π_xx: {mxx} vs {pxx}");
|
||||
assert!((myy - pyy).abs() < 1e-13, "Π_yy: {myy} vs {pyy}");
|
||||
assert!((mxy - pxy).abs() < 1e-13, "Π_xy: {mxy} vs {pxy}");
|
||||
}
|
||||
|
||||
/// Граничная сборка (2.13)+(2.14)–(2.16): при нулевых градиентах неравновесной части нет,
|
||||
/// и результат обязан совпасть с равновесием по тем же ρ, u с точностью до порядка Ma³
|
||||
/// (product-form равновесие не полиномиально, поэтому не побитово).
|
||||
#[test]
|
||||
fn grad_wall_without_gradients_is_near_equilibrium() {
|
||||
let (rho, ux, uy) = (1.0, 0.02, 0.01);
|
||||
let g = grad_wall(rho, ux, uy, 0.0, 0.0, 0.0, 0.0, 0.9);
|
||||
let fe = feq(rho, ux, uy);
|
||||
for i in 0..Q {
|
||||
assert!((g[i] - fe[i]).abs() < 1e-4, "i={i}: {} vs {}", g[i], fe[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Целевая скорость (B 1) — линейная интерполяция между стенкой и дальним соседом.
|
||||
#[test]
|
||||
fn grad_target_velocity_interpolates() {
|
||||
// стенка вплотную к узлу (q→0) ⇒ берём скорость стенки
|
||||
assert!((grad_target_velocity_term(0.0, 1.0, 0.0) - 0.0).abs() < 1e-15);
|
||||
// стенка на целую ячейку (q=1) ⇒ ровно середина между стенкой и соседом
|
||||
assert!((grad_target_velocity_term(1.0, 1.0, 0.0) - 0.5).abs() < 1e-15);
|
||||
// неподвижная стенка, покоящийся сосед ⇒ ноль при любом q
|
||||
for q in [0.1, 0.5, 0.9] {
|
||||
assert!(grad_target_velocity_term(q, 0.0, 0.0).abs() < 1e-15);
|
||||
}
|
||||
}
|
||||
|
||||
/// SDF: знак внутри/снаружи и |∇φ| ≈ 1 у поверхности для каждой формы.
|
||||
#[test]
|
||||
fn sdf_sign_and_gradient() {
|
||||
|
||||
Reference in New Issue
Block a user