Несколько тел в домене, произвольная геометрия и разбор стартовой акустики

НЕСКОЛЬКО ТЕЛ (--bodies). SDF сцены — минимум по телам, поэтому маски, поле SDF и
Bouzidi-линки строятся ровно теми же процедурами, что и для одного тела. Каждому линку
проставляется индекс тела, и сила считается по телам отдельно: для тандема и многоэлементных
конфигураций литература даёт коэффициенты каждого тела, общей суммы недостаточно. Разнос идёт
по четырём вёдрам; тела за пределом набора сваливаются в последнее, так что сумма по вёдрам
всегда точна. Синтаксис: "cylinder:d=24,x=120,y=120; cylinder:d=24,x=192,y=120".

Проверка на тандеме L/D=3 при Re=150 даёт физически правильную картину: передний цилиндр
Cd=1.22 при rms Cl=0.02, задний Cd=-0.10 при rms Cl=0.19. Отрицательное сопротивление заднего
— классический режим экранирования, когда сдвиговые слои переднего замыкаются на задний, а
восьмикратный рост rms Cl отвечает тому, что задний треплет след переднего.

ПРОИЗВОЛЬНЫЙ МНОГОУГОЛЬНИК (--shape polygon --poly) — точный SDF уже был, добавлен разбор.
Открывает клинья, зазубренные кромки, любые обводы. Проверено на клине: 100% линков идут по
интерполяционной формуле.

ПРОФИЛИ С ИЗГИБОМ (--naca 4412). Средняя линия по четырёхзначной серии, поверхности
откладываются по НОРМАЛИ к ней, а не по вертикали — иначе у заметно изогнутого профиля толщина
у носка завышается. При нулевой кривизне вырождается в прежний симметричный 00xx.

ПРОРЕЖИВАНИЕ РЯДОВ (--series-every): на 5·10^6 шагов полный ряд занимал бы сотни мегабайт.

РАЗБОР СТАРТОВОЙ АКУСТИКИ. Добавлена x-t диагностика (--xt): срез ⟨ρ⟩ и u_x вдоль осевой
линии, по строке на срез. Что она показала:

* возмущение рождается НА ТЕЛЕ, а не на входе. На нулевом шаге |ρ−1| ≈ 1.3·10^-2 у задней
  кромки при 10^-8 у входа и выхода; причина — поле стартует однородным потоком сквозь то
  место, где стоит тело;
* дальше импульс уходит полосой через весь домен — ровно то, что наблюдалось на низких Re;
* на Re ≥ 1000 при выключенной губке он раскачивает неустойчивость у ВЫХОДА, и счёт гибнет
  около шага 17000. С губкой (умолчание) Re=2000 доживает.

Попытка лечения сглаживанием стартовой скорости у тела (--init-taper) ЗАМЕРЕНА И ОТВЕРГНУТА:
ширина 24 клетки давит возмущение нулевого шага восьмикратно (2.54·10^-2 → 3.13·10^-3), но пик
за прогон при этом даже растёт (2.54·10^-2 → 3.22·10^-2). Возмущение просто переносится во
времени. Ключ оставлен выключенным, замер записан в его описании.

Добавлена губка у входа (--sponge-in); профиль β вынесен в общую math::beta_profile, раньше
он дублировался в обоих бэкендах. Исправлен вердикт о пульсации: при развале счёта отношение
уходило в минус и проверка «> 0.05» молча не срабатывала.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 12:20:07 +03:00
co-authored by Claude Opus 5
parent 039e5f4806
commit b97686c8db
4 changed files with 737 additions and 160 deletions
+105 -58
View File
@@ -8,7 +8,7 @@
use rayon::prelude::*;
use crate::math::{self, Body, Kbc, Link, LinkKind, WallModel, R, CX, CY, OPP, Q};
use crate::math::{self, Kbc, Link, LinkKind, Scene, WallModel, MAX_BODY_BUCKETS, R, CX, CY, OPP, Q};
use crate::{Collision, FieldKind, Spec, StepRec};
// ─────────────────────────────────────────────────────────────────────────────
@@ -30,21 +30,30 @@ pub struct Geom {
}
impl Geom {
/// Собрать геометрию уровня по телу. `solid` определяется знаком SDF: φ ≤ 0 — тело.
pub fn build(nx: usize, ny: usize, body: &Body) -> Self {
/// Собрать геометрию уровня по сцене. `solid` определяется знаком SDF сцены (минимум по
/// телам): φ ≤ 0 — тело. Каждому линку проставляется индекс тела, которого он касается,
/// чтобы сила считалась по телам отдельно.
pub fn build(nx: usize, ny: usize, scene: &Scene) -> Self {
let n = nx * ny;
let mut phi = vec![0.0; n];
let mut solid = vec![false; n];
let mut owner = vec![0u8; n];
for y in 0..ny {
for x in 0..nx {
let p = body.sdf(x as R, y as R);
phi[y * nx + x] = p;
solid[y * nx + x] = p <= 0.0;
let k = y * nx + x;
let p = scene.sdf(x as R, y as R);
phi[k] = p;
solid[k] = p <= 0.0;
if solid[k] {
owner[k] =
scene.nearest(x as R, y as R).min(MAX_BODY_BUCKETS - 1) as u8;
}
}
}
let links = build_links(nx, ny, &solid, &phi);
let links = build_links(nx, ny, &solid, &phi, &owner);
let wall_nodes = build_wall_nodes(&links);
Geom { nx, ny, solid, links, wall_nodes, body_cx: body.cx, body_cy: body.cy }
let (bcx, bcy) = scene.center();
Geom { nx, ny, solid, links, wall_nodes, body_cx: bcx, body_cy: bcy }
}
}
@@ -59,7 +68,7 @@ fn nb(nx: usize, ny: usize, x: usize, y: usize, i: usize, sign: i32) -> usize {
/// Для каждого жидкого узла и каждого направления, упирающегося в тело, решаем, какая из трёх
/// формул Bouzidi применима, и считаем долю пересечения q из SDF. Делается один раз.
fn build_links(nx: usize, ny: usize, solid: &[bool], phi: &[R]) -> Vec<Link> {
fn build_links(nx: usize, ny: usize, solid: &[bool], phi: &[R], owner: &[u8]) -> Vec<Link> {
let mut links = Vec::new();
for y in 0..ny {
for x in 0..nx {
@@ -88,7 +97,7 @@ fn build_links(nx: usize, ny: usize, solid: &[bool], phi: &[R]) -> Vec<Link> {
ib: OPP[i] as u8,
kind,
q,
body: true,
body: owner[s],
});
}
}
@@ -435,26 +444,26 @@ impl Level {
}
}
/// Сила и момент на теле по GMEM, суммой по линкам тела.
fn force(&self) -> (R, R, R) {
let (mut fx, mut fy, mut tz) = (0.0, 0.0, 0.0);
/// Сила и момент по GMEM, разложенные по телам: `out[b] = (F_x, F_y, T_z)` для тела b.
/// Момент считается вокруг центра ПЕРВОГО тела — общей точки отсчёта для всей сцены.
fn force(&self) -> [[R; 3]; MAX_BODY_BUCKETS] {
let mut out = [[0.0; 3]; MAX_BODY_BUCKETS];
let nx = self.nx;
for l in &self.geom.links {
if !l.body {
continue;
}
let node = l.node as usize;
let i = l.i as usize;
let (dfx, dfy) = math::gmem_link(i, self.post[node][i], self.f[node][l.ib as usize], 0.0, 0.0);
fx += dfx;
fy += dfy;
let (dfx, dfy) =
math::gmem_link(i, self.post[node][i], self.f[node][l.ib as usize], 0.0, 0.0);
// плечо — до ТОЧКИ ПЕРЕСЕЧЕНИЯ линка со стенкой r_w = r_f + q·c_i, а не до узла:
// узел дал бы ошибку плеча до целой ячейки, а q уже посчитан
let rx = (node % nx) as R - self.geom.body_cx + l.q * CX[i] as R;
let ry = (node / nx) as R - self.geom.body_cy + l.q * CY[i] as R;
tz += rx * dfy - ry * dfx;
let b = (l.body as usize).min(MAX_BODY_BUCKETS - 1);
out[b][0] += dfx;
out[b][1] += dfy;
out[b][2] += rx * dfy - ry * dfx;
}
(fx, fy, tz)
out
}
#[inline]
@@ -463,6 +472,20 @@ 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 за шаг
// ─────────────────────────────────────────────────────────────────────────────
@@ -730,22 +753,15 @@ pub struct Sim {
impl Sim {
pub fn new(spec: Spec) -> Sim {
let (nx, ny) = (spec.nx, spec.ny);
let geom0 = Geom::build(nx, ny, &spec.body);
let geom0 = Geom::build(nx, ny, &spec.scene);
// губка: плавный рост вязкости в последних sponge_len столбцах. Канал
// «скорость-вход + давление-выход» — недодемпфированный акустический резонатор,
// губка гасит и вихри, и акустику до прихода на выход.
let beta0: Vec<R> = (0..nx)
.map(|x| {
if spec.sponge_len == 0 {
return spec.beta0;
}
let start = nx - 1 - spec.sponge_len;
let s = math::smoothstep((x as R - start as R) / spec.sponge_len as R);
let nu = math::nu_of_beta(spec.beta0);
math::beta_of_nu(nu * (1.0 + (spec.sponge_mult - 1.0) * s))
})
.collect();
let beta0 = math::beta_profile(
nx,
spec.beta0,
spec.sponge_in,
spec.sponge_len,
spec.sponge_mult,
);
// стартовое поле: либо сразу набегающий поток, либо покой
let u0 = if spec.init_uniform {
@@ -754,23 +770,30 @@ impl Sim {
} else {
(0.0, 0.0)
};
let l0 = Level::new(nx, ny, beta0, geom0, u0);
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 fluid_count = l0.geom.solid.iter().filter(|s| !**s).count() as R;
let (l1, patch) = if spec.refine > 1 {
let (ax, bx, ay, by) = spec.patch.expect("refine > 1 требует патч");
let r = spec.refine;
let body1 = spec.body.refined(r as R, ax as R, ay as R);
let scene1 = spec.scene.refined(r as R, ax as R, ay as R);
let nfx = r * (bx - ax) + 1;
let nfy = r * (by - ay) + 1;
let geom1 = Geom::build(nfx, nfy, &body1);
let geom1 = Geom::build(nfx, nfy, &scene1);
// τ_f = r(τ_c − ½) + ½ ⇒ одинаковая ν на обоих уровнях
let tau0 = 1.0 / (2.0 * spec.beta0);
let tau1 = r as R * (tau0 - 0.5) + 0.5;
let beta1 = 1.0 / (2.0 * tau1);
let r01 = tau1 / (r as R * tau0);
let patch = Patch::new(&spec, &l0.geom, &geom1.solid, r01);
(Some(Level::new(nfx, nfy, vec![beta1; nfx], geom1, u0)), Some(patch))
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))
} else {
(None, None)
};
@@ -842,7 +865,7 @@ impl Sim {
self.l0.channel_bc(ux_in, uy_in, 1.0, outlet_extrap);
// ── уровень 1: r подшагов с временной интерполяцией рамки ──
let (mut fx, mut fy, mut tz) = (0.0, 0.0, 0.0);
let mut fb = [[0.0 as R; 3]; MAX_BODY_BUCKETS];
if let (Some(l1), Some(p)) = (self.l1.as_mut(), self.patch.as_ref()) {
p.ghost_values(&self.pre, &mut self.gh_old);
p.ghost_values(&self.l0.f, &mut self.gh_new);
@@ -852,23 +875,24 @@ impl Sim {
l1.apply_wall(wall);
// силу снимаем на КАЖДОМ подшаге и усредняем — мгновенное значение на
// последнем подшаге даёт лишний шум в рядах при том же среднем
let (a, b, c) = l1.force();
fx += a;
fy += b;
tz += c;
let g = l1.force();
for k in 0..MAX_BODY_BUCKETS {
for c in 0..3 {
fb[k][c] += g[k][c];
}
}
let w = (s + 1) as R / p.r as R;
p.fill(&mut l1.f, &self.gh_old, &self.gh_new, w);
}
let inv = 1.0 / p.r as R;
fx *= inv;
fy *= inv;
tz *= inv;
for k in 0..MAX_BODY_BUCKETS {
for c in 0..3 {
fb[k][c] *= inv;
}
}
p.restrict_to(&l1.f, &mut self.l0.f);
} else {
let (a, b, c) = self.l0.force();
fx = a;
fy = b;
tz = c;
fb = self.l0.force();
}
// ── диагностика ──
@@ -892,11 +916,18 @@ impl Sim {
.reduce(|| (0.0, 0.0), |a, b| (a.0 + b.0, a.1.max(b.1)));
self.step_index += 1;
let (mut fx, mut fy, mut tz) = (0.0, 0.0, 0.0);
for k in 0..MAX_BODY_BUCKETS {
fx += fb[k][0];
fy += fb[k][1];
tz += fb[k][2];
}
StepRec {
step: t,
fx,
fy,
tz,
body: fb,
uy_probe: probe.2,
rho_mean: rho_sum / self.fluid_count,
max_u,
@@ -911,8 +942,8 @@ impl Sim {
/// Характерный размер тела в единицах того уровня, где снимается сила.
pub fn force_ref_size(&self) -> R {
match &self.patch {
Some(p) => self.spec.body.d * p.r as R,
None => self.spec.body.d,
Some(p) => self.spec.scene.ref_size() * p.r as R,
None => self.spec.scene.ref_size(),
}
}
@@ -958,6 +989,22 @@ impl Sim {
(out, &self.l0.geom.solid)
}
/// Срез вдоль осевой линии: (ρ, u_x) по каждому столбцу. Из последовательности таких
/// срезов складывается x–t диаграмма, по которой видно, бежит возмущение со скоростью
/// звука или конвекции и есть ли стоячие узлы.
pub fn sample_centerline(&self) -> (Vec<R>, Vec<R>) {
let y = self.l0.ny / 2;
let nx = self.l0.nx;
let mut rho = Vec::with_capacity(nx);
let mut ux = Vec::with_capacity(nx);
for x in 0..nx {
let (r, a, _) = self.l0.macros_at(y * nx + x);
rho.push(r);
ux.push(a);
}
(rho, ux)
}
/// Есть ли в поле NaN/inf — признак развала счёта.
pub fn is_finite(&self) -> bool {
self.l0.f.par_iter().all(|c| c.iter().all(|v| v.is_finite()))
@@ -1052,8 +1099,8 @@ mod tests {
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 body = math::Body::new(math::ShapeKind::Cylinder, 60.0, 40.0 + off, 24.0, 0.0, 0.3);
let g = Geom::build(120, 80, &Scene::single(body));
let w = g.wall_stats();
assert!(w.links > 100, "смещение {off}: линков всего {}", w.links);
assert_eq!(w.simple, 0, "смещение {off}: {} ступенчатых линков", w.simple);
@@ -1069,8 +1116,8 @@ mod tests {
/// без пересечений, в том же порядке.
#[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 body = math::Body::new(math::ShapeKind::Naca, 60.0, 40.0, 30.0, 12.0, 0.25);
let g = Geom::build(120, 80, &Scene::single(body));
let mut covered = 0usize;
for w in &g.wall_nodes {
assert_eq!(w.first as usize, covered, "разрыв в индексе граничных узлов");