Эталонные течения статей, машиночитаемая сводка, радиус влияния и прореживание кадра

--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:
2026-08-15 12:52:08 +03:00
co-authored by Claude Opus 5
parent b97686c8db
commit 9120affab5
4 changed files with 745 additions and 102 deletions
+409 -9
View File
@@ -33,6 +33,38 @@ pub enum Collision {
Bgk,
}
/// Постановка задачи. Канал — рабочая; остальные три периодичны по обеим осям и не имеют
/// граничных условий вовсе: это эталонные течения из статей, на которых схема проверяется
/// в чистом виде, без вклада стенок.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Case {
/// Обтекание тела в канале: вход Zou–He по скорости, выход по давлению, стенки зеркальные.
Channel,
/// Вихрь Тейлора–Грина (разд. VI 2D-статьи) — единственное течение с ТОЧНЫМ решением.
TaylorGreen,
/// Дважды периодический сдвиговый слой (разд. VII).
ShearLayer,
/// Затухающая двумерная турбулентность.
DecayingTurbulence,
}
impl Case {
pub fn from_str(v: &str) -> Option<Case> {
Some(match v {
"channel" => Case::Channel,
"taylor-green" => Case::TaylorGreen,
"shear-layer" => Case::ShearLayer,
"decaying-turbulence" => Case::DecayingTurbulence,
_ => return None,
})
}
pub const ALL: [&'static str; 4] =
["channel", "taylor-green", "shear-layer", "decaying-turbulence"];
pub fn is_periodic(&self) -> bool {
!matches!(self, Case::Channel)
}
}
/// Что рисовать в анимации.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FieldKind {
@@ -105,6 +137,8 @@ pub struct Spec {
pub kbc_model: math::KbcModel,
/// Чем замыкаются недостающие популяции на теле (см. `math::WallModel`).
pub wall: math::WallModel,
/// Постановка задачи.
pub case: Case,
/// Узел зонда следа в координатах L0.
pub probe: (usize, usize),
}
@@ -140,6 +174,15 @@ struct Cli {
/// Направление потока, градусы (0 — вдоль канала)
#[arg(long, default_value_t = 0.0, help_heading = "Физика")]
flow_angle: R,
/// Постановка. channel — обтекание тела; остальные три периодичны по обеим осям, тела и
/// граничных условий не имеют и служат эталонами из статей: taylor-green (разд. VI, есть
/// точное решение), shear-layer (разд. VII), decaying-turbulence.
#[arg(long, default_value = "channel", value_parser = Case::ALL, help_heading = "Физика")]
case: String,
/// Период съёма метрик эталонных течений (энергия, энстрофия, палинстрофия), шагов.
/// Каждый съём тянет поле с устройства, поэтому редкий.
#[arg(long, default_value_t = 200, help_heading = "Физика")]
case_every: u64,
// ── сетка ──
/// Размер домена по x, ячеек
@@ -292,6 +335,10 @@ struct Cli {
/// Целочисленное увеличение картинки
#[arg(long, default_value_t = 2, help_heading = "Анимация")]
gif_scale: usize,
/// Усреднять блок k×k клеток в один пиксель перед отрисовкой. Обязательно на крупных
/// сетках: кадр с 4096×2048 иначе неподъёмен.
#[arg(long, default_value_t = 1, help_heading = "Анимация")]
gif_downsample: usize,
/// Диапазон нормировки цвета: lo,hi (по умолчанию — по скорости потока)
#[arg(long, help_heading = "Анимация")]
gif_range: Option<String>,
@@ -326,6 +373,13 @@ struct Cli {
/// Период записи срезов x–t, шагов
#[arg(long, default_value_t = 200, help_heading = "Вывод")]
xt_every: u64,
/// Машиночитаемая сводка прогона в JSON: все ключевые метрики одним файлом. Без неё
/// разбор кампании из десятков прогонов пришлось бы вести глазами.
#[arg(long, help_heading = "Вывод")]
summary: Option<String>,
/// Ряд метрик эталонного течения в CSV (шаг, время, энергия, энстрофия, палинстрофия).
#[arg(long, help_heading = "Вывод")]
case_csv: Option<String>,
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -402,6 +456,7 @@ fn parse_poly(v: &str) -> Result<Vec<[R; 2]>, String> {
}
fn build_spec(cli: &Cli) -> Result<Spec, String> {
let case = Case::from_str(&cli.case).ok_or("неизвестная постановка")?;
if cli.nx < 16 || cli.ny < 16 {
return Err("сетка меньше 16×16 не имеет смысла".into());
}
@@ -417,7 +472,12 @@ fn build_spec(cli: &Cli) -> Result<Spec, String> {
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 scene = match &cli.bodies {
// В эталонных течениях тела нет вовсе: сцена пуста, маска твёрдого пуста, ГУ не
// накладываются. Иначе цилиндр по умолчанию стоял бы прямо посреди эталона.
let scene = if case.is_periodic() {
Scene { bodies: Vec::new() }
} else {
match &cli.bodies {
Some(list) => {
let mut v = Vec::new();
for one in list.split(';').filter(|z| !z.trim().is_empty()) {
@@ -457,9 +517,13 @@ fn build_spec(cli: &Cli) -> Result<Spec, String> {
&verts,
))
}
}
};
let units = Units::new(cli.dx, cli.u_phys, cli.u_lat, cli.re, scene.ref_size());
// Характерный размер: калибр тела для канала и РАЗМЕР ДОМЕНА для эталонных течений —
// именно так статьи определяют Re = u₀N/ν.
let ref_len = if case.is_periodic() { cli.nx as R } else { scene.ref_size() };
let units = Units::new(cli.dx, cli.u_phys, cli.u_lat, cli.re, ref_len);
let beta0 = math::beta_of_nu(units.nu_lat);
let tau0 = 1.0 / (2.0 * beta0);
if tau0 <= 0.5 {
@@ -468,6 +532,9 @@ fn build_spec(cli: &Cli) -> Result<Spec, String> {
));
}
if case.is_periodic() && cli.refine > 1 {
return Err("эталонные течения периодичны и патча измельчения не имеют: --refine 1".into());
}
// патч измельчения: по умолчанию охватывает тело и ближний след
let patch = if cli.refine > 1 {
let d = cli.size;
@@ -577,6 +644,36 @@ fn build_spec(cli: &Cli) -> Result<Spec, String> {
(None, None) => 20_000,
};
// В периодической постановке губок нет по определению: они поднимают вязкость у границ,
// а границ здесь нет. Иначе эталон считался бы с завышенным затуханием.
if case.is_periodic() {
return Ok(Spec {
nx: cli.nx,
ny: cli.ny,
scene,
refine: 1,
patch: None,
units,
beta0,
steps,
ramp: cli.ramp.max(1),
flow_angle: cli.flow_angle * std::f64::consts::PI / 180.0,
pert_amp: 0.0,
pert_dur: 0,
outlet_extrapolate: false,
init_uniform: true,
init_taper: 0.0,
sponge_len: 0,
sponge_in: 0,
sponge_mult: 1.0,
collision: if cli.collision == "bgk" { Collision::Bgk } else { Collision::Kbc },
kbc_model: math::KbcModel::from_str(&cli.kbc_model).ok_or("неизвестная модель KBC")?,
wall: math::WallModel::from_str(&cli.wall).ok_or("неизвестная модель стенки")?,
case,
probe: (cli.nx / 2, cli.ny / 2),
});
}
Ok(Spec {
nx: cli.nx,
ny: cli.ny,
@@ -596,6 +693,7 @@ fn build_spec(cli: &Cli) -> Result<Spec, String> {
sponge_len,
sponge_in: cli.sponge_in,
sponge_mult: cli.sponge_mult,
case,
collision: if cli.collision == "bgk" { Collision::Bgk } else { Collision::Kbc },
kbc_model: math::KbcModel::from_str(&cli.kbc_model).ok_or("неизвестная модель KBC")?,
wall: math::WallModel::from_str(&cli.wall).ok_or("неизвестная модель стенки")?,
@@ -643,6 +741,13 @@ impl Backend {
Backend::Gpu(s) => s.sample_field(k),
}
}
fn sample_velocity(&mut self) -> (Vec<R>, Vec<R>) {
match self {
Backend::Cpu(s) => s.sample_velocity(),
#[cfg(feature = "gpu")]
Backend::Gpu(s) => s.sample_velocity(),
}
}
fn sample_centerline(&mut self) -> (Vec<R>, Vec<R>) {
match self {
Backend::Cpu(s) => s.sample_centerline(),
@@ -754,6 +859,7 @@ fn run(cli: Cli) -> Result<(), String> {
spec.nx,
spec.ny,
cli.gif_scale,
cli.gif_downsample,
cmap,
range,
plan,
@@ -792,6 +898,7 @@ fn run(cli: Cli) -> Result<(), String> {
})
.unwrap_or(0)) as f64;
let mut extras = Extras::default();
let series_every = cli.series_every.max(1);
let mut scratch: Vec<StepRec> = Vec::with_capacity(256);
let mut rec = StepRec::default();
@@ -823,6 +930,12 @@ 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 spec.case.is_periodic() && t % cli.case_every.max(1) == 0 {
back.flush(&mut scratch);
let (vx, vy) = back.sample_velocity();
let (e, om, pa) = periodic_metrics(&vx, &vy, spec.nx, spec.ny);
extras.case_series.push([t as R, spec.units.time_of_step(t), e, om, pa]);
}
if let Some(f) = xt_file.as_mut() {
if t % cli.xt_every.max(1) == 0 {
let (rho, ux) = back.sample_centerline();
@@ -859,6 +972,54 @@ fn run(cli: Cli) -> Result<(), String> {
}
let wall = t_start.elapsed().as_secs_f64();
// Разовые итоги: радиус влияния (канал) и сверка с точным решением (Тейлор–Грин).
if !blew_up {
let (vx, vy) = back.sample_velocity();
if spec.case == Case::Channel {
let (bcx, bcy) = spec.scene.center();
extras.influence = Some(influence_extent(
&vx,
&vy,
spec.nx,
spec.ny,
spec.units.u_lat,
bcx,
bcy,
spec.scene.ref_size(),
0.01,
));
}
if spec.case == Case::TaylorGreen {
// точное решение: u_x = −u₀·cos(K₁x)·sin(K₂y)·exp(−ν(K₁²+K₂²)t)
let (k1, k2) = (1.0, 4.0);
let kk1 = 2.0 * std::f64::consts::PI * k1 / spec.nx as R;
let kk2 = 2.0 * std::f64::consts::PI * k2 / spec.ny as R;
let nu = math::nu_of_beta(spec.beta0);
let e = (-nu * (kk1 * kk1 + kk2 * kk2) * spec.steps as R).exp();
let (mut num, mut den) = (0.0, 0.0);
for y in 0..spec.ny {
for x in 0..spec.nx {
let want =
-spec.units.u_lat * (kk1 * x as R).cos() * (kk2 * y as R).sin() * e;
num += (vx[y * spec.nx + x] - want).abs();
den += want.abs();
}
}
extras.tg_error = Some(if den > 0.0 { num / den } else { R::NAN });
}
}
if let Some(path) = &cli.case_csv {
let mut f = std::io::BufWriter::new(
std::fs::File::create(path).map_err(|e| format!("не создать {path}: {e}"))?,
);
writeln!(f, "step,t_phys_s,energy,enstrophy,palinstrophy").map_err(|e| e.to_string())?;
for r in &extras.case_series {
writeln!(f, "{:.0},{:.9e},{:.9e},{:.9e},{:.9e}", r[0], r[1], r[2], r[3], r[4])
.map_err(|e| e.to_string())?;
}
}
if let Some(w) = writer {
let frames = w.frames;
w.finish().map_err(|e| format!("закрытие гифки: {e}"))?;
@@ -874,10 +1035,103 @@ fn run(cli: Cli) -> Result<(), String> {
}
}
final_report(&spec, &cli, &recs, d_ref, wall, nodes_per_step, blew_up, &plan, full);
final_report(
&spec, &cli, &recs, d_ref, wall, nodes_per_step, blew_up, &plan, full, &extras,
);
if let Some(path) = &cli.summary {
write_summary(path, &spec, &cli, &recs, d_ref, wall, nodes_per_step, blew_up, &extras)
.map_err(|e| format!("сводка: {e}"))?;
if !quiet {
println!("сводка: {path}");
}
}
Ok(())
}
/// Итоги, которые считаются не каждый шаг, а разово или редкой выборкой.
#[derive(Default)]
struct Extras {
/// Ряд метрик эталонного течения: [шаг, t, энергия, энстрофия, палинстрофия].
case_series: Vec<[R; 5]>,
/// Радиус влияния тела в калибрах: вверх по потоку, вбок, вниз по потоку.
influence: Option<(R, R, R)>,
/// Относительная ошибка поля u_x против точного решения Тейлора–Грина.
tg_error: Option<R>,
}
/// Интегральные метрики периодического течения: энергия, энстрофия, палинстрофия.
///
/// E = ½⟨|u|²⟩, Ω = ½⟨ω²⟩, P = ½⟨|∇ω|²⟩; производные — центральные разности с заворотом,
/// как и положено в периодическом ящике. По ним статьи и сравнивают модели: энстрофия ловит
/// разрешение мелких вихрей, палинстрофия — их градиентов.
fn periodic_metrics(ux: &[R], uy: &[R], nx: usize, ny: usize) -> (R, R, R) {
let n = nx * ny;
let idx = |x: usize, y: usize| y * nx + x;
let mut w = vec![0.0; n];
let mut energy = 0.0;
for y in 0..ny {
for x in 0..nx {
let (xp, xm) = ((x + 1) % nx, (x + nx - 1) % nx);
let (yp, ym) = ((y + 1) % ny, (y + ny - 1) % ny);
w[idx(x, y)] = 0.5 * (uy[idx(xp, y)] - uy[idx(xm, y)])
- 0.5 * (ux[idx(x, yp)] - ux[idx(x, ym)]);
energy += ux[idx(x, y)] * ux[idx(x, y)] + uy[idx(x, y)] * uy[idx(x, y)];
}
}
let mut enst = 0.0;
let mut pal = 0.0;
for y in 0..ny {
for x in 0..nx {
let (xp, xm) = ((x + 1) % nx, (x + nx - 1) % nx);
let (yp, ym) = ((y + 1) % ny, (y + ny - 1) % ny);
enst += w[idx(x, y)] * w[idx(x, y)];
let gx = 0.5 * (w[idx(xp, y)] - w[idx(xm, y)]);
let gy = 0.5 * (w[idx(x, yp)] - w[idx(x, ym)]);
pal += gx * gx + gy * gy;
}
}
let inv = 1.0 / n as R;
(0.5 * energy * inv, 0.5 * enst * inv, 0.5 * pal * inv)
}
/// Насколько далеко тело возмущает поток: расстояния до самой дальней точки, где скорость
/// отклоняется от набегающей больше чем на `thresh`, в калибрах тела.
///
/// Отвечает на практический вопрос «какой домен достаточен»: если возмущение достаёт до
/// границы, домен мал и результат зависит от его размера, а не от физики.
fn influence_extent(
ux: &[R],
uy: &[R],
nx: usize,
ny: usize,
u0: R,
cx: R,
cy: R,
d: R,
thresh: R,
) -> (R, R, R) {
let lim = thresh * u0;
let (mut up, mut lat, mut down): (R, R, R) = (0.0, 0.0, 0.0);
for y in 0..ny {
for x in 0..nx {
let k = y * nx + x;
let dev = ((ux[k] - u0).powi(2) + uy[k] * uy[k]).sqrt();
if dev <= lim {
continue;
}
let (fx, fy) = (x as R, y as R);
if fx < cx {
up = up.max(cx - fx);
} else {
down = down.max(fx - cx);
}
lat = lat.max((fy - cy).abs());
}
}
let d = d.max(1e-9);
(up / d, lat / d, down / d)
}
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 },
@@ -908,10 +1162,10 @@ 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}°",
if spec.scene.bodies.len() > 1 {
format!("{} тел", spec.scene.bodies.len())
} else {
format!("{:?}", spec.scene.bodies[0].kind).to_lowercase()
match spec.scene.bodies.len() {
0 => "нет (периодич.)".to_string(),
1 => format!("{:?}", spec.scene.bodies[0].kind).to_lowercase(),
k => format!("{k} тел"),
},
u.d_phys(), u.d_lat, cli.body_angle);
println!(" Рейнольдс {:>12.1} вязкость {:.4e} м²/с", u.re, u.nu_phys());
@@ -1084,6 +1338,7 @@ fn final_report(
blew_up: bool,
plan: &gif::GifPlan,
full: bool,
extras: &Extras,
) {
let u = &spec.units;
let n = recs.len();
@@ -1113,6 +1368,33 @@ fn final_report(
println!("⚠ ПРОГОН ОБОРВАН: счёт развалился. Числа ниже относятся к тому, что успело сойтись.");
}
// ── эталонные течения: свои метрики вместо Cd/St ──
if spec.case.is_periodic() {
println!("\n── Эталонное течение: {} ─────────────────────────────────────────",
cli.case);
if let (Some(a), Some(b)) = (extras.case_series.first(), extras.case_series.last()) {
println!(" {:>10}{:>14}{:>14}{:>16}", "момент", "энергия", "энстрофия", "палинстрофия");
println!(" {:>10}{:>14.6e}{:>14.6e}{:>16.6e}", "старт", a[2], a[3], a[4]);
println!(" {:>10}{:>14.6e}{:>14.6e}{:>16.6e}", "конец", b[2], b[3], b[4]);
if a[2] > 0.0 && a[3] > 0.0 {
println!(
" отношение к начальному: энергия {:.4}, энстрофия {:.4}",
b[2] / a[2],
b[3] / a[3]
);
}
}
if let Some(e) = extras.tg_error {
println!(" относительная ошибка u_x против ТОЧНОГО решения: {e:.4e}");
println!(" (метрика рис. 1 статьи: Σ|u_x − u_x^точн| / Σ|u_x^точн|)");
}
println!("\n── Производительность ──────────────────────────────────────────────────────");
let sps = n as f64 / wall.max(1e-9);
println!(" время счёта {wall:.1} с {sps:.0} шаг/с {:.1} MLUPS", sps * nodes / 1e6);
println!();
return;
}
// ── установившийся режим ──
let beta = spec.scene.frontal_extent() / spec.ny as R;
println!("\n── Установившийся режим (вторая половина ряда, шаги {}–{}) ──────────────────", h, n - 1);
@@ -1127,8 +1409,8 @@ fn final_report(
cl_rms * (1.0 - beta).powi(2),
"—"
);
if spec.scene.bodies.len() == 1
&& spec.scene.bodies[0].kind == ShapeKind::Cylinder
if spec.scene.bodies.first().map(|b| b.kind) == Some(ShapeKind::Cylinder)
&& spec.scene.bodies.len() == 1
&& u.re > 100.0
&& u.re < 200.0
{
@@ -1212,6 +1494,23 @@ fn final_report(
println!("rms Cl насыщен ({:+.4}); дрейф-устойчивый Cd = {cdn1:.3}", clr1 - clr0);
}
if let Some((up, lat, down)) = extras.influence {
println!("\n── Радиус влияния тела (отклонение скорости больше 1% от U) ────────────────");
println!(" вверх по потоку {up:.1} калибра, вбок {lat:.1}, вниз по потоку {down:.1}");
let (bcx, _) = spec.scene.center();
let d = spec.scene.ref_size().max(1e-9);
let (room_up, room_lat, room_down) = (
bcx / d,
(spec.ny as R / 2.0) / d,
(spec.nx as R - bcx) / d,
);
let tight = up > 0.9 * room_up || lat > 0.9 * room_lat || down > 0.9 * room_down;
println!(
" до границ домена: {room_up:.1} / {room_lat:.1} / {room_down:.1} калибра{}",
if tight { " ⚠ ВОЗМУЩЕНИЕ ДОСТАЁТ ДО ГРАНИЦЫ — домен мал" } else { "" }
);
}
// ── измеренная продольная пульсация ──
// Колебание средней плотности — это и есть та самая «поршневая» мода. Переводим его в
// амплитуду скорости: для бегущей акустической волны u' = (δρ/ρ)·c_s.
@@ -1298,3 +1597,104 @@ fn write_csv(path: &str, recs: &[StepRec], spec: &Spec, d_ref: R) -> std::io::Re
}
Ok(())
}
/// Машиночитаемая сводка прогона. Пишется вручную, без serde: полей немного, а лишняя
/// зависимость в решателе не нужна.
#[allow(clippy::too_many_arguments)]
fn write_summary(
path: &str,
spec: &Spec,
cli: &Cli,
recs: &[StepRec],
d_ref: R,
wall: f64,
nodes: f64,
blew_up: bool,
extras: &Extras,
) -> std::io::Result<()> {
let u = &spec.units;
let n = recs.len();
let h = n / 2;
let q = |v: R| if v.is_finite() { format!("{v:.9e}") } else { "null".into() };
let mut f = std::io::BufWriter::new(std::fs::File::create(path)?);
writeln!(f, "{{")?;
writeln!(f, " \"case\": \"{}\",", cli.case)?;
writeln!(f, " \"backend\": \"{}\",", cli.backend)?;
writeln!(f, " \"wall\": \"{}\", \"kbc_model\": \"{}\", \"collision\": \"{}\",",
cli.wall, cli.kbc_model, cli.collision)?;
writeln!(f, " \"nx\": {}, \"ny\": {}, \"refine\": {},", spec.nx, spec.ny, spec.refine)?;
writeln!(f, " \"bodies\": {}, \"body_size_cells\": {},",
spec.scene.bodies.len(), q(spec.scene.ref_size()))?;
writeln!(f, " \"re\": {}, \"u_lat\": {}, \"mach\": {}, \"tau\": {},",
q(u.re), q(u.u_lat), q(u.mach()), q(1.0 / (2.0 * spec.beta0)))?;
writeln!(f, " \"steps\": {}, \"time_phys_s\": {}, \"convective_times\": {},",
spec.steps, q(u.time_of_step(spec.steps)), q(u.convective_times(spec.steps)))?;
writeln!(f, " \"blew_up\": {},", blew_up)?;
let sps = n as f64 / wall.max(1e-9);
writeln!(f, " \"wall_time_s\": {:.3}, \"steps_per_s\": {:.1}, \"mlups\": {:.2},",
wall, sps, sps * nodes / 1e6)?;
if n >= 8 {
if spec.case.is_periodic() {
if let (Some(a), Some(b)) = (extras.case_series.first(), extras.case_series.last()) {
writeln!(f, " \"energy_start\": {}, \"energy_end\": {},", q(a[2]), q(b[2]))?;
writeln!(f, " \"enstrophy_start\": {}, \"enstrophy_end\": {},", q(a[3]), q(b[3]))?;
writeln!(f, " \"palinstrophy_end\": {},", q(b[4]))?;
writeln!(f, " \"enstrophy_ratio\": {},",
q(if a[3] > 0.0 { b[3] / a[3] } else { R::NAN }))?;
}
writeln!(f, " \"taylor_green_error\": {},",
extras.tg_error.map(q).unwrap_or_else(|| "null".into()))?;
} else {
let sc = u.u_lat * u.u_lat * d_ref;
let cd: Vec<R> = recs[h..].iter().map(|r| 2.0 * r.fx / sc).collect();
let cl: Vec<R> = recs[h..].iter().map(|r| 2.0 * r.fy / sc).collect();
let cm: Vec<R> = recs[h..].iter().map(|r| 2.0 * r.tz / (sc * d_ref)).collect();
let uy: Vec<R> = recs.iter().map(|r| r.uy_probe).collect();
let rho: Vec<R> = recs[h..].iter().map(|r| r.rho_mean).collect();
let (st, _, _) = math::strouhal(&uy, u.d_lat, u.u_lat, 8192);
let (cdm, _) = math::mean_std(&cd);
let (_, clr) = math::mean_std(&cl);
let (cmm, _) = math::mean_std(&cm);
let (rm, _) = math::mean_std(&rho);
let bl = spec.scene.frontal_extent() / spec.ny as R;
writeln!(f, " \"strouhal\": {}, \"cd\": {}, \"cl_rms\": {}, \"cm\": {},",
q(st), q(cdm), q(clr), q(cmm))?;
writeln!(f, " \"blockage\": {}, \"strouhal_corr\": {}, \"cd_corr\": {}, \"cl_rms_corr\": {},",
q(bl), q(st * (1.0 - bl)), q(cdm * (1.0 - bl).powi(2)),
q(clr * (1.0 - bl).powi(2)))?;
writeln!(f, " \"rho_mean\": {},", q(rm))?;
// по телам
write!(f, " \"bodies_cd\": [")?;
let nb = spec.scene.bodies.len().min(MAX_BODY_BUCKETS);
for b in 0..nb {
let v: Vec<R> = recs[h..].iter().map(|r| 2.0 * r.body[b][0] / sc).collect();
let (m, _) = math::mean_std(&v);
write!(f, "{}{}", if b > 0 { ", " } else { "" }, q(m))?;
}
writeln!(f, "],")?;
let (up, lat, down) = extras.influence.unwrap_or((R::NAN, R::NAN, R::NAN));
writeln!(f, " \"influence_up\": {}, \"influence_lat\": {}, \"influence_down\": {},",
q(up), q(lat), q(down))?;
}
let cs = math::CS2.sqrt();
let last = &recs[n - n / 10..];
let lo = last.iter().map(|r| r.rho_mean).fold(R::INFINITY, R::min);
let hi = last.iter().map(|r| r.rho_mean).fold(R::NEG_INFINITY, R::max);
let mean = last.iter().map(|r| r.rho_mean).sum::<R>() / last.len() as R;
writeln!(f, " \"pulsation_u_over_U\": {},",
q(0.5 * (hi - lo) / mean * cs / u.u_lat))?;
let (gm, _) = math::mean_std(&recs[h..].iter().map(|r| r.gamma_mean).collect::<Vec<_>>());
let (dg, _) =
math::mean_std(&recs[h..].iter().map(|r| r.degenerate_frac).collect::<Vec<_>>());
let (xn, _) =
math::mean_std(&recs[h..].iter().map(|r| r.xi_negative_frac).collect::<Vec<_>>());
writeln!(f, " \"gamma_mean\": {}, \"degenerate_frac\": {}, \"xi_negative_frac\": {}",
q(gm), q(dg), q(xn))?;
} else {
writeln!(f, " \"note\": \"слишком мало шагов для статистики\"")?;
}
writeln!(f, "}}")?;
Ok(())
}