Сверка со статьями: эталон Тейлора–Грина, выбор модели KBC, лечение продольной акустики

Проверка переориентирована на первоисточники из docs/origins вместо сравнения с
python-прототипом.

ЭТАЛОН ТЕЙЛОРА–ГРИНА (разд. VI 2D-статьи) — единственное течение статьи с точным
аналитическим решением. Второй порядок сходимости воспроизведён: 2.05 и 2.01 на
сетках 64/128/256, отдельно по амплитуде и по форме. Два места потребовали разбора:
* начальное давление. Течение несёт собственное поле p ~ ρu₀², находимое из
  ∇²p = 2ρ(ψ_xx·ψ_yy − ψ_xy²). Старт с ρ ≡ 1 сбрасывает разницу в акустику, которая
  в периодическом ящике не затухает и садится полкой на ошибку. Работа 2024 года
  делает то же самое явно, решая ∂ρ/∂t + ∇·(ρu₀) = D∇²ρ до стационара;
* способ измельчения. При фиксированном u₀ ошибка упирается в полку O(Ma²) (измерено:
  относительная ошибка формы ∝ u₀^1.07). Порядок виден целиком только при диффузионном
  измельчении, ν = const и u₀ ∝ 1/N. Это свойство слабо-сжимаемого метода, а не
  реализации: LBGK на том же тесте даёт ту же полку, что согласуется с утверждением
  статьи о практически одинаковом поведении всех моделей.
Добавлено приближение Града (ур. 58) для согласованного старта эталонов.

СВЕРКА ОПЕРАТОРА с пошаговым листингом работы 2024 года — совпадает дословно, включая
Δh = f − f^eq − Δs и f′ = f − β(2Δs + γΔh). Та же работа подтверждает относительный
порог вырожденности γ: стабилизатор «далеко не постоянен», а MRT с γ = const не
достигает той же устойчивости.

ВЫБОР СОСТАВА СДВИГОВОЙ ЧАСТИ (табл. I) — ключ --kbc-model: n1 = {N, Π_xy} (KBC D),
n2 = {N, Π_xy, T} (KBC C). По точности неразличимы, как и заявляет статья. Разница в
объёмной вязкости (ур. 57): у n2 она фиксирована ξ = ν, у n1 равна c_s²(1/(γβ) − ½) и
при измеренной ⟨γ⟩ ≈ 1.73 < 2 в среднем вчетверо больше ν. Поэтому вопреки ожиданию
именно n1 сильнее демпфирует продольную акустику и оставлен умолчанием.

ПРОДОЛЬНАЯ АКУСТИКА КАНАЛА — найдена причина «поршневого» поведения потока. Вход по
скорости акустически есть жёсткий поршень, выход по давлению — открытый конец; канал
работает четвертьволновым резонатором. Измерено: период пульсации 3332 шага против
расчётных 4·Nx/c_s = 3325, первый ноль автокорреляции ровно на Nx/c_s, затухание за
30000 шагов — 3.8%, то есть мода не гаснет. Возбуждал её сам старт из покоя.
Исправлено умолчаниями: --init uniform (домен сразу заполнен потоком) и автоподбор
губки по длине домена. Пульсация упала с 75% до 1.0% от скорости потока, а числа
выправились сами: St 0.183 против прежних 0.194 (литература 0.183), rms Cl 0.38
против 0.84, ⟨ρ⟩ = 1.0000. Отчёт теперь печатает период моды, время её вязкого
затухания и измеренную пульсацию с предупреждением при превышении 5%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 21:39:08 +03:00
co-authored by Claude Opus 5
parent 5d762ad9d6
commit f174f1c9a0
5 changed files with 709 additions and 198 deletions
+113 -18
View File
@@ -88,9 +88,13 @@ pub struct Spec {
pub pert_amp: R,
pub pert_dur: u64,
pub outlet_extrapolate: bool,
/// Начальное поле уже несёт набегающий поток (иначе — покой с разгоном входа).
pub init_uniform: bool,
pub sponge_len: usize,
pub sponge_mult: R,
pub collision: Collision,
/// Что входит в сдвиговую часть s (см. `math::KbcModel`).
pub kbc_model: math::KbcModel,
/// Узел зонда следа в координатах L0.
pub probe: (usize, usize),
}
@@ -175,20 +179,32 @@ struct Cli {
/// Длительность стартового импульса, шагов
#[arg(long, default_value_t = 300, help_heading = "Время")]
pert_dur: u64,
/// Начальное поле: uniform — домен сразу заполнен набегающим потоком и вход включён на
/// полную (умолчание); rest — покой, вход разгоняется за --ramp шагов. Старт из покоя
/// закачивает в канал продольную акустическую моду, которая почти не затухает.
#[arg(long, default_value = "uniform", value_parser = ["uniform", "rest"],
help_heading = "Время")]
init: String,
// ── численная схема ──
/// Оператор столкновения
#[arg(long, default_value = "kbc", value_parser = ["kbc", "bgk"], help_heading = "Схема")]
collision: String,
/// Состав сдвиговой части KBC (табл. I 2D-статьи): n1 — только девиатор {N, Π_xy}
/// (KBC D), объёмная вязкость гуляет вместе с γ и может стать отрицательной; n2 —
/// девиатор со следом {N, Π_xy, T} (KBC C), объёмная вязкость фиксирована ξ = ν.
#[arg(long, default_value = "n1", value_parser = math::KbcModel::ALL, help_heading = "Схема")]
kbc_model: String,
/// Поперечная скорость на выходе: extrapolate — нуль-градиент (умолчание: в факторном
/// исследовании эталонного решателя это лучший вариант по всем метрикам), zero — жёсткий
/// ноль (классический Zou–He; отражает вихри дорожки назад к телу и завышает rms Cl)
#[arg(long, default_value = "extrapolate", value_parser = ["zero", "extrapolate"],
help_heading = "Схема")]
outlet: String,
/// Длина поглощающей губки перед выходом, столбцов (0 — выключена)
#[arg(long, default_value_t = 0, help_heading = "Схема")]
sponge_len: usize,
/// Длина поглощающей губки перед выходом, столбцов. Не задана — подбирается по домену;
/// 0 — выключить. Губка гасит продольную моду, которую вязкость сама не гасит.
#[arg(long, help_heading = "Схема")]
sponge_len: Option<usize>,
/// Во сколько раз губка поднимает вязкость
#[arg(long, default_value_t = 30.0, help_heading = "Схема")]
sponge_mult: R,
@@ -322,17 +338,38 @@ fn build_spec(cli: &Cli) -> Result<Spec, String> {
if !(2 <= ax && ax < bx && bx <= cli.nx - 2) {
return Err(format!("патч по x [{ax},{bx}] обязан лежать внутри (1,{})", cli.nx - 1));
}
if cli.sponge_len > 0 && cli.nx - 1 - cli.sponge_len <= bx {
return Err(format!(
"губка (последние {} столбцов) накрывает патч (bx={bx})",
cli.sponge_len
));
}
Some((ax, bx, ay, by))
} else {
None
};
// Губка перед выходом. Задача канала «вход по скорости + выход по давлению» акустически
// есть четвертьволновая труба: вход отражает продольные волны как жёсткий поршень, выход —
// как открытый конец. Собственная мода затухает как ν(π/2Nx)², то есть практически не
// затухает, и любой стартовый удар остаётся в домене навсегда. Губка — единственное, что
// её реально ест, поэтому по умолчанию она включена и подобрана под длину домена.
let sponge_len = match cli.sponge_len {
Some(v) => {
if v > 0 {
if let Some((_, bx, _, _)) = patch {
if cli.nx - 1 - v <= bx {
return Err(format!("губка (последние {v} столбцов) накрывает патч (bx={bx})"));
}
}
}
v
}
None => {
let want = (cli.nx / 12).max(16);
// губка не должна доставать до патча (и вообще до тела)
let limit = match patch {
Some((_, bx, _, _)) => cli.nx.saturating_sub(bx + 2),
None => cli.nx / 4,
};
want.min(limit)
}
};
let probe = (
((cx + 3.0 * cli.size).round() as usize).min(cli.nx - 2),
(cy.round() as usize).min(cli.ny - 2),
@@ -352,9 +389,11 @@ fn build_spec(cli: &Cli) -> Result<Spec, String> {
pert_amp: cli.pert_amp,
pert_dur: cli.pert_dur,
outlet_extrapolate: cli.outlet == "extrapolate",
sponge_len: cli.sponge_len,
init_uniform: cli.init == "uniform",
sponge_len,
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")?,
probe,
})
}
@@ -618,21 +657,52 @@ fn print_header(spec: &Spec, cli: &Cli, plan: &gif::GifPlan, field: FieldKind) {
}
None => println!(" L1 {:>12} (измельчение выключено)", "нет"),
}
println!(" оператор {:>12}", if spec.collision == Collision::Kbc { "KBC-D" } else { "LBGK" });
println!(" оператор {:>12}",
if spec.collision == Collision::Kbc {
match spec.kbc_model {
math::KbcModel::N1 => "KBC-N1 (D)",
math::KbcModel::N2 => "KBC-N2 (C)",
}
} else {
"LBGK"
});
println!(" выход по u_y {:>12} губка {} столбцов ×{:.0}",
if spec.outlet_extrapolate { "extrapolate" } else { "zero" },
spec.sponge_len, spec.sponge_mult);
// Продольная акустика — не мелочь оформления, а то, что видно глазом: при заметной
// амплитуде поток «дышит» рывками. Печатаем период моды и то, чем она гасится.
{
let cs = math::CS2.sqrt();
let period = 4.0 * spec.nx as R / cs; // четвертьволновая труба: lambda = 4L
let nu = math::nu_of_beta(spec.beta0);
let k = std::f64::consts::PI / (2.0 * spec.nx as R);
let efold = 1.0 / (nu * k * k);
println!("
── Продольная акустика канала ──────────────────────────────────────────────");
println!(" вход по скорости отражает звук как жёсткий поршень, выход по давлению — как");
println!(" открытый конец: канал работает четвертьволновым резонатором.");
println!(" период основной моды {:>10.0} шагов = {:.4} с", period, period * spec.units.dt);
println!(" затухание вязкостью {:>10.0} шагов на е-кратное — то есть сама не гаснет", efold);
print!(" чем гасится: ");
if spec.init_uniform {
print!("старт из однородного потока (моду не возбуждаем)");
} else {
print!("РАЗГОН ИЗ ПОКОЯ за {} шагов", spec.ramp);
if (spec.ramp as R) < period {
print!(" — короче периода моды, будет звон!");
}
}
if spec.sponge_len > 0 {
println!(" + губка {} столбцов ×{:.0}", spec.sponge_len, spec.sponge_mult);
} else {
println!("; губка ВЫКЛЮЧЕНА");
}
}
// Пара «вход по скорости / выход по давлению» — недодемпфированный акустический резонатор:
// затухание продольной моды ~ ν(π/Nx)², то есть на длинном домене её почти ничто не гасит,
// и стартовый транзиент способен раскачать её до смещённого режима или до развала счёта.
if spec.sponge_len == 0 && spec.nx >= 250 {
println!("
⚠ домен длинный ({} столбцов), губка выключена. Продольная акустическая", spec.nx);
println!(" мода затухает как ν(π/Nx)² и на такой длине почти не гасится: возможен уход");
println!(" ⟨ρ⟩ от единицы (режим смещён, числа несопоставимы) вплоть до развала счёта.");
println!(" Смотрите ⟨ρ⟩ в отчёте о сходимости; лечится ключом --sponge-len 32.");
}
if cli.gif.is_some() {
println!("\n── Анимация (синхронизация с физическим временем) ───────────────────────────");
@@ -791,6 +861,31 @@ fn final_report(
println!("rms Cl насыщен ({:+.4}); дрейф-устойчивый Cd = {cdn1:.3}", clr1 - clr0);
}
// ── измеренная продольная пульсация ──
// Колебание средней плотности — это и есть та самая «поршневая» мода. Переводим его в
// амплитуду скорости: для бегущей акустической волны u' = (δρ/ρ)·c_s.
{
let cs = math::CS2.sqrt();
let last = &rho[n - n / 10..];
let lo = last.iter().cloned().fold(R::INFINITY, R::min);
let hi = last.iter().cloned().fold(R::NEG_INFINITY, R::max);
let mean = last.iter().sum::<R>() / last.len() as R;
let u_ac = 0.5 * (hi - lo) / mean * cs;
let ratio = u_ac / u.u_lat;
println!("
── Продольная пульсация (последняя десятая часть прогона) ───────────────────");
println!(" размах ⟨ρ⟩ = {:.5} вокруг {mean:.5} ⇒ амплитуда скорости u' = {:.2}% от U",
hi - lo, ratio * 100.0);
if ratio > 0.05 {
println!(" ⚠ ПОТОК ЗАМЕТНО ПУЛЬСИРУЕТ: это продольная мода канала, а не физика следа.");
println!(" Лечится стартом из однородного потока (--init uniform) и губкой");
println!(" (--sponge-len {}). Числа Cd/St на таком прогоне недостоверны.",
(spec.nx / 12).max(16));
} else {
println!(" пульсация в пределах нормы — продольная мода не раскачана");
}
}
// ── диагностика KBC ──
if spec.collision == Collision::Kbc {
let (g_m, g_s) = math::mean_std(&recs[h..].iter().map(|r| r.gamma_mean).collect::<Vec<_>>());