Пристеночная функция Spalding, многоуровневый домен, группы J и K

Решатель:
- --wall-function spalding поверх bouzidi/grad/hrr: моментные схемы берут
  модельную скорость узла и u_τ²/ν в тензор давлений, Bouzidi — скольжение
  стенки, согласованное по напряжению (в вязком подслое тождественно ноль);
- --levels: цепочка вложенных уровней с рекурсивным шагом на CPU и GPU,
  одиночный патч — её частный случай, прежние постановки побитово те же;
- --sponge-side, β по узлам; в сводке y⁺, cl_mean, rho_probe_rms.
- Исправлено: на GPU при --refine >= 3 сила лишних подшагов терялась за
  краем буфера, Cd занижался в r/2 раза.

Кампания: группы J (65, схемы стенки с функцией) и K (25, внешний домен),
205 прогонов на 5 долей по ≈27 ч; bench/compare.py сводит J и K против
эталонов; parity.py сверяет Spalding и три уровня. Образ 1.4.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 12:25:43 +03:00
co-authored by Claude Opus 5.5
parent 1d495e3e89
commit 86e9c7139d
14 changed files with 5574 additions and 596 deletions
+124 -11
View File
@@ -311,8 +311,11 @@ c_s²(1/(γβ) − ½) и, поскольку измеренная ⟨γ⟩ ≈
низкими числами Рейнольдса, поскольку на границе возникают паразитные скачки») и естественная низкими числами Рейнольдса, поскольку на границе возникают паразитные скачки») и естественная
форма для подвижных стенок: скорость стенки входит в целевые значения, а не отдельной поправкой. форма для подвижных стенок: скорость стенки входит в целевые значения, а не отдельной поправкой.
В здешней канальной постановке преимущества по устойчивости воспроизвести не удалось: при росте В здешней канальной постановке преимущества по устойчивости воспроизвести не удалось: при росте
Re обе модели теряют счёт на одном и том же значении (Re ≈ 5·10⁴ при теле в 16 клеток), то есть Re обе модели теряют счёт на одном и том же значении, то есть ограничивает не стенка, а
ограничивает не стенка, а что-то другое — вероятнее всего Zou–He при τ → ½. что-то другое — вероятнее всего Zou–He при τ → ½. (Сама оценка «Re ≈ 5·10⁴ при теле в 16
клеток» была сделана по коротким прогонам; длинные дают предел τ − ½ ≈ 6·10⁻⁴, то есть при
D = 16 уже Re ≈ 4000, — см. раздел «Дальше». Вывод «стенка не ограничивает» он не меняет:
Bouzidi и HRR со Spalding разваливаются при одних и тех же Re.)
**Умолчание — `hrr`, и это решение временное.** Оно принято по устройству схемы (третий порядок **Умолчание — `hrr`, и это решение временное.** Оно принято по устройству схемы (третий порядок
Эрмита фильтрует высокочастотный мусор у стенки, чего обрыв на Π не делает), а не по здешним Эрмита фильтрует высокочастотный мусор у стенки, чего обрыв на Π не делает), а не по здешним
@@ -326,6 +329,59 @@ Re обе модели теряют счёт на одном и том же зн
это только потому, что две модели дали побитово одинаковый результат там, где обязаны были это только потому, что две модели дали побитово одинаковый результат там, где обязаны были
разойтись. разойтись.
### Пристеночная функция Spalding
Ключ `--wall-function none|spalding` (умолчание `none` — всё прежнее не меняется побитово)
ставит поверх `bouzidi`, `grad` или `hrr` закон стенки Spalding (1961): одна формула на весь
пристеночный слой, от вязкого подслоя (u⁺ = y⁺) до логарифмической зоны, κ = 0.41, B = 5.2.
С `staircase` не сочетается: ступенька не знает, где стенка.
Как устроено:
- у каждого граничного узла есть нормаль (градиент SDF), расстояние до стенки y_w и точка
отбора скорости на одну клетку дальше по нормали (Malaspinas, Sagaut 2014); скорость там —
билинейно по полю на момент t. Геометрия строится один раз в `cpu::Geom` и загружается на
GPU как есть;
- по касательной скорости в точке отбора Ньютоном находится u_τ, по нему — модельная скорость
узла u_node = u_τ·u⁺(y⁺_w) и напряжение τ_w = ρu_τ²;
- **`grad`/`hrr`**: касательная составляющая целевой скорости (B 1) заменяется на u_node, а в
тензоре давлений нормальная производная касательной скорости — на u_τ²/ν. Плотность (B 3) и
сборка популяций не меняются;
- **`bouzidi`**: стенка получает скорость скольжения u_s = u_node − u_τ²·y_w/ν и входит в
отскок поправкой подвижной стенки. Скольжение согласовано по **напряжению**: отскок передаёт
стенке ρν(u_node − u_s)/y_w = τ_w. В вязком подслое u⁺ = y⁺, и u_s тождественно ноль; в
буферной и логарифмической зонах u_s < 0 — стенка «отступает», добирая трение до τ_w.
Модуль скольжения зажат скоростью в точке отбора.
Первая версия согласовывала скольжение по **скорости** (прямая через стенку, узел и точку
отбора). В решателе без турбулентной вязкости это даёт большое положительное скольжение —
стенка становится почти свободной: цилиндр Re = 10⁴, D = 16 выдал Cd = 0.016. Вторая брала
разрешённую скорость узла вместо модельной и при Re = 20 расходилась с прилипанием на 2 %:
у искривлённой стенки узел не лежит на прямой от стенки к точке отбора. Нынешняя формула
свободна от обоих изъянов.
**Сведение к прилипанию при малом y⁺** (цилиндр Re = 20, D = 16, 320×192, y⁺ ≈ 0.25, GPU):
| модель | прилипание | Spalding | разница |
|---|---|---|---|
| `bouzidi` | 2.38252 | 2.38252 | 0 (до 7-го знака) |
| `grad` | 2.38774 | 2.38989 | +0.09 % |
| `hrr` | 2.38835 | 2.39053 | +0.09 % |
У моментных схем остаточные 0.09 % — от того, что целевая скорость строится теперь по нормали
к стенке, а не усреднением по линкам (B 1).
**Закон стенки описывает турбулентный слой, а при умеренных Re он ламинарный.** На цилиндре при
Re = 2000 (D = 16, y⁺ ≈ 5, местами до 19, 16 тыс. шагов) функция заметно двигает только Bouzidi:
Cd 2.10 против 1.56 без неё; у `grad` 1.59 против 1.65, у `hrr` 1.70 против 1.69. Bouzidi
навязывает модельное трение через обмен импульсом жёстко, моментные схемы — через тензор
давлений одного слоя узлов, мягче. Какая схема ближе к эталону — меряет группа J кампании.
Каждый прогон канала пишет в сводку y⁺ первого узла на самом тонком уровне (`yplus_mean`,
`yplus_max`, долю узлов с y⁺ > 30 `yplus_frac_log`, `u_tau_mean`) — два десятка снимков во
второй половине прогона, с функцией и без неё. Без y⁺ сравнение схем стенки не
интерпретировать.
### Паритет бэкендов и согласованность уровней ### Паритет бэкендов и согласованность уровней
CPU (f64) и GPU (f32) на одной постановке совпадают до 4–5 значащих цифр шаг в шаг: ⟨ρ⟩ CPU (f64) и GPU (f32) на одной постановке совпадают до 4–5 значащих цифр шаг в шаг: ⟨ρ⟩
@@ -335,6 +391,18 @@ CPU (f64) и GPU (f32) на одной постановке совпадают
Один и тот же случай с патчем ×2 и вовсе без измельчения (`--refine 1`) даёт St 0.1951 против Один и тот же случай с патчем ×2 и вовсе без измельчения (`--refine 1`) даёт St 0.1951 против
0.1970 и ⟨Cd⟩ 1.956 против 1.943 — связка уровней систематики не вносит. 0.1970 и ⟨Cd⟩ 1.956 против 1.943 — связка уровней систематики не вносит.
**Исправлено: сила на GPU при `--refine 3` и выше.** Рабочие слоты силы в буфере итогов были
рассчитаны ровно на два подшага тонкого уровня; сила третьего и следующих писалась за край
буфера и терялась, а делилась сумма всё равно на r. Cd на GPU выходил занижен в r/2 раза:
два тела при `--refine 3` — 1.316 против 1.972 на CPU. Теперь слотов столько, сколько подшагов
самого тонкого уровня на шаг L0: 1.9733 против 1.9716. Это затрагивало прогон `F09_refine3`
кампании и любой ручной запуск с `--refine ≥ 3` на GPU; при `--refine 2` и без патча
результат прежний побитово.
`bench/parity.py` сверяет CPU и GPU на четырёх случаях: Тейлор–Грин, стационарный цилиндр,
цилиндр с `grad` + Spalding и три уровня ×2×2. Замерено на Iris Xe (Vulkan): Cd совпадает до
1.7·10⁻⁵ (Spalding) и 7.3·10⁻⁵ (три уровня), y⁺ — до 6.4·10⁻⁵.
**Предел на размер сетки снят.** У GPU есть жёсткий предел `maxComputeWorkgroupsPerDimension` **Предел на размер сетки снят.** У GPU есть жёсткий предел `maxComputeWorkgroupsPerDimension`
= 65535, а диспетчеризация была одномерной: при 64 узлах на рабочую группу это упирало сетку в = 65535, а диспетчеризация была одномерной: при 64 узлах на рабочую группу это упирало сетку в
4.2 миллиона узлов, то есть примерно 2048×2048. Всё, что крупнее, падало ошибкой валидации — 4.2 миллиона узлов, то есть примерно 2048×2048. Всё, что крупнее, падало ошибкой валидации —
@@ -470,15 +538,56 @@ rms Cl 0.81 вместо 0.40 — то есть 75-процентная прод
просто переносится во времени: поток всё равно обязан разогнаться вокруг тела, и энергия этого просто переносится во времени: поток всё равно обязан разогнаться вокруг тела, и энергия этого
переходного процесса задана физикой, а не гладкостью начального поля. Умолчание — 0. переходного процесса задана физикой, а не гладкостью начального поля. Умолчание — 0.
## Многоуровневый домен
`--levels "r:up,down,side; r:up,down,side; …"` строит цепочку вложенных уровней вместо одного
патча: L0 — самый грубый, на весь домен, каждый следующий в r раз тоньше родителя и лежит
внутри него. `up`, `down`, `side` — отступы границ уровня от центра тела в его диаметрах
(вверх по потоку, вниз, в стороны). Так строится внешний домен с грубыми буферами: большой и
дешёвый L0, к телу сетка мельчает ступенями. `--levels` перекрывает `--refine`/`--patch`;
одиночный патч — частный случай цепочки из одного звена, и прежние постановки дают **побитово
тот же** результат на обоих бэкендах (сверено на пяти постановках: без патча, ×2, ×3, два тела,
периодический случай; единственное исключение — исправленная сила при `--refine 3` на GPU).
```sh
# L0 в 4 раза грубее тела; ступени ×2: 4D вверх, 15D вниз, ±4D, затем 1.5D, 10D следа, ±2D
./target/release/kbc2d --shape cylinder --size 8 --nx 480 --ny 320 --body-x 160 --re 150 \
--levels "2:4,15,4; 2:1.5,10,2" --wall bouzidi --steps 96000 --backend gpu
```
Важно: `--size`, `--nx`, `--ny`, `--steps` и Re задаются **в клетках и шагах L0**. Тело в 8
клеток L0 при цепочке ×2×2 — это 32 клетки на самом тонком уровне; отчёт печатает D и τ
каждого уровня. τ растёт к тонким уровням (τ_k = r_k(τ_{k−1} − ½) + ½, ν одна на всех), так
что ближе всего к пределу устойчивости τ → ½ именно L0.
Шаг рекурсивный: уровень делает шаг, затем его потомок — r подшагов с временной интерполяцией
рамки, затем рестрикция обратно; сила снимается на самом тонком уровне на каждом его подшаге.
Стенки канала, вход, выход и губки есть только у L0. На GPU это та же рекурсия в одном
командном буфере; ядра связки не менялись.
Проверено: однородный поток без тела — неподвижная точка через три уровня с точностью 10⁻¹²;
стационарный цилиндр Re = 20 при одном скачке ×4 и при цепочке ×2×2 с тем же разрешением тела
даёт Cd 2.2104 и 2.2156 (0.24 %); CPU и GPU на трёх уровнях совпадают до 7.3·10⁻⁵ по Cd.
**Боковые губки** `--sponge-side N` — N рядов L0 у верхней и нижней стенок с той же плавной
надбавкой вязкости, что у входа и выхода; β теперь поле по узлам, а не по столбцам. Нужны,
чтобы отличать отражения от боковых стенок от отражений на стыках уровней.
В сводку добавлены `levels` (цепочка с размерами), `nodes_per_step` (обновлений узлов за шаг L0
по всем уровням), `wall_function`, `sponge_side`, `rho_probe_rms` (пульсация плотности в зонде
во второй половине — мера паразитной акустики) и `cl_mean`; в `series.csv` — столбец
`rho_probe`.
## Валидационная кампания ## Валидационная кампания
`bench/` — 115 прогонов на ≈90 машинных часов, разложенных по девяти группам: эталоны `bench/` — 205 прогонов на ≈101 машинный час, разложенных по одиннадцати группам: эталоны
первоисточников, цилиндр против литературы, модели стенки, профили крыла, сложная и первоисточников, цилиндр против литературы, модели стенки, профили крыла, сложная и
множественная геометрия, границы домена, старт и время жизни, внутренние инварианты, множественная геометрия, границы домена, старт и время жизни, внутренние инварианты,
сверхмелкие сетки до 4096×2048. Из них 108 идут на GPU (≈87 часов) и 7 — на CPU (≈3 часа, сверхмелкие сетки до 4096×2048, схемы стенки с пристеночной функцией (J) и внешний домен с
исследования сходимости группы A). Каждый прогон кладёт в собственную папку `cmd.txt`, грубыми буферами (K). Из них 198 идут на GPU и 7 — на CPU (≈3 часа, исследования сходимости
`log.txt`, `report.txt`, `series.csv` и `summary.json`; гифку на всю свою длительность группы A). Каждый прогон кладёт в собственную папку `cmd.txt`, `log.txt`, `report.txt`,
пишут **59 прогонов из 115**, остальным `--gif` не передаётся вовсе. `series.csv` и `summary.json`; гифку на всю свою длительность пишут **59 прогонов из 205** —
группы J и K гифок не пишут. Итоги J и K сводит `bench/compare.py`.
```sh ```sh
cd bench cd bench
@@ -497,7 +606,7 @@ python preflight.py # каждый сценарий старту
## Развёртывание (Docker) ## Развёртывание (Docker)
Собранный образ опубликован: **`notbigghost/kbc2d:1.3.0`** (он же `latest`, платформа `linux/amd64`). Собранный образ опубликован: **`notbigghost/kbc2d:1.4.0`** (он же `latest`, платформа `linux/amd64`).
Исходники на сервере не нужны — достаточно перенести туда один файл Исходники на сервере не нужны — достаточно перенести туда один файл
`docker-compose.server.yml`: `docker-compose.server.yml`:
@@ -525,7 +634,7 @@ docker run --rm --gpus all kbc2d --calibrate
### Пять долей на пяти машинах ### Пять долей на пяти машинах
Кампания разложена на **пять равных по времени долей** (≈24 ч каждая, поле `shard` в Кампания разложена на **пять равных по времени долей** (≈27 ч каждая, поле `shard` в
`scenarios.json`), чтобы считать её одновременно на пяти чужих ПК с видеокартой под Windows `scenarios.json`), чтобы считать её одновременно на пяти чужих ПК с видеокартой под Windows
или WSL2. На машину переносятся два файла — `docker-compose.shards.yml` и `shard.bat` — и или WSL2. На машину переносятся два файла — `docker-compose.shards.yml` и `shard.bat` — и
выполняется `shard.bat 3` (или `docker compose -f docker-compose.shards.yml up -d shard3`). выполняется `shard.bat 3` (или `docker compose -f docker-compose.shards.yml up -d shard3`).
@@ -618,7 +727,11 @@ GPU не найден. Согласие даётся явно, переменн
стартовый импульс от появления тела в потоке; стартовый импульс от появления тела в потоке;
- подвижные и вращающиеся тела: GMEM уже записан в галилей-инвариантной форме и принимает - подвижные и вращающиеся тела: GMEM уже записан в галилей-инвариантной форме и принимает
скорость стенки на линке, но подача этой скорости не подключена; скорость стенки на линке, но подача этой скорости не подключена;
- разбор нерешённых 12% по Cd и предела устойчивости Re ≈ 5·10⁴ — обе задачи вынесены в - разбор нерешённых 12% по Cd и предела устойчивости — обе задачи вынесены в кампанию
кампанию (группы B и G), выводы делать по её данным; (группы B и G), выводы делать по её данным. Оценка предела Re ≈ 5·10⁴ при D = 16 была сделана
по коротким прогонам и не подтвердилась: в длинных (цилиндр 20D×12D, Bouzidi) Re = 10⁴
разваливается при D = 16 и 32 на 13 и 27 тысячах шагов, а держится при τ − ½ ≥ 6·10⁻⁴
(Re = 2000 при D = 8, 5000 при D = 32, 10⁴ при D = 64); пристеночная функция предел не
сдвигает;
- больше четырёх тел в домене: сейчас сила считается по четырём вёдрам (`MAX_BODY_BUCKETS`), - больше четырёх тел в домене: сейчас сила считается по четырём вёдрам (`MAX_BODY_BUCKETS`),
геометрия при этом собирается из любого числа тел, но силы сверх четвёртого сливаются вместе. геометрия при этом собирается из любого числа тел, но силы сверх четвёртого сливаются вместе.
+48 -10
View File
@@ -1,10 +1,10 @@
# Валидационная кампания # Валидационная кампания
115 прогонов, ≈90 машинных часов: 108 на GPU (≈87 часов на RTX 4070 Ti) и 7 на CPU (≈3 часа — 205 прогонов в одиннадцати группах, ≈101 машинный час: 198 на GPU (≈98 часов при 1200 MLUPS)
исследования сходимости группы A, где нужен f64). Проверяет решатель по трём независимым линиям: и 7 на CPU (≈3 часа — исследования сходимости группы A, где нужен f64). Проверяет решатель по трём независимым линиям:
эталонам из статей авторов метода, литературе по обтеканию тел и внутренним инвариантам самой эталонам из статей авторов метода, литературе по обтеканию тел и внутренним инвариантам самой
схемы. Каждый прогон кладёт в собственную папку `cmd.txt`, `log.txt`, `report.txt`, `series.csv` схемы. Каждый прогон кладёт в собственную папку `cmd.txt`, `log.txt`, `report.txt`, `series.csv`
и `summary.json`; гифку пишут **59 прогонов из 115** — остальным `--gif` не передаётся. и `summary.json`; гифку пишут **59 прогонов из 205** — остальным `--gif` не передаётся.
## Пять долей: чужие ПК под Windows / WSL2 ## Пять долей: чужие ПК под Windows / WSL2
@@ -44,11 +44,11 @@ Desktop с бэкендом WSL2 (так по умолчанию) либо Docke
| доля | прогонов | ≈ часов | | доля | прогонов | ≈ часов |
|---|---|---| |---|---|---|
| 1 | 22 | 24.0 | | 1 | 40 | 26.7 |
| 2 | 23 | 24.0 | | 2 | 40 | 26.7 |
| 3 | 23 | 24.0 | | 3 | 41 | 26.7 |
| 4 | 23 | 24.0 | | 4 | 42 | 26.7 |
| 5 | 24 | 24.0 | | 5 | 42 | 26.7 |
Часы — оценка при 1200 MLUPS на крупной сетке **с поправкой на размер сетки**: на dzn мелкие Часы — оценка при 1200 MLUPS на крупной сетке **с поправкой на размер сетки**: на dzn мелкие
сетки считаются медленнее на узел, а сверхмелкие из группы I идут через раздельные привязки сетки считаются медленнее на узел, а сверхмелкие из группы I идут через раздельные привязки
@@ -66,7 +66,7 @@ Desktop с бэкендом WSL2 (так по умолчанию) либо Docke
## Быстрый старт на сервере ## Быстрый старт на сервере
Образ опубликован, собирать ничего не нужно: **`notbigghost/kbc2d:1.3.0`**. Исходники на Образ опубликован, собирать ничего не нужно: **`notbigghost/kbc2d:1.4.0`**. Исходники на
сервере тоже не нужны — переносится один файл `docker-compose.server.yml`. сервере тоже не нужны — переносится один файл `docker-compose.server.yml`.
```sh ```sh
@@ -75,7 +75,7 @@ mkdir -p ~/kbc2d && cd ~/kbc2d # сюда же ляжет ./out с ре
C=docker-compose.server.yml C=docker-compose.server.yml
docker compose -f $C --profile check run --rm vulkan # 1. карта видна из контейнера? docker compose -f $C --profile check run --rm vulkan # 1. карта видна из контейнера?
docker compose -f $C --profile check run --rm preflight # 2. все 115 сценариев стартуют? docker compose -f $C --profile check run --rm preflight # 2. все 205 сценариев стартуют?
docker compose -f $C --profile check run --rm calibrate # 3. сколько MLUPS на этой машине? docker compose -f $C --profile check run --rm calibrate # 3. сколько MLUPS на этой машине?
docker compose -f $C --profile check run --rm plan # 4. смета в часах по замеренному docker compose -f $C --profile check run --rm plan # 4. смета в часах по замеренному
docker compose -f $C up -d # 5. кампания docker compose -f $C up -d # 5. кампания
@@ -277,6 +277,38 @@ out/<id>/xt_*.csv x–t диаграммы (группа G)
| **G** | 14 | старт, акустика, время жизни: x–t диаграммы, губки, предел по Re, прогон на 10⁷ шагов | | **G** | 14 | старт, акустика, время жизни: x–t диаграммы, губки, предел по Re, прогон на 10⁷ шагов |
| **H** | 9 | инварианты: симметрия, зеркальность, зависимость от числа Маха, расхождение f32 против f64 | | **H** | 9 | инварианты: симметрия, зеркальность, зависимость от числа Маха, расхождение f32 против f64 |
| **I** | 9 | сверхмелкие сетки 4096×2048 по всем формам и композиции из трёх тел | | **I** | 9 | сверхмелкие сетки 4096×2048 по всем формам и композиции из трёх тел |
| **J** | 65 | схемы стенки с пристеночной функцией: Bouzidi без неё против Bouzidi, Grad и HRR со Spalding — цилиндр Re=150 / 2000 (D=8…64) и 5000 (D=32, 64), NACA 0012 α=4° Re=2000 (хорда 24…96) и 10⁴ (48, 96); эталон каждой серии — Bouzidi при удвоенном разрешении |
| **K** | 25 | внешний домен: грубые буферы до тела, после и по бокам, переход разрешения скачком ×4 / ×8 против ступеней ×2, длина тонкого следа, ширина промежуточного уровня, боковые губки — против однородной тонкой сетки на домене 30D+60D, ±30D |
### Группы J и K: как читать
Обе группы отвечают на практические вопросы, и обе меряются против эталона внутри группы, а не
против литературы — для турбулентных Re и для домена её просто нет.
**J** — что точнее: Bouzidi (лучшая геометрия) или моментные схемы с пристеночной функцией
Spalding. Bouzidi + Spalding добавлен, чтобы отделить вклад функции от вклада схемы. Сетка
однородная, разрешение задано числом клеток на тело. Re выбраны с оглядкой на устойчивость:
предел здесь задаёт не модель стенки, а τ − ½ = 3uD/Re — при τ − ½ < 6·10⁻⁴ счёт
разваливается у всех схем одинаково (замерено: цилиндр Re = 10⁴ при D = 16 и 32), и такой
прогон сравнивал бы не схемы, а предел устойчивости. Отсюда Re = 5000 вместо 10⁴ у цилиндра и
хорды от 48 у профиля при Re = 10⁴. В каждой сводке — y⁺ первого узла: при y⁺ < 5 функция
обязана почти совпасть с прилипанием, разница возможна только там, где первая клетка дальше.
**K** — как строить внешний домен (ключ `--levels` решателя). Тело везде одно: цилиндр с
D = 32 на самом тонком уровне. База — L0 в 4 раза грубее тела, домен 20D до тела, 40D после,
±20D, две ступени ×2. Серии меняют по одному параметру. Кроме St, ⟨Cd⟩ и rms Cl сравнивается
`rho_probe_rms` — пульсация плотности в зонде, мера паразитной акустики от отражений на границах
и стыках уровней, — и стоимость: конфигурации K обходятся в 1–2 % стоимости эталона.
Сводка обеих групп:
```sh
python compare.py # Markdown-таблицы против эталонов, читает out/
python compare.py --out ../all_out --csv jk.csv # по сведённым результатам пяти долей
```
Не посчитанный прогон помечается «нет», развалившийся — «развал»; под каждой серией J — какая
схема ближе всех к эталону на каждом разрешении (по |ΔCd| у цилиндра, по |ΔCl| у профиля).
## Стоимость и время ## Стоимость и время
@@ -298,6 +330,12 @@ python gen_scenarios.py --scale 3 # все прогоны в полтора
группы A идут на CPU — это указано в самих сценариях. группы A идут на CPU — это указано в самих сценариях.
- **Развалы ожидаемы** в группе G (предел по Re) и у прогона `A09_shear_n512_lbgk`: LBGK при - **Развалы ожидаемы** в группе G (предел по Re) и у прогона `A09_shear_n512_lbgk`: LBGK при
Re=3·10⁴ обязан развалиться там, где KBC доживает — это и есть проверяемое утверждение. Re=3·10⁴ обязан развалиться там, где KBC доживает — это и есть проверяемое утверждение.
Длинные прогоны канала показали предел около τ − ½ ≈ 6·10⁻⁴ (τ − ½ = 3uD/Re), а не
Re ≈ 5·10⁴ при D = 16, как оценивалось раньше по коротким прогонам: при этом пределе
`G11_limit_re10000` (τ − ½ = 7.2·10⁻⁴) на грани, а `G12` и `G13` обязаны развалиться.
- **`F09_refine3` до версии 1.4.0 считался на GPU с заниженным в 1.5 раза Cd** — сила третьего
подшага терялась за краем буфера. Исправлено; результаты этого прогона из образов до 1.4.0
не использовать.
- **Гифки** пишутся на всю длительность прогона, в реальном времени, 10 кадр/с. Число кадров - **Гифки** пишутся на всю длительность прогона, в реальном времени, 10 кадр/с. Число кадров
этим задано жёстко (у самого длинного прогона их 16 666), поэтому единственный рычаг — этим задано жёстко (у самого длинного прогона их 16 666), поэтому единственный рычаг —
размер кадра. Замерено: 0.103 байта на пиксель после LZW; отсюда бюджет размер кадра. Замерено: 0.103 байта на пиксель после LZW; отсюда бюджет
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""Сводные таблицы групп J и K против их эталонов.
python compare.py # Markdown в консоль, читает out/
python compare.py --out ../out_all # другой каталог результатов (например, сведённый
# из пяти долей)
python compare.py --csv compare.csv # те же строки в CSV
Группа J — схемы стенки. Каждая серия (тело, Re) имеет эталон: Bouzidi без пристеночной
функции при удвоенном разрешении. Для каждого прогона печатается отклонение St, ⟨Cd⟩,
rms Cl (у профиля — ещё ⟨Cl⟩) от эталона и y⁺ первого узла; в конце серии — какая схема
ближе к эталону на каждом разрешении.
Группа K — внешний домен. Эталон каждого Re — однородная тонкая сетка на самом большом
домене (K…_ref). Для каждой конфигурации — отклонения тех же величин, пульсация плотности в
зонде (паразитная акустика) и стоимость (обновлений узлов и фактические секунды).
Прогон без summary.json (не посчитан) или развалившийся показывается строкой с пометкой —
дыры в таблице видны сразу, а не теряются.
"""
import argparse
import csv
import json
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
def load(out_dir, scen_path):
runs = json.load(open(scen_path, encoding="utf-8"))["runs"]
res = {}
for r in runs:
p = os.path.join(out_dir, r["id"], "summary.json")
if os.path.exists(p):
try:
res[r["id"]] = json.load(open(p, encoding="utf-8"))
except json.JSONDecodeError:
res[r["id"]] = None
return runs, res
def dev(v, ref):
"""Относительное отклонение в процентах; None, если сравнивать нечего."""
if v is None or ref is None or ref == 0:
return None
return 100.0 * (v - ref) / abs(ref)
def fmt(v, spec="{:+.2f}"):
return "—" if v is None else spec.format(v)
def status(s):
if s is None:
return "нет"
return "развал" if s.get("blew_up") else "ok"
# ── J ────────────────────────────────────────────────────────────────────────
J_RE = re.compile(r"^J\d+_(cyl|naca)_re(\d+)_(?:(?:d|c)(\d+)_(\w+)|ref_(?:d|c)(\d+))$")
def table_j(runs, res):
series = {}
for r in runs:
m = J_RE.match(r["id"])
if not m:
continue
body, re_, n, scheme, nref = m.groups()
key = (body, int(re_))
s = series.setdefault(key, {"ref": None, "rows": []})
if nref:
s["ref"] = r["id"]
else:
s["rows"].append((int(n), scheme, r["id"]))
rows, lines = [], []
for (body, re_), s in sorted(series.items()):
ref = res.get(s["ref"]) if s["ref"] else None
name = "цилиндр" if body == "cyl" else "NACA 0012, α=4°"
unit = "D" if body == "cyl" else "хорда"
lines.append(f"\n### J: {name}, Re = {re_}\n")
lines.append(f"Эталон `{s['ref']}`: " + (
"не посчитан" if ref is None else
f"St {fmt(ref.get('strouhal'), '{:.4f}')}, Cd {fmt(ref.get('cd'), '{:.4f}')}, "
f"Cl {fmt(ref.get('cl_mean'), '{:.4f}')}, rms Cl {fmt(ref.get('cl_rms'), '{:.4f}')}"))
lines.append("")
lines.append(f"| {unit} | схема | статус | ΔSt % | ΔCd % | ΔCl % | Δrms Cl % | y⁺ ср | y⁺ макс |")
lines.append("|---|---|---|---|---|---|---|---|---|")
best = {}
for n, scheme, rid in sorted(s["rows"]):
v = res.get(rid)
ok = v is not None and not v.get("blew_up")
g = (lambda k: v.get(k)) if ok else (lambda k: None)
rf = (lambda k: ref.get(k)) if ref else (lambda k: None)
d_st, d_cd = dev(g("strouhal"), rf("strouhal")), dev(g("cd"), rf("cd"))
d_cl, d_clr = dev(g("cl_mean"), rf("cl_mean")), dev(g("cl_rms"), rf("cl_rms"))
lines.append(
f"| {n} | {scheme} | {status(v)} | {fmt(d_st)} | {fmt(d_cd)} | "
f"{fmt(d_cl if body == 'naca' else None)} | {fmt(d_clr)} | "
f"{fmt(g('yplus_mean'), '{:.2f}')} | {fmt(g('yplus_max'), '{:.1f}')} |")
rows.append({"group": "J", "body": body, "re": re_, "n": n, "scheme": scheme,
"id": rid, "status": status(v), "d_st": d_st, "d_cd": d_cd,
"d_cl": d_cl, "d_cl_rms": d_clr, "yplus_mean": g("yplus_mean"),
"yplus_max": g("yplus_max")})
# для профиля главное — подъёмная сила, для цилиндра — сопротивление
score = d_cl if body == "naca" else d_cd
if score is not None:
b = best.get(n)
if b is None or abs(score) < abs(b[1]):
best[n] = (scheme, score)
if best:
what = "|ΔCl|" if body == "naca" else "|ΔCd|"
lines.append("")
lines.append(f"Ближе всех к эталону по {what}: " + "; ".join(
f"{unit} {n} — **{sc}** ({sv:+.2f} %)" for n, (sc, sv) in sorted(best.items())))
return rows, lines
# ── K ────────────────────────────────────────────────────────────────────────
K_RE = re.compile(r"^K\d+_re(\d+)_(\w+)$")
def table_k(runs, res):
by_re = {}
meta = {r["id"]: r for r in runs}
for r in runs:
m = K_RE.match(r["id"])
if m:
by_re.setdefault(int(m.group(1)), []).append((m.group(2), r["id"]))
rows, lines = [], []
for re_, items in sorted(by_re.items()):
ref_id = next((rid for tag, rid in items if tag == "ref"), None)
ref = res.get(ref_id) if ref_id else None
lines.append(f"\n### K: внешний домен, Re = {re_}\n")
lines.append(f"Эталон `{ref_id}`: " + (
"не посчитан" if ref is None else
f"St {fmt(ref.get('strouhal'), '{:.4f}')}, Cd {fmt(ref.get('cd'), '{:.4f}')}, "
f"rms Cl {fmt(ref.get('cl_rms'), '{:.4f}')}, ρ′ {fmt(ref.get('rho_probe_rms'), '{:.2e}')}"))
lines.append("")
lines.append("| конфигурация | статус | ΔSt % | ΔCd % | Δrms Cl % | ρ′ зонда | "
"стоимость, отн. эталона | время, с |")
lines.append("|---|---|---|---|---|---|---|---|")
ref_cost = meta[ref_id]["cost"] if ref_id else None
for tag, rid in items:
if tag == "ref":
continue
v = res.get(rid)
ok = v is not None and not v.get("blew_up")
g = (lambda k: v.get(k)) if ok else (lambda k: None)
rf = (lambda k: ref.get(k)) if ref else (lambda k: None)
d_st, d_cd = dev(g("strouhal"), rf("strouhal")), dev(g("cd"), rf("cd"))
d_clr = dev(g("cl_rms"), rf("cl_rms"))
cost = meta[rid]["cost"] / ref_cost if ref_cost else None
lines.append(
f"| {tag} | {status(v)} | {fmt(d_st)} | {fmt(d_cd)} | {fmt(d_clr)} | "
f"{fmt(g('rho_probe_rms'), '{:.2e}')} | {fmt(cost, '{:.3f}')} | "
f"{fmt(g('wall_time_s'), '{:.0f}')} |")
rows.append({"group": "K", "re": re_, "config": tag, "id": rid,
"status": status(v), "d_st": d_st, "d_cd": d_cd, "d_cl_rms": d_clr,
"rho_probe_rms": g("rho_probe_rms"), "cost_rel": cost,
"wall_time_s": g("wall_time_s")})
return rows, lines
def main():
ap = argparse.ArgumentParser(description="Сводка групп J и K против эталонов")
ap.add_argument("--out", default=os.path.join(HERE, "out"), help="каталог результатов")
ap.add_argument("--scenarios", default=os.path.join(HERE, "scenarios.json"))
ap.add_argument("--csv", help="записать строки таблиц в CSV")
args = ap.parse_args()
runs, res = load(args.out, args.scenarios)
rj, lj = table_j(runs, res)
rk, lk = table_k(runs, res)
print("\n".join(["# Сравнение схем стенки (J) и конфигураций домена (K)"] + lj + lk))
if args.csv:
keys = sorted({k for row in rj + rk for k in row})
with open(args.csv, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=keys)
w.writeheader()
for row in rj + rk:
w.writerow(row)
print(f"\nCSV: {args.csv}", file=sys.stderr)
if __name__ == "__main__":
main()
+203 -1
View File
@@ -485,6 +485,207 @@ def group_i(scale):
nx * ny, st, gif="fine_multi.gif", nx=nx) nx * ny, st, gif="fine_multi.gif", nx=nx)
# ══════════════════════════════════════════════════════════════════════════════
# J. Схемы стенки с пристеночной функцией
# ══════════════════════════════════════════════════════════════════════════════
# Главный практический вопрос: что точнее — Bouzidi (лучшая геометрия, без пристеночной
# функции) или моментные схемы с пристеночной функцией Spalding, и как это меняется с Re и
# разрешением. Bouzidi + Spalding добавлен, чтобы отделить вклад функции от вклада схемы.
# Сетка однородная (--refine 1): разрешение задаётся прямо числом клеток на тело, без
# вклада связки уровней. Эталон каждой серии — Bouzidi без функции при удвоенном разрешении.
J_SCHEMES = [
("bouzidi", "none"),
("bouzidi", "spalding"),
("grad", "spalding"),
("hrr", "spalding"),
]
def jid():
return f"J{len([r for r in RUNS if r['group'] == 'J']) + 1:02d}"
def wf_tag(wall, wf):
return wall if wf == "none" else f"{wall}_wf"
# Разрешения по Re. Предел устойчивости здесь задаёт не модель стенки, а τ − ½ = 3·u·D/Re на
# грубейшем уровне: замерено на цилиндре 20D×12D (обычный Bouzidi, длинные прогоны), что при
# τ − ½ = 2.4·10⁻⁴ и 4.8·10⁻⁴ (Re = 10⁴, D = 16 и 32) счёт разваливается — на 13 и 27 тысячах
# шагов, у HRR со Spalding так же, — а при 6·10⁻⁴ и выше (Re = 2000 при D = 8, Re = 5000 при
# D = 32, Re = 10⁴ при D = 64) держится. Поэтому большой Re цилиндра — 5000, а разрешения
# выбраны так, чтобы τ − ½ ≥ 6·10⁻⁴. Прогоны ниже предела схемы бы не сравнивали, а лишь
# повторяли предел устойчивости, который меряет группа G.
J_CYL_D = {150: (8, 16, 32, 64), 2000: (8, 16, 32, 64), 5000: (32, 64)}
J_FOIL_C = {2000: (24, 48, 96), 10000: (48, 96)}
def group_j(scale):
# цилиндр: домен 20D × 12D, как сходимость по D в группе B
for re in (150, 2000, 5000):
for d in J_CYL_D[re]:
nx, ny = 20 * d, 12 * d
st = int(conv_steps(d, 300) * scale)
for wall, wf in J_SCHEMES:
add(f"{jid()}_cyl_re{re}_d{d}_{wf_tag(wall, wf)}", "J",
f"Цилиндр Re={re}, D={d}, стенка {wall}, пристеночная функция {wf}",
"отклонение St, ⟨Cd⟩, rms Cl от эталона серии (Bouzidi при D=128); y⁺ в сводке",
cyl_args(d, re, nx, ny, wall=wall, wall_function=wf), nx * ny, st)
d = 128
nx, ny = 20 * d, 12 * d
add(f"{jid()}_cyl_re{re}_ref_d{d}", "J",
f"Цилиндр Re={re}, ЭТАЛОН серии: Bouzidi без функции, D={d}",
"сеточно сошедшееся значение, к которому меряются схемы при D=8…64",
cyl_args(d, re, nx, ny, wall="bouzidi", wall_function="none"), nx * ny,
int(conv_steps(d, 300) * scale))
# профиль: NACA 0012 под углом 4°, домен 12c × 6c
for re in (2000, 10000):
for c in J_FOIL_C[re]:
nx, ny = 12 * c, 6 * c
st = int(conv_steps(c, 200) * scale)
for wall, wf in J_SCHEMES:
add(f"{jid()}_naca_re{re}_c{c}_{wf_tag(wall, wf)}", "J",
f"NACA 0012, α=4°, Re={re}, хорда {c}, стенка {wall}, функция {wf}",
"отклонение Cl, Cd от эталона серии (Bouzidi при хорде 192); y⁺ в сводке",
foil_args(c, 4, re, nx, ny, wall=wall, wall_function=wf), nx * ny, st)
c = 192
nx, ny = 12 * c, 6 * c
add(f"{jid()}_naca_re{re}_ref_c{c}", "J",
f"NACA 0012, α=4°, Re={re}, ЭТАЛОН серии: Bouzidi без функции, хорда {c}",
"сеточно сошедшееся значение для серии по хорде",
foil_args(c, 4, re, nx, ny, wall="bouzidi", wall_function="none"), nx * ny,
int(conv_steps(c, 200) * scale))
# ══════════════════════════════════════════════════════════════════════════════
# K. Внешний домен: грубые буферы, переходы разрешения, губки
# ══════════════════════════════════════════════════════════════════════════════
# Тело одно и то же во всех прогонах — цилиндр с D = 32 клетки на САМОМ ТОНКОМ уровне.
# Меняется только то, что вокруг: размеры домена до тела, после и по бокам; как сетка
# грубеет к краю (одним скачком или ступенями ×2); сколько тонкой сетки остаётся в следе;
# есть ли губки на боковых кромках. Эталон — однородная тонкая сетка на самом большом
# домене. Метрики: St, ⟨Cd⟩, rms Cl против эталона, пульсация плотности в зонде
# (rho_probe_rms — паразитная акустика) и стоимость.
K_D = 32 # клеток на тело на самом тонком уровне
K_CONV = 300 # конвективных времён D/U до умножения на scale
def _rround(x):
"""Округление как у Rust f64::round — половина от нуля, а не к чётному."""
return math.floor(x + 0.5) if x >= 0 else -math.floor(-x + 0.5)
def level_chain(levels, cx, cy, d, nx, ny):
"""Тот же расчёт границ уровней, что `parse_levels` в main.rs. Возвращает обновлений
узлов за шаг L0 по всем уровням и строку для --levels."""
pnx, pny, scale, ox, oy = nx, ny, 1.0, 0.0, 0.0
total, sub = nx * ny, 1
for r, up, down, side in levels:
def to(x, o):
return _rround((x - o) * scale)
ax = int(max(to(cx - up * d, ox), 2.0))
bx = int(max(min(to(cx + down * d, ox), pnx - 2.0), 0.0))
ay = int(max(to(cy - side * d, oy), 1.0))
by = int(max(min(to(cy + side * d, oy), pny - 2.0), 0.0))
assert ax + 1 < bx and ay + 1 < by, (levels, ax, bx, ay, by)
ox += ax / scale
oy += ay / scale
scale *= r
pnx, pny = r * (bx - ax) + 1, r * (by - ay) + 1
sub *= r
total += sub * pnx * pny
spec = "; ".join(f"{r}:{up:g},{down:g},{side:g}" for r, up, down, side in levels)
return total, spec
def kid():
return f"K{len([r for r in RUNS if r['group'] == 'K']) + 1:02d}"
def k_run(tag, title, expect, re, scale, up, down, side, levels, sponge_side_d=0.0):
"""Прогон группы K. `up`, `down`, `side` — размеры домена L0 в диаметрах; `levels` —
цепочка [(r, up, down, side), …] от внешнего уровня к внутреннему, отступы в D.
Пустая цепочка — однородная тонкая сетка."""
s = 1
for lv in levels:
s *= lv[0]
d0 = K_D / s # тело в клетках L0
nx = int(round((up + down) * d0))
ny = int(round(2 * side * d0))
cx, cy = up * d0, ny / 2
args = {"--shape": "cylinder", "--size": f"{d0:g}", "--nx": nx, "--ny": ny,
"--body-x": f"{cx:g}", "--re": re, "--wall": "bouzidi"}
if levels:
nodes, spec = level_chain(levels, cx, cy, d0, nx, ny)
args["--levels"] = spec
else:
nodes = nx * ny
args["--refine"] = 1
if sponge_side_d > 0:
args["--sponge-side"] = int(round(sponge_side_d * d0))
# шагов L0 на одно и то же физическое время: тело на L0 в s раз мельче
st = int(conv_steps(d0, K_CONV) * scale)
add(f"{kid()}_re{re}_{tag}", "K", title, expect, flatten(args), nodes, st)
# Базовая конфигурация: L0 в 4 раза грубее тела, к телу две ступени ×2. Промежуточный
# уровень — 4D вверх, 15D вниз, 4D в стороны; тонкий — 1.5D вверх, 10D следа, 2D в стороны.
K_MID = (2, 4, 15, 4)
def k_fine(wake=10):
return (2, 1.5, wake, 2)
def group_k(scale):
for re in (150, 2000):
k_run("ref", f"Re={re}, ЭТАЛОН: однородная тонкая сетка 30D+60D, ±30D",
"значения, к которым меряются все конфигурации буферов", re, scale,
30, 60, 30, [])
k_run("base", f"Re={re}, база: домен 20D+40D, ±20D, ступени ×2×2",
"отклонение от эталона при умеренных буферах", re, scale,
20, 40, 20, [K_MID, k_fine()])
# переход: одно и то же суммарное огрубление — скачком или ступенями
k_run("jump4", f"Re={re}, огрубление ×4 одним скачком",
"скачок разрешения против ступеней ×2×2 (база)", re, scale,
20, 40, 20, [(4, 1.5, 10, 2)])
k_run("jump8", f"Re={re}, огрубление ×8 одним скачком",
"скачок ×8 против цепочки ×2×2×2", re, scale,
20, 40, 20, [(8, 1.5, 10, 2)])
k_run("chain8", f"Re={re}, огрубление ×8 цепочкой ×2×2×2",
"плавный переход при сильном огрублении", re, scale,
20, 40, 20, [(2, 8, 25, 8), K_MID, k_fine()])
k_run("sponge5", f"Re={re}, боковые губки 5D",
"гасят ли губки отражения от боковых стенок", re, scale,
20, 40, 20, [K_MID, k_fine()], sponge_side_d=5)
if re != 150:
continue
# остальные серии — только Re=150: там есть литература и чистая дорожка
for side in (5, 10, 30):
k_run(f"side{side}", f"Re={re}, боковой буфер ±{side}D",
"с какого размера бока перестают влиять (блокировка + отражения)", re, scale,
20, 40, side, [K_MID, k_fine()])
for up in (5, 10, 30):
k_run(f"up{up}", f"Re={re}, буфер до тела {up}D",
"с какого отступа вход перестаёт влиять", re, scale,
up, 40, 20, [(2, min(4, up - 1), 15, 4), k_fine()])
for down in (15, 30, 60):
k_run(f"down{down}", f"Re={re}, буфер после тела {down}D",
"с какой длины выход перестаёт влиять", re, scale,
20, down, 20, [(2, 4, min(15, down - 2), 4), k_fine(min(10, down - 4))])
for wake in (5, 20):
k_run(f"wake{wake}", f"Re={re}, тонкий след {wake}D за телом",
"сколько тонкой сетки нужно в следе до первого огрубления", re, scale,
20, 40, 20, [(2, 4, max(15, wake + 5), 4), k_fine(wake)])
k_run("midwide", f"Re={re}, широкий промежуточный уровень (8D вверх, 25D вниз, 8D вбок)",
"влияет ли ширина ступени перехода", re, scale,
20, 40, 20, [(2, 8, 25, 8), k_fine()])
k_run("sponge2", f"Re={re}, боковые губки 2D",
"ширина губки: 2D против 5D", re, scale,
20, 40, 20, [K_MID, k_fine()], sponge_side_d=2)
def est_hours(r): def est_hours(r):
"""Оценка времени прогона в часах для раскладки по долям (см. DZN_MLUPS).""" """Оценка времени прогона в часах для раскладки по долям (см. DZN_MLUPS)."""
if r["backend"] == "cpu": if r["backend"] == "cpu":
@@ -526,7 +727,8 @@ def main():
ap.add_argument("--out", default=os.path.join(os.path.dirname(__file__), "scenarios.json")) ap.add_argument("--out", default=os.path.join(os.path.dirname(__file__), "scenarios.json"))
args = ap.parse_args() args = ap.parse_args()
for g in (group_a, group_b, group_c, group_d, group_e, group_f, group_g, group_h, group_i): for g in (group_a, group_b, group_c, group_d, group_e, group_f, group_g, group_h, group_i,
group_j, group_k):
g(args.scale) g(args.scale)
shard_hours = assign_shards(RUNS, args.shards) shard_hours = assign_shards(RUNS, args.shards)
+20
View File
@@ -79,6 +79,26 @@ CASES = [
"degenerate_frac": 5.0}, "degenerate_frac": 5.0},
"report": ["strouhal"], "report": ["strouhal"],
}, },
# Пристеночная функция переписана в WGSL отдельно от math.rs (закон Spalding, Ньютон в
# f32), поэтому сверяется отдельно. При Re=20 y⁺ < 1 и функция почти не меняет
# результат — проверяется именно совпадение двух реализаций, а не физика.
{
"name": "цилиндр Re=20, Град + Spalding, 8000 шагов",
"args": ["--shape", "cylinder", "--size", "16", "--nx", "320", "--ny", "192",
"--re", "20", "--refine", "1", "--steps", "8000",
"--wall", "grad", "--wall-function", "spalding"],
"fields": {"cd": 1.0, "rho_mean": 0.05, "yplus_mean": 1.0},
"report": ["strouhal"],
},
# Три уровня: рекурсивная связка уровней и сквозная нумерация подшагов силы на GPU.
{
"name": "цилиндр Re=20, три уровня ×2×2, 3000 шагов",
"args": ["--shape", "cylinder", "--size", "8", "--nx", "240", "--ny", "160",
"--re", "20", "--levels", "2:4,10,4; 2:1.5,4,1.5", "--wall", "bouzidi",
"--steps", "3000"],
"fields": {"cd": 1.0, "rho_mean": 0.05},
"report": ["strouhal"],
},
] ]
File diff suppressed because it is too large Load Diff
@@ -22,7 +22,7 @@ name: kbc2d
# ── общая часть всех сервисов ──────────────────────────────────────────────── # ── общая часть всех сервисов ────────────────────────────────────────────────
x-kbc2d: &kbc2d x-kbc2d: &kbc2d
image: notbigghost/kbc2d:1.3.0 image: notbigghost/kbc2d:1.4.0
pull_policy: missing pull_policy: missing
volumes: volumes:
- ./out:/work/bench/out - ./out:/work/bench/out
@@ -15,7 +15,7 @@
# * драйвер видеокарты под Windows — Vulkan внутри контейнера транслируется в D3D12 (dzn); # * драйвер видеокарты под Windows — Vulkan внутри контейнера транслируется в D3D12 (dzn);
# * Docker Desktop с бэкендом WSL2 (по умолчанию так и есть) либо Docker внутри WSL2. # * Docker Desktop с бэкендом WSL2 (по умолчанию так и есть) либо Docker внутри WSL2.
# #
# Доли посчитаны так, чтобы на ОДИНАКОВЫХ картах закончиться одновременно (≈24 ч каждая при # Доли посчитаны так, чтобы на ОДИНАКОВЫХ картах закончиться одновременно (≈27 ч каждая при
# 1200 MLUPS, с поправкой на размер сетки — см. bench/gen_scenarios.py). На разных картах # 1200 MLUPS, с поправкой на размер сетки — см. bench/gen_scenarios.py). На разных картах
# закончатся в разное время. Результаты — в ./out рядом с этим файлом; собрать их с пяти # закончатся в разное время. Результаты — в ./out рядом с этим файлом; собрать их с пяти
# машин = скопировать все out/ в одну папку (каталоги прогонов не пересекаются, у каждой доли # машин = скопировать все out/ в одну папку (каталоги прогонов не пересекаются, у каждой доли
@@ -26,13 +26,13 @@
name: kbc2d name: kbc2d
x-kbc2d: &kbc2d x-kbc2d: &kbc2d
image: notbigghost/kbc2d:1.3.0 image: notbigghost/kbc2d:1.4.0
# Если образа нет ни локально, ни в реестре — `docker compose -f docker-compose.shards.yml # Если образа нет ни локально, ни в реестре — `docker compose -f docker-compose.shards.yml
# build` соберёт его из каталога с этим файлом (нужны исходники). # build` соберёт его из каталога с этим файлом (нужны исходники).
build: build:
context: . context: .
args: args:
VERSION: "1.3.0" VERSION: "1.4.0"
pull_policy: missing pull_policy: missing
devices: devices:
# сама видеокарта (WDDM); есть в любом WSL2, в том числе внутри Docker Desktop # сама видеокарта (WDDM); есть в любом WSL2, в том числе внутри Docker Desktop
+2 -2
View File
@@ -32,14 +32,14 @@ name: kbc2d
# ── общая часть всех сервисов ──────────────────────────────────────────────── # ── общая часть всех сервисов ────────────────────────────────────────────────
x-kbc2d: &kbc2d x-kbc2d: &kbc2d
image: notbigghost/kbc2d:1.3.0 image: notbigghost/kbc2d:1.4.0
# Контекст сборки — каталог с этим файлом. Если образа нет ни локально, ни в реестре, # Контекст сборки — каталог с этим файлом. Если образа нет ни локально, ни в реестре,
# достаточно `docker compose -f docker-compose.wsl.yml build`: доступ к Docker Hub # достаточно `docker compose -f docker-compose.wsl.yml build`: доступ к Docker Hub
# для запуска не обязателен. # для запуска не обязателен.
build: build:
context: . context: .
args: args:
VERSION: "1.3.0" VERSION: "1.4.0"
pull_policy: missing pull_policy: missing
devices: devices:
- /dev/dxg:/dev/dxg - /dev/dxg:/dev/dxg
+386 -167
View File
@@ -8,8 +8,10 @@
use rayon::prelude::*; use rayon::prelude::*;
use crate::math::{self, Kbc, Link, LinkKind, Scene, WallModel, MAX_BODY_BUCKETS, R, CX, CY, OPP, Q}; use crate::math::{
use crate::{Case, Collision, FieldKind, Spec, StepRec}; self, Kbc, Link, LinkKind, Scene, WallFunction, WallModel, MAX_BODY_BUCKETS, R, CX, CY, OPP, Q, W,
};
use crate::{Case, Collision, FieldKind, PatchSpec, Spec, StepRec};
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Стартовые поля // Стартовые поля
@@ -186,6 +188,8 @@ pub struct Geom {
pub links: Vec<Link>, pub links: Vec<Link>,
/// Индекс по граничным узлам: условие Града ставится сразу на весь узел, а не полинково. /// Индекс по граничным узлам: условие Града ставится сразу на весь узел, а не полинково.
pub wall_nodes: Vec<WallNode>, pub wall_nodes: Vec<WallNode>,
/// Геометрия пристеночной функции, параллельно `wall_nodes` (тот же индекс).
pub wf_nodes: Vec<WfNode>,
/// Плечи для момента: координаты узлов относительно центра тела. /// Плечи для момента: координаты узлов относительно центра тела.
pub body_cx: R, pub body_cx: R,
pub body_cy: R, pub body_cy: R,
@@ -214,8 +218,9 @@ impl Geom {
} }
let links = build_links(nx, ny, &solid, &phi, &owner); let links = build_links(nx, ny, &solid, &phi, &owner);
let wall_nodes = build_wall_nodes(&links); let wall_nodes = build_wall_nodes(&links);
let wf_nodes = build_wf_nodes(nx, ny, &solid, &wall_nodes, scene);
let (bcx, bcy) = scene.center(); let (bcx, bcy) = scene.center();
Geom { nx, ny, solid, links, wall_nodes, body_cx: bcx, body_cy: bcy } Geom { nx, ny, solid, links, wall_nodes, wf_nodes, body_cx: bcx, body_cy: bcy }
} }
} }
@@ -320,6 +325,82 @@ pub struct WallNode {
pub count: u8, pub count: u8,
} }
/// Геометрия пристеночной функции одного граничного узла (Malaspinas, Sagaut 2014):
/// нормаль к стенке, расстояние от неё до узла и до точки отбора скорости, отстоящей от узла
/// на одну клетку вдоль нормали, и билинейный стенсиль этой точки. Строится один раз; GPU
/// загружает её как есть — топология, как и линки, существует в единственном экземпляре.
#[derive(Clone, Copy, Debug)]
pub struct WfNode {
/// Единичная нормаль, направленная из тела в жидкость (∇φ).
pub nx: R,
pub ny: R,
/// Расстояние от стенки до граничного узла.
pub y_w: R,
/// Расстояние от стенки до точки отбора.
pub y_s: R,
/// Узлы и веса билинейного стенсиля точки отбора. Твёрдые узлы стенсиля исключены, веса
/// перенормированы; если жидкого веса почти нет, все веса нулевые — функция в этом
/// узле не применяется, остаётся прилипание.
pub st: [u32; 4],
pub w: [R; 4],
}
impl WfNode {
pub fn valid(&self) -> bool {
self.w.iter().any(|v| *v > 0.0)
}
}
fn build_wf_nodes(
nx: usize,
ny: usize,
solid: &[bool],
wall_nodes: &[WallNode],
scene: &Scene,
) -> Vec<WfNode> {
// SDF тел точный, поэтому нормаль — его градиент центральной разностью
let h = 0.25;
wall_nodes
.iter()
.map(|wn| {
let node = wn.node as usize;
let (x, y) = ((node % nx) as R, (node / nx) as R);
let gx = scene.sdf(x + h, y) - scene.sdf(x - h, y);
let gy = scene.sdf(x, y + h) - scene.sdf(x, y - h);
let gn = (gx * gx + gy * gy).sqrt();
let (ex, ey) = if gn > 1e-12 { (gx / gn, gy / gn) } else { (0.0, 1.0) };
let y_w = scene.sdf(x, y).max(1e-3);
let (sx, sy) = (x + ex, y + ey);
let y_s = scene.sdf(sx, sy);
let mut out = WfNode { nx: ex, ny: ey, y_w, y_s, st: [0; 4], w: [0.0; 4] };
let (x0, y0) = (sx.floor(), sy.floor());
if y_s <= y_w || x0 < 0.0 || y0 < 0.0 || x0 + 1.0 >= nx as R || y0 + 1.0 >= ny as R {
return out;
}
let (tx, ty) = (sx - x0, sy - y0);
let (x0, y0) = (x0 as usize, y0 as usize);
let idx = [y0 * nx + x0, y0 * nx + x0 + 1, (y0 + 1) * nx + x0, (y0 + 1) * nx + x0 + 1];
let wts = [(1.0 - tx) * (1.0 - ty), tx * (1.0 - ty), (1.0 - tx) * ty, tx * ty];
let mut sum = 0.0;
for k in 0..4 {
out.st[k] = idx[k] as u32;
if !solid[idx[k]] {
out.w[k] = wts[k];
sum += wts[k];
}
}
if sum < 0.5 {
out.w = [0.0; 4];
} else {
for v in out.w.iter_mut() {
*v /= sum;
}
}
out
})
.collect()
}
fn build_wall_nodes(links: &[Link]) -> Vec<WallNode> { fn build_wall_nodes(links: &[Link]) -> Vec<WallNode> {
let mut out: Vec<WallNode> = Vec::new(); let mut out: Vec<WallNode> = Vec::new();
for (k, l) in links.iter().enumerate() { for (k, l) in links.iter().enumerate() {
@@ -345,7 +426,7 @@ pub struct Level {
pub post: Vec<[R; Q]>, pub post: Vec<[R; Q]>,
/// Энтропийный стабилизатор поузлово (для диагностики и картинки). /// Энтропийный стабилизатор поузлово (для диагностики и картинки).
pub gamma: Vec<R>, pub gamma: Vec<R>,
/// β по столбцам x (губка делает его полем); длина nx. /// β по узлам (губки делают его полем); длина nx·ny.
pub beta: Vec<R>, pub beta: Vec<R>,
pub geom: Geom, pub geom: Geom,
} }
@@ -372,7 +453,6 @@ impl Level {
/// (ГУ Bouzidi перезаписывает всё, что могло бы прийти из тела в жидкость), а счёт там /// (ГУ Bouzidi перезаписывает всё, что могло бы прийти из тела в жидкость), а счёт там
/// только жжёт такты и способен родить NaN при экстремальных режимах. /// только жжёт такты и способен родить NaN при экстремальных режимах.
fn collide(&mut self, op: Collision, model: math::KbcModel) -> Stats { fn collide(&mut self, op: Collision, model: math::KbcModel) -> Stats {
let nx = self.nx;
let beta = &self.beta; let beta = &self.beta;
let solid = &self.geom.solid; let solid = &self.geom.solid;
let post = &mut self.post; let post = &mut self.post;
@@ -388,7 +468,7 @@ impl Level {
*g = 2.0; *g = 2.0;
return Stats::EMPTY; return Stats::EMPTY;
} }
let b = beta[node % nx]; let b = beta[node];
let k: Kbc = match op { let k: Kbc = match op {
Collision::Kbc => math::collide_node(p, b, model), Collision::Kbc => math::collide_node(p, b, model),
Collision::Bgk => math::collide_node_bgk(p, b), Collision::Bgk => math::collide_node_bgk(p, b),
@@ -426,6 +506,67 @@ impl Level {
} }
} }
/// Пристеночная функция граничного узла `wi` по текущему полю: скорость в точке отбора —
/// билинейно по пост-столкновительному полю (то есть на момент t, как и у Града).
fn wall_fn_at(&self, wi: usize) -> Option<math::WallFnOut> {
let g = &self.geom.wf_nodes[wi];
if !g.valid() {
return None;
}
let (mut ux, mut uy) = (0.0, 0.0);
for k in 0..4 {
if g.w[k] > 0.0 {
let (a, b) = self.u_at_t(g.st[k] as usize);
ux += g.w[k] * a;
uy += g.w[k] * b;
}
}
let node = self.geom.wall_nodes[wi].node as usize;
let nu = math::nu_of_beta(self.beta[node]);
math::wall_function(ux, uy, g.nx, g.ny, g.y_w, g.y_s, nu)
}
/// Bouzidi с пристеночной функцией: стенка получает касательную скорость скольжения,
/// согласованную по напряжению (см. `math::wall_slip`), и входит в отскок стандартной
/// поправкой подвижной стенки
/// δ = 2W_iρ(c_ī·u_s)/c_s² (Lallemand, Luo 2003); для q ≥ ½ поправка делится на 2q.
fn apply_bouzidi_wf(&mut self) {
for wi in 0..self.geom.wall_nodes.len() {
let wn = self.geom.wall_nodes[wi];
let node = wn.node as usize;
let (usx, usy) = match self.wall_fn_at(wi) {
Some(w) => {
let g = &self.geom.wf_nodes[wi];
let nu = math::nu_of_beta(self.beta[node]);
let s = math::wall_slip(&w, g.y_w, nu);
(s * w.ex, s * w.ey)
}
None => (0.0, 0.0),
};
let rho = math::macros(&self.post[node]).0;
let lo = wn.first as usize;
for l in &self.geom.links[lo..lo + wn.count as usize] {
let i = l.i as usize;
let ib = l.ib as usize;
let fi = self.post[node][i];
// c_ī = −c_i
let delta =
-2.0 * W[i] * rho * (CX[i] as R * usx + CY[i] as R * usy) / math::CS2;
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] + delta
}
LinkKind::Far => {
let h = 1.0 / (2.0 * l.q);
h * fi + (1.0 - h) * self.post[node][ib] + h * delta
}
LinkKind::Simple => fi + delta,
};
self.f[node][ib] = v;
}
}
}
/// Интерполированный отскок Bouzidi по всем линкам тела. /// Интерполированный отскок Bouzidi по всем линкам тела.
/// `f` — поле ПОСЛЕ переноса (его правим), `post` — ПОСЛЕ столкновения (до переноса). /// `f` — поле ПОСЛЕ переноса (его правим), `post` — ПОСЛЕ столкновения (до переноса).
fn apply_bouzidi(&mut self) { fn apply_bouzidi(&mut self) {
@@ -459,8 +600,7 @@ impl Level {
/// ///
/// после чего недостающие популяции собираются приближением Града (2.13). Скорости /// после чего недостающие популяции собираются приближением Града (2.13). Скорости
/// соседей и градиенты берутся с прошлого шага — см. `u_prev`. /// соседей и градиенты берутся с прошлого шага — см. `u_prev`.
fn apply_moment_wall(&mut self, third_order: bool) { fn apply_moment_wall(&mut self, third_order: bool, wf: WallFunction) {
let nx = self.nx;
// стенка неподвижна; для подвижного тела сюда пойдёт её скорость на линке, // стенка неподвижна; для подвижного тела сюда пойдёт её скорость на линке,
// и добавится динамическая часть плотности (B 4) // и добавится динамическая часть плотности (B 4)
let (uwx, uwy) = (0.0, 0.0); let (uwx, uwy) = (0.0, 0.0);
@@ -497,7 +637,21 @@ impl Level {
rho += if missing[i] { self.post[node][OPP[i]] } else { self.f[node][i] }; rho += if missing[i] { self.post[node][OPP[i]] } else { self.f[node][i] };
} }
let (dudx, dudy, dvdx, dvdy) = self.grad_u_at_t(node); let (mut dudx, mut dudy, mut dvdx, mut dvdy) = self.grad_u_at_t(node);
// Пристеночная функция: касательная составляющая целевой скорости и нормальная
// производная касательной скорости в тензоре давлений берутся из закона стенки,
// нормальная составляющая скорости и плотность (B 3) — прежние.
if wf.is_on() {
if let Some(w) = self.wall_fn_at(wi) {
let g = &self.geom.wf_nodes[wi];
let un = ux * g.nx + uy * g.ny;
ux = un * g.nx + w.u_node * w.ex;
uy = un * g.ny + w.u_node * w.ey;
let nu = math::nu_of_beta(self.beta[node]);
(dudx, dudy, dvdx, dvdy) =
math::wall_function_gradient((dudx, dudy, dvdx, dvdy), &w, g.nx, g.ny, nu);
}
}
let g = math::moment_wall( let g = math::moment_wall(
rho, rho,
ux, ux,
@@ -506,7 +660,7 @@ impl Level {
dudy, dudy,
dvdx, dvdx,
dvdy, dvdy,
self.beta[node % nx], self.beta[node],
third_order, third_order,
); );
for i in 0..Q { for i in 0..Q {
@@ -523,7 +677,7 @@ impl Level {
/// что было в f до переноса, то есть u(x, t) — именно то, что требует прил. B. Отдельное /// что было в f до переноса, то есть u(x, t) — именно то, что требует прил. B. Отдельное
/// хранилище «поля предыдущего шага» при этом не нужно. /// хранилище «поля предыдущего шага» при этом не нужно.
#[inline] #[inline]
fn u_at_t(&self, node: usize) -> (R, R) { pub fn u_at_t(&self, node: usize) -> (R, R) {
let (_, a, b) = math::macros(&self.post[node]); let (_, a, b) = math::macros(&self.post[node]);
(a, b) (a, b)
} }
@@ -553,11 +707,12 @@ impl Level {
} }
/// Замкнуть недостающие популяции выбранной моделью стенки. /// Замкнуть недостающие популяции выбранной моделью стенки.
fn apply_wall(&mut self, model: WallModel) { fn apply_wall(&mut self, model: WallModel, wf: WallFunction) {
match model { match model {
WallModel::Bouzidi if wf.is_on() => self.apply_bouzidi_wf(),
WallModel::Bouzidi => self.apply_bouzidi(), WallModel::Bouzidi => self.apply_bouzidi(),
WallModel::Grad => self.apply_moment_wall(false), WallModel::Grad => self.apply_moment_wall(false, wf),
WallModel::Hrr => self.apply_moment_wall(true), WallModel::Hrr => self.apply_moment_wall(true, wf),
WallModel::Staircase => self.apply_staircase(), WallModel::Staircase => self.apply_staircase(),
} }
} }
@@ -729,12 +884,7 @@ pub struct Ghost {
/// Неравновесная часть при смене уровня масштабируется: f^neq ∝ τ·δt, поэтому /// Неравновесная часть при смене уровня масштабируется: f^neq ∝ τ·δt, поэтому
/// коэффициент грубый→тонкий равен R01 = τ_f/(r·τ_c), обратно — 1/R01. /// коэффициент грубый→тонкий равен R01 = τ_f/(r·τ_c), обратно — 1/R01.
pub struct Patch { pub struct Patch {
pub ax: usize,
pub bx: usize,
pub ay: usize,
pub by: usize,
pub r: usize, pub r: usize,
pub nfx: usize,
pub r01: R, pub r01: R,
ghosts: Vec<Ghost>, ghosts: Vec<Ghost>,
/// Пары (узел L0, узел L1) для рестрикции — только внутренние жидкие узлы перекрытия. /// Пары (узел L0, узел L1) для рестрикции — только внутренние жидкие узлы перекрытия.
@@ -753,9 +903,9 @@ impl Patch {
&self.restrict &self.restrict
} }
pub fn new(spec: &Spec, coarse: &Geom, fine_solid: &[bool], r01: R) -> Patch { pub fn new(ps: &PatchSpec, coarse: &Geom, fine_solid: &[bool], r01: R) -> Patch {
let (ax, bx, ay, by) = spec.patch.expect("патч запрошен без границ"); let (ax, bx, ay, by) = (ps.ax, ps.bx, ps.ay, ps.by);
let r = spec.refine; let r = ps.r;
let nfx = r * (bx - ax) + 1; let nfx = r * (bx - ax) + 1;
let nfy = r * (by - ay) + 1; let nfy = r * (by - ay) + 1;
let cnx = coarse.nx; let cnx = coarse.nx;
@@ -806,7 +956,7 @@ impl Patch {
} }
// рамка — ровно периметр тонкого поля: два ряда по nfx плюс два столбца без углов // рамка — ровно периметр тонкого поля: два ряда по nfx плюс два столбца без углов
assert_eq!(ghosts.len(), 2 * nfx + 2 * (nfy - 2)); assert_eq!(ghosts.len(), 2 * nfx + 2 * (nfy - 2));
Patch { ax, bx, ay, by, r, nfx, r01, ghosts, restrict } Patch { r, r01, ghosts, restrict }
} }
/// Ghost-значения из грубого поля: равновесие по интерполированным ρ, u плюс /// Ghost-значения из грубого поля: равновесие по интерполированным ρ, u плюс
@@ -885,31 +1035,72 @@ impl Patch {
pub struct Sim { pub struct Sim {
pub spec: Spec, pub spec: Spec,
pub l0: Level, /// Уровни от L0 (весь домен) к самому тонкому.
pub l1: Option<Level>, pub levels: Vec<Level>,
pub patch: Option<Patch>, /// `patches[k]` связывает уровень k с уровнем k + 1.
/// Состояние L0 до столкновения — «старый» край для временной интерполяции рамки. pub patches: Vec<Patch>,
pre: Vec<[R; Q]>, /// Состояние уровня k до столкновения — «старый» край для временной интерполяции рамки
gh_old: Vec<[R; Q]>, /// уровня k + 1. Заведено только у уровней, у которых есть потомок.
gh_new: Vec<[R; Q]>, pre: Vec<Vec<[R; Q]>>,
gh_old: Vec<Vec<[R; Q]>>,
gh_new: Vec<Vec<[R; Q]>>,
fluid_count: R, fluid_count: R,
/// Зонд следа: уровень и узел на нём — самый тонкий уровень, накрывающий точку.
probe_level: usize,
probe_node: usize, probe_node: usize,
probe_on_fine: bool,
pub step_index: u64, pub step_index: u64,
} }
/// Сводка пристеночной функции по граничным узлам самого тонкого уровня.
#[derive(Clone, Copy, Debug, Default)]
pub struct YPlus {
/// Граничных узлов, где функция определена (есть точка отбора и касательный поток).
pub nodes: usize,
pub mean: R,
pub max: R,
/// Доля узлов с y⁺ > 30 — там первая клетка лежит в логарифмической зоне.
pub frac_log: R,
pub u_tau_mean: R,
}
/// y⁺ первого узла по полю скорости `u(node)` уровня с геометрией `geom` и вязкостью `nu`.
/// Общая для обоих бэкендов: GPU скачивает поле и считает здесь же.
pub fn yplus_stats(geom: &Geom, nu: R, u: impl Fn(usize) -> (R, R)) -> YPlus {
let mut y = YPlus::default();
for g in &geom.wf_nodes {
if !g.valid() {
continue;
}
let (mut ux, mut uy) = (0.0, 0.0);
for k in 0..4 {
if g.w[k] > 0.0 {
let (a, b) = u(g.st[k] as usize);
ux += g.w[k] * a;
uy += g.w[k] * b;
}
}
if let Some(w) = math::wall_function(ux, uy, g.nx, g.ny, g.y_w, g.y_s, nu) {
y.nodes += 1;
y.mean += w.y_plus;
y.max = y.max.max(w.y_plus);
y.u_tau_mean += w.u_tau;
if w.y_plus > 30.0 {
y.frac_log += 1.0;
}
}
}
if y.nodes > 0 {
let n = y.nodes as R;
y.mean /= n;
y.u_tau_mean /= n;
y.frac_log /= n;
}
y
}
impl Sim { impl Sim {
pub fn new(spec: Spec) -> Sim { pub fn new(spec: Spec) -> Sim {
let (nx, ny) = (spec.nx, spec.ny); let lv = spec.levels();
let geom0 = Geom::build(nx, ny, &spec.scene);
let beta0 = math::beta_profile(
nx,
spec.beta0,
spec.sponge_in,
spec.sponge_len,
spec.sponge_mult,
);
// стартовое поле: либо сразу набегающий поток, либо покой // стартовое поле: либо сразу набегающий поток, либо покой
let u0 = if spec.init_uniform { let u0 = if spec.init_uniform {
@@ -918,67 +1109,78 @@ impl Sim {
} else { } else {
(0.0, 0.0) (0.0, 0.0)
}; };
let init0 = initial_field(&Init {
case: spec.case,
nx,
ny,
u0,
beta: spec.beta0,
scene: Some(&spec.scene),
taper: if spec.init_uniform { spec.init_taper } else { 0.0 },
});
let l0 = Level::new(nx, ny, beta0, geom0, init0);
let fluid_count = l0.geom.solid.iter().filter(|s| !**s).count() as R;
let (l1, patch) = if spec.refine > 1 { let mut levels: Vec<Level> = Vec::with_capacity(lv.len());
let (ax, bx, ay, by) = spec.patch.expect("refine > 1 требует патч"); let mut patches: Vec<Patch> = Vec::with_capacity(spec.patches.len());
let r = spec.refine; for (k, g) in lv.iter().enumerate() {
let scene1 = spec.scene.refined(r as R, ax as R, ay as R); let scene = spec.level_scene(g);
let nfx = r * (bx - ax) + 1; let geom = Geom::build(g.nx, g.ny, &scene);
let nfy = r * (by - ay) + 1; // губки живут только на L0; тонкие уровни однородны, β_k = 1/(2τ_k)
let geom1 = Geom::build(nfx, nfy, &scene1); let (beta, beta_init) = if k == 0 {
// τ_f = r(τ_c − ½) + ½ ⇒ одинаковая ν на обоих уровнях (
let tau0 = 1.0 / (2.0 * spec.beta0); math::beta_field(
let tau1 = r as R * (tau0 - 0.5) + 0.5; g.nx,
let beta1 = 1.0 / (2.0 * tau1); g.ny,
let r01 = tau1 / (r as R * tau0); spec.beta0,
let patch = Patch::new(&spec, &l0.geom, &geom1.solid, r01); spec.sponge_in,
let init1 = initial_field(&Init { spec.sponge_len,
spec.sponge_side,
spec.sponge_mult,
),
spec.beta0,
)
} else {
let b = 1.0 / (2.0 * g.tau);
(vec![b; g.nx * g.ny], b)
};
let init = initial_field(&Init {
case: spec.case, case: spec.case,
nx: nfx, nx: g.nx,
ny: nfy, ny: g.ny,
u0, u0,
beta: 1.0 / (2.0 * tau1), beta: beta_init,
scene: Some(&scene1), scene: Some(&scene),
taper: if spec.init_uniform { spec.init_taper * r as R } else { 0.0 }, taper: if spec.init_uniform { spec.init_taper * g.scale } else { 0.0 },
}); });
(Some(Level::new(nfx, nfy, vec![beta1; nfx], geom1, init1)), Some(patch)) if k > 0 {
} else { // f^neq ∝ τ·δt ⇒ коэффициент грубый→тонкий R01 = τ_f/(r·τ_c)
(None, None) let ps = &spec.patches[k - 1];
}; let r01 = g.tau / (ps.r as R * lv[k - 1].tau);
patches.push(Patch::new(ps, &levels[k - 1].geom, &geom.solid, r01));
// зонд следа: берём с тонкой сетки, если точка внутри патча (меньше численного
// размытия вихрей → чище спектр и 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), levels.push(Level::new(g.nx, g.ny, beta, geom, init));
}; }
let fluid_count = levels[0].geom.solid.iter().filter(|s| !**s).count() as R;
let n = nx * ny; // зонд следа: с самого тонкого уровня, накрывающего точку (меньше численного
// размытия вихрей → чище спектр и St), иначе с грубого
let (mut px, mut py) = spec.probe;
let mut probe_level = 0;
for (k, p) in spec.patches.iter().enumerate() {
if px >= p.ax && px <= p.bx && py >= p.ay && py <= p.by {
px = (px - p.ax) * p.r;
py = (py - p.ay) * p.r;
probe_level = k + 1;
} else {
break;
}
}
let probe_node = py * lv[probe_level].nx + px;
let nlev = levels.len();
let pre = (0..nlev)
.map(|k| if k + 1 < nlev { vec![[0.0; Q]; lv[k].nx * lv[k].ny] } else { Vec::new() })
.collect();
Sim { Sim {
spec, spec,
l0, levels,
l1, patches,
patch, pre,
pre: vec![[0.0; Q]; n], gh_old: vec![Vec::new(); nlev],
gh_old: Vec::new(), gh_new: vec![Vec::new(); nlev],
gh_new: Vec::new(),
fluid_count, fluid_count,
probe_level,
probe_node, probe_node,
probe_on_fine,
step_index: 0, step_index: 0,
} }
} }
@@ -1007,69 +1209,79 @@ impl Sim {
(u * c, uy) (u * c, uy)
} }
/// Один шаг уровня k, а за ним — r_k подшагов его потомка (рекурсивно) с временной
/// интерполяцией рамки и проекцией обратно. Сила снимается на самом тонком уровне на
/// каждом его подшаге и копится в `fb`; `nf` — сколько подшагов в неё вошло.
fn advance_level(
&mut self,
k: usize,
inlet: (R, R),
fb: &mut [[R; 3]; MAX_BODY_BUCKETS],
nf: &mut usize,
) -> Stats {
let has_child = k < self.patches.len();
let channel = self.spec.case == Case::Channel;
let (wall, wf) = (self.spec.wall, self.spec.wall_fn);
if has_child {
self.pre[k].copy_from_slice(&self.levels[k].f);
}
let stats = self.levels[k].collide(self.spec.collision, self.spec.kbc_model);
self.levels[k].stream();
// Эталонные течения статей периодичны по обеим осям и ГУ не имеют вовсе: перенос
// уже периодичен, поэтому достаточно ничего не накладывать. Стенки канала, вход и
// выход есть только у L0 — тонкие уровни лежат строго внутри.
if channel {
self.levels[k].apply_wall(wall, wf);
if k == 0 {
self.levels[0].free_slip_walls();
self.levels[0].channel_bc(inlet.0, inlet.1, 1.0, self.spec.outlet_extrapolate);
}
}
if has_child {
self.patches[k].ghost_values(&self.pre[k], &mut self.gh_old[k]);
self.patches[k].ghost_values(&self.levels[k].f, &mut self.gh_new[k]);
let r = self.patches[k].r;
for s in 0..r {
self.advance_level(k + 1, inlet, fb, nf);
let w = (s + 1) as R / r as R;
self.patches[k].fill(&mut self.levels[k + 1].f, &self.gh_old[k], &self.gh_new[k], w);
}
let (lo, hi) = self.levels.split_at_mut(k + 1);
self.patches[k].restrict_to(&hi[0].f, &mut lo[k].f);
} else {
// силу снимаем на КАЖДОМ подшаге и усредняем — мгновенное значение на
// последнем подшаге даёт лишний шум в рядах при том же среднем
let g = self.levels[k].force();
for b in 0..MAX_BODY_BUCKETS {
for c in 0..3 {
fb[b][c] += g[b][c];
}
}
*nf += 1;
}
stats
}
pub fn step(&mut self) -> StepRec { pub fn step(&mut self) -> StepRec {
let t = self.step_index; let t = self.step_index;
let (ux_in, uy_in) = self.inlet(t); let inlet = 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 model = self.spec.kbc_model;
let wall = self.spec.wall;
let stats0 = self.l0.collide(sp_collision, model);
self.l0.stream();
// Эталонные течения статей периодичны по обеим осям и ГУ не имеют вовсе: перенос
// уже периодичен, поэтому достаточно ничего не накладывать.
if self.spec.case == Case::Channel {
self.l0.apply_wall(wall);
self.l0.free_slip_walls();
self.l0.channel_bc(ux_in, uy_in, 1.0, outlet_extrap);
}
// ── уровень 1: r подшагов с временной интерполяцией рамки ──
let mut fb = [[0.0 as R; 3]; MAX_BODY_BUCKETS]; let mut fb = [[0.0 as R; 3]; MAX_BODY_BUCKETS];
if let (Some(l1), Some(p)) = (self.l1.as_mut(), self.patch.as_ref()) { let mut nf = 0usize;
p.ghost_values(&self.pre, &mut self.gh_old); let stats0 = self.advance_level(0, inlet, &mut fb, &mut nf);
p.ghost_values(&self.l0.f, &mut self.gh_new); let inv = 1.0 / nf.max(1) as R;
for s in 0..p.r { for b in fb.iter_mut() {
l1.collide(sp_collision, model); for c in b.iter_mut() {
l1.stream(); *c *= inv;
if self.spec.case == Case::Channel {
l1.apply_wall(wall);
}
// силу снимаем на КАЖДОМ подшаге и усредняем — мгновенное значение на
// последнем подшаге даёт лишний шум в рядах при том же среднем
let g = l1.force();
for k in 0..MAX_BODY_BUCKETS {
for c in 0..3 {
fb[k][c] += g[k][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;
for k in 0..MAX_BODY_BUCKETS {
for c in 0..3 {
fb[k][c] *= inv;
}
}
p.restrict_to(&l1.f, &mut self.l0.f);
} else {
fb = self.l0.force();
} }
// ── диагностика ── // ── диагностика ──
let probe = if self.probe_on_fine { let probe = self.levels[self.probe_level].macros_at(self.probe_node);
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 l0 = &self.levels[0];
let (rho_sum, max_u) = self let solid = &l0.geom.solid;
.l0 let (rho_sum, max_u) = l0
.f .f
.par_iter() .par_iter()
.enumerate() .enumerate()
@@ -1082,10 +1294,10 @@ impl Sim {
self.step_index += 1; self.step_index += 1;
let (mut fx, mut fy, mut tz) = (0.0, 0.0, 0.0); let (mut fx, mut fy, mut tz) = (0.0, 0.0, 0.0);
for k in 0..MAX_BODY_BUCKETS { for b in &fb {
fx += fb[k][0]; fx += b[0];
fy += fb[k][1]; fy += b[1];
tz += fb[k][2]; tz += b[2];
} }
StepRec { StepRec {
step: t, step: t,
@@ -1094,6 +1306,7 @@ impl Sim {
tz, tz,
body: fb, body: fb,
uy_probe: probe.2, uy_probe: probe.2,
rho_probe: probe.0,
rho_mean: rho_sum / self.fluid_count, rho_mean: rho_sum / self.fluid_count,
max_u, max_u,
gamma_mean: stats0.gamma_mean(), gamma_mean: stats0.gamma_mean(),
@@ -1104,22 +1317,27 @@ impl Sim {
} }
} }
/// y⁺ первого узла на самом тонком уровне по текущему полю.
pub fn yplus(&self) -> YPlus {
let l = self.levels.last().unwrap();
let tau = self.spec.levels().last().unwrap().tau;
let nu = math::CS2 * (tau - 0.5);
yplus_stats(&l.geom, nu, |n| l.u_at_t(n))
}
/// Характерный размер тела в единицах того уровня, где снимается сила. /// Характерный размер тела в единицах того уровня, где снимается сила.
pub fn force_ref_size(&self) -> R { pub fn force_ref_size(&self) -> R {
match &self.patch { self.spec.scene.ref_size() * self.spec.levels().last().unwrap().scale
Some(p) => self.spec.scene.ref_size() * p.r as R,
None => self.spec.scene.ref_size(),
}
} }
/// Поле для картинки: (значение, маска тела) на сетке L0. /// Поле для картинки: (значение, маска тела) на сетке L0.
pub fn sample_field(&self, kind: FieldKind) -> (Vec<R>, &[bool]) { pub fn sample_field(&self, kind: FieldKind) -> (Vec<R>, &[bool]) {
let (nx, ny) = (self.l0.nx, self.l0.ny); let (nx, ny) = (self.levels[0].nx, self.levels[0].ny);
let mut out = vec![0.0; nx * ny]; let mut out = vec![0.0; nx * ny];
match kind { match kind {
FieldKind::Speed => { FieldKind::Speed => {
for n in 0..nx * ny { for n in 0..nx * ny {
let (_, ux, uy) = self.l0.macros_at(n); let (_, ux, uy) = self.levels[0].macros_at(n);
out[n] = (ux * ux + uy * uy).sqrt(); out[n] = (ux * ux + uy * uy).sqrt();
} }
} }
@@ -1128,7 +1346,7 @@ impl Sim {
let mut ux = vec![0.0; nx * ny]; let mut ux = vec![0.0; nx * ny];
let mut uy = vec![0.0; nx * ny]; let mut uy = vec![0.0; nx * ny];
for n in 0..nx * ny { for n in 0..nx * ny {
let (_, a, b) = self.l0.macros_at(n); let (_, a, b) = self.levels[0].macros_at(n);
ux[n] = a; ux[n] = a;
uy[n] = b; uy[n] = b;
} }
@@ -1146,21 +1364,21 @@ impl Sim {
} }
FieldKind::Density => { FieldKind::Density => {
for n in 0..nx * ny { for n in 0..nx * ny {
out[n] = self.l0.macros_at(n).0; out[n] = self.levels[0].macros_at(n).0;
} }
} }
FieldKind::Gamma => out.copy_from_slice(&self.l0.gamma), FieldKind::Gamma => out.copy_from_slice(&self.levels[0].gamma),
} }
(out, &self.l0.geom.solid) (out, &self.levels[0].geom.solid)
} }
/// Полное поле скорости уровня L0 — для метрик эталонных течений и радиуса влияния. /// Полное поле скорости уровня L0 — для метрик эталонных течений и радиуса влияния.
pub fn sample_velocity(&self) -> (Vec<R>, Vec<R>) { pub fn sample_velocity(&self) -> (Vec<R>, Vec<R>) {
let n = self.l0.nx * self.l0.ny; let n = self.levels[0].nx * self.levels[0].ny;
let mut ux = Vec::with_capacity(n); let mut ux = Vec::with_capacity(n);
let mut uy = Vec::with_capacity(n); let mut uy = Vec::with_capacity(n);
for k in 0..n { for k in 0..n {
let (_, a, b) = self.l0.macros_at(k); let (_, a, b) = self.levels[0].macros_at(k);
ux.push(a); ux.push(a);
uy.push(b); uy.push(b);
} }
@@ -1171,12 +1389,12 @@ impl Sim {
/// срезов складывается x–t диаграмма, по которой видно, бежит возмущение со скоростью /// срезов складывается x–t диаграмма, по которой видно, бежит возмущение со скоростью
/// звука или конвекции и есть ли стоячие узлы. /// звука или конвекции и есть ли стоячие узлы.
pub fn sample_centerline(&self) -> (Vec<R>, Vec<R>) { pub fn sample_centerline(&self) -> (Vec<R>, Vec<R>) {
let y = self.l0.ny / 2; let y = self.levels[0].ny / 2;
let nx = self.l0.nx; let nx = self.levels[0].nx;
let mut rho = Vec::with_capacity(nx); let mut rho = Vec::with_capacity(nx);
let mut ux = Vec::with_capacity(nx); let mut ux = Vec::with_capacity(nx);
for x in 0..nx { for x in 0..nx {
let (r, a, _) = self.l0.macros_at(y * nx + x); let (r, a, _) = self.levels[0].macros_at(y * nx + x);
rho.push(r); rho.push(r);
ux.push(a); ux.push(a);
} }
@@ -1185,7 +1403,7 @@ impl Sim {
/// Есть ли в поле NaN/inf — признак развала счёта. /// Есть ли в поле NaN/inf — признак развала счёта.
pub fn is_finite(&self) -> bool { pub fn is_finite(&self) -> bool {
self.l0.f.par_iter().all(|c| c.iter().all(|v| v.is_finite())) self.levels[0].f.par_iter().all(|c| c.iter().all(|v| v.is_finite()))
} }
} }
@@ -1201,13 +1419,14 @@ mod tests {
Level::new( Level::new(
nx, nx,
ny, ny,
vec![0.5; nx], vec![0.5; nx * ny],
Geom { Geom {
nx, nx,
ny, ny,
solid: vec![false; nx * ny], solid: vec![false; nx * ny],
links: Vec::new(), links: Vec::new(),
wall_nodes: Vec::new(), wall_nodes: Vec::new(),
wf_nodes: Vec::new(),
body_cx: 0.0, body_cx: 0.0,
body_cy: 0.0, body_cy: 0.0,
}, },
+5 -4
View File
@@ -382,7 +382,8 @@ pub struct GifWriter {
h: usize, h: usize,
range: Range, range: Range,
hud: Hud, hud: Hud,
patch: Option<(usize, usize, usize, usize)>, /// Контуры патчей измельчения в клетках L0, от внешнего к внутреннему.
patches: Vec<(usize, usize, usize, usize)>,
dither: DelayDither, dither: DelayDither,
pub frames: u32, pub frames: u32,
} }
@@ -399,7 +400,7 @@ impl GifWriter {
range: Range, range: Range,
plan: GifPlan, plan: GifPlan,
hud: Hud, hud: Hud,
patch: Option<(usize, usize, usize, usize)>, patches: Vec<(usize, usize, usize, usize)>,
) -> std::io::Result<GifWriter> { ) -> std::io::Result<GifWriter> {
let scale = scale.max(1); let scale = scale.max(1);
let down = down.max(1); let down = down.max(1);
@@ -426,7 +427,7 @@ impl GifWriter {
h, h,
range, range,
hud, hud,
patch, patches,
dither: DelayDither::default(), dither: DelayDither::default(),
frames: 0, frames: 0,
}) })
@@ -483,7 +484,7 @@ impl GifWriter {
} }
} }
} }
if let Some((ax, bx, ay, by)) = self.patch { for &(ax, bx, ay, by) in &self.patches {
let d = self.down; let d = self.down;
self.draw_patch_outline(&mut buf, ax / d, bx / d, ay / d, by / d); self.draw_patch_outline(&mut buf, ax / d, bx / d, ay / d, by / d);
} }
File diff suppressed because it is too large Load Diff
+377 -38
View File
@@ -97,6 +97,9 @@ pub struct StepRec {
/// сваливаются в последнее, поэтому сумма по вёдрам всегда точна. /// сваливаются в последнее, поэтому сумма по вёдрам всегда точна.
pub body: [[R; 3]; MAX_BODY_BUCKETS], pub body: [[R; 3]; MAX_BODY_BUCKETS],
pub uy_probe: R, pub uy_probe: R,
/// Плотность в том же зонде: её пульсации после установления — мера паразитной
/// акустики (отражений от границ и от стыков уровней).
pub rho_probe: R,
pub rho_mean: R, pub rho_mean: R,
pub max_u: R, pub max_u: R,
pub gamma_mean: R, pub gamma_mean: R,
@@ -112,9 +115,9 @@ pub struct Spec {
pub nx: usize, pub nx: usize,
pub ny: usize, pub ny: usize,
pub scene: Scene, pub scene: Scene,
pub refine: usize, /// Цепочка вложенных патчей измельчения, от внешнего к внутреннему. Пусто — один
/// Границы патча измельчения в координатах L0: (ax, bx, ay, by). /// уровень L0. Каждый патч задан в координатах СВОЕГО РОДИТЕЛЯ (для первого — L0).
pub patch: Option<(usize, usize, usize, usize)>, pub patches: Vec<PatchSpec>,
pub units: Units, pub units: Units,
pub beta0: R, pub beta0: R,
pub steps: u64, pub steps: u64,
@@ -132,17 +135,116 @@ pub struct Spec {
/// Длина губки после входа, столбцов. /// Длина губки после входа, столбцов.
pub sponge_in: usize, pub sponge_in: usize,
pub sponge_mult: R, pub sponge_mult: R,
/// Боковые губки у верхней и нижней стенок канала, рядов L0 (0 — нет).
pub sponge_side: usize,
pub collision: Collision, pub collision: Collision,
/// Что входит в сдвиговую часть s (см. `math::KbcModel`). /// Что входит в сдвиговую часть s (см. `math::KbcModel`).
pub kbc_model: math::KbcModel, pub kbc_model: math::KbcModel,
/// Чем замыкаются недостающие популяции на теле (см. `math::WallModel`). /// Чем замыкаются недостающие популяции на теле (см. `math::WallModel`).
pub wall: math::WallModel, pub wall: math::WallModel,
/// Пристеночная функция поверх модели стенки (см. `math::WallFunction`).
pub wall_fn: math::WallFunction,
/// Постановка задачи. /// Постановка задачи.
pub case: Case, pub case: Case,
/// Узел зонда следа в координатах L0. /// Узел зонда следа в координатах L0.
pub probe: (usize, usize), pub probe: (usize, usize),
} }
/// Патч измельчения: прямоугольник узлов РОДИТЕЛЬСКОГО уровня [ax, bx] × [ay, by] и
/// коэффициент r. Тонкий уровень имеет r·(bx − ax) + 1 узлов по x, его узел (0, 0) совпадает
/// с узлом (ax, ay) родителя, и за один шаг родителя он делает r подшагов.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PatchSpec {
pub r: usize,
pub ax: usize,
pub bx: usize,
pub ay: usize,
pub by: usize,
}
/// Геометрия уровня, выведенная из цепочки патчей.
#[derive(Clone, Copy, Debug)]
pub struct LevelGeo {
pub nx: usize,
pub ny: usize,
/// Клеток этого уровня на клетку L0.
pub scale: R,
/// Положение узла (0, 0) уровня в координатах L0.
pub ox: R,
pub oy: R,
/// Время релаксации уровня: τ_k = r_k(τ_{k−1} − ½) + ½ — одна и та же ν на всех уровнях.
pub tau: R,
}
impl Spec {
/// Все уровни, от L0 к самому тонкому.
pub fn levels(&self) -> Vec<LevelGeo> {
let tau0 = 1.0 / (2.0 * self.beta0);
let mut out = vec![LevelGeo {
nx: self.nx,
ny: self.ny,
scale: 1.0,
ox: 0.0,
oy: 0.0,
tau: tau0,
}];
for p in &self.patches {
let par = *out.last().unwrap();
out.push(LevelGeo {
nx: p.r * (p.bx - p.ax) + 1,
ny: p.r * (p.by - p.ay) + 1,
scale: par.scale * p.r as R,
ox: par.ox + p.ax as R / par.scale,
oy: par.oy + p.ay as R / par.scale,
tau: p.r as R * (par.tau - 0.5) + 0.5,
});
}
out
}
/// Сцена в координатах уровня `g`.
pub fn level_scene(&self, g: &LevelGeo) -> Scene {
if g.scale == 1.0 && g.ox == 0.0 && g.oy == 0.0 {
self.scene.clone()
} else {
self.scene.refined(g.scale, g.ox, g.oy)
}
}
/// Подшагов самого тонкого уровня на один шаг L0.
pub fn substeps(&self) -> usize {
self.patches.iter().map(|p| p.r).product()
}
/// Обновлений узлов за один шаг L0 по всем уровням — мера стоимости.
pub fn nodes_per_step(&self) -> f64 {
let mut sub = 1usize;
let mut total = 0usize;
for (k, g) in self.levels().iter().enumerate() {
if k > 0 {
sub *= self.patches[k - 1].r;
}
total += sub * g.nx * g.ny;
}
total as f64
}
/// Контуры патчей в клетках L0 — для рамок на гифке.
pub fn patch_boxes_l0(&self) -> Vec<(usize, usize, usize, usize)> {
let lv = self.levels();
self.patches
.iter()
.enumerate()
.map(|(k, p)| {
let par = &lv[k];
let x = |v: usize| (par.ox + v as R / par.scale).round() as usize;
let y = |v: usize| (par.oy + v as R / par.scale).round() as usize;
(x(p.ax), x(p.bx), y(p.ay), y(p.by))
})
.collect()
}
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Параметры запуска // Параметры запуска
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -197,6 +299,13 @@ struct Cli {
/// Границы патча в координатах L0: ax,bx,ay,by (по умолчанию строятся вокруг тела) /// Границы патча в координатах L0: ax,bx,ay,by (по умолчанию строятся вокруг тела)
#[arg(long, help_heading = "Сетка")] #[arg(long, help_heading = "Сетка")]
patch: Option<String>, patch: Option<String>,
/// ЦЕПОЧКА вложенных уровней вместо одного патча: "r:up,down,side; r:up,down,side; …" —
/// от внешнего к внутреннему. r — во сколько раз уровень тоньше родителя; up, down, side —
/// отступы его границ от центра тела в диаметрах тела (вверх по потоку, вниз, в стороны).
/// Так строится внешний домен с грубыми буферами: L0 крупный и большой, к телу сетка
/// мельчает ступенями. Перекрывает --refine и --patch.
#[arg(long, help_heading = "Сетка")]
levels: Option<String>,
// ── тело ── // ── тело ──
/// Форма обтекаемого тела /// Форма обтекаемого тела
@@ -282,6 +391,13 @@ struct Cli {
#[arg(long, default_value = "hrr", value_parser = math::WallModel::ALL, #[arg(long, default_value = "hrr", value_parser = math::WallModel::ALL,
help_heading = "Схема")] help_heading = "Схема")]
wall: String, wall: String,
/// Пристеночная функция поверх модели стенки: none — обычное прилипание; spalding — закон
/// Spalding для неразрешённого пограничного слоя (для grad/hrr через целевую скорость и
/// тензор давлений, для bouzidi через скорость скольжения стенки). В вязком подслое сама
/// сводится к прилипанию. С --wall staircase не сочетается.
#[arg(long, default_value = "none", value_parser = math::WallFunction::ALL,
help_heading = "Схема")]
wall_function: String,
/// Состав сдвиговой части KBC (табл. I 2D-статьи): n1 — только девиатор {N, Π_xy} /// Состав сдвиговой части KBC (табл. I 2D-статьи): n1 — только девиатор {N, Π_xy}
/// (KBC D), объёмная вязкость гуляет вместе с γ и может стать отрицательной; n2 — /// (KBC D), объёмная вязкость гуляет вместе с γ и может стать отрицательной; n2 —
/// девиатор со следом {N, Π_xy, T} (KBC C), объёмная вязкость фиксирована ξ = ν. /// девиатор со следом {N, Π_xy, T} (KBC C), объёмная вязкость фиксирована ξ = ν.
@@ -301,6 +417,10 @@ struct Cli {
/// волны как жёсткий поршень; губка у входа гасит их до отражения. /// волны как жёсткий поршень; губка у входа гасит их до отражения.
#[arg(long, default_value_t = 0, help_heading = "Схема")] #[arg(long, default_value_t = 0, help_heading = "Схема")]
sponge_in: usize, sponge_in: usize,
/// Боковые губки у верхней и нижней стенок канала, рядов L0 (0 — выключены). Гасят
/// волны, бегущие поперёк потока, до отражения от зеркальных стенок.
#[arg(long, default_value_t = 0, help_heading = "Схема")]
sponge_side: usize,
/// Во сколько раз губка поднимает вязкость /// Во сколько раз губка поднимает вязкость
#[arg(long, default_value_t = 30.0, help_heading = "Схема")] #[arg(long, default_value_t = 30.0, help_heading = "Схема")]
sponge_mult: R, sponge_mult: R,
@@ -455,6 +575,57 @@ fn parse_poly(v: &str) -> Result<Vec<[R; 2]>, String> {
Ok(out) Ok(out)
} }
/// Разбор --levels: "r:up,down,side; …" от внешнего уровня к внутреннему. Отступы — от центра
/// тела в его диаметрах; граница каждого уровня пересчитывается в координаты родителя и
/// зажимается внутрь него (не ближе двух узлов к краю по x и одного по y — как у одиночного
/// патча: рамка тонкого уровня не должна лечь на рамку или ряды стенок родителя).
fn parse_levels(
s: &str,
cx: R,
cy: R,
d: R,
nx: usize,
ny: usize,
) -> Result<Vec<PatchSpec>, String> {
let mut out = Vec::new();
// родитель: размеры, масштаб и начало в координатах L0
let (mut pnx, mut pny, mut scale, mut ox, mut oy) = (nx, ny, 1.0 as R, 0.0 as R, 0.0 as R);
for item in s.split(';').map(str::trim).filter(|z| !z.is_empty()) {
let (rs, rest) = item
.split_once(':')
.ok_or(format!("--levels: в «{item}» ожидалось r:up,down,side"))?;
let r: usize = rs.trim().parse().map_err(|e| format!("--levels, r в «{item}»: {e}"))?;
if r < 2 {
return Err(format!("--levels: r = {r} в «{item}», уровень обязан быть тоньше родителя"));
}
let v: Result<Vec<R>, _> = rest.split(',').map(|t| t.trim().parse::<R>()).collect();
let v = v.map_err(|e| format!("--levels, отступы в «{item}»: {e}"))?;
if v.len() != 3 || v.iter().any(|z| !(*z > 0.0)) {
return Err(format!("--levels: в «{item}» нужны три положительных отступа up,down,side"));
}
let to = |x: R, o: R| ((x - o) * scale).round();
let ax = to(cx - v[0] * d, ox).max(2.0) as usize;
let bx = to(cx + v[1] * d, ox).min(pnx as R - 2.0).max(0.0) as usize;
let ay = to(cy - v[2] * d, oy).max(1.0) as usize;
let by = to(cy + v[2] * d, oy).min(pny as R - 2.0).max(0.0) as usize;
if ax + 1 >= bx || ay + 1 >= by {
return Err(format!(
"--levels: уровень «{item}» не помещается в родителя {pnx}×{pny} (получилось x [{ax},{bx}], y [{ay},{by}])"
));
}
out.push(PatchSpec { r, ax, bx, ay, by });
ox += ax as R / scale;
oy += ay as R / scale;
scale *= r as R;
pnx = r * (bx - ax) + 1;
pny = r * (by - ay) + 1;
}
if out.is_empty() {
return Err("--levels: список уровней пуст".into());
}
Ok(out)
}
fn build_spec(cli: &Cli) -> Result<Spec, String> { fn build_spec(cli: &Cli) -> Result<Spec, String> {
let case = Case::from_str(&cli.case).ok_or("неизвестная постановка")?; let case = Case::from_str(&cli.case).ok_or("неизвестная постановка")?;
if cli.nx < 16 || cli.ny < 16 { if cli.nx < 16 || cli.ny < 16 {
@@ -532,11 +703,22 @@ fn build_spec(cli: &Cli) -> Result<Spec, String> {
)); ));
} }
if case.is_periodic() && cli.refine > 1 { let wall = math::WallModel::from_str(&cli.wall).ok_or("неизвестная модель стенки")?;
let wall_fn =
math::WallFunction::from_str(&cli.wall_function).ok_or("неизвестная пристеночная функция")?;
if wall_fn.is_on() && wall == math::WallModel::Staircase {
return Err("--wall-function не сочетается с --wall staircase: ступенька не знает, где стенка, а пристеночной функции нужно расстояние до неё"
.into());
}
if case.is_periodic() && (cli.refine > 1 || cli.levels.is_some()) {
return Err("эталонные течения периодичны и патча измельчения не имеют: --refine 1".into()); return Err("эталонные течения периодичны и патча измельчения не имеют: --refine 1".into());
} }
// патч измельчения: по умолчанию охватывает тело и ближний след // Цепочка патчей. --levels строит её по отступам от тела; без него — прежний одиночный
let patch = if cli.refine > 1 { // патч из --refine/--patch, который по умолчанию охватывает тело и ближний след.
let patches: Vec<PatchSpec> = if let Some(lv) = &cli.levels {
let (bcx, bcy) = scene.center();
parse_levels(lv, bcx, bcy, scene.ref_size(), cli.nx, cli.ny)?
} else if cli.refine > 1 {
let d = cli.size; let d = cli.size;
let (ax, bx, ay, by) = match &cli.patch { let (ax, bx, ay, by) = match &cli.patch {
Some(s) => { Some(s) => {
@@ -560,8 +742,7 @@ fn build_spec(cli: &Cli) -> Result<Spec, String> {
// на них ГУ работает по всему ряду, а рамка патча их бы перезаписала // на них ГУ работает по всему ряду, а рамка патча их бы перезаписала
if !(1 <= ay && ay < by && by <= cli.ny - 2) { if !(1 <= ay && ay < by && by <= cli.ny - 2) {
return Err(format!( return Err(format!(
"патч по y [{ay},{by}] обязан лежать внутри (0,{}) и не трогать ряды стенок; \ "патч по y [{ay},{by}] обязан лежать внутри (0,{}) и не трогать ряды стенок; при теле {d} ячеек минимальное ny ≈ {}",
при теле {d} ячеек минимальное ny ≈ {}",
cli.ny - 1, cli.ny - 1,
(4.0 * d) as usize + 6 (4.0 * d) as usize + 6
)); ));
@@ -569,10 +750,26 @@ fn build_spec(cli: &Cli) -> Result<Spec, String> {
if !(2 <= ax && ax < bx && bx <= cli.nx - 2) { if !(2 <= ax && ax < bx && bx <= cli.nx - 2) {
return Err(format!("патч по x [{ax},{bx}] обязан лежать внутри (1,{})", cli.nx - 1)); return Err(format!("патч по x [{ax},{bx}] обязан лежать внутри (1,{})", cli.nx - 1));
} }
Some((ax, bx, ay, by)) vec![PatchSpec { r: cli.refine, ax, bx, ay, by }]
} else { } else {
None Vec::new()
}; };
// Губки живут на L0 и не должны доставать до первого патча: внутри патча L0 перезаписывается
// рестрикцией, и губка на нём просто бы не действовала.
let patch = patches.first().map(|p| (p.ax, p.bx, p.ay, p.by));
if cli.sponge_side > 0 {
if 2 * cli.sponge_side + 4 >= cli.ny {
return Err(format!("боковые губки ({} рядов) перекрывают весь канал", cli.sponge_side));
}
if let Some((_, _, ay, by)) = patch {
if cli.sponge_side + 1 >= ay || by + cli.sponge_side + 1 >= cli.ny - 1 {
return Err(format!(
"боковые губки ({} рядов) достают до патча (ay={ay}, by={by})",
cli.sponge_side
));
}
}
}
// Губка перед выходом. Задача канала «вход по скорости + выход по давлению» акустически // Губка перед выходом. Задача канала «вход по скорости + выход по давлению» акустически
// есть четвертьволновая труба: вход отражает продольные волны как жёсткий поршень, выход — // есть четвертьволновая труба: вход отражает продольные волны как жёсткий поршень, выход —
@@ -651,8 +848,7 @@ fn build_spec(cli: &Cli) -> Result<Spec, String> {
nx: cli.nx, nx: cli.nx,
ny: cli.ny, ny: cli.ny,
scene, scene,
refine: 1, patches: Vec::new(),
patch: None,
units, units,
beta0, beta0,
steps, steps,
@@ -666,9 +862,11 @@ fn build_spec(cli: &Cli) -> Result<Spec, String> {
sponge_len: 0, sponge_len: 0,
sponge_in: 0, sponge_in: 0,
sponge_mult: 1.0, sponge_mult: 1.0,
sponge_side: 0,
collision: if cli.collision == "bgk" { Collision::Bgk } else { Collision::Kbc }, collision: if cli.collision == "bgk" { Collision::Bgk } else { Collision::Kbc },
kbc_model: math::KbcModel::from_str(&cli.kbc_model).ok_or("неизвестная модель KBC")?, kbc_model: math::KbcModel::from_str(&cli.kbc_model).ok_or("неизвестная модель KBC")?,
wall: math::WallModel::from_str(&cli.wall).ok_or("неизвестная модель стенки")?, wall,
wall_fn,
case, case,
probe: (cli.nx / 2, cli.ny / 2), probe: (cli.nx / 2, cli.ny / 2),
}); });
@@ -678,8 +876,7 @@ fn build_spec(cli: &Cli) -> Result<Spec, String> {
nx: cli.nx, nx: cli.nx,
ny: cli.ny, ny: cli.ny,
scene, scene,
refine: cli.refine, patches,
patch,
units, units,
beta0, beta0,
steps, steps,
@@ -693,10 +890,12 @@ fn build_spec(cli: &Cli) -> Result<Spec, String> {
sponge_len, sponge_len,
sponge_in: cli.sponge_in, sponge_in: cli.sponge_in,
sponge_mult: cli.sponge_mult, sponge_mult: cli.sponge_mult,
sponge_side: cli.sponge_side,
case, case,
collision: if cli.collision == "bgk" { Collision::Bgk } else { Collision::Kbc }, collision: if cli.collision == "bgk" { Collision::Bgk } else { Collision::Kbc },
kbc_model: math::KbcModel::from_str(&cli.kbc_model).ok_or("неизвестная модель KBC")?, kbc_model: math::KbcModel::from_str(&cli.kbc_model).ok_or("неизвестная модель KBC")?,
wall: math::WallModel::from_str(&cli.wall).ok_or("неизвестная модель стенки")?, wall,
wall_fn,
probe, probe,
}) })
} }
@@ -762,6 +961,13 @@ impl Backend {
Backend::Gpu(s) => s.is_finite(), Backend::Gpu(s) => s.is_finite(),
} }
} }
fn yplus(&mut self) -> cpu::YPlus {
match self {
Backend::Cpu(s) => s.yplus(),
#[cfg(feature = "gpu")]
Backend::Gpu(s) => s.yplus(),
}
}
fn force_ref_size(&self) -> R { fn force_ref_size(&self) -> R {
match self { match self {
Backend::Cpu(s) => s.force_ref_size(), Backend::Cpu(s) => s.force_ref_size(),
@@ -864,7 +1070,7 @@ fn run(cli: Cli) -> Result<(), String> {
range, range,
plan, plan,
hud, hud,
spec.patch, spec.patch_boxes_l0(),
) )
.map_err(|e| format!("не удалось создать {path}: {e}"))?, .map_err(|e| format!("не удалось создать {path}: {e}"))?,
) )
@@ -890,13 +1096,7 @@ fn run(cli: Cli) -> Result<(), String> {
let mut recs: Vec<StepRec> = Vec::with_capacity(spec.steps as usize + 1); let mut recs: Vec<StepRec> = Vec::with_capacity(spec.steps as usize + 1);
let t_start = Instant::now(); let t_start = Instant::now();
let mut blew_up = false; let mut blew_up = false;
let nodes_per_step = (spec.nx * spec.ny let nodes_per_step = spec.nodes_per_step();
+ spec
.patch
.map(|(ax, bx, ay, by)| {
spec.refine * (spec.refine * (bx - ax) + 1) * (spec.refine * (by - ay) + 1)
})
.unwrap_or(0)) as f64;
let mut extras = Extras::default(); let mut extras = Extras::default();
let series_every = cli.series_every.max(1); let series_every = cli.series_every.max(1);
@@ -930,6 +1130,15 @@ fn run(cli: Cli) -> Result<(), String> {
if need_report { if need_report {
live_line(&spec, &rec, d_ref, t_start.elapsed().as_secs_f64(), nodes_per_step, full); live_line(&spec, &rec, d_ref, t_start.elapsed().as_secs_f64(), nodes_per_step, full);
} }
// y⁺ на теле: два десятка снимков во второй половине прогона. На GPU каждый снимок —
// скачивание самого тонкого уровня, поэтому редко.
if spec.case == Case::Channel && !spec.scene.bodies.is_empty() {
let half = spec.steps / 2;
let every = (spec.steps / 40).max(1);
if t >= half && (t - half) % every == 0 {
extras.yplus.push(back.yplus());
}
}
if spec.case.is_periodic() && t % cli.case_every.max(1) == 0 { if spec.case.is_periodic() && t % cli.case_every.max(1) == 0 {
back.flush(&mut scratch); back.flush(&mut scratch);
let (vx, vy) = back.sample_velocity(); let (vx, vy) = back.sample_velocity();
@@ -1057,6 +1266,8 @@ struct Extras {
influence: Option<(R, R, R)>, influence: Option<(R, R, R)>,
/// Относительная ошибка поля u_x против точного решения Тейлора–Грина. /// Относительная ошибка поля u_x против точного решения Тейлора–Грина.
tg_error: Option<R>, tg_error: Option<R>,
/// Снимки y⁺ первого узла на теле во второй половине прогона (канал).
yplus: Vec<cpu::YPlus>,
} }
/// Интегральные метрики периодического течения: энергия, энстрофия, палинстрофия. /// Интегральные метрики периодического течения: энергия, энстрофия, палинстрофия.
@@ -1183,15 +1394,20 @@ fn print_header(spec: &Spec, cli: &Cli, plan: &gif::GifPlan, field: FieldKind) {
println!("\n── Сетка ───────────────────────────────────────────────────────────────────"); println!("\n── Сетка ───────────────────────────────────────────────────────────────────");
println!(" L0 {:>12} {} узлов", format!("{}×{}", spec.nx, spec.ny), println!(" L0 {:>12} {} узлов", format!("{}×{}", spec.nx, spec.ny),
spec.nx * spec.ny); spec.nx * spec.ny);
match spec.patch { let _ = tau;
Some((ax, bx, ay, by)) => { let lv = spec.levels();
let (nfx, nfy) = (spec.refine * (bx - ax) + 1, spec.refine * (by - ay) + 1); if lv.len() == 1 {
let tau1 = spec.refine as R * (tau - 0.5) + 0.5; println!(" L1 {:>12} (измельчение выключено)", "нет");
println!(" L1 (×{}) {:>12} область x∈[{ax},{bx}] y∈[{ay},{by}], τ₁={:.4}", }
spec.refine, format!("{nfx}×{nfy}"), tau1); for (k, p) in spec.patches.iter().enumerate() {
println!(" {:>12} {} подшагов на шаг L0", "", spec.refine); let g = &lv[k + 1];
} let (ax, bx, ay, by) = (p.ax, p.bx, p.ay, p.by);
None => println!(" L1 {:>12} (измельчение выключено)", "нет"), println!(" L{} (×{}) {:>12} область L{k} x∈[{ax},{bx}] y∈[{ay},{by}], τ={:.4}, D={:.1}",
k + 1, p.r, format!("{}×{}", g.nx, g.ny), g.tau, spec.scene.ref_size() * g.scale);
}
if !spec.patches.is_empty() {
println!(" {:>12} {} подшагов тонкого уровня на шаг L0, {:.3e} обновлений узлов на шаг L0",
"", spec.substeps(), spec.nodes_per_step());
} }
println!(" оператор {:>12}", println!(" оператор {:>12}",
if spec.collision == Collision::Kbc { if spec.collision == Collision::Kbc {
@@ -1574,12 +1790,12 @@ fn final_report(
fn write_csv(path: &str, recs: &[StepRec], spec: &Spec, d_ref: R) -> std::io::Result<()> { 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)?); 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")?; 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,rho_probe")?;
let s = spec.units.u_lat * spec.units.u_lat * d_ref; let s = spec.units.u_lat * spec.units.u_lat * d_ref;
for r in recs { for r in recs {
writeln!( writeln!(
f, f,
"{},{:.9e},{:.6e},{:.6e},{:.6e},{:.6e},{:.9},{:.6e},{:.6},{:.6},{:.6},{:.6},{:.6}", "{},{:.9e},{:.6e},{:.6e},{:.6e},{:.6e},{:.9},{:.6e},{:.6},{:.6},{:.6},{:.6},{:.6},{:.9}",
r.step, r.step,
spec.units.time_of_step(r.step), spec.units.time_of_step(r.step),
2.0 * r.fx / s, 2.0 * r.fx / s,
@@ -1592,7 +1808,8 @@ fn write_csv(path: &str, recs: &[StepRec], spec: &Spec, d_ref: R) -> std::io::Re
r.gamma_min, r.gamma_min,
r.gamma_max, r.gamma_max,
r.degenerate_frac, r.degenerate_frac,
r.xi_negative_frac r.xi_negative_frac,
r.rho_probe
)?; )?;
} }
Ok(()) Ok(())
@@ -1623,7 +1840,19 @@ fn write_summary(
writeln!(f, " \"backend\": \"{}\",", cli.backend)?; writeln!(f, " \"backend\": \"{}\",", cli.backend)?;
writeln!(f, " \"wall\": \"{}\", \"kbc_model\": \"{}\", \"collision\": \"{}\",", writeln!(f, " \"wall\": \"{}\", \"kbc_model\": \"{}\", \"collision\": \"{}\",",
cli.wall, cli.kbc_model, cli.collision)?; cli.wall, cli.kbc_model, cli.collision)?;
writeln!(f, " \"nx\": {}, \"ny\": {}, \"refine\": {},", spec.nx, spec.ny, spec.refine)?; writeln!(f, " \"nx\": {}, \"ny\": {}, \"refine\": {},", spec.nx, spec.ny, spec.substeps())?;
let lv = spec.levels();
let levels: Vec<String> = spec
.patches
.iter()
.zip(lv.iter().skip(1))
.map(|(p, g)| {
format!("{{\"r\": {}, \"nx\": {}, \"ny\": {}, \"box\": [{}, {}, {}, {}]}}",
p.r, g.nx, g.ny, p.ax, p.bx, p.ay, p.by)
})
.collect();
writeln!(f, " \"levels\": [{}], \"nodes_per_step\": {}, \"wall_function\": \"{}\", \"sponge_side\": {},",
levels.join(", "), spec.nodes_per_step(), spec.wall_fn.name(), spec.sponge_side)?;
writeln!(f, " \"bodies\": {}, \"body_size_cells\": {},", writeln!(f, " \"bodies\": {}, \"body_size_cells\": {},",
spec.scene.bodies.len(), q(spec.scene.ref_size()))?; spec.scene.bodies.len(), q(spec.scene.ref_size()))?;
writeln!(f, " \"re\": {}, \"u_lat\": {}, \"mach\": {}, \"tau\": {},", writeln!(f, " \"re\": {}, \"u_lat\": {}, \"mach\": {}, \"tau\": {},",
@@ -1655,10 +1884,11 @@ fn write_summary(
let rho: Vec<R> = recs[h..].iter().map(|r| r.rho_mean).collect(); let rho: Vec<R> = recs[h..].iter().map(|r| r.rho_mean).collect();
let (st, _, _) = math::strouhal(&uy, u.d_lat, u.u_lat, 8192); let (st, _, _) = math::strouhal(&uy, u.d_lat, u.u_lat, 8192);
let (cdm, _) = math::mean_std(&cd); let (cdm, _) = math::mean_std(&cd);
let (_, clr) = math::mean_std(&cl); let (clm, clr) = math::mean_std(&cl);
let (cmm, _) = math::mean_std(&cm); let (cmm, _) = math::mean_std(&cm);
let (rm, _) = math::mean_std(&rho); let (rm, _) = math::mean_std(&rho);
let bl = spec.scene.frontal_extent() / spec.ny as R; let bl = spec.scene.frontal_extent() / spec.ny as R;
writeln!(f, " \"cl_mean\": {},", q(clm))?;
writeln!(f, " \"strouhal\": {}, \"cd\": {}, \"cl_rms\": {}, \"cm\": {},", writeln!(f, " \"strouhal\": {}, \"cd\": {}, \"cl_rms\": {}, \"cm\": {},",
q(st), q(cdm), q(clr), q(cmm))?; q(st), q(cdm), q(clr), q(cmm))?;
writeln!(f, " \"blockage\": {}, \"strouhal_corr\": {}, \"cd_corr\": {}, \"cl_rms_corr\": {},", writeln!(f, " \"blockage\": {}, \"strouhal_corr\": {}, \"cd_corr\": {}, \"cl_rms_corr\": {},",
@@ -1690,6 +1920,18 @@ fn write_summary(
math::mean_std(&recs[h..].iter().map(|r| r.degenerate_frac).collect::<Vec<_>>()); math::mean_std(&recs[h..].iter().map(|r| r.degenerate_frac).collect::<Vec<_>>());
let (xn, _) = let (xn, _) =
math::mean_std(&recs[h..].iter().map(|r| r.xi_negative_frac).collect::<Vec<_>>()); math::mean_std(&recs[h..].iter().map(|r| r.xi_negative_frac).collect::<Vec<_>>());
// Пульсация плотности в зонде во второй половине: мера паразитной акустики —
// волн, отражённых от границ домена и от стыков уровней.
let (_, rp) = math::mean_std(&recs[h..].iter().map(|r| r.rho_probe).collect::<Vec<_>>());
writeln!(f, " \"rho_probe_rms\": {},", q(rp))?;
if !extras.yplus.is_empty() {
let k = extras.yplus.len() as R;
let avg = |g: &dyn Fn(&cpu::YPlus) -> R| extras.yplus.iter().map(g).sum::<R>() / k;
let ymax = extras.yplus.iter().map(|y| y.max).fold(0.0, R::max);
writeln!(f, " \"yplus_mean\": {}, \"yplus_max\": {}, \"yplus_frac_log\": {}, \"u_tau_mean\": {}, \"yplus_nodes\": {},",
q(avg(&|y| y.mean)), q(ymax), q(avg(&|y| y.frac_log)),
q(avg(&|y| y.u_tau_mean)), extras.yplus[0].nodes)?;
}
writeln!(f, " \"gamma_mean\": {}, \"degenerate_frac\": {}, \"xi_negative_frac\": {}", writeln!(f, " \"gamma_mean\": {}, \"degenerate_frac\": {}, \"xi_negative_frac\": {}",
q(gm), q(dg), q(xn))?; q(gm), q(dg), q(xn))?;
} else { } else {
@@ -1698,3 +1940,100 @@ fn write_summary(
writeln!(f, "}}")?; writeln!(f, "}}")?;
Ok(()) Ok(())
} }
// ─────────────────────────────────────────────────────────────────────────────
// Тесты постановки и многоуровневой сетки
// ─────────────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn spec_of(args: &[&str]) -> Spec {
let mut v = vec!["kbc2d"];
v.extend_from_slice(args);
build_spec(&Cli::try_parse_from(v).expect("ключи")).expect("постановка")
}
/// Цепочка уровней: каждый следующий в r раз тоньше, лежит внутри родителя, а τ растёт
/// так, что ν одинакова на всех уровнях: ν = c_s²(τ − ½)/scale в единицах L0.
#[test]
fn levels_chain_is_consistent() {
let s = spec_of(&[
"--shape", "cylinder", "--size", "8", "--nx", "240", "--ny", "160", "--re", "20",
"--levels", "2:4,10,4; 2:1.5,4,1.5",
]);
let lv = s.levels();
assert_eq!(lv.len(), 3);
assert_eq!(s.substeps(), 4);
let nu0 = math::CS2 * (lv[0].tau - 0.5);
for (k, p) in s.patches.iter().enumerate() {
let (par, g) = (&lv[k], &lv[k + 1]);
assert!(p.ax >= 2 && p.bx + 2 <= par.nx && p.ay >= 1 && p.by + 2 <= par.ny);
assert_eq!(g.nx, p.r * (p.bx - p.ax) + 1);
assert_eq!(g.scale, par.scale * p.r as R);
let nu = math::CS2 * (g.tau - 0.5) / g.scale;
assert!((nu - nu0).abs() < 1e-14 * nu0, "ν уровня {} = {nu}, L0 = {nu0}", k + 1);
}
// тело лежит внутри самого тонкого уровня
let (cx, cy) = s.scene.center();
let f = lv[2];
let (x, y) = ((cx - f.ox) * f.scale, (cy - f.oy) * f.scale);
assert!(x > 0.0 && x < f.nx as R && y > 0.0 && y < f.ny as R);
}
/// Однородный поток без тела — неподвижная точка всей схемы: столкновения, переноса,
/// стенок, входа/выхода и связки трёх уровней. Любая ошибка в рамке, рестрикции или
/// пересчёте неравновесия между уровнями сдвинула бы его с места.
#[test]
fn uniform_flow_is_fixed_point_across_levels() {
let mut s = spec_of(&[
"--shape", "cylinder", "--size", "6", "--nx", "120", "--ny", "80", "--re", "50",
"--levels", "2:3,8,3; 2:1.5,4,1.5", "--sponge-len", "0",
]);
s.scene.bodies.clear();
let u = s.units.u_lat;
let mut sim = cpu::Sim::new(s);
for _ in 0..200 {
sim.step();
}
for (k, l) in sim.levels.iter().enumerate() {
let mut worst: R = 0.0;
for n in 0..l.nx * l.ny {
let (rho, ux, uy) = l.macros_at(n);
worst = worst.max((rho - 1.0).abs()).max((ux - u).abs()).max(uy.abs());
}
assert!(worst < 1e-12, "уровень {k}: отклонение {worst:.3e}");
}
}
/// Три уровня (×2, ×2) против одного патча ×4 с тем же разрешением тела — стационарное
/// обтекание при Re = 20 обязано дать тот же Cd. Различие — только в огрублении снаружи.
#[test]
#[ignore = "длинный прогон (~10 с): cargo test --release -- --ignored levels_match"]
fn levels_match_single_patch_on_steady_cylinder() {
let common = [
"--shape", "cylinder", "--size", "6", "--nx", "180", "--ny", "120", "--re", "20",
"--wall", "bouzidi", "--steps", "2500",
];
let cd = |extra: &[&str]| {
let mut a: Vec<&str> = common.to_vec();
a.extend_from_slice(extra);
let s = spec_of(&a);
let mut sim = cpu::Sim::new(s.clone());
let d = sim.force_ref_size();
let mut acc = Vec::new();
for t in 0..s.steps {
let r = sim.step();
if t > s.steps * 4 / 5 {
acc.push(math::coefficients(r.fx, r.fy, r.tz, s.units.u_lat, d).0);
}
}
acc.iter().sum::<R>() / acc.len() as R
};
let one = cd(&["--levels", "4:2.5,6,2.5"]);
let two = cd(&["--levels", "2:2.5,6,2.5; 2:1.5,4,1.5"]);
println!("Cd: один патч ×4 {one:.5}, цепочка ×2×2 {two:.5}");
assert!((one - two).abs() < 0.01 * one, "Cd {one} против {two}");
}
}
+304
View File
@@ -290,6 +290,43 @@ pub fn beta_profile(nx: usize, beta0: R, sponge_in: usize, sponge_out: usize, mu
.collect() .collect()
} }
/// Поле β по ВСЕМ узлам уровня (узел = y·nx + x): губки у входа и выхода, как в
/// `beta_profile`, плюс боковые губки шириной `sponge_side` рядов у верхней и нижней стенок.
/// Губки не складываются — в каждом узле берётся сильнейшая. При `sponge_side = 0` значения
/// совпадают с `beta_profile` побитово.
pub fn beta_field(
nx: usize,
ny: usize,
beta0: R,
sponge_in: usize,
sponge_out: usize,
sponge_side: usize,
mult: R,
) -> Vec<R> {
let cols = beta_profile(nx, beta0, sponge_in, sponge_out, mult);
if sponge_side == 0 || mult == 1.0 {
let mut out = Vec::with_capacity(nx * ny);
for _ in 0..ny {
out.extend_from_slice(&cols);
}
return out;
}
let nu = nu_of_beta(beta0);
let strength = |b: R| if b == beta0 { 0.0 } else { (nu_of_beta(b) / nu - 1.0) / (mult - 1.0) };
let w = sponge_side as R;
let mut out = Vec::with_capacity(nx * ny);
for y in 0..ny {
let lo = smoothstep((w - y as R) / w);
let hi = smoothstep((y as R - (ny - 1) as R + w) / w);
let sy = lo.max(hi);
for &bc in &cols {
// где боковая губка не сильнее продольной, остаётся значение столбца как есть
out.push(if sy <= strength(bc) { bc } else { beta_of_nu(nu * (1.0 + (mult - 1.0) * sy)) });
}
}
out
}
/// Кинематическая вязкость по β, формула (5): ν = c_s²(1/(2β) − 1/2). /// Кинематическая вязкость по β, формула (5): ν = c_s²(1/(2β) − 1/2).
#[inline] #[inline]
pub fn nu_of_beta(beta: R) -> R { pub fn nu_of_beta(beta: R) -> R {
@@ -985,6 +1022,195 @@ pub fn grad_target_velocity_term(q: R, u_far: R, u_wall: R) -> R {
(q * u_far + u_wall) / (1.0 + q) (q * u_far + u_wall) / (1.0 + q)
} }
// ─────────────────────────────────────────────────────────────────────────────
// Пристеночная функция
// ─────────────────────────────────────────────────────────────────────────────
/// Пристеночная функция поверх модели стенки: чем задаётся профиль скорости между стенкой и
/// первым жидким узлом, когда сетка пограничный слой не разрешает.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WallFunction {
/// Нет: стенка — обычное прилипание выбранной модели.
None,
/// Закон Spalding (1961) — ОДНА формула на весь пристеночный слой, от вязкого подслоя
/// (u⁺ = y⁺) до логарифмической зоны. Поэтому он безопасен и там, где первая клетка
/// лежит в подслое: тогда профиль сам сводится к линейному, то есть к прилипанию.
Spalding,
}
impl WallFunction {
pub fn from_str(s: &str) -> Option<Self> {
Some(match s.to_ascii_lowercase().as_str() {
"none" | "off" => WallFunction::None,
"spalding" => WallFunction::Spalding,
_ => return None,
})
}
pub const ALL: [&'static str; 2] = ["none", "spalding"];
pub fn is_on(&self) -> bool {
!matches!(self, WallFunction::None)
}
pub fn name(&self) -> &'static str {
match self {
WallFunction::None => "none",
WallFunction::Spalding => "spalding",
}
}
}
/// Постоянные закона стенки (Spalding 1961, классические значения).
pub const KAPPA: R = 0.41;
pub const LOG_B: R = 5.2;
/// y⁺(u⁺) по Spalding:
///
/// y⁺ = u⁺ + e^{−κB}·[e^{κu⁺} − 1 − κu⁺ − (κu⁺)²/2 − (κu⁺)³/6]
///
/// Возвращает (y⁺, dy⁺/du⁺). Функция монотонно растёт и выпукла при u⁺ > 0.
#[inline]
pub fn spalding_yplus(up: R) -> (R, R) {
let k = KAPPA * up;
let e = (-KAPPA * LOG_B).exp();
let ek = k.exp();
let y = up + e * (ek - 1.0 - k - 0.5 * k * k - k * k * k / 6.0);
let dy = 1.0 + e * KAPPA * (ek - 1.0 - k - 0.5 * k * k);
(y, dy)
}
/// u⁺(y⁺) — обращение закона Spalding методом Ньютона.
///
/// Старт берётся из нужной асимптоты: в подслое u⁺ ≈ y⁺, в логарифмической зоне
/// u⁺ ≈ ln(y⁺)/κ + B. Функция y⁺(u⁺) выпукла, поэтому Ньютон от старта не меньше корня
/// сходится монотонно; на всякий случай итерация зажата снизу нулём.
pub fn spalding_uplus(yp: R) -> R {
if yp <= 0.0 {
return 0.0;
}
let mut u = if yp < 11.0 { yp } else { (yp.ln() / KAPPA + LOG_B).max(1e-6) };
for _ in 0..50 {
let (y, dy) = spalding_yplus(u);
let du = (y - yp) / dy;
u = (u - du).max(0.0);
if du.abs() <= 1e-12 * (1.0 + u) {
break;
}
}
u
}
/// Динамическая скорость u_τ по касательной скорости `u` в точке на расстоянии `y` от стенки
/// и кинематической вязкости `nu` (всё в решёточных единицах).
///
/// Неизвестным удобнее брать x = u⁺ = u/u_τ: тогда y⁺ = y·u/(ν·x), и уравнение
/// x·y⁺(x) = Re_y, где Re_y = y·u/ν, — с монотонной левой частью. Старт x = √Re_y — точный
/// корень в подслое (там y⁺(x) = x).
pub fn spalding_utau(u: R, y: R, nu: R) -> R {
if u <= 1e-14 || y <= 0.0 {
return 0.0;
}
let re_y = y * u / nu;
let mut x = re_y.sqrt();
for _ in 0..60 {
let (g, dg) = spalding_yplus(x);
let h = x * g - re_y;
let dh = g + x * dg;
let dx = h / dh;
x = (x - dx).max(1e-9);
if dx.abs() <= 1e-12 * (1.0 + x) {
break;
}
}
u / x
}
/// Итог пристеночной функции для одного граничного узла.
#[derive(Clone, Copy, Debug, Default)]
pub struct WallFnOut {
/// Модельная касательная скорость в самом граничном узле (вдоль `e`).
pub u_node: R,
/// Динамическая скорость.
pub u_tau: R,
/// y⁺ граничного узла: y_w·u_τ/ν.
pub y_plus: R,
/// Единичное касательное направление течения у стенки.
pub ex: R,
pub ey: R,
/// Касательная скорость в точке отбора.
pub u_sample: R,
}
/// Пристеночная функция в одном узле: по скорости (usx, usy) в точке отбора на расстоянии
/// `y_s` от стенки и нормали (nx, ny) — u_τ и модельная касательная скорость в самом узле на
/// расстоянии `y_w`. `None`, если касательной скорости в точке отбора нет вовсе.
#[allow(clippy::too_many_arguments)]
pub fn wall_function(
usx: R,
usy: R,
nx: R,
ny: R,
y_w: R,
y_s: R,
nu: R,
) -> Option<WallFnOut> {
let un = usx * nx + usy * ny;
let tx = usx - un * nx;
let ty = usy - un * ny;
let ut = (tx * tx + ty * ty).sqrt();
if ut <= 1e-14 {
return None;
}
let u_tau = spalding_utau(ut, y_s, nu);
let y_plus = y_w * u_tau / nu;
let u_node = u_tau * spalding_uplus(y_plus);
Some(WallFnOut { u_node, u_tau, y_plus, ex: tx / ut, ey: ty / ut, u_sample: ut })
}
/// Скорость скольжения стенки для полинковой схемы (Bouzidi), согласованная по НАПРЯЖЕНИЮ:
/// отскок от подвижной стенки передаёт ей импульс ≈ ρν(u_узла − u_s)/y_w, и чтобы при
/// модельной скорости узла это было модельным τ_w = ρu_τ², стенка должна двигаться со
/// скоростью
///
/// u_s = u_node − u_τ²·y_w/ν = u_τ·(u⁺(y⁺_w) − y⁺_w), зажато в [−u_sample, u_sample].
///
/// В вязком подслое u⁺ = y⁺ и скольжение ТОЖДЕСТВЕННО ноль — обычное прилипание, побитово.
/// В буферной и логарифмической зонах u⁺ < y⁺, и стенка «отступает» против потока (u_s < 0),
/// добирая трение до τ_w. Это та же пара (u_node, τ_w), что моментные схемы ставят через
/// целевую скорость и тензор давлений, — так сравнение схем честное.
///
/// Разрешённую скорость узла вместо модельной брать нельзя: у искривлённой стенки она не
/// лежит на прямой от стенки к точке отбора, и скольжение переставало обращаться в ноль
/// даже при y⁺ ≪ 1 (цилиндр Re = 20: −2 % по Cd против прилипания).
///
/// Почему не согласование по СКОРОСТИ (прямая через стенку, узел и точку отбора): решатель
/// не имеет турбулентной вязкости, и такое скольжение выходит положительным и большим —
/// стенка становится почти свободной, исчезают трение и отрыв. Замерено: цилиндр Re = 10⁴,
/// D = 16 давал Cd = 0.016. Зажим по модулю скорости в точке отбора держит скорость стенки
/// в пределах скоростей самого течения — иначе при y⁺ ≫ 30 она выходила бы за число Маха.
#[inline]
pub fn wall_slip(w: &WallFnOut, y_w: R, nu: R) -> R {
(w.u_node - w.u_tau * w.u_tau * y_w / nu).clamp(-w.u_sample, w.u_sample)
}
/// Поправка градиента скорости граничного узла под пристеночную функцию: нормальная
/// производная касательной скорости заменяется модельной u_τ²/ν, остальные компоненты
/// остаются конечно-разностными. Добавка имеет вид d·(e⊗n), поэтому дивергенцию не меняет
/// (e ⊥ n). Возвращает исправленные (∂u/∂x, ∂u/∂y, ∂v/∂x, ∂v/∂y).
#[allow(clippy::too_many_arguments)]
#[inline]
pub fn wall_function_gradient(
g: (R, R, R, R),
w: &WallFnOut,
nx: R,
ny: R,
nu: R,
) -> (R, R, R, R) {
let (dudx, dudy, dvdx, dvdy) = g;
let (ex, ey) = (w.ex, w.ey);
let g_en = ex * (dudx * nx + dudy * ny) + ey * (dvdx * nx + dvdy * ny);
let d = w.u_tau * w.u_tau / nu - g_en;
(dudx + d * ex * nx, dudy + d * ex * ny, dvdx + d * ey * nx, dvdy + d * ey * ny)
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Сила на теле // Сила на теле
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -1529,4 +1755,82 @@ mod tests {
} }
inv inv
} }
/// Закон Spalding в обеих асимптотах: в вязком подслое u⁺ = y⁺, в логарифмической зоне
/// u⁺ = ln(y⁺)/κ + B. Формула одна, асимптоты — её свойства, а не ветки кода.
#[test]
fn spalding_matches_both_asymptotes() {
for yp in [0.01, 0.1, 1.0] {
let up = spalding_uplus(yp);
assert!((up - yp).abs() < 1e-3 * yp.max(1e-3), "подслой: y⁺={yp}, u⁺={up}");
}
for yp in [300.0, 1000.0, 5000.0] {
let up = spalding_uplus(yp);
let log = yp.ln() / KAPPA + LOG_B;
assert!((up - log).abs() < 0.02 * log, "log-зона: y⁺={yp}, u⁺={up}, лог {log}");
}
}
/// Обращение точное и монотонное: y⁺(u⁺(y⁺)) = y⁺ на всём диапазоне.
#[test]
fn spalding_inverse_roundtrip_and_monotone() {
let mut prev = 0.0;
let mut yp = 1e-3;
while yp < 1e5 {
let up = spalding_uplus(yp);
assert!(up > prev, "u⁺ обязан расти: y⁺={yp}");
prev = up;
let (back, _) = spalding_yplus(up);
assert!((back - yp).abs() < 1e-9 * yp.max(1.0), "y⁺={yp} → u⁺={up} → {back}");
yp *= 1.7;
}
}
/// u_τ по скорости в точке: подставленная обратно, она даёт ту же скорость. В подслое
/// u_τ = √(ν·u/y) — линейный профиль, то есть ровно прилипание.
#[test]
fn spalding_utau_consistent() {
let nu = 1e-3;
for (u, y) in [(0.05, 1.5), (0.05, 30.0), (1e-4, 1.0), (0.1, 200.0)] {
let ut = spalding_utau(u, y, nu);
let up = spalding_uplus(y * ut / nu);
assert!((ut * up - u).abs() < 1e-9 * u, "u={u} y={y}: u_τ·u⁺ = {}", ut * up);
}
let (u, y) = (1e-5, 0.5);
let ut = spalding_utau(u, y, nu);
assert!((ut - (nu * u / y).sqrt()).abs() < 1e-6 * ut, "подслой: u_τ = {ut}");
assert_eq!(spalding_utau(0.0, 1.0, nu), 0.0);
}
/// В вязком подслое пристеночная функция обязана свестись к прилипанию: модельная
/// скорость узла — линейная интерполяция от стенки, скольжение — ноль, а модельный
/// градиент — тот же наклон прямой.
#[test]
fn wall_function_reduces_to_no_slip_in_sublayer() {
let nu = 0.05; // низкий Re: y⁺ первого узла заведомо < 1
let (y_w, y_s) = (0.4, 1.4);
let (ux, uy) = (0.01, 0.0); // стенка снизу, нормаль +y
let w = wall_function(ux, uy, 0.0, 1.0, y_w, y_s, nu).unwrap();
assert!(w.y_plus < 1.0, "y⁺ = {}", w.y_plus);
let lin = ux * y_w / y_s;
assert!((w.u_node - lin).abs() < 1e-3 * lin, "u_node {} против {lin}", w.u_node);
assert!(wall_slip(&w, y_w, nu).abs() < 1e-3 * lin);
let g = wall_function_gradient((0.0, 0.0, 0.0, 0.0), &w, 0.0, 1.0, nu);
assert!((g.1 - ux / y_s).abs() < 1e-3 * ux / y_s, "∂u/∂y = {}", g.1);
assert_eq!((g.0, g.2, g.3), (0.0, 0.0, 0.0));
}
/// В логарифмической зоне разрешённый градиент меньше модельного напряжения, и стенка
/// отступает против потока: скольжение отрицательно, но по модулю не больше скорости
/// в точке отбора.
#[test]
fn wall_function_slips_in_log_region() {
let nu = 1e-5;
let (y_w, y_s) = (0.5, 1.5);
let w = wall_function(0.05, 0.0, 0.0, 1.0, y_w, y_s, nu).unwrap();
assert!(w.y_plus > 30.0, "y⁺ = {}", w.y_plus);
let s = wall_slip(&w, y_w, nu);
assert!(s < 0.0 && s >= -w.u_sample, "скольжение {s}, u_sample {}", w.u_sample);
assert!(wall_function(0.0, 0.03, 0.0, 1.0, y_w, y_s, nu).is_none(), "нормальный поток");
}
} }