Решатель 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:
+13
@@ -40,6 +40,19 @@ docs/theory/solver_2x_sdf/out/
|
||||
# ноутбук kbc_lbm.ipynb их встраивает, и без них он нечитаем на свежем клоне
|
||||
# (перегенерация требует GPU-сервера). См. заметку о них в README ветки research.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rust (docs/theory/2d_solver — решатель KBC на Rust)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Каталог сборки cargo: объектники, зависимости, инкрементальный кэш.
|
||||
target/
|
||||
# Cargo.lock для бинарного крейта обычно коммитят (воспроизводимость прогонов) —
|
||||
# он НЕ игнорируется намеренно.
|
||||
|
||||
# Артефакты прогонов решателя: гифки, ряды, сводки. Воспроизводятся запуском.
|
||||
docs/theory/2d_solver/*.gif
|
||||
docs/theory/2d_solver/*.csv
|
||||
docs/theory/2d_solver/out/
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Редакторы и ОС
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Generated
+1308
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
[package]
|
||||
name = "kbc2d"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.75"
|
||||
description = "Двумерный решатель LBM D2Q9 с энтропийным оператором столкновения KBC (модель D / N1)"
|
||||
|
||||
[[bin]]
|
||||
name = "kbc2d"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
rayon = "1"
|
||||
gif = "0.13"
|
||||
bytemuck = { version = "1", features = ["derive"] }
|
||||
wgpu = { version = "22", optional = true }
|
||||
pollster = { version = "0.3", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["gpu"]
|
||||
# GPU-бэкенд на wgpu (Vulkan/DX12/Metal). Отключается: --no-default-features
|
||||
gpu = ["dep:wgpu", "dep:pollster"]
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
|
||||
# Отдельный профиль для прогонов с проверками переполнения индексов
|
||||
[profile.release-dbg]
|
||||
inherits = "release"
|
||||
debug = true
|
||||
panic = "unwind"
|
||||
@@ -0,0 +1,226 @@
|
||||
# kbc2d — двумерный решатель LBM D2Q9 с энтропийным столкновением KBC
|
||||
|
||||
Переписанный на Rust решатель обтекания тела в канале. Физика — та же, что в
|
||||
`docs/theory/solver_2x_sdf` (python/CuPy), но собранная заново: с тестами против формул
|
||||
первоисточников, двумя взаимозаменяемыми бэкендами и анимацией, привязанной к физическому
|
||||
времени потока.
|
||||
|
||||
Модель столкновения — **KBC D** по таблице I работы Bösch, Chikatamarla, Karlin, *Entropic
|
||||
Multi-Relaxation Models for Simulation of Fluid Turbulence* (arXiv:1507.02509); в трёхмерных
|
||||
работах тех же авторов она называется **KBC-N1**. Сдвиговая часть `s` несёт натуральные
|
||||
моменты {N, Π_xy}, всё остальное (T, Q_xyy, Q_yxx, A) уходит в `h`. Статьи лежат в
|
||||
`docs/origins/`; ссылки на формулы в коде даны по нумерации 2D-статьи.
|
||||
|
||||
## Устройство
|
||||
|
||||
Пять файлов по ролям — каждый отвечает ровно за одно:
|
||||
|
||||
| файл | роль |
|
||||
|---|---|
|
||||
| `src/math.rs` | **математика решателя.** Решётка D2Q9, энтропийное равновесие в product-form, проектор на сдвиг, стабилизатор γ, столкновение, Zou–He, геометрия тел через SDF, перевод единиц, спектральная диагностика. Всё узловое и чистое; здесь же тесты против формул статьи. |
|
||||
| `src/cpu.rs` | **бэкенд под процессор.** Раскладка AoS, обход сетки, rayon, сборка Bouzidi-линков, связка уровней AMR. Физику берёт из `math`. |
|
||||
| `src/gpu.rs` | **бэкенд под видеокарту.** wgpu + WGSL (Vulkan/DX12/Metal), раскладка SoA. Физика построчно повторяет `math.rs` на f32; топологию (маски, линки, рамка патча) не дублирует, а берёт из `cpu`. |
|
||||
| `src/main.rs` | **запуск и оркестрирование.** Разбор параметров, сборка постановки, цикл по шагам, живой вывод и итоговый отчёт, выгрузка рядов в CSV. Здесь же контракт `Spec` / `StepRec` / `FieldKind`, общий для обоих бэкендов. |
|
||||
| `src/gif.rs` | **создание гифок.** Тайминг относительно физического времени, палитры, нормировка, служебная надпись, кодирование. |
|
||||
|
||||
## Сборка и запуск
|
||||
|
||||
Нужен Rust 1.75+.
|
||||
|
||||
```sh
|
||||
cargo build --release # с GPU-бэкендом
|
||||
cargo build --release --no-default-features # только CPU (без wgpu)
|
||||
cargo test --release # 21 быстрый тест
|
||||
cargo test --release -- --ignored # плюс эталонный сдвиговый слой (~3 с)
|
||||
```
|
||||
|
||||
Пример: цилиндр Re=150, гифка завихренности в реальном времени.
|
||||
|
||||
```sh
|
||||
./target/release/kbc2d --shape cylinder --size 24 --re 150 \
|
||||
--nx 480 --ny 240 --steps 40000 --sponge-len 32 \
|
||||
--gif wake.gif --gif-field vorticity --verbose full
|
||||
```
|
||||
|
||||
`--help` показывает все ключи, разбитые по группам: Физика, Сетка, Тело, Время, Схема,
|
||||
Анимация, Вывод.
|
||||
|
||||
## Синхронизация анимации с физическим временем
|
||||
|
||||
Требование: гифка идёт с той же скоростью, что и настоящий поток, независимо от того, с какой
|
||||
скоростью считает машина. Реальная производительность в тайминг не входит вообще.
|
||||
|
||||
В LBM скорость самой решётки жёстко равна c = δx/δt = 1, поэтому шаг по времени однозначно
|
||||
определяется тем, какую **решёточную** скорость `u_lat` мы назначаем физическому потоку:
|
||||
|
||||
```
|
||||
δt = u_lat · δx / u_phys [с/шаг]
|
||||
шагов в секунду = u_phys / (u_lat · δx)
|
||||
задержка кадра = (шагов на кадр) · δt / playback
|
||||
```
|
||||
|
||||
**Про пример из постановки.** «30 м/с, ячейка 0.1 м ⇒ 300 шагов/с» — это арифметика
|
||||
δt = δx/u_phys, то есть `u_lat = 1`: поток проходит ровно ячейку за шаг. Формула
|
||||
воспроизводится буквально ключом `--u-lat 1.0` (тест `units_time_scaling` это проверяет), но
|
||||
физически такой режим негоден: Ma = u_lat/c_s = √3 ≈ 1.73, сверхзвук, разложение
|
||||
Чепмена–Энскога не работает. Поэтому по умолчанию `u_lat = 0.05` (Ma ≈ 0.087), и те же 30 м/с
|
||||
при ячейке 0.1 м дают 6000 шагов/с. Синхронность гифки выдерживается в обоих случаях — меняется
|
||||
только, сколько шагов приходится на кадр.
|
||||
|
||||
**Две тонкости формата GIF**, обе разобраны:
|
||||
|
||||
1. *Задержка хранится в сотых долях секунды.* Точная физическая задержка почти никогда не целая:
|
||||
30 кадр/с — это 3⅓ сотых. Покадровое округление до 3 дало бы анимацию на 11% быстрее
|
||||
реальности, и уход копился бы линейно (к тысячному кадру — 3.3 секунды). Поэтому задержки
|
||||
выдаёт накопитель `DelayDither`: суммарное время кадров отслеживает точное физическое с
|
||||
точностью до одной сотой (3, 3, 4, 3, 3, 4, …), ошибка ограничена ±5 мс и не растёт.
|
||||
2. *Меньше одной сотой не бывает.* При физически корректном `u_lat` кадр каждые 10 шагов — это
|
||||
600 кадр/с, чего формат не умеет. Поэтому режим по умолчанию `--gif-every auto` подбирает
|
||||
шаг сам под `--gif-fps` (30) так, чтобы получилось ровно реальное время. Если шаг задан
|
||||
жёстко и задержка не представима, программа печатает фактический коэффициент расхождения и
|
||||
конкретный совет, как починить.
|
||||
|
||||
`--gif-speed 0.1` даёт замедление в 10 раз (тоже точно, через тот же накопитель).
|
||||
|
||||
## Что параметризовано
|
||||
|
||||
- **поток**: скорость (м/с), направление, число Рейнольдса, решёточная скорость (число Маха);
|
||||
- **сетка**: размеры домена и размер ячейки в метрах (плотность сетки), коэффициент измельчения
|
||||
вложенного патча и его границы;
|
||||
- **тело**: семь форм на выбор — `cylinder`, `square`, `diamond`, `ellipse`, `naca`, `triangle`,
|
||||
`plate` — плюс характерный размер, относительная толщина, угол атаки и положение;
|
||||
- **время**: число шагов, длина разгона, амплитуда и длительность стартового возмущения;
|
||||
- **схема**: оператор столкновения (`kbc`/`bgk`), режим выхода, поглощающая губка, бэкенд,
|
||||
число потоков;
|
||||
- **анимация**: файл, поле (`speed`/`vorticity`/`density`/`gamma`), палитра, масштаб, шаг кадра,
|
||||
частота, скорость воспроизведения, диапазон нормировки;
|
||||
- **вывод**: период живых строк, три уровня подробности, число окон в отчёте о сходимости, CSV.
|
||||
|
||||
## Что печатает отчёт
|
||||
|
||||
*Шапка* — вся постановка с производными величинами: δt, шагов на секунду, Ma, τ, физическая
|
||||
вязкость, блокировка канала, геометрия патча, полный план тайминга анимации.
|
||||
|
||||
*Живой вывод* — шаг, физическое время, ⟨ρ⟩, max|u|, Cd, Cl, скорость счёта и ETA; на уровне
|
||||
`full` дополнительно ⟨γ⟩ с размахом, доля вырожденных узлов, доля узлов с ξ < 0, MLUPS и
|
||||
отношение скорости счёта к реальному времени.
|
||||
|
||||
*Итог* — установившийся режим (St, ⟨Cd⟩, rms Cl, ⟨Cm⟩) сырой и с поправкой на блокировку, рядом
|
||||
литературные значения для цилиндра; таблица сходимости по окнам с вердиктом о дрейфе массы и
|
||||
насыщении; разбор стабилизатора γ; производительность.
|
||||
|
||||
## Состояние проверки
|
||||
|
||||
**Ядро схемы проверено против формул статей** (`cargo test`, 21 тест + 1 длинный):
|
||||
|
||||
- проектор Δs, выписанный аналитически из представления популяций через натуральные моменты
|
||||
(ур. 10), совпадает с матричным `M⁻¹·diag(…,1,1,…)·M` до 1e-13; он идемпотентен и не несёт
|
||||
ни массы, ни импульса;
|
||||
- равновесие в product-form сохраняет ρ и ρu до 1e-13;
|
||||
- γ из замкнутой оценки (ур. 17) — корень условия критической точки энтропии (ур. 15): невязка
|
||||
при γ\* более чем в 20 раз меньше, чем при γ\*±1;
|
||||
- при γ = 2 схема совпадает с LBGK поточечно;
|
||||
- **сдвиговые моменты релаксируют ровно с 2β при любой γ** (проверено при β = 0.3, 0.6, 0.95) —
|
||||
это и есть гарантия того, что стабилизатор не трогает вязкость;
|
||||
- затухание сдвиговой волны даёт ν из ур. (5) с погрешностью < 1% при τ = 0.6 и τ = 1.0;
|
||||
- Zou–He ставит ровно заданные скорость на входе и плотность на выходе;
|
||||
- SDF всех семи форм: знак верен внутри и снаружи, |∇φ| = 1 ± 0.05 на контрольном кольце.
|
||||
|
||||
**Сквозная сверка с эталоном.** Дважды периодический сдвиговый слой — один из трёх бенчмарков
|
||||
2D-статьи (N=128, Re=30000, u₀=0.04, κ=80, δ=0.05, одно конвективное время). Отношение
|
||||
энстрофии к начальной сходится с fp64-эталоном питоновского решателя **0.6035**. Тест
|
||||
чувствителен именно к тому, что важно: на испорченном (абсолютном) пороге вырожденности γ тот
|
||||
же прогон давал 0.6599, то есть +9.3%, а чистый LBGK при этих параметрах разваливается.
|
||||
|
||||
**Паритет бэкендов.** CPU (f64) и GPU (f32) на одной постановке совпадают до 4–5 значащих
|
||||
цифр шаг в шаг: ⟨ρ⟩ 1.04933 против 1.04934, Cd 2.339 против 2.338, ⟨γ⟩ 1.2645 против 1.2646.
|
||||
На Intel Iris Xe GPU даёт ≈82 MLUPS против ≈18 MLUPS у процессора.
|
||||
|
||||
**Согласованность уровней AMR.** Один и тот же случай, посчитанный с патчем ×2 и вовсе без
|
||||
измельчения (`--refine 1`), даёт St 0.1951 против 0.1970 и ⟨Cd⟩ 1.956 против 1.943 — расхождение
|
||||
в пределах 1–3%. То есть связка уровней (рамка, подшаги, рестрикция) не вносит систематики.
|
||||
|
||||
**Формы тел ведут себя физично.** Один короткий прогон на каждую форму (260×130, размер 20,
|
||||
угол атаки 12°) даёт ожидаемый порядок сопротивления: обтекаемые — профиль 0.44, эллипс 0.45,
|
||||
пластина 0.60; промежуточные — цилиндр 1.33, ромб 1.65; тупые — квадрат 2.38, треугольник 2.72.
|
||||
Момент ⟨Cm⟩ при этом ≈ 0 **только** у круга (−0.0002), которому угол атаки безразличен, а у
|
||||
несимметричных под углом тел он ненулевой (профиль +0.31, эллипс +0.15, пластина +0.13) —
|
||||
то есть и плечо, и знак момента считаются осмысленно.
|
||||
|
||||
### Сверка канального случая с питоновским решателем
|
||||
|
||||
Постановка ровно та же, что в `solver_2x_sdf` по умолчанию: 174×90, D=16, cx=40, Re=150,
|
||||
U=0.07, патч ×2 на [16,140]×[13,77], жёсткий ноль поперечной скорости на выходе, 100 000 шагов.
|
||||
Слева — что даёт этот решатель, справа — что записано в README питоновского.
|
||||
|
||||
| величина | kbc2d | solver_2x_sdf | |
|
||||
|---|---|---|---|
|
||||
| ⟨ρ⟩ | 1.001 | 1.001 | совпало |
|
||||
| ⟨Cm⟩ | +0.00001 | −0.00003 | оба ≈ 0 — симметрия считывания в порядке |
|
||||
| rms Cl (с попр.) | 0.571 | 0.557 | расхождение 2.5% |
|
||||
| ⟨Cd⟩ (сырой) | 1.956 | 1.799 | **на 9% выше** |
|
||||
| St·(1−β) | 0.161 | 0.183 | **на 12% ниже** |
|
||||
|
||||
Расхождение по Cd и St не объяснено. Что про него известно:
|
||||
|
||||
- оно **не** от связки уровней: без измельчения картина та же (см. выше);
|
||||
- оно **не** от ядра схемы: сдвиговый слой из статьи воспроизводится с эталоном, вязкость точна
|
||||
до 1%, сдвиговые моменты релаксируют ровно с 2β;
|
||||
- после поправки на блокировку **этот** Cd = 1.32 ближе к литературным 1.33 (0.6%), чем
|
||||
питоновский 1.22 (8.6% ниже), а вот питоновский St ближе к литературным 0.183;
|
||||
- rms Cl у обоих решателей вдвое выше литературной ~0.3 — это известная особенность постановки,
|
||||
разобранная в `solver_2x_sdf/README.md` (продольные границы завышают давленческие амплитуды).
|
||||
|
||||
Развести это можно только прогоном обоих кодов бок о бок на одной машине; питоновская версия
|
||||
требует GPU и CuPy, которых на машине сборки нет, поэтому сравнение сделано с числами,
|
||||
записанными в её README.
|
||||
|
||||
### Известные отличия от питоновского решателя
|
||||
|
||||
- **Внутри тела столкновение не считается.** Эти популяции фиктивны: Bouzidi перекрывает всё,
|
||||
что могло бы прийти из тела в жидкость, так что на физику они не влияют. Питоновская версия
|
||||
считает их наравне со всеми. Побочный эффект — статистика γ здесь собирается строго по
|
||||
жидкости.
|
||||
- **Рестрикция дополнительно пропускает узлы, у которых тонкий узел-источник лежит внутри
|
||||
тела.** В питоновской версии маска строится только по грубому уровню. Случай краевой (тело на
|
||||
обоих уровнях одно и то же), но здесь он закрыт явно: из фиктивного узла в жидкий переносить
|
||||
нечего.
|
||||
- **Точность.** Процессорный бэкенд работает в f64 (питоновский по умолчанию в f32,
|
||||
переключается переменной `AMR_FP64`); GPU-бэкенд — в f32, потому что в WGSL нет двойной
|
||||
точности.
|
||||
|
||||
### Порог вырожденности γ
|
||||
|
||||
`GREL = 1e-8` — **относительный** порог, доля от ⟨Δ|Δ⟩, а не абсолютный. Это принципиально:
|
||||
знаменатель ⟨Δh|Δh⟩ квадратичен по неравновесию и физически мал (~1e-7…1e-9 в развитом следе),
|
||||
поэтому абсолютный порог срабатывает на подавляющем большинстве узлов и молча подменяет γ на 2,
|
||||
то есть гонит чистый LBGK вместо KBC. Доля вырожденных узлов печатается в отчёте — на исправном
|
||||
пороге она обязана быть ~0.
|
||||
|
||||
Единственное исключение — самый первый шаг: поле в точности равно равновесию, Δ ≡ 0, и порог
|
||||
честно срабатывает на всех узлах. На γ это не влияет, потому что она умножается на Δh = 0.
|
||||
|
||||
## Акустика канала
|
||||
|
||||
Пара «вход по скорости / выход по давлению» — недодемпфированный акустический резонатор:
|
||||
затухание продольной моды идёт как ν(π/Nx)², то есть на длинном домене её почти ничто не гасит.
|
||||
Это разобрано в факторном исследовании питоновского решателя (`solver_2x_sdf/README.md`,
|
||||
эксперимент №1): длинные домены без демпфера дают смещённый режим ⟨ρ⟩ ≈ 1.66 или развал счёта.
|
||||
|
||||
Отсюда два решения в умолчаниях:
|
||||
|
||||
- `--outlet extrapolate` стоит по умолчанию (в том исследовании — лучший вариант по всем
|
||||
метрикам: жёсткий ноль поперечной скорости отражает вихри дорожки обратно к телу);
|
||||
- при `nx ≥ 250` и выключенной губке программа печатает предупреждение с готовым рецептом
|
||||
(`--sponge-len 32`).
|
||||
|
||||
Мгновенное ⟨ρ⟩ при этом всё равно осциллирует вокруг единицы — это сама акустическая мода.
|
||||
Смотреть надо на **оконные средние** в таблице сходимости: именно они должны стоять на 1.000.
|
||||
|
||||
## Дальше
|
||||
|
||||
- σ·n-кросс-чек силы (интеграл тензора напряжений по контуру) как независимая проверка GMEM —
|
||||
в питоновской версии есть, здесь пока нет;
|
||||
- подвижные и вращающиеся тела: GMEM уже записан в галилей-инвариантной форме и принимает
|
||||
скорость стенки на линке, но подача этой скорости не подключена;
|
||||
- несколько тел одновременно (сейчас — одно тело на выбор из семи форм).
|
||||
@@ -0,0 +1,935 @@
|
||||
//! БЭКЕНД ПОД ПРОЦЕССОР. Раскладка памяти, обход сетки, AMR-связка уровней и параллелизм
|
||||
//! через rayon. Вся физика берётся из `math` — здесь её нет, есть только организация счёта.
|
||||
//!
|
||||
//! Раскладка: AoS, `Vec<[R; Q]>`, узел = y·nx + x. Столкновение — самая дорогая часть шага
|
||||
//! (sqrt на узел) и в AoS оно тривиально распараллеливается и идеально ложится в кэш;
|
||||
//! перенос при этом собирает 9 значений с 9 разных узлов, что дешевле, чем кажется, т.к.
|
||||
//! соседи по x лежат рядом. GPU-бэкенд использует SoA — там важнее коалесцированный доступ.
|
||||
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::math::{self, Body, Kbc, Link, LinkKind, R, CX, CY, OPP, Q};
|
||||
use crate::{Collision, FieldKind, Spec, StepRec};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Геометрия уровня
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Маски, SDF и предсобранные Bouzidi-линки одного уровня сетки.
|
||||
pub struct Geom {
|
||||
pub nx: usize,
|
||||
pub ny: usize,
|
||||
pub solid: Vec<bool>,
|
||||
/// Линки «жидкий → твёрдый» с готовой геометрией пересечения.
|
||||
pub links: Vec<Link>,
|
||||
/// Плечи для момента: координаты узлов относительно центра тела.
|
||||
pub body_cx: R,
|
||||
pub body_cy: R,
|
||||
}
|
||||
|
||||
impl Geom {
|
||||
/// Собрать геометрию уровня по телу. `solid` определяется знаком SDF: φ ≤ 0 — тело.
|
||||
pub fn build(nx: usize, ny: usize, body: &Body) -> Self {
|
||||
let n = nx * ny;
|
||||
let mut phi = vec![0.0; n];
|
||||
let mut solid = vec![false; 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 links = build_links(nx, ny, &solid, &phi);
|
||||
Geom { nx, ny, solid, links, body_cx: body.cx, body_cy: body.cy }
|
||||
}
|
||||
}
|
||||
|
||||
/// Индекс соседа по направлению i с периодическим заворотом (перенос сделан через roll,
|
||||
/// поэтому решётка периодична по обеим осям; ГУ снимают заворот там, где он не физичен).
|
||||
#[inline]
|
||||
fn nb(nx: usize, ny: usize, x: usize, y: usize, i: usize, sign: i32) -> usize {
|
||||
let xs = (x as i32 + sign * CX[i]).rem_euclid(nx as i32) as usize;
|
||||
let ys = (y as i32 + sign * CY[i]).rem_euclid(ny as i32) as usize;
|
||||
ys * nx + xs
|
||||
}
|
||||
|
||||
/// Для каждого жидкого узла и каждого направления, упирающегося в тело, решаем, какая из трёх
|
||||
/// формул Bouzidi применима, и считаем долю пересечения q из SDF. Делается один раз.
|
||||
fn build_links(nx: usize, ny: usize, solid: &[bool], phi: &[R]) -> Vec<Link> {
|
||||
let mut links = Vec::new();
|
||||
for y in 0..ny {
|
||||
for x in 0..nx {
|
||||
let node = y * nx + x;
|
||||
if solid[node] {
|
||||
continue;
|
||||
}
|
||||
for i in 1..Q {
|
||||
let s = nb(nx, ny, x, y, i, 1); // сосед по +c_i
|
||||
if !solid[s] {
|
||||
continue;
|
||||
}
|
||||
let far = nb(nx, ny, x, y, i, -1); // «дальний» сосед x_f − c_i
|
||||
let q = math::bouzidi_q(phi[node], phi[s]);
|
||||
let kind = if q >= 0.5 {
|
||||
LinkKind::Far
|
||||
} else if !solid[far] {
|
||||
LinkKind::Near
|
||||
} else {
|
||||
LinkKind::Simple
|
||||
};
|
||||
links.push(Link {
|
||||
node: node as u32,
|
||||
far: far as u32,
|
||||
i: i as u8,
|
||||
ib: OPP[i] as u8,
|
||||
kind,
|
||||
q,
|
||||
body: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
links
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Уровень сетки
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Один уровень: поля популяций до/после столкновения, β и геометрия.
|
||||
pub struct Level {
|
||||
pub nx: usize,
|
||||
pub ny: usize,
|
||||
/// Текущее состояние (после переноса и ГУ).
|
||||
pub f: Vec<[R; Q]>,
|
||||
/// Состояние после столкновения — источник переноса и вход Bouzidi/GMEM.
|
||||
pub post: Vec<[R; Q]>,
|
||||
/// Энтропийный стабилизатор поузлово (для диагностики и картинки).
|
||||
pub gamma: Vec<R>,
|
||||
/// β по столбцам x (губка делает его полем); длина nx.
|
||||
pub beta: Vec<R>,
|
||||
pub geom: Geom,
|
||||
}
|
||||
|
||||
impl Level {
|
||||
fn new(nx: usize, ny: usize, beta: Vec<R>, geom: Geom) -> Self {
|
||||
let f0 = math::feq(1.0, 0.0, 0.0);
|
||||
let n = nx * ny;
|
||||
Level { nx, ny, f: vec![f0; n], post: vec![f0; n], gamma: vec![2.0; n], beta, geom }
|
||||
}
|
||||
|
||||
/// Столкновение по всем жидким узлам. Внутри тела не считаем: эти популяции фиктивны
|
||||
/// (ГУ Bouzidi перезаписывает всё, что могло бы прийти из тела в жидкость), а счёт там
|
||||
/// только жжёт такты и способен родить NaN при экстремальных режимах.
|
||||
fn collide(&mut self, op: Collision) -> Stats {
|
||||
let nx = self.nx;
|
||||
let beta = &self.beta;
|
||||
let solid = &self.geom.solid;
|
||||
let post = &mut self.post;
|
||||
let gamma = &mut self.gamma;
|
||||
let f = &self.f;
|
||||
|
||||
post.par_iter_mut()
|
||||
.zip(gamma.par_iter_mut())
|
||||
.enumerate()
|
||||
.map(|(node, (p, g))| {
|
||||
*p = f[node];
|
||||
if solid[node] {
|
||||
*g = 2.0;
|
||||
return Stats::EMPTY;
|
||||
}
|
||||
let b = beta[node % nx];
|
||||
let k: Kbc = match op {
|
||||
Collision::Kbc => math::collide_node(p, b),
|
||||
Collision::Bgk => math::collide_node_bgk(p, b),
|
||||
};
|
||||
*g = k.gamma;
|
||||
Stats::of(k, b)
|
||||
})
|
||||
.reduce(|| Stats::EMPTY, Stats::merge)
|
||||
}
|
||||
|
||||
/// Перенос f_i(x, t+1) = f_i(x − c_i, t), периодический по обеим осям.
|
||||
fn stream(&mut self) {
|
||||
let (nx, ny) = (self.nx, self.ny);
|
||||
let post = &self.post;
|
||||
self.f
|
||||
.par_chunks_mut(nx)
|
||||
.enumerate()
|
||||
.for_each(|(y, row)| {
|
||||
for (x, cell) in row.iter_mut().enumerate() {
|
||||
for i in 0..Q {
|
||||
let xs = (x as i32 - CX[i]).rem_euclid(nx as i32) as usize;
|
||||
let ys = (y as i32 - CY[i]).rem_euclid(ny as i32) as usize;
|
||||
cell[i] = post[ys * nx + xs][i];
|
||||
}
|
||||
}
|
||||
});
|
||||
let _ = ny;
|
||||
}
|
||||
|
||||
/// Интерполированный отскок Bouzidi по всем линкам тела.
|
||||
/// `f` — поле ПОСЛЕ переноса (его правим), `post` — ПОСЛЕ столкновения (до переноса).
|
||||
fn apply_bouzidi(&mut self) {
|
||||
for l in &self.geom.links {
|
||||
let node = l.node as usize;
|
||||
let i = l.i as usize;
|
||||
let ib = l.ib as usize;
|
||||
let fi = self.post[node][i];
|
||||
let v = match l.kind {
|
||||
LinkKind::Near => 2.0 * l.q * fi + (1.0 - 2.0 * l.q) * self.post[l.far as usize][i],
|
||||
LinkKind::Far => {
|
||||
let h = 1.0 / (2.0 * l.q);
|
||||
h * fi + (1.0 - h) * self.post[node][ib]
|
||||
}
|
||||
LinkKind::Simple => fi,
|
||||
};
|
||||
self.f[node][ib] = v;
|
||||
}
|
||||
}
|
||||
|
||||
/// FREE-SLIP (зеркальные) стенки канала сверху и снизу: касательный импульс сохраняется,
|
||||
/// нормальный заворачивается, масса сохраняется. Применять ПОСЛЕ переноса и ПЕРЕД Zou–He.
|
||||
fn free_slip_walls(&mut self) {
|
||||
let (nx, ny) = (self.nx, self.ny);
|
||||
for x in 0..nx {
|
||||
let b = &mut self.f[x];
|
||||
b[2] = b[4];
|
||||
b[5] = b[8];
|
||||
b[6] = b[7];
|
||||
}
|
||||
let top = (ny - 1) * nx;
|
||||
for x in 0..nx {
|
||||
let t = &mut self.f[top + x];
|
||||
t[4] = t[2];
|
||||
t[7] = t[6];
|
||||
t[8] = t[5];
|
||||
}
|
||||
}
|
||||
|
||||
/// Zou–He вход/выход по всему столбцу, включая угловые узлы.
|
||||
///
|
||||
/// ПОРЯДОК КРИТИЧЕН: только ПОСЛЕ free_slip_walls и обязательно на ВЕСЬ столбец. Перенос
|
||||
/// сделан через roll и потому периодичен по обеим осям: заворот по y снимают стенки,
|
||||
/// заворот по x — этот ГУ. Если оставить ряды 0 и ny−1 без Zou–He, в них популяции с
|
||||
/// c_x = +1 на входе приходят прямо из столбца выхода, и вход с выходом оказываются
|
||||
/// физически связаны в четырёх узлах.
|
||||
fn channel_bc(&mut self, ux_in: R, uy_in: R, rho_out: R, outlet_extrapolate: bool) {
|
||||
let (nx, ny) = (self.nx, self.ny);
|
||||
for y in 0..ny {
|
||||
math::zou_he_inlet(&mut self.f[y * nx], ux_in, uy_in);
|
||||
}
|
||||
for y in 0..ny {
|
||||
let uy_out = if outlet_extrapolate {
|
||||
// нуль-градиент поперечной скорости: берём u_y с предвыходного столбца
|
||||
let c = &self.f[y * nx + nx - 2];
|
||||
let s: R = c.iter().sum();
|
||||
((c[2] + c[5] + c[6]) - (c[4] + c[7] + c[8])) / s
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
math::zou_he_outlet(&mut self.f[y * nx + nx - 1], rho_out, uy_out);
|
||||
}
|
||||
}
|
||||
|
||||
/// Сила и момент на теле по GMEM, суммой по линкам тела.
|
||||
fn force(&self) -> (R, R, R) {
|
||||
let (mut fx, mut fy, mut tz) = (0.0, 0.0, 0.0);
|
||||
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;
|
||||
// плечо — до ТОЧКИ ПЕРЕСЕЧЕНИЯ линка со стенкой 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;
|
||||
}
|
||||
(fx, fy, tz)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn macros_at(&self, node: usize) -> (R, R, R) {
|
||||
math::macros(&self.f[node])
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Статистика KBC за шаг
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Сводка по стабилизатору γ за один проход столкновения. Собирается редукцией rayon.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Stats {
|
||||
pub n: u64,
|
||||
pub gsum: R,
|
||||
pub gmin: R,
|
||||
pub gmax: R,
|
||||
/// Узлы, где ⟨Δh|Δh⟩ выродилось и γ подменён на 2 (локально — чистый LBGK).
|
||||
pub degenerate: u64,
|
||||
/// Узлы с отрицательной объёмной вязкостью ξ = c_s²(1/(γβ) − ½) — локальное антизатухание.
|
||||
pub xi_negative: u64,
|
||||
}
|
||||
|
||||
impl Stats {
|
||||
pub const EMPTY: Stats = Stats {
|
||||
n: 0,
|
||||
gsum: 0.0,
|
||||
gmin: R::INFINITY,
|
||||
gmax: R::NEG_INFINITY,
|
||||
degenerate: 0,
|
||||
xi_negative: 0,
|
||||
};
|
||||
#[inline]
|
||||
fn of(k: Kbc, beta: R) -> Stats {
|
||||
Stats {
|
||||
n: 1,
|
||||
gsum: k.gamma,
|
||||
gmin: k.gamma,
|
||||
gmax: k.gamma,
|
||||
degenerate: k.degenerate as u64,
|
||||
xi_negative: (math::xi_of_gamma(k.gamma, beta) < 0.0) as u64,
|
||||
}
|
||||
}
|
||||
fn merge(a: Stats, b: Stats) -> Stats {
|
||||
Stats {
|
||||
n: a.n + b.n,
|
||||
gsum: a.gsum + b.gsum,
|
||||
gmin: a.gmin.min(b.gmin),
|
||||
gmax: a.gmax.max(b.gmax),
|
||||
degenerate: a.degenerate + b.degenerate,
|
||||
xi_negative: a.xi_negative + b.xi_negative,
|
||||
}
|
||||
}
|
||||
pub fn gamma_mean(&self) -> R {
|
||||
if self.n == 0 {
|
||||
0.0
|
||||
} else {
|
||||
self.gsum / self.n as R
|
||||
}
|
||||
}
|
||||
pub fn degenerate_frac(&self) -> R {
|
||||
if self.n == 0 {
|
||||
0.0
|
||||
} else {
|
||||
self.degenerate as R / self.n as R
|
||||
}
|
||||
}
|
||||
pub fn xi_negative_frac(&self) -> R {
|
||||
if self.n == 0 {
|
||||
0.0
|
||||
} else {
|
||||
self.xi_negative as R / self.n as R
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AMR-патч
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Узел рамки тонкого уровня с готовым билинейным стенсилем с грубого уровня.
|
||||
/// Публичен: GPU-бэкенд переиспользует ту же топологию патча, а не строит её заново.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Ghost {
|
||||
pub fine: u32,
|
||||
pub c00: u32,
|
||||
pub c10: u32,
|
||||
pub c01: u32,
|
||||
pub c11: u32,
|
||||
pub tx: R,
|
||||
pub ty: R,
|
||||
}
|
||||
|
||||
/// Связка «грубый L0 ↔ тонкий L1»: на каждый шаг L0 тонкий уровень делает r подшагов.
|
||||
/// Рамка патча заполняется интерполяцией с L0 (билинейно по пространству и линейно по
|
||||
/// времени между состояниями «до» и «после» шага L0), внутренность после подшагов
|
||||
/// проецируется обратно на L0.
|
||||
///
|
||||
/// Неравновесная часть при смене уровня масштабируется: f^neq ∝ τ·δt, поэтому
|
||||
/// коэффициент грубый→тонкий равен R01 = τ_f/(r·τ_c), обратно — 1/R01.
|
||||
pub struct Patch {
|
||||
pub ax: usize,
|
||||
pub bx: usize,
|
||||
pub ay: usize,
|
||||
pub by: usize,
|
||||
pub r: usize,
|
||||
pub nfx: usize,
|
||||
pub r01: R,
|
||||
ghosts: Vec<Ghost>,
|
||||
/// Пары (узел L0, узел L1) для рестрикции — только внутренние жидкие узлы перекрытия.
|
||||
restrict: Vec<(u32, u32)>,
|
||||
}
|
||||
|
||||
impl Patch {
|
||||
/// Топология рамки и рестрикции. Публична: GPU-бэкенд загружает эти же списки в буферы,
|
||||
/// вместо того чтобы строить их заново. В сборке без GPU читателей у них нет.
|
||||
#[cfg_attr(not(feature = "gpu"), allow(dead_code))]
|
||||
pub fn ghosts(&self) -> &[Ghost] {
|
||||
&self.ghosts
|
||||
}
|
||||
#[cfg_attr(not(feature = "gpu"), allow(dead_code))]
|
||||
pub fn restrict_pairs(&self) -> &[(u32, u32)] {
|
||||
&self.restrict
|
||||
}
|
||||
|
||||
pub fn new(spec: &Spec, coarse: &Geom, fine_solid: &[bool], r01: R) -> Patch {
|
||||
let (ax, bx, ay, by) = spec.patch.expect("патч запрошен без границ");
|
||||
let r = spec.refine;
|
||||
let nfx = r * (bx - ax) + 1;
|
||||
let nfy = r * (by - ay) + 1;
|
||||
let cnx = coarse.nx;
|
||||
|
||||
// рамка: один ряд по периметру тонкого поля
|
||||
let mut ghosts = Vec::with_capacity(2 * (nfx + nfy));
|
||||
let push = |gx: usize, gy: usize, out: &mut Vec<Ghost>| {
|
||||
// координата тонкого узла в системе L0
|
||||
let fx = ax as R + gx as R / r as R;
|
||||
let fy = ay as R + gy as R / r as R;
|
||||
let x0 = fx.floor() as usize;
|
||||
let y0 = fy.floor() as usize;
|
||||
let x1 = (x0 + 1).min(cnx - 1);
|
||||
let y1 = (y0 + 1).min(coarse.ny - 1);
|
||||
out.push(Ghost {
|
||||
fine: (gy * nfx + gx) as u32,
|
||||
c00: (y0 * cnx + x0) as u32,
|
||||
c10: (y0 * cnx + x1) as u32,
|
||||
c01: (y1 * cnx + x0) as u32,
|
||||
c11: (y1 * cnx + x1) as u32,
|
||||
tx: fx - x0 as R,
|
||||
ty: fy - y0 as R,
|
||||
});
|
||||
};
|
||||
for gx in 0..nfx {
|
||||
push(gx, 0, &mut ghosts);
|
||||
push(gx, nfy - 1, &mut ghosts);
|
||||
}
|
||||
for gy in 1..nfy - 1 {
|
||||
push(0, gy, &mut ghosts);
|
||||
push(nfx - 1, gy, &mut ghosts);
|
||||
}
|
||||
|
||||
// рестрикция: каждый r-й тонкий узел внутренности, только там, где на L0 жидкость
|
||||
let mut restrict = Vec::new();
|
||||
for cy in ay + 1..by {
|
||||
for cx in ax + 1..bx {
|
||||
let cnode = cy * cnx + cx;
|
||||
if coarse.solid[cnode] {
|
||||
continue;
|
||||
}
|
||||
let fnode = ((cy - ay) * r) * nfx + (cx - ax) * r;
|
||||
if fine_solid[fnode] {
|
||||
continue;
|
||||
}
|
||||
restrict.push((cnode as u32, fnode as u32));
|
||||
}
|
||||
}
|
||||
// рамка — ровно периметр тонкого поля: два ряда по nfx плюс два столбца без углов
|
||||
assert_eq!(ghosts.len(), 2 * nfx + 2 * (nfy - 2));
|
||||
Patch { ax, bx, ay, by, r, nfx, r01, ghosts, restrict }
|
||||
}
|
||||
|
||||
/// Ghost-значения из грубого поля: равновесие по интерполированным ρ, u плюс
|
||||
/// масштабированная неравновесная часть.
|
||||
fn ghost_values(&self, coarse: &[[R; Q]], out: &mut Vec<[R; Q]>) {
|
||||
out.clear();
|
||||
out.reserve(self.ghosts.len());
|
||||
for g in &self.ghosts {
|
||||
let w00 = (1.0 - g.tx) * (1.0 - g.ty);
|
||||
let w10 = g.tx * (1.0 - g.ty);
|
||||
let w01 = (1.0 - g.tx) * g.ty;
|
||||
let w11 = g.tx * g.ty;
|
||||
// Интерполируются ОТДЕЛЬНО макропеременные и отдельно неравновесная часть:
|
||||
// рамка = feq(интерполированные ρ, u) + R01·интерполированная neq. Так сделано в
|
||||
// эталонном python-решателе, и это не то же самое, что интерполировать сами
|
||||
// популяции: u = interp(ρu)/interp(ρ) отличается от interp(u) во втором порядке,
|
||||
// и расщепление на eq/neq тогда тоже смещается. Разница мала поузлово, но рамка
|
||||
// задаёт весь обмен между уровнями каждый шаг, так что копится.
|
||||
let mut rho = 0.0;
|
||||
let mut ux = 0.0;
|
||||
let mut uy = 0.0;
|
||||
let mut neq = [0.0; Q];
|
||||
for (w, c) in [
|
||||
(w00, g.c00 as usize),
|
||||
(w10, g.c10 as usize),
|
||||
(w01, g.c01 as usize),
|
||||
(w11, g.c11 as usize),
|
||||
] {
|
||||
let cf = &coarse[c];
|
||||
let (r, x, y) = math::macros(cf);
|
||||
rho += w * r;
|
||||
ux += w * x;
|
||||
uy += w * y;
|
||||
let fe = math::feq(r, x, y);
|
||||
for i in 0..Q {
|
||||
neq[i] += w * (cf[i] - fe[i]);
|
||||
}
|
||||
}
|
||||
let fe = math::feq(rho, ux, uy);
|
||||
let mut v = [0.0; Q];
|
||||
for i in 0..Q {
|
||||
v[i] = fe[i] + self.r01 * neq[i];
|
||||
}
|
||||
out.push(v);
|
||||
}
|
||||
}
|
||||
|
||||
/// Записать рамку тонкого поля линейной комбинацией ghost-значений «до» и «после».
|
||||
fn fill(&self, fine: &mut [[R; Q]], old: &[[R; Q]], new: &[[R; Q]], w: R) {
|
||||
for (k, g) in self.ghosts.iter().enumerate() {
|
||||
let dst = &mut fine[g.fine as usize];
|
||||
for i in 0..Q {
|
||||
dst[i] = (1.0 - w) * old[k][i] + w * new[k][i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Спроецировать внутренность тонкого уровня обратно на грубый, масштабируя neq на 1/R01.
|
||||
fn restrict_to(&self, fine: &[[R; Q]], coarse: &mut [[R; Q]]) {
|
||||
let rfc = 1.0 / self.r01;
|
||||
for &(cnode, fnode) in &self.restrict {
|
||||
let ff = &fine[fnode as usize];
|
||||
let (rho, ux, uy) = math::macros(ff);
|
||||
let fe = math::feq(rho, ux, uy);
|
||||
let dst = &mut coarse[cnode as usize];
|
||||
for i in 0..Q {
|
||||
dst[i] = fe[i] + rfc * (ff[i] - fe[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Симуляция
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct Sim {
|
||||
pub spec: Spec,
|
||||
pub l0: Level,
|
||||
pub l1: Option<Level>,
|
||||
pub patch: Option<Patch>,
|
||||
/// Состояние L0 до столкновения — «старый» край для временной интерполяции рамки.
|
||||
pre: Vec<[R; Q]>,
|
||||
gh_old: Vec<[R; Q]>,
|
||||
gh_new: Vec<[R; Q]>,
|
||||
fluid_count: R,
|
||||
probe_node: usize,
|
||||
probe_on_fine: bool,
|
||||
pub step_index: u64,
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
pub fn new(spec: Spec) -> Sim {
|
||||
let (nx, ny) = (spec.nx, spec.ny);
|
||||
let geom0 = Geom::build(nx, ny, &spec.body);
|
||||
|
||||
// губка: плавный рост вязкости в последних 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 l0 = Level::new(nx, ny, beta0, geom0);
|
||||
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 nfx = r * (bx - ax) + 1;
|
||||
let nfy = r * (by - ay) + 1;
|
||||
let geom1 = Geom::build(nfx, nfy, &body1);
|
||||
// τ_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)), Some(patch))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
// зонд следа: берём с тонкой сетки, если точка внутри патча (меньше численного
|
||||
// размытия вихрей → чище спектр и St), иначе с грубой
|
||||
let (px, py) = spec.probe;
|
||||
let (probe_node, probe_on_fine) = match &patch {
|
||||
Some(p) if px >= p.ax && px <= p.bx && py >= p.ay && py <= p.by => {
|
||||
(((py - p.ay) * p.r) * p.nfx + (px - p.ax) * p.r, true)
|
||||
}
|
||||
_ => (py * nx + px, false),
|
||||
};
|
||||
|
||||
let n = nx * ny;
|
||||
Sim {
|
||||
spec,
|
||||
l0,
|
||||
l1,
|
||||
patch,
|
||||
pre: vec![[0.0; Q]; n],
|
||||
gh_old: Vec::new(),
|
||||
gh_new: Vec::new(),
|
||||
fluid_count,
|
||||
probe_node,
|
||||
probe_on_fine,
|
||||
step_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Скорость на входе в момент t: разгон smoothstep плюс окно поперечного возмущения.
|
||||
///
|
||||
/// Разгон гасит импульсный старт (мгновенное включение входа шлёт по домену ударную волну).
|
||||
/// Возмущение — короткий поперечный импульс, сбивающий симметрию: без него дорожка Кармана
|
||||
/// заводится только на численном шуме и стартует на порядок позже.
|
||||
fn inlet(&self, t: u64) -> (R, R) {
|
||||
let sp = &self.spec;
|
||||
let u = sp.units.u_lat * math::smoothstep(t as R / sp.ramp.max(1) as R);
|
||||
let (s, c) = sp.flow_angle.sin_cos();
|
||||
let mut uy = u * s;
|
||||
if sp.pert_dur > 0 && t >= sp.ramp && t < sp.ramp + sp.pert_dur {
|
||||
let ph = (t - sp.ramp) as R / sp.pert_dur as R;
|
||||
uy += sp.pert_amp * sp.units.u_lat * (std::f64::consts::PI * ph).sin();
|
||||
}
|
||||
(u * c, uy)
|
||||
}
|
||||
|
||||
pub fn step(&mut self) -> StepRec {
|
||||
let t = self.step_index;
|
||||
let (ux_in, uy_in) = self.inlet(t);
|
||||
let sp_collision = self.spec.collision;
|
||||
let outlet_extrap = self.spec.outlet_extrapolate;
|
||||
|
||||
// ── уровень 0 ──
|
||||
self.pre.copy_from_slice(&self.l0.f);
|
||||
let stats0 = self.l0.collide(sp_collision);
|
||||
self.l0.stream();
|
||||
self.l0.apply_bouzidi();
|
||||
self.l0.free_slip_walls();
|
||||
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);
|
||||
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);
|
||||
for s in 0..p.r {
|
||||
l1.collide(sp_collision);
|
||||
l1.stream();
|
||||
l1.apply_bouzidi();
|
||||
// силу снимаем на КАЖДОМ подшаге и усредняем — мгновенное значение на
|
||||
// последнем подшаге даёт лишний шум в рядах при том же среднем
|
||||
let (a, b, c) = l1.force();
|
||||
fx += a;
|
||||
fy += b;
|
||||
tz += 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;
|
||||
p.restrict_to(&l1.f, &mut self.l0.f);
|
||||
} else {
|
||||
let (a, b, c) = self.l0.force();
|
||||
fx = a;
|
||||
fy = b;
|
||||
tz = c;
|
||||
}
|
||||
|
||||
// ── диагностика ──
|
||||
let probe = if self.probe_on_fine {
|
||||
self.l1.as_ref().unwrap().macros_at(self.probe_node)
|
||||
} else {
|
||||
self.l0.macros_at(self.probe_node)
|
||||
};
|
||||
|
||||
let solid = &self.l0.geom.solid;
|
||||
let (rho_sum, max_u) = self
|
||||
.l0
|
||||
.f
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.filter(|(n, _)| !solid[*n])
|
||||
.map(|(_, c)| {
|
||||
let (r, ux, uy) = math::macros(c);
|
||||
(r, (ux * ux + uy * uy).sqrt())
|
||||
})
|
||||
.reduce(|| (0.0, 0.0), |a, b| (a.0 + b.0, a.1.max(b.1)));
|
||||
|
||||
self.step_index += 1;
|
||||
StepRec {
|
||||
step: t,
|
||||
fx,
|
||||
fy,
|
||||
tz,
|
||||
uy_probe: probe.2,
|
||||
rho_mean: rho_sum / self.fluid_count,
|
||||
max_u,
|
||||
gamma_mean: stats0.gamma_mean(),
|
||||
gamma_min: stats0.gmin,
|
||||
gamma_max: stats0.gmax,
|
||||
degenerate_frac: stats0.degenerate_frac(),
|
||||
xi_negative_frac: stats0.xi_negative_frac(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Характерный размер тела в единицах того уровня, где снимается сила.
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Поле для картинки: (значение, маска тела) на сетке L0.
|
||||
pub fn sample_field(&self, kind: FieldKind) -> (Vec<R>, &[bool]) {
|
||||
let (nx, ny) = (self.l0.nx, self.l0.ny);
|
||||
let mut out = vec![0.0; nx * ny];
|
||||
match kind {
|
||||
FieldKind::Speed => {
|
||||
for n in 0..nx * ny {
|
||||
let (_, ux, uy) = self.l0.macros_at(n);
|
||||
out[n] = (ux * ux + uy * uy).sqrt();
|
||||
}
|
||||
}
|
||||
FieldKind::Vorticity => {
|
||||
// ω = ∂u_y/∂x − ∂u_x/∂y, центральные разности с заворотом по краям
|
||||
let mut ux = vec![0.0; nx * ny];
|
||||
let mut uy = vec![0.0; nx * ny];
|
||||
for n in 0..nx * ny {
|
||||
let (_, a, b) = self.l0.macros_at(n);
|
||||
ux[n] = a;
|
||||
uy[n] = b;
|
||||
}
|
||||
for y in 0..ny {
|
||||
for x in 0..nx {
|
||||
let xp = (x + 1).min(nx - 1);
|
||||
let xm = x.saturating_sub(1);
|
||||
let yp = (y + 1).min(ny - 1);
|
||||
let ym = y.saturating_sub(1);
|
||||
let dvdx = (uy[y * nx + xp] - uy[y * nx + xm]) / (xp - xm).max(1) as R;
|
||||
let dudy = (ux[yp * nx + x] - ux[ym * nx + x]) / (yp - ym).max(1) as R;
|
||||
out[y * nx + x] = dvdx - dudy;
|
||||
}
|
||||
}
|
||||
}
|
||||
FieldKind::Density => {
|
||||
for n in 0..nx * ny {
|
||||
out[n] = self.l0.macros_at(n).0;
|
||||
}
|
||||
}
|
||||
FieldKind::Gamma => out.copy_from_slice(&self.l0.gamma),
|
||||
}
|
||||
(out, &self.l0.geom.solid)
|
||||
}
|
||||
|
||||
/// Есть ли в поле NaN/inf — признак развала счёта.
|
||||
pub fn is_finite(&self) -> bool {
|
||||
self.l0.f.par_iter().all(|c| c.iter().all(|v| v.is_finite()))
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Тесты бэкенда
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bare_level(nx: usize, ny: usize) -> Level {
|
||||
Level::new(
|
||||
nx,
|
||||
ny,
|
||||
vec![0.5; nx],
|
||||
Geom {
|
||||
nx,
|
||||
ny,
|
||||
solid: vec![false; nx * ny],
|
||||
links: Vec::new(),
|
||||
body_cx: 0.0,
|
||||
body_cy: 0.0,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Голый периодический шаг: столкновение + перенос, без единого ГУ.
|
||||
/// Нужен, чтобы отделить ядро схемы от граничных условий.
|
||||
fn periodic_step(f: &mut [[R; Q]], tmp: &mut [[R; Q]], nx: usize, ny: usize, beta: R) {
|
||||
for (n, c) in f.iter().enumerate() {
|
||||
tmp[n] = *c;
|
||||
math::collide_node(&mut tmp[n], beta);
|
||||
}
|
||||
for y in 0..ny {
|
||||
for x in 0..nx {
|
||||
for i in 0..Q {
|
||||
let xs = (x as i32 - CX[i]).rem_euclid(nx as i32) as usize;
|
||||
let ys = (y as i32 - CY[i]).rem_euclid(ny as i32) as usize;
|
||||
f[y * nx + x][i] = tmp[ys * nx + xs][i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Перенос обязан сдвигать каждую популяцию ровно на её c_i, с заворотом.
|
||||
#[test]
|
||||
fn streaming_shifts_by_lattice_velocity() {
|
||||
let (nx, ny) = (7usize, 5usize);
|
||||
for i in 0..Q {
|
||||
let mut lvl = bare_level(nx, ny);
|
||||
for c in lvl.post.iter_mut() {
|
||||
*c = [0.0; Q];
|
||||
}
|
||||
lvl.post[2 * nx + 3][i] = 1.0; // дельта в (x=3, y=2)
|
||||
lvl.stream();
|
||||
let xd = (3 + CX[i]).rem_euclid(nx as i32) as usize;
|
||||
let yd = (2 + CY[i]).rem_euclid(ny as i32) as usize;
|
||||
assert!(
|
||||
(lvl.f[yd * nx + xd][i] - 1.0).abs() < 1e-15,
|
||||
"направление {i}: масса не пришла в ({xd},{yd})"
|
||||
);
|
||||
let total: R = lvl.f.iter().map(|c| c[i]).sum();
|
||||
assert!((total - 1.0).abs() < 1e-15, "направление {i}: масса не сохранилась");
|
||||
}
|
||||
}
|
||||
|
||||
/// Однородное равновесие — неподвижная точка схемы: не должно никуда уехать.
|
||||
#[test]
|
||||
fn uniform_equilibrium_is_fixed_point() {
|
||||
let (nx, ny) = (16usize, 16usize);
|
||||
let mut f = vec![math::feq(1.0, 0.03, -0.01); nx * ny];
|
||||
let mut tmp = f.clone();
|
||||
let f0 = f[0];
|
||||
for _ in 0..50 {
|
||||
periodic_step(&mut f, &mut tmp, nx, ny, 0.9);
|
||||
}
|
||||
for c in &f {
|
||||
for i in 0..Q {
|
||||
assert!((c[i] - f0[i]).abs() < 1e-12, "однородное равновесие поехало");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Затухание сдвиговой волны обязано идти с ν = c_s²(1/(2β) − ½), формула (5).
|
||||
/// Это прямая проверка того, что стабилизатор γ НЕ трогает вязкость.
|
||||
#[test]
|
||||
fn shear_wave_decays_at_prescribed_viscosity() {
|
||||
let n = 48usize;
|
||||
for &tau in &[0.6, 1.0] {
|
||||
let beta = 1.0 / (2.0 * tau);
|
||||
let nu = math::nu_of_beta(beta);
|
||||
let k = 2.0 * std::f64::consts::PI / n as R;
|
||||
let amp = 0.01;
|
||||
let mut f = vec![[0.0; Q]; n * n];
|
||||
for y in 0..n {
|
||||
for x in 0..n {
|
||||
f[y * n + x] = math::feq(1.0, amp * (k * y as R).sin(), 0.0);
|
||||
}
|
||||
}
|
||||
let mut tmp = f.clone();
|
||||
let mode = |f: &[[R; Q]]| -> R {
|
||||
let mut s = 0.0;
|
||||
for y in 0..n {
|
||||
for x in 0..n {
|
||||
s += math::macros(&f[y * n + x]).1 * (k * y as R).sin();
|
||||
}
|
||||
}
|
||||
(2.0 * s / (n * n) as R).abs()
|
||||
};
|
||||
let a0 = mode(&f);
|
||||
let steps = 1500;
|
||||
for _ in 0..steps {
|
||||
periodic_step(&mut f, &mut tmp, n, n, beta);
|
||||
}
|
||||
let a1 = mode(&f);
|
||||
let nu_measured = -(a1 / a0).ln() / (k * k * steps as R);
|
||||
let err = (nu_measured - nu).abs() / nu;
|
||||
assert!(err < 1e-2, "τ={tau}: ν измеренная {nu_measured:.6e} vs заданная {nu:.6e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// СКВОЗНАЯ СВЕРКА С ЭТАЛОНОМ. Дважды периодический сдвиговый слой — один из трёх
|
||||
/// бенчмарков 2D-статьи (разд. VIII). Постановка: N=128, Re=30000, u0=0.04, κ=80, δ=0.05,
|
||||
/// одно конвективное время t_c = N/u0 шагов.
|
||||
///
|
||||
/// Эталон 0.6035 — отношение энстрофии к начальной на fp64-версии python-решателя
|
||||
/// (docs/theory/solver_2x_sdf) после исправления порога вырожденности γ. Тот же прогон на
|
||||
/// испорченном (абсолютном) пороге давал 0.6599, то есть +9.3%, а чистый LBGK при этих
|
||||
/// параметрах разваливается. Тест ловит и «схема молча выродилась в LBGK», и «схема
|
||||
/// считает не то».
|
||||
#[test]
|
||||
#[ignore = "долгий (3200 шагов на 128²); запуск: cargo test --release -- --ignored"]
|
||||
fn doubly_periodic_shear_layer_matches_reference() {
|
||||
let n = 128usize;
|
||||
let (u0, kappa, delta, re) = (0.04, 80.0, 0.05, 30000.0);
|
||||
let nu = u0 * n as R / re;
|
||||
let beta = math::beta_of_nu(nu);
|
||||
let tc = (n as R / u0) as usize;
|
||||
|
||||
let mut f = vec![[0.0; Q]; n * n];
|
||||
for y in 0..n {
|
||||
let yy = y as R / n as R;
|
||||
let ux = if y as R <= n as R / 2.0 {
|
||||
u0 * (kappa * (yy - 0.25)).tanh()
|
||||
} else {
|
||||
u0 * (kappa * (0.75 - yy)).tanh()
|
||||
};
|
||||
for x in 0..n {
|
||||
let uy = delta * u0 * (2.0 * std::f64::consts::PI * (x as R / n as R + 0.25)).sin();
|
||||
f[y * n + x] = math::feq(1.0, ux, uy);
|
||||
}
|
||||
}
|
||||
let mut tmp = f.clone();
|
||||
|
||||
let enstrophy = |f: &[[R; Q]]| -> R {
|
||||
let mut ux = vec![0.0; n * n];
|
||||
let mut uy = vec![0.0; n * n];
|
||||
for k in 0..n * n {
|
||||
let (_, a, b) = math::macros(&f[k]);
|
||||
ux[k] = a;
|
||||
uy[k] = b;
|
||||
}
|
||||
let mut s = 0.0;
|
||||
for y in 0..n {
|
||||
for x in 0..n {
|
||||
let (xp, xm) = ((x + 1) % n, (x + n - 1) % n);
|
||||
let (yp, ym) = ((y + 1) % n, (y + n - 1) % n);
|
||||
let w = 0.5 * (uy[y * n + xp] - uy[y * n + xm])
|
||||
- 0.5 * (ux[yp * n + x] - ux[ym * n + x]);
|
||||
s += w * w;
|
||||
}
|
||||
}
|
||||
s / (n * n) as R
|
||||
};
|
||||
|
||||
let e0 = enstrophy(&f);
|
||||
for _ in 0..=tc {
|
||||
periodic_step(&mut f, &mut tmp, n, n, beta);
|
||||
}
|
||||
assert!(f.iter().all(|c| c.iter().all(|v| v.is_finite())), "счёт развалился");
|
||||
let ratio = enstrophy(&f) / e0;
|
||||
assert!(
|
||||
(ratio - 0.6035).abs() < 0.012,
|
||||
"энстрофия/начальная = {ratio:.4}, эталон fp64 python-решателя 0.6035 \
|
||||
(испорченный порог γ давал 0.6599)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
//! БЛОК СОЗДАНИЯ ГИФОК: перевод поля в индексированный кадр, палитры, служебная надпись и —
|
||||
//! главное — СИНХРОНИЗАЦИЯ АНИМАЦИИ С ФИЗИЧЕСКИМ ВРЕМЕНЕМ ПОТОКА.
|
||||
//!
|
||||
//! Требование: гифка идёт с той же скоростью, что и настоящий поток, независимо от того, с
|
||||
//! какой скоростью считает машина. Реальная производительность (шагов в секунду wall-clock)
|
||||
//! в тайминг не входит вообще — берётся только физическая длительность шага δt из `Units`:
|
||||
//!
|
||||
//! δt = u_lat·δx/u_phys с/шаг
|
||||
//! кадр каждые S шагов ⇒ между кадрами S·δt секунд физического времени
|
||||
//! задержка кадра = S·δt / playback с (playback = 1 ⇒ реальное время)
|
||||
//!
|
||||
//! Ограничение формата: задержка в GIF хранится в СОТЫХ долях секунды (u16), т.е. сетка 10 мс,
|
||||
//! и почти все декодеры считают задержку 0–1 сс как «как можно быстрее». Поэтому реальное время
|
||||
//! достижимо не при любом S: при δt = 1.7e-4 с (30 м/с, ячейка 0.1 м, u_lat = 0.05) кадр каждые
|
||||
//! 10 шагов — это 600 кадр/с, чего GIF не умеет. Отсюда два режима:
|
||||
//!
|
||||
//! * `--gif-every N` — шаг задан жёстко; если реальное время не представимо, задержка
|
||||
//! зажимается и в отчёт печатается фактический коэффициент замедления;
|
||||
//! * `--gif-every auto` — шаг подбирается так, чтобы реальное время получилось точно при
|
||||
//! целевой частоте `--gif-fps` (по умолчанию 30). Это режим по
|
||||
//! умолчанию: синхронность важнее, чем конкретное число шагов на кадр.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::BufWriter;
|
||||
|
||||
use crate::math::{Units, R};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Тайминг
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Как кадры анимации ложатся на физическое время.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct GifPlan {
|
||||
/// Кадр каждые столько шагов симуляции.
|
||||
pub stride: u64,
|
||||
/// ТОЧНАЯ задержка кадра в сотых долях секунды — вообще говоря дробная.
|
||||
pub exact_delay_cs: R,
|
||||
/// Физическое время между соседними кадрами, с.
|
||||
pub frame_dt: R,
|
||||
/// Запрошенная скорость воспроизведения (1 = реальное время, 0.1 = 10× замедление).
|
||||
pub requested_playback: R,
|
||||
/// Фактическая, в среднем по анимации.
|
||||
pub actual_playback: R,
|
||||
/// Средняя частота кадров получившейся гифки.
|
||||
pub fps: R,
|
||||
/// Шаг подбирался автоматически.
|
||||
pub auto: bool,
|
||||
/// Точная задержка нецелая ⇒ задержки кадров чередуются (см. `DelayDither`).
|
||||
pub dithered: bool,
|
||||
/// Точная задержка меньше одной сотой ⇒ формат физически не тянет, время сжато.
|
||||
pub clamped: bool,
|
||||
}
|
||||
|
||||
impl GifPlan {
|
||||
/// `stride`: Some(N) — жёстко N шагов на кадр; None — подобрать под `target_fps`.
|
||||
pub fn new(units: &Units, stride: Option<u64>, target_fps: R, playback: R) -> GifPlan {
|
||||
let dt = units.dt;
|
||||
let playback = if playback > 0.0 { playback } else { 1.0 };
|
||||
let auto = stride.is_none();
|
||||
|
||||
let stride = match stride {
|
||||
Some(s) => s.max(1),
|
||||
None => {
|
||||
// хотим кадр раз в 1/target_fps секунды экранного времени, что при скорости
|
||||
// playback отвечает playback/target_fps секундам физического времени
|
||||
((playback / target_fps / dt).round() as i64).max(1) as u64
|
||||
}
|
||||
};
|
||||
|
||||
let frame_dt = stride as R * dt;
|
||||
let exact_delay_cs = 100.0 * frame_dt / playback;
|
||||
// 0 сотых большинство декодеров трактует как «как можно быстрее», поэтому пол — единица
|
||||
let clamped = exact_delay_cs < 1.0;
|
||||
let mean_delay_cs = if clamped { 1.0 } else { exact_delay_cs };
|
||||
|
||||
GifPlan {
|
||||
stride,
|
||||
exact_delay_cs,
|
||||
frame_dt,
|
||||
requested_playback: playback,
|
||||
actual_playback: frame_dt / (mean_delay_cs / 100.0),
|
||||
fps: 100.0 / mean_delay_cs,
|
||||
auto,
|
||||
dithered: !clamped && (exact_delay_cs - exact_delay_cs.round()).abs() > 1e-9,
|
||||
clamped,
|
||||
}
|
||||
}
|
||||
|
||||
/// Отношение фактической скорости воспроизведения к запрошенной (1.0 = точно).
|
||||
pub fn sync_error(&self) -> R {
|
||||
self.actual_playback / self.requested_playback
|
||||
}
|
||||
|
||||
/// Синхронность выдержана: средняя задержка совпадает с точной.
|
||||
pub fn is_synced(&self) -> bool {
|
||||
(self.sync_error() - 1.0).abs() < 0.02
|
||||
}
|
||||
|
||||
/// Совет, как починить рассинхрон, если формат его не тянет.
|
||||
pub fn advice(&self, units: &Units) -> Option<String> {
|
||||
// разойтись с запрошенной скоростью можно только одним способом: точная задержка
|
||||
// не влезла в минимальную единицу формата и была зажата
|
||||
if !self.clamped {
|
||||
return None;
|
||||
}
|
||||
let good = ((0.01 * self.requested_playback / units.dt).ceil() as i64).max(1);
|
||||
Some(format!(
|
||||
"кадр требует задержки {:.4} сотых — меньше минимальной единицы формата, поэтому \
|
||||
анимация идёт в {:.2}× быстрее запрошенного. Починить: --gif-every auto (подберёт \
|
||||
шаг сам), либо --gif-every {} (минимальный шаг с представимой задержкой), либо \
|
||||
--gif-speed {:.4} (замедление вместо реального времени)",
|
||||
self.exact_delay_cs,
|
||||
self.sync_error(),
|
||||
good,
|
||||
self.requested_playback / self.sync_error()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Раскладка дробной задержки по целым сотым без накопления ошибки.
|
||||
///
|
||||
/// Формат GIF хранит задержку кадра целым числом сотых долей секунды. Точная физическая
|
||||
/// задержка почти никогда не целая: 30 кадр/с — это 3⅓ сотых. Округление каждого кадра
|
||||
/// по отдельности даёт систематический уход (3 вместо 3.333 — анимация на 11% быстрее
|
||||
/// реальности, и расхождение копится линейно). Поэтому задержки выдаются так, чтобы
|
||||
/// НАКОПЛЕННОЕ время кадров всегда совпадало с накопленным физическим с точностью до одной
|
||||
/// сотой: для 3.333 получается 3, 3, 4, 3, 3, 4, … Ошибка ограничена ±5 мс и не растёт.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct DelayDither {
|
||||
exact_cum: R,
|
||||
emitted_cum: i64,
|
||||
}
|
||||
|
||||
impl DelayDither {
|
||||
/// Задержка очередного кадра в сотых долях секунды.
|
||||
pub fn next(&mut self, exact_delay_cs: R) -> u16 {
|
||||
self.exact_cum += exact_delay_cs;
|
||||
let want = self.exact_cum.round() as i64;
|
||||
let d = (want - self.emitted_cum).max(1);
|
||||
self.emitted_cum += d;
|
||||
d.clamp(1, u16::MAX as i64) as u16
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Палитры
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Индексы служебных цветов в палитре (после градиента).
|
||||
const NCOLORS: usize = 250;
|
||||
const IDX_BODY: u8 = 250;
|
||||
const IDX_PATCH: u8 = 251;
|
||||
const IDX_TEXT: u8 = 252;
|
||||
const IDX_TEXT_BG: u8 = 253;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ColorMap {
|
||||
/// Последовательная, воспринимаемо-равномерная — для |u| и ρ.
|
||||
Viridis,
|
||||
/// Яркая последовательная с широким динамическим диапазоном.
|
||||
Turbo,
|
||||
/// Расходящаяся сине-бело-красная — для знакопеременных полей (завихренность, γ−2).
|
||||
CoolWarm,
|
||||
}
|
||||
|
||||
impl ColorMap {
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
Some(match s.to_ascii_lowercase().as_str() {
|
||||
"viridis" => ColorMap::Viridis,
|
||||
"turbo" => ColorMap::Turbo,
|
||||
"coolwarm" | "rdbu" => ColorMap::CoolWarm,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
pub const ALL: [&'static str; 3] = ["viridis", "turbo", "coolwarm"];
|
||||
|
||||
/// Опорные точки градиента; между ними линейная интерполяция в sRGB.
|
||||
fn anchors(&self) -> &'static [[u8; 3]] {
|
||||
match self {
|
||||
ColorMap::Viridis => &[
|
||||
[68, 1, 84],
|
||||
[72, 40, 120],
|
||||
[62, 74, 137],
|
||||
[49, 104, 142],
|
||||
[38, 130, 142],
|
||||
[31, 158, 137],
|
||||
[53, 183, 121],
|
||||
[109, 205, 89],
|
||||
[180, 222, 44],
|
||||
[253, 231, 37],
|
||||
],
|
||||
ColorMap::Turbo => &[
|
||||
[48, 18, 59],
|
||||
[70, 107, 227],
|
||||
[40, 187, 236],
|
||||
[49, 242, 153],
|
||||
[138, 252, 61],
|
||||
[210, 226, 27],
|
||||
[254, 165, 45],
|
||||
[239, 89, 17],
|
||||
[180, 27, 1],
|
||||
[122, 4, 3],
|
||||
],
|
||||
// Расходящаяся палитра Морланда. Опорных точек НЕЧЁТНОЕ число: только тогда
|
||||
// середина диапазона (для знакопеременного поля — ноль) попадает ровно на
|
||||
// нейтральный серый, а не между двумя соседними точками.
|
||||
ColorMap::CoolWarm => &[
|
||||
[59, 76, 192],
|
||||
[98, 130, 234],
|
||||
[141, 176, 254],
|
||||
[184, 208, 249],
|
||||
[221, 221, 221],
|
||||
[245, 196, 173],
|
||||
[244, 154, 123],
|
||||
[222, 96, 77],
|
||||
[180, 4, 38],
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// Палитра GIF: NCOLORS ступеней градиента плюс служебные цвета.
|
||||
fn palette(&self) -> Vec<u8> {
|
||||
let a = self.anchors();
|
||||
let mut p = vec![0u8; 256 * 3];
|
||||
for k in 0..NCOLORS {
|
||||
let t = k as R / (NCOLORS - 1) as R * (a.len() - 1) as R;
|
||||
let i = (t.floor() as usize).min(a.len() - 2);
|
||||
let fr = t - i as R;
|
||||
for c in 0..3 {
|
||||
p[k * 3 + c] = (a[i][c] as R * (1.0 - fr) + a[i + 1][c] as R * fr).round() as u8;
|
||||
}
|
||||
}
|
||||
let set = |p: &mut Vec<u8>, idx: u8, rgb: [u8; 3]| {
|
||||
let o = idx as usize * 3;
|
||||
p[o] = rgb[0];
|
||||
p[o + 1] = rgb[1];
|
||||
p[o + 2] = rgb[2];
|
||||
};
|
||||
set(&mut p, IDX_BODY, [25, 25, 28]);
|
||||
set(&mut p, IDX_PATCH, [255, 255, 255]);
|
||||
set(&mut p, IDX_TEXT, [255, 255, 255]);
|
||||
set(&mut p, IDX_TEXT_BG, [0, 0, 0]);
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Микрошрифт 5×7 для служебной надписи
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Битовая маска глифа: 7 строк по 5 бит (старший бит — левый пиксель).
|
||||
fn glyph(c: char) -> [u8; 7] {
|
||||
match c.to_ascii_uppercase() {
|
||||
'0' => [0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E],
|
||||
'1' => [0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E],
|
||||
'2' => [0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F],
|
||||
'3' => [0x1F, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0E],
|
||||
'4' => [0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02],
|
||||
'5' => [0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E],
|
||||
'6' => [0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E],
|
||||
'7' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08],
|
||||
'8' => [0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E],
|
||||
'9' => [0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C],
|
||||
'A' => [0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
|
||||
'B' => [0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E],
|
||||
'C' => [0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E],
|
||||
'D' => [0x1C, 0x12, 0x11, 0x11, 0x11, 0x12, 0x1C],
|
||||
'E' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F],
|
||||
'F' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10],
|
||||
'G' => [0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0F],
|
||||
'H' => [0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
|
||||
'I' => [0x0E, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E],
|
||||
'K' => [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11],
|
||||
'L' => [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F],
|
||||
'M' => [0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11],
|
||||
'N' => [0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11],
|
||||
'O' => [0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
|
||||
'P' => [0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10],
|
||||
'R' => [0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11],
|
||||
'S' => [0x0F, 0x10, 0x10, 0x0E, 0x01, 0x01, 0x1E],
|
||||
'T' => [0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04],
|
||||
'U' => [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
|
||||
'V' => [0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04],
|
||||
'W' => [0x11, 0x11, 0x11, 0x15, 0x15, 0x1B, 0x11],
|
||||
'X' => [0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11],
|
||||
'Y' => [0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04],
|
||||
'Z' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F],
|
||||
'.' => [0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C],
|
||||
',' => [0x00, 0x00, 0x00, 0x00, 0x0C, 0x04, 0x08],
|
||||
':' => [0x00, 0x0C, 0x0C, 0x00, 0x0C, 0x0C, 0x00],
|
||||
'-' => [0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00],
|
||||
'+' => [0x00, 0x04, 0x04, 0x1F, 0x04, 0x04, 0x00],
|
||||
'=' => [0x00, 0x00, 0x1F, 0x00, 0x1F, 0x00, 0x00],
|
||||
'/' => [0x01, 0x02, 0x02, 0x04, 0x08, 0x08, 0x10],
|
||||
'%' => [0x19, 0x1A, 0x02, 0x04, 0x08, 0x0B, 0x13],
|
||||
'(' => [0x02, 0x04, 0x08, 0x08, 0x08, 0x04, 0x02],
|
||||
')' => [0x08, 0x04, 0x02, 0x02, 0x02, 0x04, 0x08],
|
||||
'Ч' => [0x11, 0x11, 0x11, 0x0F, 0x01, 0x01, 0x01],
|
||||
_ => [0; 7], // пробел и всё незнакомое
|
||||
}
|
||||
}
|
||||
|
||||
/// Нарисовать строку в индексированный буфер (координаты — левый верхний угол).
|
||||
fn draw_text(buf: &mut [u8], w: usize, h: usize, x0: usize, y0: usize, s: &str, scale: usize) {
|
||||
let gw = 6 * scale; // 5 пикселей глифа + 1 пробел
|
||||
// подложка, чтобы текст читался на любом фоне
|
||||
let tw = s.chars().count() * gw;
|
||||
for yy in y0.saturating_sub(scale)..(y0 + 7 * scale + scale).min(h) {
|
||||
for xx in x0.saturating_sub(scale)..(x0 + tw + scale).min(w) {
|
||||
buf[yy * w + xx] = IDX_TEXT_BG;
|
||||
}
|
||||
}
|
||||
for (k, ch) in s.chars().enumerate() {
|
||||
let g = glyph(ch);
|
||||
for (row, bits) in g.iter().enumerate() {
|
||||
for col in 0..5 {
|
||||
if bits & (1 << (4 - col)) == 0 {
|
||||
continue;
|
||||
}
|
||||
for sy in 0..scale {
|
||||
for sx in 0..scale {
|
||||
let px = x0 + k * gw + col * scale + sx;
|
||||
let py = y0 + row * scale + sy;
|
||||
if px < w && py < h {
|
||||
buf[py * w + px] = IDX_TEXT;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Диапазон нормировки
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Диапазон значений, отображаемый на палитру. Фиксируется на весь прогон: плавающая
|
||||
/// автонормировка делает анимацию нечитаемой — цвет перестаёт что-либо значить.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Range {
|
||||
pub lo: R,
|
||||
pub hi: R,
|
||||
}
|
||||
|
||||
impl Range {
|
||||
#[inline]
|
||||
fn index(&self, v: R) -> u8 {
|
||||
if !v.is_finite() {
|
||||
return 0;
|
||||
}
|
||||
let t = ((v - self.lo) / (self.hi - self.lo)).clamp(0.0, 1.0);
|
||||
(t * (NCOLORS - 1) as R).round() as u8
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Запись гифки
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Что подписывать на кадре.
|
||||
pub struct Hud {
|
||||
pub u_phys: R,
|
||||
pub field: &'static str,
|
||||
pub show: bool,
|
||||
}
|
||||
|
||||
pub struct GifWriter {
|
||||
enc: gif::Encoder<BufWriter<File>>,
|
||||
pub plan: GifPlan,
|
||||
nx: usize,
|
||||
ny: usize,
|
||||
scale: usize,
|
||||
w: usize,
|
||||
h: usize,
|
||||
range: Range,
|
||||
hud: Hud,
|
||||
patch: Option<(usize, usize, usize, usize)>,
|
||||
dither: DelayDither,
|
||||
pub frames: u32,
|
||||
}
|
||||
|
||||
impl GifWriter {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn create(
|
||||
path: &str,
|
||||
nx: usize,
|
||||
ny: usize,
|
||||
scale: usize,
|
||||
cmap: ColorMap,
|
||||
range: Range,
|
||||
plan: GifPlan,
|
||||
hud: Hud,
|
||||
patch: Option<(usize, usize, usize, usize)>,
|
||||
) -> std::io::Result<GifWriter> {
|
||||
let scale = scale.max(1);
|
||||
let w = nx * scale;
|
||||
let h = ny * scale;
|
||||
let file = BufWriter::new(File::create(path)?);
|
||||
let mut enc = gif::Encoder::new(file, w as u16, h as u16, &cmap.palette())
|
||||
.map_err(std::io::Error::other)?;
|
||||
enc.set_repeat(gif::Repeat::Infinite).map_err(std::io::Error::other)?;
|
||||
Ok(GifWriter {
|
||||
enc,
|
||||
plan,
|
||||
nx,
|
||||
ny,
|
||||
scale,
|
||||
w,
|
||||
h,
|
||||
range,
|
||||
hud,
|
||||
patch,
|
||||
dither: DelayDither::default(),
|
||||
frames: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Добавить кадр. `field` — значения по узлам L0, `solid` — маска тела,
|
||||
/// `t_phys` — физическое время этого кадра (с), `cd` — текущий коэффициент сопротивления.
|
||||
pub fn push(
|
||||
&mut self,
|
||||
field: &[R],
|
||||
solid: &[bool],
|
||||
t_phys: R,
|
||||
cd: Option<R>,
|
||||
) -> std::io::Result<()> {
|
||||
let mut buf = vec![0u8; self.w * self.h];
|
||||
// строка 0 изображения — это ВЕРХ, а y = 0 решётки — низ канала: переворачиваем
|
||||
for y in 0..self.ny {
|
||||
let src = (self.ny - 1 - y) * self.nx;
|
||||
for x in 0..self.nx {
|
||||
let idx = if solid[src + x] { IDX_BODY } else { self.range.index(field[src + x]) };
|
||||
for sy in 0..self.scale {
|
||||
let row = (y * self.scale + sy) * self.w + x * self.scale;
|
||||
for sx in 0..self.scale {
|
||||
buf[row + sx] = idx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((ax, bx, ay, by)) = self.patch {
|
||||
self.draw_patch_outline(&mut buf, ax, bx, ay, by);
|
||||
}
|
||||
if self.hud.show {
|
||||
let line = match cd {
|
||||
Some(c) => format!(
|
||||
"T={:.4}S U={:.1}M/S CD={:.2}",
|
||||
t_phys, self.hud.u_phys, c
|
||||
),
|
||||
None => format!("T={:.4}S U={:.1}M/S", t_phys, self.hud.u_phys),
|
||||
};
|
||||
let sc = (self.scale.min(3)).max(1);
|
||||
draw_text(&mut buf, self.w, self.h, 2 * sc, 2 * sc, &line, sc);
|
||||
draw_text(&mut buf, self.w, self.h, 2 * sc, self.h - 9 * sc, self.hud.field, sc);
|
||||
}
|
||||
|
||||
let mut frame = gif::Frame::from_indexed_pixels(self.w as u16, self.h as u16, buf, None);
|
||||
// задержка берётся из накопителя: сумма задержек кадров отслеживает точное
|
||||
// физическое время, а не округляется покадрово (см. DelayDither)
|
||||
frame.delay = self.dither.next(self.plan.exact_delay_cs.max(1.0));
|
||||
self.enc.write_frame(&frame).map_err(std::io::Error::other)?;
|
||||
self.frames += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Контур области измельчения — видно, где считает тонкая сетка.
|
||||
fn draw_patch_outline(&self, buf: &mut [u8], ax: usize, bx: usize, ay: usize, by: usize) {
|
||||
let flip = |y: usize| self.ny - 1 - y.min(self.ny - 1);
|
||||
let mut put = |x: usize, y: usize| {
|
||||
if x < self.nx && y < self.ny {
|
||||
let py = flip(y) * self.scale;
|
||||
let px = x * self.scale;
|
||||
for sy in 0..self.scale {
|
||||
for sx in 0..self.scale {
|
||||
let o = (py + sy) * self.w + px + sx;
|
||||
if o < buf.len() {
|
||||
buf[o] = IDX_PATCH;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
for x in ax..=bx.min(self.nx - 1) {
|
||||
put(x, ay);
|
||||
put(x, by);
|
||||
}
|
||||
for y in ay..=by.min(self.ny - 1) {
|
||||
put(ax, y);
|
||||
put(bx, y);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish(self) -> std::io::Result<u32> {
|
||||
Ok(self.frames)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Пример из постановки: 30 м/с, ячейка 0.1 м, u_lat = 1 ⇒ 300 шагов/с; кадр каждые
|
||||
/// 10 шагов ⇒ 30 кадр/с. Точная задержка 3⅓ сотых — нецелая, поэтому включается дизеринг,
|
||||
/// и в среднем гифка идёт ровно в реальном времени.
|
||||
#[test]
|
||||
fn spec_example_is_realtime() {
|
||||
let u = Units::new(0.1, 30.0, 1.0, 150.0, 16.0);
|
||||
assert!((u.steps_per_second() - 300.0).abs() < 1e-9);
|
||||
let p = GifPlan::new(&u, Some(10), 30.0, 1.0);
|
||||
assert!((p.exact_delay_cs - 10.0 / 3.0).abs() < 1e-9, "{}", p.exact_delay_cs);
|
||||
assert!(p.dithered && !p.clamped);
|
||||
assert!((p.fps - 30.0).abs() < 1e-9);
|
||||
assert!(p.is_synced(), "коэффициент {}", p.sync_error());
|
||||
}
|
||||
|
||||
/// Дизеринг обязан вести НАКОПЛЕННОЕ время кадров вплотную к физическому, без ухода.
|
||||
/// Покадровое округление 3.333 → 3 дало бы к тысячному кадру уход в 3.3 секунды.
|
||||
#[test]
|
||||
fn dither_tracks_exact_time_without_drift() {
|
||||
let exact = 10.0 / 3.0;
|
||||
let mut d = DelayDither::default();
|
||||
let mut sum = 0i64;
|
||||
for k in 1..=1000 {
|
||||
sum += d.next(exact) as i64;
|
||||
let want = exact * k as R;
|
||||
assert!(
|
||||
(sum as R - want).abs() <= 0.5 + 1e-9,
|
||||
"кадр {k}: накоплено {sum} сотых против точных {want:.3}"
|
||||
);
|
||||
}
|
||||
// а наивное округление к этому моменту отстало бы на треть сотой на кадр
|
||||
assert!((sum as R - exact * 1000.0).abs() < 1.0);
|
||||
assert!((3000 - sum).abs() > 300, "дизеринг обязан отличаться от покадрового округления");
|
||||
}
|
||||
|
||||
/// Тайминг обязан зависеть ТОЛЬКО от физики, не от того, как быстро считает машина:
|
||||
/// один и тот же δt даёт одну и ту же задержку, вдвое больший шаг — вдвое большую.
|
||||
#[test]
|
||||
fn timing_ignores_wall_clock() {
|
||||
let u = Units::new(0.1, 30.0, 1.0, 150.0, 16.0);
|
||||
let a = GifPlan::new(&u, Some(10), 30.0, 1.0);
|
||||
let b = GifPlan::new(&u, Some(10), 30.0, 1.0);
|
||||
assert!((a.exact_delay_cs - b.exact_delay_cs).abs() < 1e-12);
|
||||
let c = GifPlan::new(&u, Some(20), 30.0, 1.0);
|
||||
assert!((c.exact_delay_cs - 2.0 * a.exact_delay_cs).abs() < 1e-12);
|
||||
assert!(c.is_synced() && a.is_synced());
|
||||
}
|
||||
|
||||
/// Авто-режим обязан вытянуть реальное время при физически корректном u_lat, где жёсткий
|
||||
/// шаг «каждые 10» потребовал бы 600 кадр/с и формат бы этого не вытянул.
|
||||
#[test]
|
||||
fn auto_stride_restores_realtime() {
|
||||
let u = Units::new(0.1, 30.0, 0.05, 150.0, 16.0);
|
||||
assert!((u.steps_per_second() - 6000.0).abs() < 1e-6);
|
||||
let fixed = GifPlan::new(&u, Some(10), 30.0, 1.0);
|
||||
assert!(fixed.clamped, "0.167 сотых обязаны упереться в пол формата");
|
||||
assert!(!fixed.is_synced(), "жёсткий шаг не должен был попасть в реальное время");
|
||||
assert!(fixed.advice(&u).is_some(), "в таком режиме обязан быть совет");
|
||||
let auto = GifPlan::new(&u, None, 30.0, 1.0);
|
||||
assert_eq!(auto.stride, 200); // 1/30 с при 6000 шаг/с
|
||||
assert!(auto.is_synced(), "коэффициент {}", auto.sync_error());
|
||||
assert!((auto.fps - 30.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// Замедление: playback = 0.1 растягивает то же физическое время ровно в 10 раз.
|
||||
#[test]
|
||||
fn slow_motion_scales_delay() {
|
||||
let u = Units::new(0.1, 30.0, 1.0, 150.0, 16.0);
|
||||
let fast = GifPlan::new(&u, Some(10), 30.0, 1.0);
|
||||
let slow = GifPlan::new(&u, Some(10), 30.0, 0.1);
|
||||
assert!((slow.exact_delay_cs - 10.0 * fast.exact_delay_cs).abs() < 1e-9);
|
||||
assert!((slow.actual_playback - 0.1).abs() < 1e-9);
|
||||
assert!(slow.is_synced());
|
||||
}
|
||||
|
||||
/// Палитра обязана быть ровно 256 цветов по 3 байта и не затирать служебные индексы.
|
||||
#[test]
|
||||
fn palette_layout() {
|
||||
for cm in [ColorMap::Viridis, ColorMap::Turbo, ColorMap::CoolWarm] {
|
||||
let p = cm.palette();
|
||||
assert_eq!(p.len(), 768);
|
||||
let o = IDX_BODY as usize * 3;
|
||||
assert_eq!(&p[o..o + 3], &[25, 25, 28]);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user