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

НЕСКОЛЬКО ТЕЛ (--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
+278 -19
View File
@@ -19,7 +19,7 @@ use std::time::Instant;
use clap::Parser;
use math::{Body, ShapeKind, Units, R};
use math::{Body, Scene, ShapeKind, Units, MAX_BODY_BUCKETS, R};
// ─────────────────────────────────────────────────────────────────────────────
// Контракт оркестратор ↔ бэкенд
@@ -57,9 +57,13 @@ impl FieldKind {
#[derive(Clone, Copy, Debug, Default)]
pub struct StepRec {
pub step: u64,
/// Суммарная сила и момент по всей сцене.
pub fx: R,
pub fy: R,
pub tz: R,
/// Она же по телам: `body[b] = (F_x, F_y, T_z)`. Тела за пределом набора вёдер
/// сваливаются в последнее, поэтому сумма по вёдрам всегда точна.
pub body: [[R; 3]; MAX_BODY_BUCKETS],
pub uy_probe: R,
pub rho_mean: R,
pub max_u: R,
@@ -75,7 +79,7 @@ pub struct StepRec {
pub struct Spec {
pub nx: usize,
pub ny: usize,
pub body: Body,
pub scene: Scene,
pub refine: usize,
/// Границы патча измельчения в координатах L0: (ax, bx, ay, by).
pub patch: Option<(usize, usize, usize, usize)>,
@@ -90,7 +94,11 @@ pub struct Spec {
pub outlet_extrapolate: bool,
/// Начальное поле уже несёт набегающий поток (иначе — покой с разгоном входа).
pub init_uniform: bool,
/// Ширина сведения стартовой скорости к нулю у тела, клеток.
pub init_taper: R,
pub sponge_len: usize,
/// Длина губки после входа, столбцов.
pub sponge_in: usize,
pub sponge_mult: R,
pub collision: Collision,
/// Что входит в сдвиговую часть s (см. `math::KbcModel`).
@@ -167,6 +175,20 @@ struct Cli {
/// Положение центра тела по y, ячеек (по умолчанию ny/2)
#[arg(long, help_heading = "Тело")]
body_y: Option<R>,
/// Четырёхзначное обозначение профиля NACA, например 4412 (кривизна 4%, её максимум на
/// 40% хорды, толщина 12%). Действует при --shape naca и перекрывает --thickness.
#[arg(long, help_heading = "Тело")]
naca: Option<String>,
/// Вершины произвольного многоугольника при --shape polygon: "x1,y1;x2,y2;..." в клетках,
/// отсчёт от центра тела. Обход любой, SDF точный.
#[arg(long, allow_hyphen_values = true, help_heading = "Тело")]
poly: Option<String>,
/// НЕСКОЛЬКО ТЕЛ вместо одного. Список через точку с запятой, каждое — форма и параметры:
/// "cylinder:d=24,x=120,y=120; cylinder:d=24,x=200,y=120". Ключи: d — размер, x/y — центр,
/// a — угол атаки (град), t — относительная толщина, naca — код профиля,
/// p — вершины многоугольника через |. Перекрывает --shape и прочие одиночные ключи.
#[arg(long, allow_hyphen_values = true, help_heading = "Тело")]
bodies: Option<String>,
// ── время ──
/// Число шагов симуляции. Взаимоисключающе с --time.
@@ -192,6 +214,17 @@ struct Cli {
#[arg(long, default_value = "uniform", value_parser = ["uniform", "rest"],
help_heading = "Время")]
init: String,
/// Ширина полосы у тела, на которой стартовая скорость сводится к нулю, клеток.
///
/// ЗАМЕРЕНО, что это НЕ ПОМОГАЕТ, и ключ оставлен только для повторной проверки.
/// Однородный старт заливает потоком место, где стоит тело, и на первом шаге у стенки
/// рождается возмущение плотности ~2.5·10⁻². Сглаживание монотонно давит именно его
/// (при ширине 24 клетки — до 3.1·10⁻³, восьмикратно), но ПИК ЗА ПРОГОН при этом даже
/// подрастает: 2.54·10⁻² → 3.22·10⁻². Возмущение просто переносится во времени — поток
/// всё равно обязан разогнаться вокруг тела, и энергия этого переходного процесса задана
/// физикой, а не гладкостью начального поля. Продольную моду ест губка, а не это.
#[arg(long, default_value_t = 0.0, help_heading = "Время")]
init_taper: R,
// ── численная схема ──
/// Оператор столкновения
@@ -221,6 +254,10 @@ struct Cli {
/// 0 — выключить. Губка гасит продольную моду, которую вязкость сама не гасит.
#[arg(long, help_heading = "Схема")]
sponge_len: Option<usize>,
/// Длина губки ПОСЛЕ входа, столбцов (0 — выключена). Вход по скорости отражает продольные
/// волны как жёсткий поршень; губка у входа гасит их до отражения.
#[arg(long, default_value_t = 0, help_heading = "Схема")]
sponge_in: usize,
/// Во сколько раз губка поднимает вязкость
#[arg(long, default_value_t = 30.0, help_heading = "Схема")]
sponge_mult: R,
@@ -273,9 +310,22 @@ struct Cli {
/// Число окон в отчёте о сходимости
#[arg(long, default_value_t = 10, help_heading = "Вывод")]
windows: usize,
/// Прореживание временных рядов: хранить каждую N-ю запись. Статистика и спектр от этого
/// не страдают, пока N много меньше периода схода вихрей, зато сверхдлинные прогоны
/// перестают съедать память (на 5·10⁶ шагов полный ряд — сотни мегабайт).
#[arg(long, default_value_t = 1, help_heading = "Вывод")]
series_every: u64,
/// Выгрузить временные ряды в CSV
#[arg(long, help_heading = "Вывод")]
csv: Option<String>,
/// Файл x–t диаграммы: срез ⟨ρ⟩ и u_x вдоль осевой линии, по строке на срез. По наклону
/// полос видно, бежит возмущение со скоростью звука или конвекции, а стоячие узлы
/// проявляются сразу. Основной инструмент разбора стартовой акустики.
#[arg(long, help_heading = "Вывод")]
xt: Option<String>,
/// Период записи срезов x–t, шагов
#[arg(long, default_value_t = 200, help_heading = "Вывод")]
xt_every: u64,
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -292,6 +342,65 @@ fn parse_pair(s: &str, what: &str) -> Result<(R, R), String> {
Ok((a, b))
}
/// Разбор одного тела из мини-синтаксиса `форма:ключ=значение,...`.
fn parse_body(spec: &str, def_x: R, def_y: R) -> Result<Body, String> {
let (name, rest) = match spec.split_once(':') {
Some((a, b)) => (a.trim(), b),
None => (spec.trim(), ""),
};
let kind = ShapeKind::from_str(name).ok_or(format!("неизвестная форма «{name}»"))?;
let (mut d, mut x, mut y, mut a, mut t) = (24.0, def_x, def_y, 0.0, 0.3);
let (mut camber, mut cpos) = (0.0, 0.0);
let mut verts: Vec<[R; 2]> = Vec::new();
for kv in rest.split(',').filter(|z| !z.trim().is_empty()) {
let (k, v) = kv
.split_once('=')
.ok_or(format!("в «{}» ожидалось ключ=значение", kv.trim()))?;
let (k, v) = (k.trim(), v.trim());
let num = |v: &str| v.parse::<R>().map_err(|e| format!("{k}: {e}"));
match k {
"d" | "size" | "c" => d = num(v)?,
"x" => x = num(v)?,
"y" => y = num(v)?,
"a" | "angle" => a = num(v)?,
"t" | "thickness" => t = num(v)?,
"naca" => {
let (m, pp, tt) =
Body::naca_code(v).ok_or(format!("naca: ожидалось четыре цифры, «{v}»"))?;
camber = m;
cpos = pp;
t = tt;
}
"p" | "poly" => {
let n: Vec<&str> = v.split('|').collect();
if n.len() < 6 || n.len() % 2 != 0 {
return Err("p: нужно чётное число координат, минимум три вершины".into());
}
for pair in n.chunks(2) {
verts.push([num(pair[0])?, num(pair[1])?]);
}
}
_ => return Err(format!("неизвестный ключ «{k}»")),
}
}
if kind == ShapeKind::Polygon && verts.len() < 3 {
return Err("для polygon нужен ключ p с вершинами".into());
}
Ok(Body::full(kind, x, y, d, a, t, camber, cpos, &verts))
}
fn parse_poly(v: &str) -> Result<Vec<[R; 2]>, String> {
let mut out = Vec::new();
for pt in v.split(';').filter(|z| !z.trim().is_empty()) {
let (a, b) = parse_pair(pt, "--poly")?;
out.push([a, b]);
}
if out.len() < 3 {
return Err("--poly: нужно минимум три вершины".into());
}
Ok(out)
}
fn build_spec(cli: &Cli) -> Result<Spec, String> {
if cli.nx < 16 || cli.ny < 16 {
return Err("сетка меньше 16×16 не имеет смысла".into());
@@ -306,12 +415,51 @@ fn build_spec(cli: &Cli) -> Result<Spec, String> {
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 scene = match &cli.bodies {
Some(list) => {
let mut v = Vec::new();
for one in list.split(';').filter(|z| !z.trim().is_empty()) {
v.push(parse_body(one, cx, cy)?);
}
if v.is_empty() {
return Err("--bodies: список пуст".into());
}
Scene { bodies: v }
}
None => {
let kind = ShapeKind::from_str(&cli.shape).ok_or("неизвестная форма")?;
let (mut camber, mut cpos, mut thick) = (0.0, 0.0, cli.thickness);
if let Some(code) = &cli.naca {
let (m, pp, tt) = Body::naca_code(code)
.ok_or("--naca: ожидалось четыре цифры, например 4412")?;
camber = m;
cpos = pp;
thick = tt;
}
let verts = match &cli.poly {
Some(v) => parse_poly(v)?,
None => Vec::new(),
};
if kind == ShapeKind::Polygon && verts.is_empty() {
return Err("--shape polygon требует --poly с вершинами".into());
}
Scene::single(Body::full(
kind,
cx,
cy,
cli.size,
cli.body_angle,
thick,
camber,
cpos,
&verts,
))
}
};
let units = Units::new(cli.dx, cli.u_phys, cli.u_lat, cli.re, cli.size);
let units = Units::new(cli.dx, cli.u_phys, cli.u_lat, cli.re, scene.ref_size());
let beta0 = math::beta_of_nu(units.nu_lat);
let tau0 = 1.0 / (2.0 * beta0);
if tau0 <= 0.5 {
@@ -364,6 +512,17 @@ fn build_spec(cli: &Cli) -> Result<Spec, String> {
// как открытый конец. Собственная мода затухает как ν(π/2Nx)², то есть практически не
// затухает, и любой стартовый удар остаётся в домене навсегда. Губка — единственное, что
// её реально ест, поэтому по умолчанию она включена и подобрана под длину домена.
// губка входа не должна доставать до патча измельчения
if cli.sponge_in > 0 {
if let Some((ax, _, _, _)) = patch {
if cli.sponge_in + 2 >= ax {
return Err(format!(
"губка входа ({} столбцов) достаёт до патча (ax={ax})",
cli.sponge_in
));
}
}
}
let sponge_len = match cli.sponge_len {
Some(v) => {
if v > 0 {
@@ -421,7 +580,7 @@ fn build_spec(cli: &Cli) -> Result<Spec, String> {
Ok(Spec {
nx: cli.nx,
ny: cli.ny,
body,
scene,
refine: cli.refine,
patch,
units,
@@ -433,7 +592,9 @@ fn build_spec(cli: &Cli) -> Result<Spec, String> {
pert_dur: cli.pert_dur,
outlet_extrapolate: cli.outlet == "extrapolate",
init_uniform: cli.init == "uniform",
init_taper: cli.init_taper.max(0.0),
sponge_len,
sponge_in: cli.sponge_in,
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")?,
@@ -482,6 +643,13 @@ impl Backend {
Backend::Gpu(s) => s.sample_field(k),
}
}
fn sample_centerline(&mut self) -> (Vec<R>, Vec<R>) {
match self {
Backend::Cpu(s) => s.sample_centerline(),
#[cfg(feature = "gpu")]
Backend::Gpu(s) => s.sample_centerline(),
}
}
fn is_finite(&mut self) -> bool {
match self {
Backend::Cpu(s) => s.is_finite(),
@@ -598,6 +766,19 @@ fn run(cli: Cli) -> Result<(), String> {
None => None,
};
// ── x–t диагностика ──
let mut xt_file = match &cli.xt {
Some(path) => {
let mut f = std::io::BufWriter::new(
std::fs::File::create(path).map_err(|e| format!("не создать {path}: {e}"))?,
);
writeln!(f, "# срез вдоль осевой линии, столбцы: step,t_phys_s,field,v0..v{}", spec.nx - 1)
.map_err(|e| e.to_string())?;
Some(f)
}
None => None,
};
// ── цикл ──
let d_ref = back.force_ref_size();
let mut recs: Vec<StepRec> = Vec::with_capacity(spec.steps as usize + 1);
@@ -611,16 +792,24 @@ fn run(cli: Cli) -> Result<(), String> {
})
.unwrap_or(0)) as f64;
let series_every = cli.series_every.max(1);
let mut scratch: Vec<StepRec> = Vec::with_capacity(256);
let mut rec = StepRec::default();
for t in 0..=spec.steps {
back.advance(&mut recs);
back.advance(&mut scratch);
let need_frame = writer.is_some() && t % plan.stride == 0;
let need_report = cli.report_every > 0 && !quiet && t % cli.report_every == 0;
// и кадр, и живая строка требуют актуальных данных — синхронизируемся только здесь
if need_frame || need_report {
back.flush(&mut recs);
back.flush(&mut scratch);
}
for r in scratch.drain(..) {
if r.step % series_every == 0 {
recs.push(r);
}
rec = r;
}
let rec = *recs.last().unwrap_or(&StepRec::default());
if need_frame {
if let Some(w) = writer.as_mut() {
@@ -634,18 +823,36 @@ fn run(cli: Cli) -> Result<(), String> {
if need_report {
live_line(&spec, &rec, d_ref, t_start.elapsed().as_secs_f64(), nodes_per_step, full);
}
if let Some(f) = xt_file.as_mut() {
if t % cli.xt_every.max(1) == 0 {
let (rho, ux) = back.sample_centerline();
let tp = spec.units.time_of_step(t);
for (name, v) in [("rho", &rho), ("ux", &ux)] {
write!(f, "{t},{tp:.9e},{name}").map_err(|e| e.to_string())?;
for z in v {
write!(f, ",{z:.7e}").map_err(|e| e.to_string())?;
}
writeln!(f).map_err(|e| e.to_string())?;
}
}
}
// Развал счёта ловится по УЖЕ ПОСЧИТАННЫМ величинам, а не полным проходом по полю:
// на сетке 4096×2048 такой проход тянет с устройства сотни мегабайт, и делать его
// регулярно нельзя. Полная проверка — один раз, для подтверждения.
if !rec.max_u.is_finite() || !rec.rho_mean.is_finite() {
back.flush(&mut recs);
back.flush(&mut scratch);
eprintln!("\nСЧЁТ РАЗВАЛИЛСЯ на шаге {t}: в поле появились NaN/inf.");
blew_up = true;
break;
}
}
back.flush(&mut recs);
back.flush(&mut scratch);
for r in scratch.drain(..) {
if r.step % series_every == 0 {
recs.push(r);
}
}
if !blew_up && !back.is_finite() {
eprintln!("\nСЧЁТ РАЗВАЛИЛСЯ: итоговое поле содержит NaN/inf.");
blew_up = true;
@@ -690,7 +897,7 @@ fn default_range(k: FieldKind, u_lat: R, d_lat: R) -> gif::Range {
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;
let blockage = spec.scene.frontal_extent() / spec.ny as R;
println!("╔══════════════════════════════════════════════════════════════════════════╗");
println!("║ KBC-2D — обтекание тела в канале, D2Q9 + энтропийное столкновение KBC-D ║");
@@ -701,7 +908,12 @@ fn print_header(spec: &Spec, cli: &Cli, plan: &gif::GifPlan, field: FieldKind) {
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);
if spec.scene.bodies.len() > 1 {
format!("{} тел", spec.scene.bodies.len())
} else {
format!("{:?}", spec.scene.bodies[0].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);
@@ -739,7 +951,7 @@ fn print_header(spec: &Spec, cli: &Cli, plan: &gif::GifPlan, field: FieldKind) {
// Действительно ли субсеточная граница работает: если большинство линков сваливается в
// простой отскок, стенка де-факто ступенчатая, как её ни называй.
{
let g = cpu::Geom::build(spec.nx, spec.ny, &spec.body);
let g = cpu::Geom::build(spec.nx, spec.ny, &spec.scene);
let w = g.wall_stats();
let pc = |k: usize| 100.0 * k as R / w.links.max(1) as R;
println!("
@@ -787,8 +999,11 @@ fn print_header(spec: &Spec, cli: &Cli, plan: &gif::GifPlan, field: FieldKind) {
print!(" — короче периода моды, будет звон!");
}
}
if spec.sponge_len > 0 {
println!(" + губка {} столбцов ×{:.0}", spec.sponge_len, spec.sponge_mult);
if spec.sponge_len > 0 || spec.sponge_in > 0 {
println!(
" + губка: вход {} / выход {} столбцов ×{:.0}",
spec.sponge_in, spec.sponge_len, spec.sponge_mult
);
} else {
println!("; губка ВЫКЛЮЧЕНА");
}
@@ -899,7 +1114,7 @@ fn final_report(
}
// ── установившийся режим ──
let beta = spec.body.frontal_extent() / spec.ny as R;
let beta = spec.scene.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);
@@ -912,13 +1127,55 @@ fn final_report(
cl_rms * (1.0 - beta).powi(2),
"—"
);
if spec.body.kind == ShapeKind::Cylinder && u.re > 100.0 && u.re < 200.0 {
if spec.scene.bodies.len() == 1
&& spec.scene.bodies[0].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 nb = spec.scene.bodies.len().min(MAX_BODY_BUCKETS);
if nb > 1 {
println!("\n── Силы по телам (вторая половина ряда) ────────────────────────────────────");
println!(" {:>5}{:>12}{:>10}{:>10}{:>11}", "тело", "положение", "⟨Cd⟩", "rms Cl", "⟨Cm⟩");
for b in 0..nb {
let cdb: Vec<R> =
recs[h..].iter().map(|r| 2.0 * r.body[b][0] / (u.u_lat * u.u_lat * d_ref)).collect();
let clb: Vec<R> =
recs[h..].iter().map(|r| 2.0 * r.body[b][1] / (u.u_lat * u.u_lat * d_ref)).collect();
let cmb: Vec<R> = recs[h..]
.iter()
.map(|r| 2.0 * r.body[b][2] / (u.u_lat * u.u_lat * d_ref * d_ref))
.collect();
let (m, _) = math::mean_std(&cdb);
let (_, sl) = math::mean_std(&clb);
let (mm, _) = math::mean_std(&cmb);
let pos = spec
.scene
.bodies
.get(b)
.map(|bd| format!("{:.0},{:.0}", bd.cx, bd.cy))
.unwrap_or_else(|| "—".into());
println!(" {:>5}{:>12}{:>10.4}{:>10.4}{:>11.5}", b + 1, pos, m, sl, mm);
}
if spec.scene.bodies.len() > MAX_BODY_BUCKETS {
println!(
" (тел {}, вёдер {} — тела с {}-го и дальше сведены в последнюю строку;",
spec.scene.bodies.len(),
MAX_BODY_BUCKETS,
MAX_BODY_BUCKETS
);
println!(" сумма по строкам при этом точна и равна общему ⟨Cd⟩ выше)");
}
}
// ── сходимость ──
let nw = cli.windows.max(2).min(n / 2);
let w = n / nw;
@@ -970,7 +1227,9 @@ fn final_report(
── Продольная пульсация (последняя десятая часть прогона) ───────────────────");
println!(" размах ⟨ρ⟩ = {:.5} вокруг {mean:.5} ⇒ амплитуда скорости u' = {:.2}% от U",
hi - lo, ratio * 100.0);
if ratio > 0.05 {
if !ratio.is_finite() {
println!(" ⚠ ПУЛЬСАЦИЯ НЕ ОПРЕДЕЛЕНА: в рядах NaN/inf, счёт развалился.");
} else if ratio.abs() > 0.05 {
println!(" ⚠ ПОТОК ЗАМЕТНО ПУЛЬСИРУЕТ: это продольная мода канала, а не физика следа.");
println!(" Лечится стартом из однородного потока (--init uniform) и губкой");
println!(" (--sponge-len {}). Числа Cd/St на таком прогоне недостоверны.",