Эталонные течения статей, машиночитаемая сводка, радиус влияния и прореживание кадра
--case: помимо канала появились три ПЕРИОДИЧЕСКИХ постановки из статей — вихрь Тейлора-Грина
(разд. VI), дважды периодический сдвиговый слой (разд. VII) и затухающая двумерная
турбулентность. Перенос уже был периодичен, поэтому достаточно не накладывать ГУ вовсе.
Стартовые поля строятся общим кодом cpu::initial_field для обоих бэкендов: GPU только
перекладывает результат в свою раскладку, так что эталон задан ровно одним куском кода.
Три вещи, без которых эталоны считались неверно, и все три нашлись замером:
* в периодической постановке всё равно строилось тело — цилиндр по умолчанию стоял прямо
посреди эталонного течения. Теперь сцена пуста;
* Re считался по калибру тела, а статьи определяют его по РАЗМЕРУ ДОМЕНА (Re = u0*N/nu);
* и, главное, включалась выходная губка, поднимающая вязкость в 30 раз на последних столбцах.
Отсюда затухание было в 1.37 раза выше положенного. Губок в периодической постановке нет.
Что эталоны показали. Сдвиговый слой при Re=3e4 воспроизводит главное утверждение статьи:
KBC доживает до конца (энстрофия 0.871 от начальной), LBGK разваливается на шаге 4480.
Тейлор-Грин на CPU даёт порядок сходимости 2.03 и 2.01 — чистый второй.
ЗАМЕРЕНА ГРАНИЦА ПРИМЕНИМОСТИ f32, ровно та, ради которой планировалась отдельная группа
прогонов. Ошибка против точного решения, диффузионное измельчение:
N=64 CPU 9.83e-3 GPU 9.80e-3
N=128 CPU 2.40e-3 GPU 3.29e-3
N=256 CPU 5.97e-4 GPU 2.37e-2
GPU совпадает с f64, пока истинная ошибка выше ~1e-3, и промахивается в 40 раз, как только
она опускается ниже. Правило для кампании: точностные исследования сходимости — только на CPU.
--summary: сводка прогона одним JSON (St, Cd, rms Cl, Cm по каждому телу, ⟨ρ⟩, пульсация,
статистика гамма, радиус влияния, MLUPS, признак развала). Без неё разбор кампании из десятков
прогонов пришлось бы вести глазами.
--gif-downsample: усреднение блока k*k в пиксель. Без него кадр с сетки 4096x2048 неподъёмен;
проверено на 960x480 при k=3 — гифка 320x160 и 0.17 МБ.
РАДИУС ВЛИЯНИЯ в отчёте и сводке: докуда тело возмущает поток больше чем на 1% от U, в
калибрах, с предупреждением, если возмущение достаёт до границы домена. На проверочном прогоне
боковое влияние вышло ровно на границу (5.0 калибра при полуширине 5.0) — сигнал работает.
Также: --case-csv с рядом энергии, энстрофии и палинстрофии; --series-every для прореживания
рядов на сверхдлинных прогонах.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,169 @@
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::math::{self, Kbc, Link, LinkKind, Scene, WallModel, MAX_BODY_BUCKETS, R, CX, CY, OPP, Q};
|
||||
use crate::{Collision, FieldKind, Spec, StepRec};
|
||||
use crate::{Case, Collision, FieldKind, Spec, StepRec};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Стартовые поля
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Что нужно, чтобы разложить стартовое поле уровня.
|
||||
pub struct Init<'a> {
|
||||
pub case: Case,
|
||||
pub nx: usize,
|
||||
pub ny: usize,
|
||||
/// Скорость набегающего потока (канал) либо амплитуда эталонного течения.
|
||||
pub u0: (R, R),
|
||||
pub beta: R,
|
||||
pub scene: Option<&'a Scene>,
|
||||
/// Ширина сведения скорости к нулю у тела; 0 — не сводить.
|
||||
pub taper: R,
|
||||
}
|
||||
|
||||
/// Стартовое поле в виде популяций. Строится на хосте для ОБОИХ бэкендов: GPU просто
|
||||
/// перекладывает результат в свою раскладку. Так эталонные течения из статей задаются
|
||||
/// ровно одним куском кода.
|
||||
pub fn initial_field(p: &Init) -> Vec<[R; Q]> {
|
||||
let (nx, ny) = (p.nx, p.ny);
|
||||
let n = nx * ny;
|
||||
let tau = 1.0 / (2.0 * p.beta);
|
||||
let nu = math::nu_of_beta(p.beta);
|
||||
let two_pi = 2.0 * std::f64::consts::PI;
|
||||
let mut f = vec![[0.0; Q]; n];
|
||||
|
||||
match p.case {
|
||||
Case::Channel => {
|
||||
let plain = math::feq(1.0, p.u0.0, p.u0.1);
|
||||
for (k, cell) in f.iter_mut().enumerate() {
|
||||
*cell = match (p.scene, p.taper > 0.0) {
|
||||
(Some(sc), true) => {
|
||||
let (x, y) = ((k % nx) as R, (k / nx) as R);
|
||||
let c = math::wall_taper(sc.sdf(x, y), p.taper);
|
||||
if c < 1.0 {
|
||||
math::feq(1.0, p.u0.0 * c, p.u0.1 * c)
|
||||
} else {
|
||||
plain
|
||||
}
|
||||
}
|
||||
_ => plain,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Разд. VI 2D-статьи: u = ∇×[(u₀/k₂)cos(k₁x)cos(k₂y)·exp(−ν(k₁²+k₂²)t)], k₁=1, k₂=4.
|
||||
// Стартуем приближением Града и с СОГЛАСОВАННЫМ давлением: течение несёт собственное
|
||||
// поле p ~ ρu₀², и старт с ρ ≡ 1 сбрасывает эту разницу в акустику, которая в
|
||||
// периодическом ящике почти не затухает.
|
||||
Case::TaylorGreen => {
|
||||
let (k1, k2) = (1.0, 4.0);
|
||||
let kk1 = two_pi * k1 / nx as R;
|
||||
let kk2 = two_pi * k2 / ny as R;
|
||||
let a = p.u0.0;
|
||||
for y in 0..ny {
|
||||
for x in 0..nx {
|
||||
let (px, py) = (kk1 * x as R, kk2 * y as R);
|
||||
let ux = -a * px.cos() * py.sin();
|
||||
let uy = (k1 / k2) * a * px.sin() * py.cos();
|
||||
let pr = -(a * a / 4.0)
|
||||
* ((2.0 * px).cos() + (k1 * k1) / (k2 * k2) * (2.0 * py).cos());
|
||||
let rho = 1.0 + pr / math::CS2;
|
||||
let dxux = a * kk1 * px.sin() * py.sin();
|
||||
let dyux = -a * kk2 * px.cos() * py.cos();
|
||||
let dxuy = (k1 / k2) * a * kk1 * px.cos() * py.cos();
|
||||
let dyuy = -(k1 / k2) * a * kk2 * px.sin() * py.sin();
|
||||
let pref = -tau * rho * math::CS2;
|
||||
f[y * nx + x] = math::grad_init(
|
||||
rho,
|
||||
ux,
|
||||
uy,
|
||||
rho * math::CS2 + rho * ux * ux + pref * 2.0 * dxux,
|
||||
rho * ux * uy + pref * (dyux + dxuy),
|
||||
rho * math::CS2 + rho * uy * uy + pref * 2.0 * dyuy,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Разд. VII: дважды периодический сдвиговый слой, κ = 80, δ = 0.05.
|
||||
Case::ShearLayer => {
|
||||
let (kappa, delta) = (80.0, 0.05);
|
||||
let a = p.u0.0;
|
||||
for y in 0..ny {
|
||||
let yy = y as R / ny as R;
|
||||
let ux = if y as R <= ny as R / 2.0 {
|
||||
a * (kappa * (yy - 0.25)).tanh()
|
||||
} else {
|
||||
a * (kappa * (0.75 - yy)).tanh()
|
||||
};
|
||||
for x in 0..nx {
|
||||
let uy = delta * a * (two_pi * (x as R / nx as R + 0.25)).sin();
|
||||
f[y * nx + x] = math::feq(1.0, ux, uy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Затухающая турбулентность: функция тока собирается из мод в узкой полосе вокруг
|
||||
// k₀, фазы — детерминированный ГПСЧ (прогон обязан быть воспроизводимым).
|
||||
Case::DecayingTurbulence => {
|
||||
let k0 = 8.0;
|
||||
let a = p.u0.0;
|
||||
let mut seed = 0x2545_F491_4F6C_DD1Du64;
|
||||
let mut rnd = || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
(seed >> 11) as R / (1u64 << 53) as R
|
||||
};
|
||||
let mut modes = Vec::new();
|
||||
for ky in -12i32..=12 {
|
||||
for kx in -12i32..=12 {
|
||||
if kx == 0 && ky == 0 {
|
||||
continue;
|
||||
}
|
||||
let km = ((kx * kx + ky * ky) as R).sqrt();
|
||||
if !(k0 - 4.0..=k0 + 4.0).contains(&km) || kx < 0 {
|
||||
continue;
|
||||
}
|
||||
let amp = (-(km - k0) * (km - k0) / 8.0).exp() / km;
|
||||
modes.push((kx as R, ky as R, amp, two_pi * rnd()));
|
||||
}
|
||||
}
|
||||
let norm: R = modes.iter().map(|m| m.2 * m.2).sum::<R>().sqrt().max(1e-30);
|
||||
for y in 0..ny {
|
||||
for x in 0..nx {
|
||||
let (fx, fy) = (x as R / nx as R, y as R / ny as R);
|
||||
let (mut ux, mut uy) = (0.0, 0.0);
|
||||
for &(kx, ky, amp, ph) in &modes {
|
||||
let arg = two_pi * (kx * fx + ky * fy) + ph;
|
||||
let c = arg.cos() * amp / norm;
|
||||
// u = ∇×(ψ ẑ) для ψ = Σ amp·sin(2π(k·r)+φ)
|
||||
ux += c * two_pi * ky;
|
||||
uy -= c * two_pi * kx;
|
||||
}
|
||||
f[y * nx + x] = math::feq(1.0, a * ux, a * uy);
|
||||
}
|
||||
}
|
||||
// приводим к заданной среднеквадратичной скорости
|
||||
let rms = (f
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let (_, ux, uy) = math::macros(c);
|
||||
ux * ux + uy * uy
|
||||
})
|
||||
.sum::<R>()
|
||||
/ n as R)
|
||||
.sqrt()
|
||||
.max(1e-30);
|
||||
let scale = a / rms;
|
||||
for cell in f.iter_mut() {
|
||||
let (_, ux, uy) = math::macros(cell);
|
||||
*cell = math::feq(1.0, ux * scale, uy * scale);
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = nu;
|
||||
f
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Геометрия уровня
|
||||
@@ -192,14 +354,14 @@ impl Level {
|
||||
/// `u0` — скорость, которой заполняется поле на старте. Заполнять сразу набегающим
|
||||
/// потоком принципиально: старт из покоя разгоняет весь столб жидкости и закачивает в
|
||||
/// канал продольную акустическую моду, которую вязкость потом почти не гасит.
|
||||
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);
|
||||
fn new(nx: usize, ny: usize, beta: Vec<R>, geom: Geom, f0: Vec<[R; Q]>) -> Self {
|
||||
let n = nx * ny;
|
||||
debug_assert_eq!(f0.len(), n);
|
||||
Level {
|
||||
nx,
|
||||
ny,
|
||||
f: vec![f0; n],
|
||||
post: vec![f0; n],
|
||||
post: f0.clone(),
|
||||
f: f0,
|
||||
gamma: vec![2.0; n],
|
||||
beta,
|
||||
geom,
|
||||
@@ -472,20 +634,6 @@ impl Level {
|
||||
}
|
||||
}
|
||||
|
||||
/// Свести стартовую скорость к нулю на подходе к телу, чтобы на первом шаге не возникло
|
||||
/// разрыва. Вдали от тела поле не трогается вовсе.
|
||||
fn taper_start(lvl: &mut Level, scene: &Scene, u0: (R, R), width: R) {
|
||||
let nx = lvl.nx;
|
||||
for (n, cell) in lvl.f.iter_mut().enumerate() {
|
||||
let (x, y) = ((n % nx) as R, (n / nx) as R);
|
||||
let k = math::wall_taper(scene.sdf(x, y), width);
|
||||
if k < 1.0 {
|
||||
*cell = math::feq(1.0, u0.0 * k, u0.1 * k);
|
||||
}
|
||||
}
|
||||
lvl.post.copy_from_slice(&lvl.f);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Статистика KBC за шаг
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -770,10 +918,16 @@ impl Sim {
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
};
|
||||
let mut l0 = Level::new(nx, ny, beta0, geom0, u0);
|
||||
if spec.init_uniform {
|
||||
taper_start(&mut l0, &spec.scene, u0, spec.init_taper);
|
||||
}
|
||||
let init0 = initial_field(&Init {
|
||||
case: spec.case,
|
||||
nx,
|
||||
ny,
|
||||
u0,
|
||||
beta: spec.beta0,
|
||||
scene: Some(&spec.scene),
|
||||
taper: if spec.init_uniform { spec.init_taper } else { 0.0 },
|
||||
});
|
||||
let l0 = Level::new(nx, ny, beta0, geom0, init0);
|
||||
let fluid_count = l0.geom.solid.iter().filter(|s| !**s).count() as R;
|
||||
|
||||
let (l1, patch) = if spec.refine > 1 {
|
||||
@@ -789,11 +943,16 @@ impl Sim {
|
||||
let beta1 = 1.0 / (2.0 * tau1);
|
||||
let r01 = tau1 / (r as R * tau0);
|
||||
let patch = Patch::new(&spec, &l0.geom, &geom1.solid, r01);
|
||||
let mut lvl1 = Level::new(nfx, nfy, vec![beta1; nfx], geom1, u0);
|
||||
if spec.init_uniform {
|
||||
taper_start(&mut lvl1, &scene1, u0, spec.init_taper * r as R);
|
||||
}
|
||||
(Some(lvl1), Some(patch))
|
||||
let init1 = initial_field(&Init {
|
||||
case: spec.case,
|
||||
nx: nfx,
|
||||
ny: nfy,
|
||||
u0,
|
||||
beta: 1.0 / (2.0 * tau1),
|
||||
scene: Some(&scene1),
|
||||
taper: if spec.init_uniform { spec.init_taper * r as R } else { 0.0 },
|
||||
});
|
||||
(Some(Level::new(nfx, nfy, vec![beta1; nfx], geom1, init1)), Some(patch))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
@@ -860,9 +1019,13 @@ impl Sim {
|
||||
let wall = self.spec.wall;
|
||||
let stats0 = self.l0.collide(sp_collision, model);
|
||||
self.l0.stream();
|
||||
self.l0.apply_wall(wall);
|
||||
self.l0.free_slip_walls();
|
||||
self.l0.channel_bc(ux_in, uy_in, 1.0, outlet_extrap);
|
||||
// Эталонные течения статей периодичны по обеим осям и ГУ не имеют вовсе: перенос
|
||||
// уже периодичен, поэтому достаточно ничего не накладывать.
|
||||
if self.spec.case == Case::Channel {
|
||||
self.l0.apply_wall(wall);
|
||||
self.l0.free_slip_walls();
|
||||
self.l0.channel_bc(ux_in, uy_in, 1.0, outlet_extrap);
|
||||
}
|
||||
|
||||
// ── уровень 1: r подшагов с временной интерполяцией рамки ──
|
||||
let mut fb = [[0.0 as R; 3]; MAX_BODY_BUCKETS];
|
||||
@@ -872,7 +1035,9 @@ impl Sim {
|
||||
for s in 0..p.r {
|
||||
l1.collide(sp_collision, model);
|
||||
l1.stream();
|
||||
l1.apply_wall(wall);
|
||||
if self.spec.case == Case::Channel {
|
||||
l1.apply_wall(wall);
|
||||
}
|
||||
// силу снимаем на КАЖДОМ подшаге и усредняем — мгновенное значение на
|
||||
// последнем подшаге даёт лишний шум в рядах при том же среднем
|
||||
let g = l1.force();
|
||||
@@ -989,6 +1154,19 @@ impl Sim {
|
||||
(out, &self.l0.geom.solid)
|
||||
}
|
||||
|
||||
/// Полное поле скорости уровня L0 — для метрик эталонных течений и радиуса влияния.
|
||||
pub fn sample_velocity(&self) -> (Vec<R>, Vec<R>) {
|
||||
let n = self.l0.nx * self.l0.ny;
|
||||
let mut ux = Vec::with_capacity(n);
|
||||
let mut uy = Vec::with_capacity(n);
|
||||
for k in 0..n {
|
||||
let (_, a, b) = self.l0.macros_at(k);
|
||||
ux.push(a);
|
||||
uy.push(b);
|
||||
}
|
||||
(ux, uy)
|
||||
}
|
||||
|
||||
/// Срез вдоль осевой линии: (ρ, u_x) по каждому столбцу. Из последовательности таких
|
||||
/// срезов складывается x–t диаграмма, по которой видно, бежит возмущение со скоростью
|
||||
/// звука или конвекции и есть ли стоячие узлы.
|
||||
@@ -1033,7 +1211,7 @@ mod tests {
|
||||
body_cx: 0.0,
|
||||
body_cy: 0.0,
|
||||
},
|
||||
(0.0, 0.0),
|
||||
vec![math::feq(1.0, 0.0, 0.0); nx * ny],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user