Решатель KBC-2D на Rust: ядро по статьям, два бэкенда, гифка в реальном времени

Переписанный с нуля двумерный решатель LBM D2Q9 с энтропийным столкновением KBC
в варианте «модель D» (табл. I 2D-статьи Bösch/Chikatamarla/Karlin, arXiv:1507.02509;
в трёхмерных работах — KBC-N1). Код разложен по ролям на пять файлов: математика
решателя, бэкенд под процессор, бэкенд под видеокарту, оркестратор, блок гифок.

Ядро сверено с первоисточниками тестами (22 шт.):
* проектор на сдвиг, выписанный аналитически из представления популяций через
  натуральные моменты (ур. 10), совпадает с матричным до 1e-13, идемпотентен;
* γ из замкнутой оценки (ур. 17) — корень условия максимума энтропии (ур. 15);
* сдвиговые моменты релаксируют ровно с 2β при любой γ, вязкость по ур. (5)
  воспроизводится затуханием сдвиговой волны с точностью лучше 1%;
* сквозной бенчмарк статьи (дважды периодический сдвиговый слой, Re=3e4) сходится
  с fp64-эталоном питоновского решателя 0.6035.

Порог вырожденности γ относительный (доля от ⟨Δ|Δ⟩): абсолютный подменял бы γ на 2
на большинстве узлов, молча превращая KBC в LBGK. Доля таких узлов печатается в отчёте.

Бэкенды взаимозаменяемы и согласованы: CPU (rayon, f64) и GPU (wgpu/WGSL, f32) на одной
постановке совпадают до 4–5 значащих цифр шаг в шаг; на Intel Iris Xe GPU даёт ~105 MLUPS
против ~18 у процессора. Топология задачи строится один раз в cpu.rs и загружается в
буферы, дублируется только физика — в WGSL.

Анимация привязана к физическому времени потока, а не к скорости счёта: задержка кадра
берётся из δt = u_lat·δx/u_phys. Дробная задержка раскладывается по целым сотым долям
секунды накопителем (3,3,4,3,3,4,…), поэтому накопленное время кадров не уходит от
физического; режим --gif-every auto подбирает шаг под реальное время при заданной частоте.

Параметризовано: скорость и направление потока, число Рейнольдса, размер домена и размер
ячейки в метрах, коэффициент и границы вложенного патча измельчения, семь форм тела
(цилиндр, квадрат, ромб, эллипс, профиль NACA, треугольник, пластина) с углом атаки,
время и разгон, оператор столкновения, режим выхода и губка, бэкенд, вся анимация и
три уровня подробности отчёта.

Известное расхождение с питоновским решателем на канальном случае (Cd выше на 9%,
St ниже на 12% при совпадающих ⟨ρ⟩, ⟨Cm⟩ и rms Cl) описано в README вместе с тем,
что уже исключено как причина.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 18:23:03 +03:00
co-authored by Claude Opus 5
parent 11ff7b79b4
commit 5d762ad9d6
9 changed files with 6379 additions and 0 deletions
+852
View File
@@ -0,0 +1,852 @@
//! ОСНОВНОЙ ФАЙЛ: разбор параметров, сборка задачи, цикл по шагам, вывод.
//!
//! Здесь же живёт контракт между оркестратором и бэкендами: `Spec` (что считать),
//! `StepRec` (что бэкенд отдаёт за шаг) и `FieldKind` (что рисовать). Бэкенды (cpu.rs,
//! gpu.rs) реализуют один и тот же интерфейс и взаимозаменяемы флагом `--backend`.
//!
//! Раскладка задачи — канал с обтекаемым телом: вход слева (Zou–He по скорости), выход
//! справа (Zou–He по давлению), сверху и снизу зеркальные (free-slip) стенки, тело —
//! no-slip через SDF + интерполированный отскок Bouzidi.
mod cpu;
mod gif;
mod math;
#[cfg(feature = "gpu")]
mod gpu;
use std::io::Write as _;
use std::time::Instant;
use clap::Parser;
use math::{Body, ShapeKind, Units, R};
// ─────────────────────────────────────────────────────────────────────────────
// Контракт оркестратор ↔ бэкенд
// ─────────────────────────────────────────────────────────────────────────────
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Collision {
/// Энтропийный KBC-D (он же N1) — основной оператор модели.
Kbc,
/// Классический LBGK — для сравнительных прогонов; это ровно KBC при γ ≡ 2.
Bgk,
}
/// Что рисовать в анимации.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FieldKind {
Speed,
Vorticity,
Density,
Gamma,
}
impl FieldKind {
pub fn label(&self) -> &'static str {
match self {
FieldKind::Speed => "SPEED",
FieldKind::Vorticity => "VORTICITY",
FieldKind::Density => "DENSITY",
FieldKind::Gamma => "GAMMA",
}
}
}
/// Запись за один шаг — сырьё для всего отчёта.
#[derive(Clone, Copy, Debug, Default)]
pub struct StepRec {
pub step: u64,
pub fx: R,
pub fy: R,
pub tz: R,
pub uy_probe: R,
pub rho_mean: R,
pub max_u: R,
pub gamma_mean: R,
pub gamma_min: R,
pub gamma_max: R,
pub degenerate_frac: R,
pub xi_negative_frac: R,
}
/// Полностью разрешённая постановка задачи в решёточных единицах.
#[derive(Clone)]
pub struct Spec {
pub nx: usize,
pub ny: usize,
pub body: Body,
pub refine: usize,
/// Границы патча измельчения в координатах L0: (ax, bx, ay, by).
pub patch: Option<(usize, usize, usize, usize)>,
pub units: Units,
pub beta0: R,
pub steps: u64,
pub ramp: u64,
/// Направление набегающего потока, рад (0 — вдоль +x).
pub flow_angle: R,
pub pert_amp: R,
pub pert_dur: u64,
pub outlet_extrapolate: bool,
pub sponge_len: usize,
pub sponge_mult: R,
pub collision: Collision,
/// Узел зонда следа в координатах L0.
pub probe: (usize, usize),
}
// ─────────────────────────────────────────────────────────────────────────────
// Параметры запуска
// ─────────────────────────────────────────────────────────────────────────────
#[derive(Parser, Debug)]
#[command(
name = "kbc2d",
about = "Двумерный решатель LBM D2Q9 с энтропийным столкновением KBC (модель D/N1)",
long_about = "Обтекание тела в канале: KBC-D по Bösch/Chikatamarla/Karlin, SDF+Bouzidi на \
теле, Zou–He вход/выход, вложенный патч измельчения. Анимация синхронизируется с ФИЗИЧЕСКИМ \
временем потока, а не со скоростью счёта."
)]
struct Cli {
// ── физика потока ──
/// Скорость набегающего потока, м/с
#[arg(long, default_value_t = 30.0, help_heading = "Физика")]
u_phys: R,
/// Размер ячейки (плотность сетки), м
#[arg(long, default_value_t = 0.1, help_heading = "Физика")]
dx: R,
/// Решёточная скорость потока (ячеек за шаг). Задаёт δt = u_lat·dx/u_phys.
/// 0.05 ⇒ Ma≈0.09. Значение 1.0 воспроизводит арифметику «30 м/с ÷ 0.1 м = 300 шагов/с»,
/// но это Ma≈1.73 — за пределами применимости LBM.
#[arg(long, default_value_t = 0.05, help_heading = "Физика")]
u_lat: R,
/// Число Рейнольдса по характерному размеру тела
#[arg(long, default_value_t = 150.0, help_heading = "Физика")]
re: R,
/// Направление потока, градусы (0 — вдоль канала)
#[arg(long, default_value_t = 0.0, help_heading = "Физика")]
flow_angle: R,
// ── сетка ──
/// Размер домена по x, ячеек
#[arg(long, default_value_t = 480, help_heading = "Сетка")]
nx: usize,
/// Размер домена по y, ячеек
#[arg(long, default_value_t = 240, help_heading = "Сетка")]
ny: usize,
/// Коэффициент измельчения вложенного патча (1 — патча нет)
#[arg(long, default_value_t = 2, help_heading = "Сетка")]
refine: usize,
/// Границы патча в координатах L0: ax,bx,ay,by (по умолчанию строятся вокруг тела)
#[arg(long, help_heading = "Сетка")]
patch: Option<String>,
// ── тело ──
/// Форма обтекаемого тела
#[arg(long, default_value = "cylinder",
value_parser = ShapeKind::ALL, help_heading = "Тело")]
shape: String,
/// Характерный размер тела (диаметр/сторона/хорда), ячеек
#[arg(long, default_value_t = 24.0, help_heading = "Тело")]
size: R,
/// Относительная толщина для ellipse / naca / plate
#[arg(long, default_value_t = 0.3, help_heading = "Тело")]
thickness: R,
/// Угол атаки тела, градусы
#[arg(long, default_value_t = 0.0, help_heading = "Тело")]
body_angle: R,
/// Положение центра тела по x, ячеек (по умолчанию nx/4)
#[arg(long, help_heading = "Тело")]
body_x: Option<R>,
/// Положение центра тела по y, ячеек (по умолчанию ny/2)
#[arg(long, help_heading = "Тело")]
body_y: Option<R>,
// ── время ──
/// Число шагов симуляции
#[arg(long, default_value_t = 20000, help_heading = "Время")]
steps: u64,
/// Длина smoothstep-разгона входа, шагов
#[arg(long, default_value_t = 1000, help_heading = "Время")]
ramp: u64,
/// Амплитуда стартового поперечного импульса (доля от U); 0 — выключить
#[arg(long, default_value_t = 0.1, help_heading = "Время")]
pert_amp: R,
/// Длительность стартового импульса, шагов
#[arg(long, default_value_t = 300, help_heading = "Время")]
pert_dur: u64,
// ── численная схема ──
/// Оператор столкновения
#[arg(long, default_value = "kbc", value_parser = ["kbc", "bgk"], help_heading = "Схема")]
collision: 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,
/// Во сколько раз губка поднимает вязкость
#[arg(long, default_value_t = 30.0, help_heading = "Схема")]
sponge_mult: R,
/// Бэкенд вычислений
#[arg(long, default_value = "cpu", value_parser = ["cpu", "gpu"], help_heading = "Схема")]
backend: String,
/// Число потоков CPU (0 — по числу ядер)
#[arg(long, default_value_t = 0, help_heading = "Схема")]
threads: usize,
// ── анимация ──
/// Файл гифки (не указан — анимация не пишется)
#[arg(long, help_heading = "Анимация")]
gif: Option<String>,
/// Шагов на кадр: число либо auto (подобрать под --gif-fps в реальном времени)
#[arg(long, default_value = "auto", help_heading = "Анимация")]
gif_every: String,
/// Целевая частота кадров для режима auto
#[arg(long, default_value_t = 30.0, help_heading = "Анимация")]
gif_fps: R,
/// Скорость воспроизведения: 1 — реальное время, 0.1 — замедление в 10 раз
#[arg(long, default_value_t = 1.0, help_heading = "Анимация")]
gif_speed: R,
/// Что рисовать
#[arg(long, default_value = "vorticity",
value_parser = ["speed", "vorticity", "density", "gamma"], help_heading = "Анимация")]
gif_field: String,
/// Палитра
#[arg(long, default_value = "coolwarm",
value_parser = gif::ColorMap::ALL, help_heading = "Анимация")]
gif_cmap: String,
/// Целочисленное увеличение картинки
#[arg(long, default_value_t = 2, help_heading = "Анимация")]
gif_scale: usize,
/// Диапазон нормировки цвета: lo,hi (по умолчанию — по скорости потока)
#[arg(long, help_heading = "Анимация")]
gif_range: Option<String>,
/// Не рисовать служебную надпись на кадре
#[arg(long, help_heading = "Анимация")]
no_hud: bool,
// ── вывод ──
/// Период строк живого отчёта, шагов (0 — выключить)
#[arg(long, default_value_t = 1000, help_heading = "Вывод")]
report_every: u64,
/// Подробность: quiet — только итог, normal — живой отчёт, full — плюс разбор KBC
#[arg(long, default_value = "normal", value_parser = ["quiet", "normal", "full"],
help_heading = "Вывод")]
verbose: String,
/// Число окон в отчёте о сходимости
#[arg(long, default_value_t = 10, help_heading = "Вывод")]
windows: usize,
/// Выгрузить временные ряды в CSV
#[arg(long, help_heading = "Вывод")]
csv: Option<String>,
}
// ─────────────────────────────────────────────────────────────────────────────
// Сборка постановки
// ─────────────────────────────────────────────────────────────────────────────
fn parse_pair(s: &str, what: &str) -> Result<(R, R), String> {
let p: Vec<&str> = s.split(',').collect();
if p.len() != 2 {
return Err(format!("{what}: ожидалось два числа через запятую, получено «{s}»"));
}
let a = p[0].trim().parse::<R>().map_err(|e| format!("{what}: {e}"))?;
let b = p[1].trim().parse::<R>().map_err(|e| format!("{what}: {e}"))?;
Ok((a, b))
}
fn build_spec(cli: &Cli) -> Result<Spec, String> {
if cli.nx < 16 || cli.ny < 16 {
return Err("сетка меньше 16×16 не имеет смысла".into());
}
if cli.u_lat <= 0.0 || cli.u_lat >= 1.0 {
return Err(format!("--u-lat обязан лежать в (0,1), получено {}", cli.u_lat));
}
if cli.u_phys <= 0.0 || cli.dx <= 0.0 || cli.re <= 0.0 {
return Err("--u-phys, --dx и --re обязаны быть положительными".into());
}
if cli.refine == 0 {
return Err("--refine обязан быть ≥ 1".into());
}
let kind = ShapeKind::from_str(&cli.shape).ok_or("неизвестная форма")?;
let cx = cli.body_x.unwrap_or(cli.nx as R / 4.0);
let cy = cli.body_y.unwrap_or(cli.ny as R / 2.0);
let body = Body::new(kind, cx, cy, cli.size, cli.body_angle, cli.thickness);
let units = Units::new(cli.dx, cli.u_phys, cli.u_lat, cli.re, cli.size);
let beta0 = math::beta_of_nu(units.nu_lat);
let tau0 = 1.0 / (2.0 * beta0);
if tau0 <= 0.5 {
return Err(format!(
"τ = {tau0:.4} ≤ ½: вязкость неположительна. Подними --u-lat или опусти --re"
));
}
// патч измельчения: по умолчанию охватывает тело и ближний след
let patch = if cli.refine > 1 {
let d = cli.size;
let (ax, bx, ay, by) = match &cli.patch {
Some(s) => {
let p: Vec<&str> = s.split(',').collect();
if p.len() != 4 {
return Err("--patch: ожидалось ax,bx,ay,by".into());
}
let v: Result<Vec<usize>, _> =
p.iter().map(|t| t.trim().parse::<usize>()).collect();
let v = v.map_err(|e| format!("--patch: {e}"))?;
(v[0], v[1], v[2], v[3])
}
None => (
(cx - 1.5 * d).round().max(2.0) as usize,
(cx + 6.25 * d).round().min(cli.nx as R - 3.0) as usize,
(cy - 2.0 * d).round().max(1.0) as usize,
(cy + 2.0 * d).round().min(cli.ny as R - 2.0) as usize,
),
};
// патч обязан лежать строго внутри домена и НЕ накрывать зеркальные ряды стенок:
// на них ГУ работает по всему ряду, а рамка патча их бы перезаписала
if !(1 <= ay && ay < by && by <= cli.ny - 2) {
return Err(format!(
"патч по y [{ay},{by}] обязан лежать внутри (0,{}) и не трогать ряды стенок; \
при теле {d} ячеек минимальное ny ≈ {}",
cli.ny - 1,
(4.0 * d) as usize + 6
));
}
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
};
let probe = (
((cx + 3.0 * cli.size).round() as usize).min(cli.nx - 2),
(cy.round() as usize).min(cli.ny - 2),
);
Ok(Spec {
nx: cli.nx,
ny: cli.ny,
body,
refine: cli.refine,
patch,
units,
beta0,
steps: cli.steps,
ramp: cli.ramp.max(1),
flow_angle: cli.flow_angle * std::f64::consts::PI / 180.0,
pert_amp: cli.pert_amp,
pert_dur: cli.pert_dur,
outlet_extrapolate: cli.outlet == "extrapolate",
sponge_len: cli.sponge_len,
sponge_mult: cli.sponge_mult,
collision: if cli.collision == "bgk" { Collision::Bgk } else { Collision::Kbc },
probe,
})
}
// ─────────────────────────────────────────────────────────────────────────────
// Бэкенд
// ─────────────────────────────────────────────────────────────────────────────
enum Backend {
Cpu(Box<cpu::Sim>),
#[cfg(feature = "gpu")]
Gpu(Box<gpu::Sim>),
}
impl Backend {
fn step(&mut self) -> StepRec {
match self {
Backend::Cpu(s) => s.step(),
#[cfg(feature = "gpu")]
Backend::Gpu(s) => s.step(),
}
}
fn sample_field(&mut self, k: FieldKind) -> (Vec<R>, Vec<bool>) {
match self {
Backend::Cpu(s) => {
let (v, m) = s.sample_field(k);
(v, m.to_vec())
}
#[cfg(feature = "gpu")]
Backend::Gpu(s) => s.sample_field(k),
}
}
fn is_finite(&mut self) -> bool {
match self {
Backend::Cpu(s) => s.is_finite(),
#[cfg(feature = "gpu")]
Backend::Gpu(s) => s.is_finite(),
}
}
fn force_ref_size(&self) -> R {
match self {
Backend::Cpu(s) => s.force_ref_size(),
#[cfg(feature = "gpu")]
Backend::Gpu(s) => s.force_ref_size(),
}
}
fn name(&self) -> &'static str {
match self {
Backend::Cpu(_) => "CPU (rayon, f64)",
#[cfg(feature = "gpu")]
Backend::Gpu(s) => s.name(),
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// main
// ─────────────────────────────────────────────────────────────────────────────
fn main() {
let cli = Cli::parse();
if let Err(e) = run(cli) {
eprintln!("\nОШИБКА: {e}");
std::process::exit(1);
}
}
fn run(cli: Cli) -> Result<(), String> {
if cli.threads > 0 {
rayon::ThreadPoolBuilder::new()
.num_threads(cli.threads)
.build_global()
.map_err(|e| e.to_string())?;
}
let spec = build_spec(&cli)?;
let quiet = cli.verbose == "quiet";
let full = cli.verbose == "full";
// ── анимация: план тайминга считается ДО прогона и печатается в шапке ──
let stride = if cli.gif_every == "auto" {
None
} else {
Some(cli.gif_every.parse::<u64>().map_err(|e| format!("--gif-every: {e}"))?)
};
let plan = gif::GifPlan::new(&spec.units, stride, cli.gif_fps, cli.gif_speed);
let field_kind = match cli.gif_field.as_str() {
"speed" => FieldKind::Speed,
"density" => FieldKind::Density,
"gamma" => FieldKind::Gamma,
_ => FieldKind::Vorticity,
};
if !quiet {
print_header(&spec, &cli, &plan, field_kind);
}
// ── бэкенд ──
let mut back = match cli.backend.as_str() {
"gpu" => {
#[cfg(feature = "gpu")]
{
Backend::Gpu(Box::new(gpu::Sim::new(spec.clone())?))
}
#[cfg(not(feature = "gpu"))]
{
return Err("сборка без поддержки GPU (--no-default-features)".into());
}
}
_ => Backend::Cpu(Box::new(cpu::Sim::new(spec.clone()))),
};
if !quiet {
println!("бэкенд: {}", back.name());
}
// ── писатель гифки ──
let mut writer = match &cli.gif {
Some(path) => {
let cmap = gif::ColorMap::from_str(&cli.gif_cmap).ok_or("неизвестная палитра")?;
let range = match &cli.gif_range {
Some(s) => {
let (lo, hi) = parse_pair(s, "--gif-range")?;
gif::Range { lo, hi }
}
None => default_range(field_kind, spec.units.u_lat, spec.units.d_lat),
};
let hud = gif::Hud {
u_phys: spec.units.u_phys,
field: field_kind.label(),
show: !cli.no_hud,
};
Some(
gif::GifWriter::create(
path,
spec.nx,
spec.ny,
cli.gif_scale,
cmap,
range,
plan,
hud,
spec.patch,
)
.map_err(|e| format!("не удалось создать {path}: {e}"))?,
)
}
None => None,
};
// ── цикл ──
let d_ref = back.force_ref_size();
let mut recs: Vec<StepRec> = Vec::with_capacity(spec.steps as usize + 1);
let t_start = Instant::now();
let mut blew_up = false;
let nodes_per_step = (spec.nx * spec.ny
+ spec
.patch
.map(|(ax, bx, ay, by)| {
spec.refine * (spec.refine * (bx - ax) + 1) * (spec.refine * (by - ay) + 1)
})
.unwrap_or(0)) as f64;
for t in 0..=spec.steps {
let rec = back.step();
recs.push(rec);
if let Some(w) = writer.as_mut() {
if t % plan.stride == 0 {
let (field, solid) = back.sample_field(field_kind);
let (cd, _, _) =
math::coefficients(rec.fx, rec.fy, rec.tz, spec.units.u_lat, d_ref);
w.push(&field, &solid, spec.units.time_of_step(t), Some(cd))
.map_err(|e| format!("запись кадра: {e}"))?;
}
}
if cli.report_every > 0 && !quiet && t % cli.report_every == 0 {
live_line(&spec, &rec, d_ref, t_start.elapsed().as_secs_f64(), nodes_per_step, full);
}
// развал счёта: проверяем редко, проверка стоит полного прохода по полю
if t % 500 == 0 && !back.is_finite() {
eprintln!("\nСЧЁТ РАЗВАЛИЛСЯ на шаге {t}: в поле появились NaN/inf.");
blew_up = true;
break;
}
}
let wall = t_start.elapsed().as_secs_f64();
if let Some(w) = writer {
let frames = w.frames;
w.finish().map_err(|e| format!("закрытие гифки: {e}"))?;
if !quiet {
println!("\nгифка: {} — кадров {frames}", cli.gif.as_ref().unwrap());
}
}
if let Some(path) = &cli.csv {
write_csv(path, &recs, &spec, d_ref).map_err(|e| format!("CSV: {e}"))?;
if !quiet {
println!("ряды: {path}");
}
}
final_report(&spec, &cli, &recs, d_ref, wall, nodes_per_step, blew_up, &plan, full);
Ok(())
}
fn default_range(k: FieldKind, u_lat: R, d_lat: R) -> gif::Range {
match k {
FieldKind::Speed => gif::Range { lo: 0.0, hi: 1.7 * u_lat },
FieldKind::Vorticity => {
let w = 5.0 * u_lat / d_lat;
gif::Range { lo: -w, hi: w }
}
FieldKind::Density => gif::Range { lo: 0.985, hi: 1.015 },
FieldKind::Gamma => gif::Range { lo: 0.0, hi: 4.0 },
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Вывод
// ─────────────────────────────────────────────────────────────────────────────
fn print_header(spec: &Spec, cli: &Cli, plan: &gif::GifPlan, field: FieldKind) {
let u = &spec.units;
let tau = 1.0 / (2.0 * spec.beta0);
let blockage = spec.body.frontal_extent() / spec.ny as R;
println!("╔══════════════════════════════════════════════════════════════════════════╗");
println!("║ KBC-2D — обтекание тела в канале, D2Q9 + энтропийное столкновение KBC-D ║");
println!("╚══════════════════════════════════════════════════════════════════════════╝");
println!("\n── Физика ──────────────────────────────────────────────────────────────────");
println!(" скорость потока {:>12.4} м/с направление {:>6.1}°", u.u_phys, cli.flow_angle);
println!(" размер ячейки {:>12.5} м домен {:.2} × {:.2} м",
u.dx, spec.nx as R * u.dx, spec.ny as R * u.dx);
println!(" тело {:>12} {:.3} м ({:.0} ячеек), угол атаки {:.1}°",
format!("{:?}", spec.body.kind).to_lowercase(), u.d_phys(), u.d_lat, cli.body_angle);
println!(" Рейнольдс {:>12.1} вязкость {:.4e} м²/с", u.re, u.nu_phys());
println!(" Мах (реш.) {:>12.4} u_lat = {:.4}, τ = {:.4}", u.mach(), u.u_lat, tau);
println!(" блокировка канала {:>12.3} (габарит тела / ширина канала)", blockage);
println!("\n── Время ───────────────────────────────────────────────────────────────────");
println!(" шаг по времени {:>12.6e} с {:.1} шагов на секунду физ. времени",
u.dt, u.steps_per_second());
println!(" всего шагов {:>12} = {:.4} с физ. времени = {:.1} конв. времён D/U",
spec.steps, u.time_of_step(spec.steps), u.convective_times(spec.steps));
println!(" разгон / возмущение {:>12} {} шагов импульса амплитудой {:.2}·U",
spec.ramp, spec.pert_dur, spec.pert_amp);
println!("\n── Сетка ───────────────────────────────────────────────────────────────────");
println!(" L0 {:>12} {} узлов", format!("{}×{}", spec.nx, spec.ny),
spec.nx * spec.ny);
match spec.patch {
Some((ax, bx, ay, by)) => {
let (nfx, nfy) = (spec.refine * (bx - ax) + 1, spec.refine * (by - ay) + 1);
let tau1 = spec.refine as R * (tau - 0.5) + 0.5;
println!(" L1 (×{}) {:>12} область x∈[{ax},{bx}] y∈[{ay},{by}], τ₁={:.4}",
spec.refine, format!("{nfx}×{nfy}"), tau1);
println!(" {:>12} {} подшагов на шаг L0", "", spec.refine);
}
None => println!(" L1 {:>12} (измельчение выключено)", "нет"),
}
println!(" оператор {:>12}", if spec.collision == Collision::Kbc { "KBC-D" } else { "LBGK" });
println!(" выход по u_y {:>12} губка {} столбцов ×{:.0}",
if spec.outlet_extrapolate { "extrapolate" } else { "zero" },
spec.sponge_len, spec.sponge_mult);
// Пара «вход по скорости / выход по давлению» — недодемпфированный акустический резонатор:
// затухание продольной моды ~ ν(π/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── Анимация (синхронизация с физическим временем) ───────────────────────────");
println!(" поле {:>12} палитра {}", field.label().to_lowercase(), cli.gif_cmap);
println!(" шаг кадра {:>12} {}", plan.stride,
if plan.auto { "подобран автоматически" } else { "задан явно" });
println!(" между кадрами {:>12.6e} с физ. времени", plan.frame_dt);
println!(" задержка кадра {:>12.4} сотых {:.2} кадр/с{}", plan.exact_delay_cs,
plan.fps,
if plan.dithered { " (задержки чередуются — точное время без ухода)" } else { "" });
println!(" воспроизведение {:>12.4}× от реального времени (запрошено {:.4}×)",
plan.actual_playback, plan.requested_playback);
match plan.advice(&spec.units) {
Some(a) => println!(" ⚠ {a}"),
None if plan.is_synced() => {
println!(" ✓ синхронно с физическим временем (расхождение {:.4}%)",
(plan.sync_error() - 1.0).abs() * 100.0)
}
None => println!(" ⚠ расхождение с реальным временем {:.3}×", plan.sync_error()),
}
}
println!();
}
fn live_line(spec: &Spec, r: &StepRec, d_ref: R, wall: f64, nodes: f64, full: bool) {
let (cd, cl, _) = math::coefficients(r.fx, r.fy, r.tz, spec.units.u_lat, d_ref);
let done = (r.step + 1) as f64;
let sps = done / wall.max(1e-9);
let eta = (spec.steps + 1 - r.step.min(spec.steps)) as f64 / sps.max(1e-9);
let mlups = sps * nodes / 1e6;
// во сколько раз счёт быстрее реального времени
let rt = sps / spec.units.steps_per_second();
let head = format!(
"шаг {:>8} t={:>9.4}с ⟨ρ⟩={:.5} max|u|={:.4} Cd={:>7.3} Cl={:>7.3}",
r.step,
spec.units.time_of_step(r.step),
r.rho_mean,
r.max_u,
cd,
cl
);
if full {
println!(
"{head}\n ⟨γ⟩={:.3} [{:.2}…{:.2}] вырожд={:.2}% ξ<0={:.2}% \
{:.0} шаг/с ({:.1} MLUPS, {:.2}× реального) ETA {:.0}с",
r.gamma_mean,
r.gamma_min,
r.gamma_max,
r.degenerate_frac * 100.0,
r.xi_negative_frac * 100.0,
sps,
mlups,
rt,
eta
);
} else {
println!("{head} {:.0} шаг/с ETA {:.0}с", sps, eta);
}
std::io::stdout().flush().ok();
}
#[allow(clippy::too_many_arguments)]
fn final_report(
spec: &Spec,
cli: &Cli,
recs: &[StepRec],
d_ref: R,
wall: f64,
nodes: f64,
blew_up: bool,
plan: &gif::GifPlan,
full: bool,
) {
let u = &spec.units;
let n = recs.len();
if n < 8 {
println!("слишком мало шагов для отчёта");
return;
}
let h = n / 2; // установившийся режим — вторая половина ряда
let cd: Vec<R> = recs.iter().map(|r| 2.0 * r.fx / (u.u_lat * u.u_lat * d_ref)).collect();
let cl: Vec<R> = recs.iter().map(|r| 2.0 * r.fy / (u.u_lat * u.u_lat * d_ref)).collect();
let cm: Vec<R> =
recs.iter().map(|r| 2.0 * r.tz / (u.u_lat * u.u_lat * d_ref * d_ref)).collect();
let uy: Vec<R> = recs.iter().map(|r| r.uy_probe).collect();
let rho: Vec<R> = recs.iter().map(|r| r.rho_mean).collect();
let (cd_m, _) = math::mean_std(&cd[h..]);
let (_, cl_rms) = math::mean_std(&cl[h..]);
let (cm_m, _) = math::mean_std(&cm[h..]);
let (_, uy_rms) = math::mean_std(&uy[h..]);
let (st, _, _) = math::strouhal(&uy, u.d_lat, u.u_lat, 8192);
println!("\n╔══════════════════════════════════════════════════════════════════════════╗");
println!("║ ИТОГОВЫЙ ОТЧЁТ ║");
println!("╚══════════════════════════════════════════════════════════════════════════╝");
if blew_up {
println!("⚠ ПРОГОН ОБОРВАН: счёт развалился. Числа ниже относятся к тому, что успело сойтись.");
}
// ── установившийся режим ──
let beta = spec.body.frontal_extent() / spec.ny as R;
println!("\n── Установившийся режим (вторая половина ряда, шаги {}–{}) ──────────────────", h, n - 1);
println!(" {:<22}{:>10}{:>10}{:>10}{:>10}", "величина", "St", "⟨Cd⟩", "rms Cl", "⟨Cm⟩");
println!(" {:<22}{:>10.4}{:>10.4}{:>10.4}{:>10.5}", "как посчитано", st, cd_m, cl_rms, cm_m);
// поправка на стеснение канала: St кинематическое ⇒ ×(1−β); Cd и Cl ~ скорость² ⇒ ×(1−β)²
println!(
" {:<22}{:>10.4}{:>10.4}{:>10.4}{:>10}",
"с поправ. на блокир.",
st * (1.0 - beta),
cd_m * (1.0 - beta).powi(2),
cl_rms * (1.0 - beta).powi(2),
"—"
);
if spec.body.kind == ShapeKind::Cylinder && u.re > 100.0 && u.re < 200.0 {
println!(" {:<22}{:>10.4}{:>10.4}{:>10}{:>10.1}", "литература Re≈150", 0.183, 1.330, "~0.30", 0.0);
}
println!(" rms u_y в зонде = {uy_rms:.5} (узел {:?}); блокировка β = {beta:.3}", spec.probe);
println!(" поправка: St×(1−β), Cd и rms Cl ×(1−β)². Литература — безграничный цилиндр;");
println!(" ⟨Cm⟩≈0 для симметричного тела под нулевым углом — это контроль симметрии считывания.");
// ── сходимость ──
let nw = cli.windows.max(2).min(n / 2);
let w = n / nw;
println!("\n── Сходимость по времени (окна по {w} шагов) ────────────────────────────────");
println!(" {:>4}{:>16}{:>10}{:>10}{:>10}{:>10}{:>10}", "окно", "шаги", "⟨ρ⟩", "⟨Cd⟩", "⟨Cd⟩/ρ", "rms Cl", "rms u_y");
let mut rows = Vec::new();
for k in 0..nw {
let s = k * w;
let e = if k + 1 == nw { n } else { (k + 1) * w };
let (rm, _) = math::mean_std(&rho[s..e]);
let (cdm, _) = math::mean_std(&cd[s..e]);
let (_, clr) = math::mean_std(&cl[s..e]);
let (_, uyr) = math::mean_std(&uy[s..e]);
let cdn = if rm != 0.0 { cdm / rm } else { R::NAN };
rows.push((rm, cdm, cdn, clr, uyr));
println!(" {:>4}{:>16}{:>10.5}{:>10.4}{:>10.4}{:>10.4}{:>10.5}",
k + 1, format!("{s}–{e}"), rm, cdm, cdn, clr, uyr);
}
let (rm1, _, cdn1, clr1, _) = rows[nw - 1];
let (rm0, _, _, clr0, _) = rows[nw - 2];
print!(" вердикт: ");
if (rm1 - 1.0).abs() > 0.02 {
// «стабильно, но не там»: система села на смещённую ветвь (акустическая накачка массы),
// на ней Cd/Cl несопоставимы с литературой
print!("⟨ρ⟩={rm1:.4} — РЕЖИМ СМЕЩЁН, прогон невалиден для сравнения; ");
} else if (rm1 - rm0).abs() > 1e-3 {
print!("⟨ρ⟩={rm1:.4}, Δ за окно {:+.5} — ДРЕЙФ МАССЫ (правь ГУ выхода); ", rm1 - rm0);
} else {
print!("масса стабильна (⟨ρ⟩={rm1:.4}, Δ {:+.5}); ", rm1 - rm0);
}
if clr1 - clr0 > 1e-3 {
println!("rms Cl ещё растёт ({:+.4}) — не насыщено; дрейф-устойчивый Cd = {cdn1:.3}", clr1 - clr0);
} else {
println!("rms Cl насыщен ({:+.4}); дрейф-устойчивый Cd = {cdn1:.3}", clr1 - clr0);
}
// ── диагностика KBC ──
if spec.collision == Collision::Kbc {
let (g_m, g_s) = math::mean_std(&recs[h..].iter().map(|r| r.gamma_mean).collect::<Vec<_>>());
let gmin = recs[h..].iter().map(|r| r.gamma_min).fold(R::INFINITY, R::min);
let gmax = recs[h..].iter().map(|r| r.gamma_max).fold(R::NEG_INFINITY, R::max);
let (deg, _) = math::mean_std(&recs[h..].iter().map(|r| r.degenerate_frac).collect::<Vec<_>>());
let (xin, _) = math::mean_std(&recs[h..].iter().map(|r| r.xi_negative_frac).collect::<Vec<_>>());
println!("\n── Энтропийный стабилизатор γ (формула (17) статьи) ─────────────────────────");
println!(" ⟨γ⟩ = {g_m:.4} ± {g_s:.4} размах по узлам [{gmin:.3} … {gmax:.3}]");
println!(" вырожденных узлов (⟨Δh|Δh⟩→0, γ подменён на 2 = локальный LBGK): {:.3}%", deg * 100.0);
println!(" узлов с отрицательной объёмной вязкостью ξ = c_s²(1/(γβ) − ½): {:.2}%", xin * 100.0);
if full {
println!(" Пояснение. γ ≡ 2 — это в точности LBGK, поэтому доля вырожденных узлов есть");
println!(" доля решётки, где KBC ничего не даёт; при относительном пороге она обязана быть");
println!(" ~0 (абсолютный порог загонял её в 77–99% и молча превращал схему в LBGK).");
println!(" ξ по формуле (57) у моделей B и D зависит от γ, то есть от точки и времени;");
println!(" ξ < 0 означает локальное антизатухание акустики. У моделей A и C ξ = ν всегда.");
}
}
// ── производительность и синхронность ──
let sps = n as f64 / wall.max(1e-9);
println!("\n── Производительность ──────────────────────────────────────────────────────");
println!(" время счёта {wall:.1} с {sps:.0} шаг/с {:.1} MLUPS", sps * nodes / 1e6);
println!(" физического времени просчитано {:.4} с ⇒ счёт идёт в {:.3}× от реального времени",
u.time_of_step(n as u64 - 1), sps / u.steps_per_second());
if cli.gif.is_some() {
println!(" гифка воспроизводится в {:.4}× от реального времени — это НЕ зависит от скорости",
plan.actual_playback);
println!(" счёта: задержка кадра берётся из δt, а не из wall-clock.");
}
println!();
}
fn write_csv(path: &str, recs: &[StepRec], spec: &Spec, d_ref: R) -> std::io::Result<()> {
let mut f = std::io::BufWriter::new(std::fs::File::create(path)?);
writeln!(f, "step,t_phys_s,cd,cl,cm,uy_probe,rho_mean,max_u,gamma_mean,gamma_min,gamma_max,degenerate_frac,xi_negative_frac")?;
let s = spec.units.u_lat * spec.units.u_lat * d_ref;
for r in recs {
writeln!(
f,
"{},{:.9e},{:.6e},{:.6e},{:.6e},{:.6e},{:.9},{:.6e},{:.6},{:.6},{:.6},{:.6},{:.6}",
r.step,
spec.units.time_of_step(r.step),
2.0 * r.fx / s,
2.0 * r.fy / s,
2.0 * r.tz / (s * d_ref),
r.uy_probe,
r.rho_mean,
r.max_u,
r.gamma_mean,
r.gamma_min,
r.gamma_max,
r.degenerate_frac,
r.xi_negative_frac
)?;
}
Ok(())
}