14 Commits
Author SHA1 Message Date
NotBigGhostandClaude Opus 5 251293cd05 CLAUDE.md вне версионирования: один файл на все ветки
Файл описывает всё дерево целиком, а ветки содержат разные его части
(порт на Rust, исследование CFD, базовый C++-редактор). Отслеживаемая
копия при каждом переключении подменялась бы версией своей ветки, тогда
как нужна одна общая. Содержимое на всех ветках было идентично, так что
расхождений открепление не теряет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B9Gcr11JJJyf8NnWsXjDzZ
2026-09-06 16:25:03 +03:00
NotBigGhostandClaude Opus 5 d5e37fcb8d Раздельные привязки популяций: снят предел dzn на размер сетки
Массив популяций занимает nx*ny*9*4 байта и показывался шейдеру одной привязкой.
dzn объявляет max_storage_buffer_binding_size = 128 МиБ, поэтому потолок выходил
3.73 млн узлов: 14 прогонов кампании из 115 падали на создании bind group, а на
них приходится 70.7% её стоимости.

Ограничена при этом ровно привязка: max_buffer_size у dzn 2047 МиБ. Поэтому тот
же буфер теперь показывается девятью привязками, по одному направлению в каждой,
и потолок поднимается до 33.5 млн узлов — самая крупная сетка кампании
(4096x4096, 16.8 млн) проходит с запасом.

Как устроено:
 * шаг между направлениями выровнен на 256 байт (dir_stride), потому что смещение
   привязки обязано быть кратно min_storage_buffer_offset_alignment;
 * обращения к популяциям в WGSL идут через fget/fset/pget/pset, а их тело
   генерируется под вариант (build_shader);
 * вариант выбирается по max_storage_buffer_binding_size адаптера — где предела
   нет, собирается прежний общий, без switch в аксессорах;
 * KBC2D_SPLIT_POPULATIONS=1 включает раздельные принудительно: иначе сверить два
   варианта на одной карте нечем.

Проверено:
 * на одном драйвере оба варианта дают одно и то же — Cd 2.39486, energy_end
   5.74813e-05; раздельный стоит 5.7% пропускной способности;
 * на сетке 2048x2048, где раздельные привязки и нужны, нативный прогон против
   контейнерного: energy_end расходится на 2.4e-07, enstrophy_end на 1.3e-07;
 * preflight в контейнере: 115 сценариев из 115, ни одного отказа (было 14);
 * 29 собственных тестов решателя зелёные.

Образ опубликован как notbigghost/kbc2d:1.2.0 (он же latest).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 19:08:12 +03:00
NotBigGhostandClaude Opus 5 6344c7d205 Внятные сообщения preflight и предел dzn на размер привязки
preflight печатал последнюю строку вывода упавшего прогона, а у паники Rust
последняя строка — «note: run with RUST_BACKTRACE=1», то есть ноль сведений о
причине. Теперь из вывода достаётся собственное сообщение решателя, а для паники —
то, что стоит ПОСЛЕ строки «panicked at». Вместо

  A10_turb_n2048_kbc   note: run with `RUST_BACKTRACE=1` ...

печатается

  A10_turb_n2048_kbc
      wgpu error: Validation Error | In Device::create_bind_group, label = 'level'
      | Buffer binding 0 range 150994944 exceeds `max_*_buffer_binding_size` limit 134217728

Заодно задокументировано само ограничение. dzn объявляет
max_storage_buffer_binding_size = 128 МиБ против гигабайтов у нативных драйверов,
при том что max_buffer_size у него 2047 МиБ: держать большой буфер можно, показать
шейдеру одной привязкой — нет. Потолок выходит 3.73 млн узлов (около 1920x1920),
и 14 прогонов кампании из 115 в него не влезают. На эти 14 приходится 70.7%
стоимости, так что для кампании ограничение решающее.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:35:07 +03:00
NotBigGhostandClaude Opus 5 81985b169e Запуск в WSL2 через docker compose без NVIDIA-runtime
В WSL2 драйвера Vulkan для Linux у NVIDIA нет: карта отдаётся через /dev/dxg по
протоколу WDDM, нативный libGLX_nvidia про него не знает и перечисляет ноль
устройств, поэтому Container Toolkit нечего подкладывать внутрь и docker падает
с `could not select device driver "nvidia"`.

С /dev/dxg умеет говорить dzn (Dozen) — драйвер Mesa, транслирующий Vulkan в
D3D12. Он положен в образ, и NVIDIA-runtime для этого пути не нужен вовсе: нужны
проброс устройства и монтирование /usr/lib/wsl, где Microsoft держит libd3d12.so.

Правка в решателе одна: флаги инстанса wgpu теперь читаются из окружения
(InstanceFlags::from_build_config().with_env()). Без этого переменная
WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER не действует, а wgpu по умолчанию
МОЛЧА прячет адаптеры, не прошедшие тесты соответствия Vulkan, — под это правило
попадает dzn, и решатель сообщал, что GPU не найден. Поведение по умолчанию не
изменилось: без переменной такие адаптеры по-прежнему скрыты.

База образа сменена с debian:bookworm-slim на archlinux:base: в пакетах Mesa у
Debian и Ubuntu dzn не собирают (проверено по спискам файлов), в Arch он лежит
отдельным пакетом той же версии Mesa, что и на хосте WSL.

Новое:
 * docker-compose.wsl.yml — путь через /dev/dxg, с профилем проверок и с build:
   на случай, когда доступа к реестру нет;
 * bench/parity.py — сверка GPU-пути с CPU в f64 на течениях, где расхождение
   f32 и f64 не нарастает. Соответствие dzn вендором не проверено, значит
   проверяем сами, а не верим на слово.

Замерено на Intel Iris Xe (та же карта, нативный драйвер против dzn):
 * точность: cd 2.39486 против 2.39486, energy_end 5.74813e-05 против
   5.74810e-05 — совпадение до 5-6 значащих цифр;
 * скорость: плата за трансляцию падает с ростом сетки, 4.2x на 61 тыс. узлов,
   1.46x на 461 тыс., 1.30x на 1.84 млн. На 95.1% стоимости кампании сетки
   крупнее 600 тыс. узлов, поэтому ожидаемое удорожание — около трети, не в разы.

В калибровку добавлена сетка 1920x960: оценивать кампанию по 240x120 значит
занижать пропускную способность вчетверо. Образ опубликован как
notbigghost/kbc2d:1.1.0 (он же latest), проверен вытягиванием из реестра.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 17:15:53 +03:00
NotBigGhostandClaude Opus 5 e75b404f8a Опубликовать образ в реестр и добавить compose для сервера
Образ собран под linux/amd64 и выложен как notbigghost/kbc2d — теги 1.0.0,
latest и 8d17bc8 указывают на один digest sha256:932d881d. В Dockerfile
добавлены метки OCI: версия, хеш коммита и ссылка на репозиторий, чтобы
образ на сервере однозначно сопоставлялся с состоянием исходников.

docker-compose.server.yml самодостаточен: тянет готовый образ из реестра,
исходников не требует, на сервер переносится одним файлом. Кампания —
единственный сервис, поднимающийся по up -d; проверки (vulkaninfo,
preflight, calibrate, dry-run) вынесены в профиль check и сами не
стартуют. Порядок проверок задан документацией: карта, сценарии,
калибровка, смета — и только потом счёт.

Политика перезапуска on-failure, а не unless-stopped: кампания завершается
штатно с кодом 0, и «перезапускать всегда» крутило бы контейнер вхолостую
по кругу, тогда как падение и перезагрузку хоста on-failure подхватывает,
а --resume продолжает с места. Журнал ограничен по размеру: девяносто
часов вывода иначе съедят диск, полные логи каждого прогона всё равно
лежат в out/<id>/log.txt.

Проверено локально из каталога без исходников: образ тянется из реестра,
смоук проходит, результаты ложатся на хост. Часть с картой проверяема
только на сервере — здесь запрос устройства ожидаемо отвергается
«no adapters were found», что само по себе подтверждает, что резервация
GPU в compose действует.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 17:15:01 +03:00
NotBigGhostandClaude Opus 5 8d17bc8014 Валидационная кампания на 115 прогонов и Docker-образ для сервера
bench/ — список сценариев (порождается gen_scenarios.py, а не правится
руками), драйверы под Linux и Windows, предполётная проверка и описание.
Девять групп: эталоны первоисточников, цилиндр против литературы, модели
стенки и субсеточность, профили крыла, сложная и множественная геометрия,
границы домена, старт и время жизни, внутренние инварианты, сверхмелкие
сетки до 4096×2048. Стоимость планируется в обновлениях узлов —
единственной переносимой между машинами мере; в часы драйвер переводит её
по фактическим MLUPS, которые замеряет на месте (--calibrate). Итого
3.76e14 обновлений, ≈90 часов на 4070 Ti.

Гифки пишутся на всю длительность прогона в реальном времени, 10 кадр/с.
Число кадров этим задано жёстко, поэтому размер регулируется только кадром:
замерено 0.103 байта на пиксель после LZW, отсюда бюджет кадры×пиксели с
нижней границей по ширине. Итог 4.4 ГБ, самый тяжёлый файл 226 МБ.

preflight.py гоняет каждый сценарий на два шага. Окупился сразу: поймал,
что вся группа сверхмелких сеток падала на пределе GPU, а девять прогонов
группы F передавали --body-x дважды. Оба отказа проявились бы только на
сервере, часов через двадцать после старта кампании.

Docker: двухстадийная сборка, в образе libvulkan1 и vulkan-tools.
NVIDIA Container Toolkit подкладывает Vulkan-ICD только при graphics в
NVIDIA_DRIVER_CAPABILITIES — без него wgpu не увидит карту, поэтому
capabilities прописаны в образе, а vulkaninfo лежит внутрь для проверки.
ENTRYPOINT — драйвер кампании, CMD по умолчанию --dry-run, чтобы случайный
docker run не запустил сточасовую задачу. Проверено локально: образ
собирается, смоук проходит с монтированием результатов на хост, физика
совпадает с хостовой до последней цифры, --backend gpu без карты отказывает
явно. GPU-путь в контейнере проверяется только на машине с картой.

В README поправлено разделение вкладов в продольную акустику: измерением
2×2 показано, что 74.9% → 1.1% даёт ОДНОРОДНЫЙ СТАРТ, причём при полностью
выключенной губке, а губка снимает лишь остаток (1.12% → 0.83% при
утроении длины). При старте из покоя губка не помогает совсем — у стоячей
четвертьволновой моды там узел давления, где вязкость её не трогает.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:36:40 +03:00
NotBigGhostandClaude Opus 5 26a8475396 Снять предел GPU на размер сетки и молчаливые подмены модели стенки
Диспетчеризация была одномерной, а у GPU жёсткий предел 65535 рабочих групп
на измерение: при 64 узлах на группу это упирало сетку в 4.2 млн узлов, то
есть примерно 2048×2048. Всё, что крупнее, не считало медленно, а падало
ошибкой валидации — то есть вся группа сверхмелких сеток кампании (4096×2048
и 4096×4096) не запустилась бы вовсе. Теперь сетка рабочих групп двумерная, а
линейный индекс собирается в шейдере через lin()/wlin(); отображение
«группа → узлы» при этом остаётся ровно линейным, поэтому редукции и буфер
частичных сумм не потребовали изменений, кроме отсечения хвостовых групп.
Проверено до 4096×4096; паритет с CPU не сдвинулся ни в одной цифре
(Cd 2.4650 против 2.4649, как и до правки).

--wall staircase на GPU молча считался по Bouzidi: ветка выбиралась по
is_moment_based(), и ступенчатая модель попадала в ту же ветку, что
интерполированный отскок. Обнаружилось по тому, что две модели дали
побитово одинаковый Cd там, где обязаны были разойтись. Вместо булева
«третий порядок» в Dyn теперь режим стенки числом, и все четыре модели
живут на обоих бэкендах.

clap отвергал --body-angle -8 как неизвестный ключ, из-за чего не
запускался прогон на зеркальную симметрию профиля.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:36:18 +03:00
NotBigGhostandClaude Opus 5 9120affab5 Эталонные течения статей, машиночитаемая сводка, радиус влияния и прореживание кадра
--case: помимо канала появились три ПЕРИОДИЧЕСКИХ постановки из статей — вихрь Тейлора-Грина
(разд. VI), дважды периодический сдвиговый слой (разд. VII) и затухающая двумерная
турбулентность. Перенос уже был периодичен, поэтому достаточно не накладывать ГУ вовсе.
Стартовые поля строятся общим кодом cpu::initial_field для обоих бэкендов: GPU только
перекладывает результат в свою раскладку, так что эталон задан ровно одним куском кода.

Три вещи, без которых эталоны считались неверно, и все три нашлись замером:
* в периодической постановке всё равно строилось тело — цилиндр по умолчанию стоял прямо
  посреди эталонного течения. Теперь сцена пуста;
* Re считался по калибру тела, а статьи определяют его по РАЗМЕРУ ДОМЕНА (Re = u0*N/nu);
* и, главное, включалась выходная губка, поднимающая вязкость в 30 раз на последних столбцах.
  Отсюда затухание было в 1.37 раза выше положенного. Губок в периодической постановке нет.

Что эталоны показали. Сдвиговый слой при Re=3e4 воспроизводит главное утверждение статьи:
KBC доживает до конца (энстрофия 0.871 от начальной), LBGK разваливается на шаге 4480.
Тейлор-Грин на CPU даёт порядок сходимости 2.03 и 2.01 — чистый второй.

ЗАМЕРЕНА ГРАНИЦА ПРИМЕНИМОСТИ f32, ровно та, ради которой планировалась отдельная группа
прогонов. Ошибка против точного решения, диффузионное измельчение:
    N=64    CPU 9.83e-3   GPU 9.80e-3
    N=128   CPU 2.40e-3   GPU 3.29e-3
    N=256   CPU 5.97e-4   GPU 2.37e-2
GPU совпадает с f64, пока истинная ошибка выше ~1e-3, и промахивается в 40 раз, как только
она опускается ниже. Правило для кампании: точностные исследования сходимости — только на CPU.

--summary: сводка прогона одним JSON (St, Cd, rms Cl, Cm по каждому телу, ⟨ρ⟩, пульсация,
статистика гамма, радиус влияния, MLUPS, признак развала). Без неё разбор кампании из десятков
прогонов пришлось бы вести глазами.

--gif-downsample: усреднение блока k*k в пиксель. Без него кадр с сетки 4096x2048 неподъёмен;
проверено на 960x480 при k=3 — гифка 320x160 и 0.17 МБ.

РАДИУС ВЛИЯНИЯ в отчёте и сводке: докуда тело возмущает поток больше чем на 1% от U, в
калибрах, с предупреждением, если возмущение достаёт до границы домена. На проверочном прогоне
боковое влияние вышло ровно на границу (5.0 калибра при полуширине 5.0) — сигнал работает.

Также: --case-csv с рядом энергии, энстрофии и палинстрофии; --series-every для прореживания
рядов на сверхдлинных прогонах.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 12:52:08 +03:00
NotBigGhostandClaude Opus 5 b97686c8db Несколько тел в домене, произвольная геометрия и разбор стартовой акустики
НЕСКОЛЬКО ТЕЛ (--bodies). SDF сцены — минимум по телам, поэтому маски, поле SDF и
Bouzidi-линки строятся ровно теми же процедурами, что и для одного тела. Каждому линку
проставляется индекс тела, и сила считается по телам отдельно: для тандема и многоэлементных
конфигураций литература даёт коэффициенты каждого тела, общей суммы недостаточно. Разнос идёт
по четырём вёдрам; тела за пределом набора сваливаются в последнее, так что сумма по вёдрам
всегда точна. Синтаксис: "cylinder:d=24,x=120,y=120; cylinder:d=24,x=192,y=120".

Проверка на тандеме L/D=3 при Re=150 даёт физически правильную картину: передний цилиндр
Cd=1.22 при rms Cl=0.02, задний Cd=-0.10 при rms Cl=0.19. Отрицательное сопротивление заднего
— классический режим экранирования, когда сдвиговые слои переднего замыкаются на задний, а
восьмикратный рост rms Cl отвечает тому, что задний треплет след переднего.

ПРОИЗВОЛЬНЫЙ МНОГОУГОЛЬНИК (--shape polygon --poly) — точный SDF уже был, добавлен разбор.
Открывает клинья, зазубренные кромки, любые обводы. Проверено на клине: 100% линков идут по
интерполяционной формуле.

ПРОФИЛИ С ИЗГИБОМ (--naca 4412). Средняя линия по четырёхзначной серии, поверхности
откладываются по НОРМАЛИ к ней, а не по вертикали — иначе у заметно изогнутого профиля толщина
у носка завышается. При нулевой кривизне вырождается в прежний симметричный 00xx.

ПРОРЕЖИВАНИЕ РЯДОВ (--series-every): на 5·10^6 шагов полный ряд занимал бы сотни мегабайт.

РАЗБОР СТАРТОВОЙ АКУСТИКИ. Добавлена x-t диагностика (--xt): срез ⟨ρ⟩ и u_x вдоль осевой
линии, по строке на срез. Что она показала:

* возмущение рождается НА ТЕЛЕ, а не на входе. На нулевом шаге |ρ−1| ≈ 1.3·10^-2 у задней
  кромки при 10^-8 у входа и выхода; причина — поле стартует однородным потоком сквозь то
  место, где стоит тело;
* дальше импульс уходит полосой через весь домен — ровно то, что наблюдалось на низких Re;
* на Re ≥ 1000 при выключенной губке он раскачивает неустойчивость у ВЫХОДА, и счёт гибнет
  около шага 17000. С губкой (умолчание) Re=2000 доживает.

Попытка лечения сглаживанием стартовой скорости у тела (--init-taper) ЗАМЕРЕНА И ОТВЕРГНУТА:
ширина 24 клетки давит возмущение нулевого шага восьмикратно (2.54·10^-2 → 3.13·10^-3), но пик
за прогон при этом даже растёт (2.54·10^-2 → 3.22·10^-2). Возмущение просто переносится во
времени. Ключ оставлен выключенным, замер записан в его описании.

Добавлена губка у входа (--sponge-in); профиль β вынесен в общую math::beta_profile, раньше
он дублировался в обоих бэкендах. Исправлен вердикт о пульсации: при развале счёта отношение
уходило в минус и проверка «> 0.05» молча не срабатывала.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 12:20:07 +03:00
NotBigGhostandClaude Opus 5 039e5f4806 Батчинг GPU-чтения, компенсированные суммы и HRR-стенка на обоих бэкендах
БАТЧИНГ. Раньше после каждого шага делался map_async + poll(Wait) ради 48 байт итогов: на
сетке 240×120 счёт упирался в 863 шаг/с при том, что сам счёт занимал 0.27 мс из 1.16 — три
четверти времени машина стояла. Теперь k_stats2 пишет итоги в слот истории step % 128, а хост
читает пачкой. Контракт бэкенда изменён с пошагового step() на advance(&mut out) + flush():
записи дописываются в вектор, синхронизация происходит только там, где дальше нужны
актуальные данные (кадр гифки, живая строка отчёта).

Замер: 240×120 — 6715 шаг/с против 863 (7.8×); пропускная способность 195 MLUPS против прежних
105. Паритет CPU/GPU сохранён: Cd 1.798 против 1.799, ⟨ρ⟩ 1.00090 против 1.00091.

КОМПЕНСИРОВАННОЕ СУММИРОВАНИЕ (Кэхена–Ноймайера) в редукциях статистики и в сумме сил. Именно
там теряется основная точность f32: наивная сумма по 10^5…10^7 узлам съедает ~log2(N) бит.
Аппаратного f64 на целевом железе нет (в WGSL типа f64 не существует вовсе, а локальная Iris Xe
сообщает shaderFloat64 = false), поэтому компенсация — единственный доступный способ.

РАЗВАЛ СЧЁТА теперь ловится по уже посчитанным max|u| и ⟨ρ⟩, а не полным проходом по полю:
на сетке 4096×2048 такой проход тянет с устройства сотни мегабайт, и делать его регулярно
нельзя. Полная проверка осталась одна, в конце.

HRR-СТЕНКА (--wall hrr, теперь умолчание). Условие Града — это ряд Эрмита, оборванный на 2-м
порядке (ρ, u, Π). HRR продолжает его на третий, вычисляя коэффициенты не из популяций (их на
стенке как раз и не хватает), а рекурсивно из уже известных:
  a₃_xxy = 2·u_x·a₂_xy + u_y·a₂_xx,  a₃_xyy = 2·u_y·a₂_xy + u_x·a₂_yy
В D2Q9 a₃_xxx и a₃_yyy решёткой не поддерживаются и отбрасываются. Третий порядок не трогает
ρ, ρu и Π — соответствующие моменты весов обнуляются по симметрии, — что закреплено тестом.

Моментная стенка перенесена на GPU (раньше её там не было вовсе, бэкенд отказывался
запускаться): буфер индекса граничных узлов плюс ядро k_moment_wall. Скорость на момент t
берётся из пост-столкновительного поля — столкновение сохраняет ρ и ρu, поэтому отдельное
хранилище прошлого шага не нужно ни на одном бэкенде. Добавлена проверка лимита
storage-биндингов адаптера: моментной стенке нужно 9 против 8 гарантируемых.

Паритет на HRR: Cd 1.847 (CPU) против 1.848 (GPU). Три модели стенки на одной постановке дают
1.847 (hrr) / 1.845 (grad) / 1.856 (bouzidi).

33 теста, обе сборки чисты.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 03:51:05 +03:00
NotBigGhostandClaude Opus 5 e3c2d417f8 Длительность в секундах (--time) и граничное условие Града как альтернатива Bouzidi
--time <секунды> задаёт длительность прогона прямо в физическом времени, число шагов
считается как time/δt. Мотив: шаг не есть фиксированная порция времени — δt = u_lat·δx/u_phys
привязан к размеру клетки, поэтому одно и то же число шагов на разных сетках покрывает разное
физическое время (клетка втрое мельче ⇒ вместо 6 секунд получается 2).

ГРАНИЧНОЕ УСЛОВИЕ ГРАДА (--wall grad) по Dorschner, Bösch, Chikatamarla, Boulouchos, Karlin,
J. Fluid Mech. 801 (2016), разд. 2.1 и прил. B: недостающие популяции задаются не напрямую, а
через целевые моменты — скорость (B 1), плотность (B 3) и тензор давлений (2.14)–(2.16), —
после чего собираются приближением Града (2.13). Переиспользует grad_init, уже проверенный на
эталоне Тейлора–Грина. Скорость на момент t берётся из пост-столкновительного поля: столкновение
сохраняет ρ и ρu, поэтому отдельное хранилище прошлого шага не нужно.

Добавлена также заведомо ступенчатая модель (--wall staircase) — не для счёта, а как база
сравнения, показывающая, сколько именно даёт субсеточность.

ИЗМЕРЕНО, насколько каждая модель субсеточна. Тело сдвигается внутри клетки, смотрится разброс
Cd (Re=20, D=16, стационар): staircase 1.11%, grad 0.64%, bouzidi 0.19%. Град оказывается ровно
между ступенькой и Bouzidi, и это следует из его устройства: положение стенки входит туда только
через целевую скорость — одну усреднённую по узлу величину, тогда как Bouzidi подставляет свою
долю пересечения в каждую популяцию отдельно.

Сходимость по разрешению тела (домен 15D×10D, Re=20): bouzidi 2.581/2.529/2.521 и grad
2.618/2.534/2.521 при D=8/16/32. Обе состоятельны, сходятся к одному пределу с наблюдаемым
порядком ≈2.7 и к D=32 неразличимы; на грубой сетке Град заметно хуже. Ступенчатая модель при
D=16 даёт 2.69, то есть +6.7% к пределу против +0.4% у субсеточных.

Заявленного в статье выигрыша Града по устойчивости на высоких Re в здешней канальной постановке
воспроизвести не удалось: обе модели теряют счёт на одном и том же Re, то есть ограничивает не
стенка. Поэтому умолчание остаётся bouzidi.

В шапку добавлена диагностика границы тела: сколько линков идут по интерполяционной формуле,
сколько сваливаются в ступенчатый отскок, каков разброс доли пересечения. На NACA и цилиндре
интерполяция покрывает 100% линков.

GPU-бэкенд условие Града пока не поддерживает и при таком выборе отказывается запускаться явно,
а не считает молча по Bouzidi.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:54:22 +03:00
NotBigGhostandClaude Opus 5 f174f1c9a0 Сверка со статьями: эталон Тейлора–Грина, выбор модели KBC, лечение продольной акустики
Проверка переориентирована на первоисточники из docs/origins вместо сравнения с
python-прототипом.

ЭТАЛОН ТЕЙЛОРА–ГРИНА (разд. VI 2D-статьи) — единственное течение статьи с точным
аналитическим решением. Второй порядок сходимости воспроизведён: 2.05 и 2.01 на
сетках 64/128/256, отдельно по амплитуде и по форме. Два места потребовали разбора:
* начальное давление. Течение несёт собственное поле p ~ ρu₀², находимое из
  ∇²p = 2ρ(ψ_xx·ψ_yy − ψ_xy²). Старт с ρ ≡ 1 сбрасывает разницу в акустику, которая
  в периодическом ящике не затухает и садится полкой на ошибку. Работа 2024 года
  делает то же самое явно, решая ∂ρ/∂t + ∇·(ρu₀) = D∇²ρ до стационара;
* способ измельчения. При фиксированном u₀ ошибка упирается в полку O(Ma²) (измерено:
  относительная ошибка формы ∝ u₀^1.07). Порядок виден целиком только при диффузионном
  измельчении, ν = const и u₀ ∝ 1/N. Это свойство слабо-сжимаемого метода, а не
  реализации: LBGK на том же тесте даёт ту же полку, что согласуется с утверждением
  статьи о практически одинаковом поведении всех моделей.
Добавлено приближение Града (ур. 58) для согласованного старта эталонов.

СВЕРКА ОПЕРАТОРА с пошаговым листингом работы 2024 года — совпадает дословно, включая
Δh = f − f^eq − Δs и f′ = f − β(2Δs + γΔh). Та же работа подтверждает относительный
порог вырожденности γ: стабилизатор «далеко не постоянен», а MRT с γ = const не
достигает той же устойчивости.

ВЫБОР СОСТАВА СДВИГОВОЙ ЧАСТИ (табл. I) — ключ --kbc-model: n1 = {N, Π_xy} (KBC D),
n2 = {N, Π_xy, T} (KBC C). По точности неразличимы, как и заявляет статья. Разница в
объёмной вязкости (ур. 57): у n2 она фиксирована ξ = ν, у n1 равна c_s²(1/(γβ) − ½) и
при измеренной ⟨γ⟩ ≈ 1.73 < 2 в среднем вчетверо больше ν. Поэтому вопреки ожиданию
именно n1 сильнее демпфирует продольную акустику и оставлен умолчанием.

ПРОДОЛЬНАЯ АКУСТИКА КАНАЛА — найдена причина «поршневого» поведения потока. Вход по
скорости акустически есть жёсткий поршень, выход по давлению — открытый конец; канал
работает четвертьволновым резонатором. Измерено: период пульсации 3332 шага против
расчётных 4·Nx/c_s = 3325, первый ноль автокорреляции ровно на Nx/c_s, затухание за
30000 шагов — 3.8%, то есть мода не гаснет. Возбуждал её сам старт из покоя.
Исправлено умолчаниями: --init uniform (домен сразу заполнен потоком) и автоподбор
губки по длине домена. Пульсация упала с 75% до 1.0% от скорости потока, а числа
выправились сами: St 0.183 против прежних 0.194 (литература 0.183), rms Cl 0.38
против 0.84, ⟨ρ⟩ = 1.0000. Отчёт теперь печатает период моды, время её вязкого
затухания и измеренную пульсацию с предупреждением при превышении 5%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:39:08 +03:00
NotBigGhostandClaude Opus 5 5d762ad9d6 Решатель KBC-2D на Rust: ядро по статьям, два бэкенда, гифка в реальном времени
Переписанный с нуля двумерный решатель LBM D2Q9 с энтропийным столкновением KBC
в варианте «модель D» (табл. I 2D-статьи Bösch/Chikatamarla/Karlin, arXiv:1507.02509;
в трёхмерных работах — KBC-N1). Код разложен по ролям на пять файлов: математика
решателя, бэкенд под процессор, бэкенд под видеокарту, оркестратор, блок гифок.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 18:23:03 +03:00
NotBigGhostandClaude Opus 5 11ff7b79b4 Начальный коммит: Vulkan-редактор SimVulcan + исследование KBC-LBM
Состояние на момент заведения репозитория.

C++ приложение (src/, shaders/, tests/) — минимальный редактор 3D-моделей
на Vulkan 1.3: орбитальная камера, три опорные сетки через начало координат,
загрузка .obj с режимами отображения. Весь Vulkan изолирован в src/vk/.

Исследование (docs/) — оригинальные статьи по KBC (docs/origins) и
Python-решатель D2Q9 KBC-N1 с AMR 2x и SDF+Bouzidi (docs/theory).

В решателе перед коммитом исправлены дефекты, найденные сверкой с
первоисточниками: относительный порог знаменателя энтропийного стабилизатора
(абсолютный вырождал KBC в LBGK на 77-99% узлов), заворот вход/выход в углах
домена, диагностика средней плотности по фиктивным узлам тела, зашитый
refine=2. Подробности — docs/theory/solver_2x_sdf/README.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 16:37:38 +03:00
160 changed files with 104540 additions and 729 deletions
+24
View File
@@ -0,0 +1,24 @@
# Детерминированные переводы строк. У пользователя core.autocrlf=true (дефолт Git for Windows),
# из-за чего git ругался на LF→CRLF почти на каждом файле. Проект собирается ещё под Linux и
# macOS (см. CMakePresets.json), поэтому в репозитории храним LF, а в рабочей копии Windows
# git сам развернёт CRLF там, где это нужно.
* text=auto eol=lf
# .bat обязан быть CRLF — иначе cmd.exe спотыкается на многострочных конструкциях
*.bat text eol=crlf
# Бинарные: не диффать, не трогать переводы строк
*.png binary
*.gif binary
*.pdf binary
*.npz binary
*.spv binary
*.bin binary
*.exe binary
# Wavefront OBJ — текст (ASCII), но диффы бессмысленны
*.obj text diff=none
# Ноутбуки: слияние по строкам всегда даёт мусор из-за outputs/метаданных —
# конфликт решается вручную, а не авто-мержем
*.ipynb -merge
+74
View File
@@ -0,0 +1,74 @@
# ---------------------------------------------------------------------------
# Сборка
# ---------------------------------------------------------------------------
# Каталог пресетов CMake целиком: объектники, SPIR-V, скопированные assets,
# и _deps — клоны зависимостей через FetchContent (только они ~775 МБ).
/build/
/out/
CMakeUserPresets.json # локальные пресеты разработчика, не общие
compile_commands.json # генерится (CMAKE_EXPORT_COMPILE_COMMANDS), часто симлинк в корень
# Зависимости, если их когда-нибудь начнут класть вручную (сейчас каталог пуст,
# всё тянет FetchContent). Убрать строку, если вендорить их в репозиторий.
/extern/*
!/extern/.gitkeep
# ---------------------------------------------------------------------------
# Артефакты выполнения приложения
# ---------------------------------------------------------------------------
# SimVulcan пишет их в текущий каталог при запуске (обычно внутри build/,
# но при запуске из другого места окажутся здесь).
imgui.ini
pipeline_cache.bin
# ---------------------------------------------------------------------------
# Python (docs/theory — исследовательский код)
# ---------------------------------------------------------------------------
__pycache__/
*.py[cod]
.ipynb_checkpoints/
.venv/
venv/
.pytest_cache/
# Числовые сводки демо-прогонов: генерятся скриптами demos_gpu/*.py на GPU-сервере
# (ноутбук цитирует их значения в markdown). Сами прогоны воспроизводимы.
docs/theory/demos_gpu/out/
docs/theory/solver_2x_sdf/out/
# ВАЖНО: docs/theory/figures/ и docs/theory/anim/ НЕ игнорируются намеренно —
# ноутбук kbc_lbm.ipynb их встраивает, и без них он нечитаем на свежем клоне
# (перегенерация требует GPU-сервера). См. заметку о них в README ветки research.
# ---------------------------------------------------------------------------
# Rust (docs/theory/2d_solver — решатель KBC на Rust)
# ---------------------------------------------------------------------------
# Каталог сборки cargo: объектники, зависимости, инкрементальный кэш.
target/
# Cargo.lock для бинарного крейта обычно коммитят (воспроизводимость прогонов) —
# он НЕ игнорируется намеренно.
# Артефакты прогонов решателя: гифки, ряды, сводки. Воспроизводятся запуском.
docs/theory/2d_solver/*.gif
docs/theory/2d_solver/*.csv
docs/theory/2d_solver/out/
# Результаты валидационной кампании — гигабайты гифок и рядов. Сам список сценариев
# (bench/scenarios.json) лежит рядом с драйверами и версионируется.
docs/theory/2d_solver/bench/out/
# ---------------------------------------------------------------------------
# Редакторы и ОС
# ---------------------------------------------------------------------------
.vs/
.vscode/
.idea/
*.user
*.suo
*.swp
*~
.DS_Store
Thumbs.db
desktop.ini
# Общие заметки Claude Code: один файл на все ветки, вне версионирования
/CLAUDE.md
-729
View File
@@ -1,729 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Read this first: the file is shared across branches
`CLAUDE.md` is **deliberately untracked on every working branch** — `/CLAUDE.md`
sits in `.gitignore` on `main`, `dev`, `research` and `rust`. One physical file
therefore survives every `git checkout`, so these notes stay put while the tree
around them changes, and editing them never shows up in `git status`.
The master copy lives on the **orphan branch `notes`**, which holds this one file
and nothing else, one commit per revision — that branch is both the backup and the
change history. It shares no ancestor with any code branch, so it never merges into
anything and never appears in a diff.
```sh
git show notes:CLAUDE.md > CLAUDE.md # restore it in a fresh clone
git log --oneline notes # history of these notes
git diff notes~1 notes # what changed in the last revision
```
After editing this file, publish the new revision — this touches neither the
working tree nor the checked-out branch:
```sh
blob=$(git hash-object -w CLAUDE.md)
tree=$(printf '100644 blob %s\tCLAUDE.md\n' "$blob" | git mktree)
git branch -f notes "$(git commit-tree "$tree" -p notes -m 'CLAUDE.md: what changed')"
git push CFDManager notes
```
`git clean -xfd` deletes the working copy, since it is untracked — restore it with
the first command above.
Drift is guarded automatically: a `PostToolUse` hook
(`.claude/hooks/notes_guard.py`, wired in `.claude/settings.local.json`) compares
this file against `notes` after every edit and demands a described commit when the
two diverge — necessary because an untracked file never shows up in `git status`.
Both live outside git, excluded through `.git/info/exclude` rather than
`.gitignore`, so no branch has to carry them.
The consequence to keep in mind: **this file describes the whole project, but no
single branch contains all of it.** Before assuming a directory exists, check
`git branch --show-current` against the map below — `rust/` and
`docs/theory/2d_solver/` each live on one branch only.
## Branch map
| branch | what it adds | trees present |
|---|---|---|
| `main`, `dev` | baseline; both at the same commit, one past the initial import — and that commit only untracks `CLAUDE.md` | `src/`, `shaders/`, `tests/`, `docs/theory/` (Python) |
| `research` | the CFD work — **`kbc2d`**, a Rust LBM solver, its validation campaign and Docker images | `+ docs/theory/2d_solver/`, `+ docs/origins/Grad's_aproximation.pdf` |
| `rust` | a **Rust port of the C++ editor** plus a measured C++/Rust comparison | `+ rust/`, `docs/rust_vs_cpp.md` |
All five branches (including `notes`) are fully pushed to the `CFDManager` remote;
verify with `git ls-remote CFDManager` rather than trusting a stale tracking ref.
Nothing is shared between the two Rust efforts: `docs/theory/2d_solver/` (a CFD
solver, branch `research`) and `rust/` (a port of the 3D editor, branch `rust`) are
unrelated projects that merely both happen to be Rust. Neither is part of the CMake
build — `CMakeLists.txt` never mentions either tree.
**A checkout away from `research` does not remove `docs/theory/2d_solver/`** — the
tracked sources vanish but the untracked artefacts stay, so on `rust` that directory
contains only `out/`, `target/`, `wake.gif` and `demo_wake.gif` and looks like a
mutilated copy of the solver. To read the solver from another branch, go through
git rather than the filesystem:
```sh
git show research:docs/theory/2d_solver/src/math.rs
git archive research docs/theory/2d_solver | tar -x -C /tmp/kbc2d # to grep it
```
Untracked leftovers you will see regardless of branch: `build/`, `rust/target/`,
`docs/theory/2d_solver/target/`, the `__pycache__` dirs under `docs/theory/`, and
`docs/theory/2d_solver/out/` plus the two ~9 MB GIFs beside it (campaign results —
**the user's data, do not clean**). `pipeline_cache.bin` and `imgui.ini` are written
next to whichever executable ran, i.e. in `build/<preset>/src/app/` and
`rust/pipeline_cache.bin` — not at the repo root.
**`build/` in this working tree is foreign and stale.** Its
`CMakeCache.txt` records `CMAKE_HOME_DIRECTORY=C:/Users/ivan/UAS/SimV4(Not_KBC)/SimVulcan`,
and `_deps/` carries a 262 MB `nlohmann_json` clone that this project never declares.
Do not measure anything from it without re-configuring first.
---
# SimVulcan — C++/Vulkan editor (all branches)
A minimal **Vulkan 1.3 / C++20** 3D model editor: three reference grid planes (XY,
XZ, YZ) through the origin plus coloured X/Y/Z axes, an orbit camera, and `.obj`
loading with selectable display modes (solid, wireframe, solid + wireframe). It
runs **no simulation** — no compute pipeline or `.comp` shader exists anywhere, and
`Renderer.cpp` disables async compute explicitly. It is a rendering skeleton with
two ImGui windows, titled **`Viewport`** and **`Mesh`** (the class behind the second
is `MeshLoadPanel`, which is why the docs keep calling it the "mesh load panel").
Unchanged since the initial commit on every branch — `git log --all -- src` returns
exactly one commit.
## Build
Prerequisites: **Vulkan SDK** (for `glslangValidator`), **CMake 3.26+**, **Ninja**,
a C++20 compiler. Only CMake 3.26 and C++20 are actually enforced
(`CMakeLists.txt:1`, `:9-11`). `cmake/VulkanSetup.cmake:3` is
`find_package(Vulkan REQUIRED COMPONENTS glslangValidator)` with **no version
argument**, so the "SDK 1.3.290+" in `README.md` is a comment, not a check; the
de-facto floor comes from the pinned volk `1.3.295` and vk-bootstrap `v1.3.295`.
The compiler minimums (MSVC 19.36+, gcc 11+, clang 14+) are likewise README prose,
enforced nowhere. On macOS the only hard constraint is
`CMAKE_OSX_ARCHITECTURES: arm64` in the preset; MoltenVK is mentioned in a
`message(STATUS)`.
```sh
cmake --preset windows-msvc-release
cmake --build --preset windows-msvc-release
```
Presets: `windows-msvc-debug`, `windows-msvc-release`, `linux-gcc-release`,
`linux-clang-release`, `macos-arm64-release` (all Ninja, one dir per preset under
`build/<presetName>/`). None of them pins a compiler — they inherit whatever the
environment provides, so `README.md`'s "MSVC / clang-cl" is aspirational.
The first configure fetches nine repositories via FetchContent (GLFW, GLM, volk,
vk-bootstrap, VulkanMemoryAllocator, Dear ImGui, spdlog, tinyobjloader, Catch2) and
needs network access. There is no `GIT_SHALLOW`, so each is a clone with full
history, and **no shared cache** is configured (the only `FETCHCONTENT_*` variable
set is `FETCHCONTENT_QUIET OFF`) — every new build directory downloads all of it
again. Budget **~400 MB of sources / ~510 MB of `_deps` once built**, not the
775 MB quoted in `docs/rust_vs_cpp.md`: that figure was measured on the foreign
`build/` tree described above and includes 262 MB of `nlohmann_json` that nothing
here declares.
`VK_NO_PROTOTYPES` is **not** project-wide — there is no `add_compile_definitions`
at project scope. It is set `PUBLIC` on exactly two targets, `imgui`
(`CMakeLists.txt:114`) and `simv_vk` (`src/vk/CMakeLists.txt:29`). Because
`simv_core` links `simv_vk` **PRIVATE**, the definition never reaches `simv_mesh`,
`simv_editor` or `simv_tests`. Vulkan entry points load through **volk**.
Warnings come from `simv_set_warnings` (`cmake/CompilerWarnings.cmake`), called by
all six targets: `/W4 /permissive- /Zc:preprocessor /Zc:__cplusplus /wd4100` plus
the defines `_CRT_SECURE_NO_WARNINGS`, `NOMINMAX`, `WIN32_LEAN_AND_MEAN` on MSVC;
`-Wall -Wextra -Wpedantic -Wno-unused-parameter -Wshadow -Wnon-virtual-dtor
-Wold-style-cast -Wcast-align -Wunused -Woverloaded-virtual` elsewhere. They are
**not** errors — no `/WX` or `-Werror` anywhere. `cmake/Sanitizers.cmake` defines
`simv_enable_sanitizers` (Debug-only; ASan **only** on MSVC, ASan+UBSan elsewhere)
but no target calls it — wire it in manually when chasing memory bugs.
## Running
The executable resolves SPIR-V relative to the working directory (`FindSpvPath`,
`src/vk/Shader.cpp:24-33`, probes `spirv/<rel>` then `current_path()/spirv/<rel>` —
the second probe is a no-op, since the first is already CWD-relative). Two
`SimVulcan` POST_BUILD steps copy `assets/` and the compiled `spirv/` tree next to
the executable, so **run from the executable's own directory**
(`build/<preset>/src/app/`). `main.cpp:40-50` additionally probes four `../`
ancestors for `assets/meshes`. Meshes load at runtime through the `Mesh` panel.
Running writes two files into the CWD: `pipeline_cache.bin` (serialised
`VkPipelineCache`, reloaded on the next start) and ImGui's `imgui.ini` (no
`IniFilename` is ever set, so ImGui's default applies). Both are disposable —
delete them if pipeline creation or the panel layout misbehaves.
`ContextOptions::enableValidation` / `enableDebugUtils` default to **true**
(`src/vk/Context.h:15-16`) and nothing overrides them, so the Khronos validation
layer is requested even in Release. The severity mask is error + warning only
(`Context.cpp:56-58`), which makes the `INFO` branch of the callback dead code.
Those messages go to **raw `spdlog::`**, i.e. spdlog's default logger — not the
`"simv"` logger that `core::Logger` creates.
`run.bat` at the repo root is **stale**: it launches
`build/vs2022/src/app/Release/SimVulcan.exe`, a path the Ninja presets never
produce — while its own error message tells the user to run those presets. Do not
point users at it without fixing the path first.
## Tests
Catch2 unit tests, mesh bounds + welding. No GPU is touched at runtime, but the
binary is **not** free of Vulkan: static-lib propagation through
`simv_mesh → simv_core → (PRIVATE) simv_vk` makes `simv_tests.exe` link
`simv_vk`, `vk-bootstrap`, `imgui`, `volk` and `glfw3`.
```sh
cmake --build --preset windows-msvc-debug --target simv_tests
ctest --preset windows-msvc-debug
```
`windows-msvc-debug` is the only preset with a `testPreset` — for the other
configs, invoke `ctest` in `build/<preset>/` directly.
There are exactly three `TEST_CASE`s, all in `tests/unit/test_mesh.cpp`:
`Mesh::RecalculateBounds finds AABB` `[mesh]`, `WeldVertices collapses
near-duplicate vertices` and `WeldVertices drops degenerate triangles`
(both `[mesh][decimator]`). Each is registered individually by
`catch_discover_tests`, so either filter works:
```sh
ctest --preset windows-msvc-debug -R "WeldVertices" -V # matches the last two
./build/windows-msvc-debug/tests/simv_tests.exe "[decimator]"
./build/windows-msvc-debug/tests/simv_tests.exe --list-tests
```
## Architecture
### Library / target map
Arrows below show the **project** edges; each target also links third-party
libraries, and the visibility keywords matter more than they look.
```
SimVulcan (exe) → simv_core, simv_vk, simv_mesh, simv_editor (all PRIVATE)
+ glm, spdlog; add_dependencies(… simv_shaders)
simv_core → PUBLIC spdlog · PRIVATE simv_vk, glfw (App owns Window + Renderer)
simv_editor → PUBLIC simv_core, simv_mesh, imgui, glm (no Vulkan)
simv_mesh → PUBLIC simv_core, glm, tinyobjloader (no Vulkan)
simv_vk → PUBLIC Vulkan::Headers, volk, vk-bootstrap, VMA, glfw, glm, spdlog
PRIVATE imgui
simv_shaders → glslangValidator (GLSL → SPIR-V), an ALL custom target
```
The `simv_core → simv_vk` edge being **PRIVATE** is what keeps `VK_NO_PROTOTYPES`
(and volk) from reaching the rest of the tree — see the Build section.
Namespaces follow directories: `simv::core`, `simv::vk`, `simv::mesh`,
`simv::editor`. Each library exports `src/` as its include root, so includes are
written module-qualified (`#include "mesh/Mesh.h"`, `#include "vk/Renderer.h"`).
### Frame loop
`main.cpp` creates `core::App`, which owns the `core::Window` and a
`vk::Renderer`, then runs the loop. Each frame `Renderer::DrawFrame` acquires a
swapchain image, calls the UI callback (between ImGui NewFrame/Render), *then*
begins the command buffer and records the scene: grid + mesh into one
dynamic-rendering pass with a depth attachment, followed by ImGui, then presents
(2 frames in flight, `vkQueueSubmit2` / `VkImageMemoryBarrier2`).
The acquire-before-callback ordering is the root of defect 2 below — keep it in
mind before moving work into the callback.
`main.cpp` wires the editor via the UI callback: it draws the panels, applies
mouse input to the `editor::Camera`, and pushes the resulting state into the
renderer (`SetRenderMode`, `SetGridVisible`, `SetViewProj`). Mesh loads go through
`MeshLoadPanel`'s callback → `Renderer::SetMeshCpu`.
### Mesh load path
`MeshLoadPanel` lists `*.obj` in the mesh directory (extension match is
**case-insensitive**, so `.OBJ` shows up too) and kicks off `mesh::LoadObjAsync`
(`std::async(std::launch::async, …)`, `std::future`). The future is **drained on
the main thread** at the top of `MeshLoadPanel::Draw`, before the first
`ImGui::Begin`, so the `OnLoaded` callback — and therefore the GPU upload — always
runs on the render thread. Loader exceptions surface as the panel's status string.
`main.cpp`'s `OnLoaded` welds the mesh (`WeldVertices`, tolerance `1e-4`), logs
the counts, uploads via `SetMeshCpu`, and reframes the camera to the bbox radius.
Despite the file name, `mesh/MeshDecimator.h` declares **one** function and
implements **only** spatial-hash vertex welding (round-to-nearest cell hash,
collapse near-duplicates, drop degenerate triangles, recompute bounds) — there is
no LOD/decimation.
### Non-obvious invariants (read before editing)
- **All Vulkan lives in `src/vk/`.** `core/`, `mesh/`, `editor/` and `app/` make no
Vulkan API calls — a grep for `volk|vulkan|Vk[A-Z]` across them returns zero hits.
`vk::Renderer`'s public header is deliberately Vulkan-free (pImpl + glm/`RenderMode`
only) so `App` can own it without pulling in volk. Keep it that way — do not leak
`Vk*` types into the public interfaces of those modules. Note this is **convention
only**: every library exports `src/` publicly, so nothing stops a
`#include <volk.h>` in `mesh/`; the compiler will not catch it.
- **Single render pass with depth.** Scene (grid + mesh) and ImGui draw into one
`vkCmdBeginRendering` pass that has both a colour and a `D32_SFLOAT` depth
attachment (depth `storeOp` is `DONT_CARE`). The ImGui backend is initialised with
`depthAttachmentFormat` set so its pipeline matches the pass.
- **Wireframe needs `fillModeNonSolid`.** `MeshRenderer` builds a fill pipeline and
a `VK_POLYGON_MODE_LINE` pipeline; the line pipeline uses a small *static* depth
bias (`constantFactor` and `slopeFactor` both `-1.0`) so the overlay sits on top
of the fill. The device feature is requested in `Context`. Culling is off
(`VK_CULL_MODE_NONE`) — loaded models may have mixed winding.
- **Camera is fixed on the origin.** `editor::Camera` orbits (yaw/pitch/distance)
the world origin; loaded models are recentred there via a translate-only model
matrix. Projection uses Vulkan clip space (`GLM_FORCE_DEPTH_ZERO_TO_ONE` + Y flip,
isolated to `Camera.cpp` — the macro appears nowhere else in the tree).
- **Buffers.** `vk::GpuMesh` owns the model's vertex/index buffers (staged upload on
the dedicated transfer queue). `GridRenderer` builds a static host-visible mapped
line buffer once in its constructor. Both scene renderers create pipeline layouts
with one push-constant range and **no** `pSetLayouts` — no descriptor sets at all.
- **Push-constant layout is a cross-file contract.** `MeshRenderer.cpp`'s anonymous
`MeshPC { mat4 mvp; vec4 color; }` (range `VERTEX|FRAGMENT`) must stay
byte-identical to the `push_constant` block in `mesh.vert`/`mesh.frag`; `color.a`
is a *flag*, not alpha — `mesh.frag` does `mix(base, base*shade, pc.color.a)`, so
1 = flat-shaded and 0 = constant colour for wireframe. `GridRenderer` pushes a
bare `mat4` (vertex stage only). Change either side and you must change both.
- **Swapchain recreation rebuilds depth *and* sync objects.** `RecreateSwapDependent`
runs `WaitIdle` → `swap->Recreate()` → **`CreateDepth`** → per-frame
`imageAvailable` semaphores (a failed acquire can leave one signalled) → per-
swapchain-image `renderFinished` semaphores (the image count may change) → re-ensure
both pipelines. Keep that ordering if you touch resize handling.
- **Mesh upload stalls the device.** `SetMeshCpu`/`ClearMesh` call `WaitIdle`
before touching `GpuMesh` — acceptable because loads are rare; do not copy that
pattern into per-frame paths. `SetMeshCpu` early-returns on an empty mesh *before*
the wait.
### Known defects (found by the Rust port, all still present here)
Porting the editor surfaced four real bugs that nobody was looking for. They are
unfixed in the C++ tree on every branch; full write-up in `docs/rust_vs_cpp.md`
(branch `rust`).
1. **A source-level dependency cycle that the CMake graph hides.** `core::App` owns
`vk::Renderer`, and `vk::Renderer` takes a `core::Window&` — so the *sources*
are mutually dependent. The **CMake graph is acyclic**, which is exactly why
nothing complains: `simv_vk` links no project target at all, seeing
`core/Window.h` through `target_include_directories(simv_vk PUBLIC ..)` and
getting `Window`'s symbols at executable link time. Cargo rejects the same shape
outright, which is what surfaced it.
2. **Mesh upload runs inside the frame.** `MeshLoadPanel::Draw` calls `OnLoaded`
from the UI callback, which `Renderer::DrawFrame` invokes *after*
`vkAcquireNextImageKHR`; the handler calls `SetMeshCpu` → `vkDeviceWaitIdle`.
Legal, and command recording has not started yet (`vkBeginCommandBuffer` comes
later) — but it waits for device idle while holding an acquired swapchain image.
3. **Two sources of truth for "is there a mesh".** `GpuMesh::IsValid()` and
`Renderer`'s separate `hasMesh` flag must be kept consistent by hand;
`MeshRenderer::Draw` then re-checks `IsValid()` a third time.
4. **`Mesh ready` is never printed.** `ObjLoader::LoadObj` logs to category
`MeshIO`, and `main.cpp`'s handler logs `Mesh ready: …` to the same category
immediately after; the category throttles at 0.5 s and both are `Info`, so the
second message is always dropped (only Warn/Error/Critical bypass the throttle).
Post-weld counts have therefore never appeared in any log.
Also dead weight: **`vk::Buffer` and `vk::Image` are used by nobody** — 362 lines
across four files, still compiled into `simv_vk`. `GridRenderer`, `GpuMesh` and the
depth attachment all call VMA directly. `Image::Desc` even defaults to
`VK_IMAGE_TYPE_3D` and an `R32G32B32A32_SFLOAT` storage image, a leftover from some
compute/CFD design. Note `README.md:53-54` still lists both as part of the working
Vulkan stack — that line is wrong.
### Shaders
GLSL under `shaders/editor/` (`mesh.{vert,frag}`, `grid.{vert,frag}`). `simv_shaders`
compiles each to `build/<preset>/spirv/editor/<name>.<stage>.spv` — the stage suffix
is **kept**, so the real artefacts are `mesh.vert.spv`, `mesh.frag.spv` and so on —
targeting `vulkan1.3`, with `shaders/` as the `-I` root (so `#include
"common/foo.glsl"` would resolve). The glob is `CONFIGURE_DEPENDS`, so a new shader
is picked up automatically, but it matches **only `editor/*.vert` and
`editor/*.frag`**; a `.comp` or `.geom` dropped there is silently ignored.
`mesh.frag` reconstructs a flat normal from screen-space derivatives, so the mesh
vertex stream carries only positions (tight `float3`, one binding, one attribute).
The *grid* pipeline is different — two attributes, position + colour.
**The Rust port on branch `rust` compiles this same directory** (`build.rs` reaches
`../../../shaders`) with the same `glslangValidator` flags — a shader change affects
both versions.
### Logging
`simv::core::Logger` (spdlog-backed, singleton) with category-based throttling.
Log via `LogFmt(LogCategory, LogLevel, fmt, args...)`. Categories: `Core`,
`Vulkan`, `MeshIO`, `UI`, `Test` (printed as `core`, `vk`, `mesh`, `ui`, `test`).
Two things the API surface does not tell you:
- **Only `MeshIO` is ever used.** The entire tree contains three `LogFmt` call
sites — two in `ObjLoader.cpp`, one in `main.cpp`. `Core`, `Vulkan`, `UI` and
`Test` have zero.
- **The runtime knobs are never turned.** `SetMinLevel` / `SetThrottle` /
`SetEnabled` exist but have no callers, and no flag, env var or UI control is
wired to them. They are settable programmatically, not configurable.
And the whole of `vk/` logs through **raw `spdlog::`**, never through `LogFmt` —
as do `core/App.cpp` and `main.cpp`'s fatal handler. That bypasses not just the
category throttle but the `"simv"` logger entirely (different pattern, unaffected
by `Logger::Shutdown()`), which is the only reason the GPU name survives startup;
see defect 4 above.
---
# `rust/` — Rust port of the editor (branch `rust` only)
A port of SimVulcan from C++20 to Rust, existing **for comparison**: both versions
sit side by side and build independently. Same Vulkan 1.3, dynamic rendering,
synchronization2, and the same `shaders/` directory.
**The declared MSRV is wrong.** `rust/Cargo.toml:13` says `rust-version = "1.82"`
(and `rust/README.md` repeats it), but the locked `egui`/`egui-winit`/`epaint`
0.36.1 each declare `rust-version = "1.95"`, so Cargo refuses to resolve on 1.82.
**The real minimum is 1.95**; the tree is tested on 1.97.1. The Vulkan SDK is needed
for `glslangValidator` only.
```sh
cd rust
cargo build --release
./target/release/SimVulcan # SimVulcan.exe on Windows; package is simv-app
cargo test # 13 tests + 1 #[ignore]d diagnostic
```
**Work on the code with plain `cargo build`, not `--release`.** The release profile
sets `lto = "thin"` with `codegen-units = 1`, which makes a one-line edit
re-optimise and relink the whole binary: 52 s versus ~5 s in debug.
The tests cover mesh bounds, welding (three cases), `Cube.obj` loading and its error
path, camera clip space and zoom/pitch clamping, and logger category mapping. They
need no GPU but are **not** pure math: the loader tests read real files from
`assets/meshes` via `$CARGO_MANIFEST_DIR`, so they require the repo checkout. The
ignored `weld_counts_for_bundled_assets` is the diagnostic that produced the loader
parity table below.
Five crates mirror the CMake target map, and the "all Vulkan in one place"
invariant is **compiler-enforced** here — `simv-mesh` and `simv-editor` have
neither `ash` nor `simv-vk` among their dependencies. Third-party deps in full
(each crate also depends on the workspace crates shown):
```
crates/simv-core/ logger, window → log, chrono, winit
crates/simv-mesh/ .obj, welding, bounds → glam, tobj, log, thiserror
(+ simv-core)
crates/simv-vk/ all Vulkan + build.rs → ash, ash-window, raw-window-handle,
(GLSL→SPIR-V) gpu-allocator, egui, egui-winit,
egui-ash-renderer, winit, glam,
bytemuck, log, thiserror
(+ simv-core, simv-mesh)
crates/simv-editor/ camera, input, panels → egui, glam, log
(+ simv-core, simv-mesh)
crates/simv-app/ App and entry point → the four crates above
+ egui, winit, glam, log, anyhow
```
Structural differences from C++, each with a reason (full list in
`docs/rust_vs_cpp.md`): `App` lives in the executable crate (Cargo rejects the
target-graph cycle); the UI closure **returns** a `FrameState` instead of calling
setters (it is invoked from a renderer method, so it cannot borrow the renderer) —
`Renderer`'s public API has no `Set*` methods at all; the loaded mesh is uploaded
*after* `draw_frame` returns, not from inside it; `Buffer` and `Image` are actually
used; no pImpl (private module fields give the same isolation); winit owns the
event loop, so `ShouldClose`/`PollEvents` are gone.
SPIR-V is embedded via `include_bytes!` from `build.rs`, so there is no runtime
shader lookup. Two runtime path dependencies remain, though, so "runs from any
directory" is not quite true: `assets/meshes` is searched from the executable's
ancestors **and then the CWD's**, and `pipeline_cache.bin` is written next to the
exe. Unlike the C++ build, nothing copies `assets/` — the port finds the repo-root
copy by walking up from `rust/target/<profile>/`.
The UI toolkit differs **by design**: egui instead of Dear ImGui, because the
`imgui` crate is bindings and would compile ~40k lines of C++, defeating the point
of comparing ecosystems.
## Comparison result (`docs/rust_vs_cpp.md`)
The document deliberately picks no winner. Machine: i5-1135G7 / Iris Xe /
Windows 11, both builds release.
| | C++ | Rust |
|---|---|---|
| lines of code | 2434 | 2976 (+22 %) |
| release from scratch | **114 s** | 194 s (thin LTO) · 182 s (no LTO) |
| release, one-file edit | **4.4 s** | 52.3 s (LTO) · **5.5 s** (no LTO) |
| debug from scratch / one-file edit | 112 s / 8.0 s | **83 s** / **5.2 s** |
| release exe | **1.18 MB** | 5.80 MB |
| dependency sources | ~400 MB **per build dir** | **80 MB** shared registry |
| startup to renderer ready (best of 3) | 1068 ms | **754 ms** |
| CPU at 60 Hz | **11.2 %** | 12.6 % |
Frame rate measures nothing — both are FIFO on a 60 Hz screen. The 1.4 pp CPU gap
is egui rebuilding its layout every frame, not the language. The one sharp build
cell (52 s) is the price of thin LTO, not of Rust — with LTO off it is 5.5 s
against 4.4 s. Note the document's summary claim that *no* measurement differs by
an order of magnitude is false on its own numbers: 52.3 / 4.4 = **11.9×**. Every
other cell stays under 10×.
The +22 % line count is **mostly, not almost entirely**, the missing vk-bootstrap
replacement: `Context` + `Swapchain` go from 301 to 606 lines, which is 305 of the
542-line overhang (56 %); the whole Vulkan layer accounts for 346 (64 %). The rest
is real — mesh +117, editor +104, app +90, core −64, and 176 in-module test lines
against C++'s 51.
Loader parity: `cow.obj` matches exactly (2451 verts / 4898 tris); `plane.obj`
differs by 0.13 %, entirely due to the reading libraries (tobj drops the 35
vertices no face references; fan triangulation of n-gons versus tinyobjloader's
earcut).
**Known errors in `docs/rust_vs_cpp.md`** (numbers below are the measured truth):
line-count total is 2976 / 494 comments, not 2962 / 492 — the `mesh` row is stale by
the 17 lines of the ignored diagnostic test; "775 MB per build dir" is the foreign
`build/` tree (see the Build section) and so is the matching "10 packages in the
C++ graph" — there are nine; the Rust package graph is 82, not 76; `plane.obj` has
119 faces with five or more vertices, not 121; "three Catch2 tests ported verbatim"
should be *all three ported, two more added*; and `Renderer::Impl`'s destructor is
22 lines, not thirty.
---
# `docs/theory/2d_solver/` — kbc2d, Rust LBM solver (branch `research` only)
The active research work. A **D2Q9 Lattice Boltzmann solver with the entropic KBC
collision operator**, written from scratch in Rust (not a port): flow past bodies in
a channel, two interchangeable backends, a ×2 nested AMR patch, sub-grid wall
models, and GIF output locked to *physical* flow time. `Cargo.toml` declares
**Rust 1.75+**; the Dockerfile pins `rust:1.97-bookworm`. Physics is anchored to the
method authors' papers in `docs/origins/`; formula references in the code follow the
2D paper (arXiv:1507.02509), the only arXiv id in the tree, with the wall models
citing Dorschner et al. *JFM* 801 (2016) for `grad` and Malaspinas 2015 /
Coreixas et al. *PRE* 96 for `hrr`.
`README.md` there is the authoritative status/validation log (609 lines: what is
verified, against which equation, with what measured numbers) and `bench/README.md`
(248 lines) covers the validation campaign. **Read them before changing physics or
defaults** — most defaults are the outcome of a documented measurement, not a guess.
## Build / test / run
```sh
cd docs/theory/2d_solver
cargo build --release # with the GPU backend (default feature `gpu`)
cargo build --release --no-default-features # CPU only, no wgpu
cargo test --release # 29 fast tests
cargo test --release -- --include-ignored # + 4 long CPU runs (~18 s)
cargo test --release -- --ignored taylor_green_kbc_vs_bgk --nocapture # one diagnostic
```
33 `#[test]` functions exist (18 in `math.rs`, 9 in `cpu.rs`, 6 in `gif.rs`); the
4 `#[ignore]`d ones are all in `cpu.rs` and are two reference benchmarks plus two
diagnostics, not four benchmarks.
**Never build `--no-default-features` last.** Both builds write the same
`target/release/kbc2d`, so a CPU-only build silently overwrites the wgpu one and
`--backend gpu` then refuses to run. Order: no-default-features first, normal
build second.
```sh
./target/release/kbc2d --shape cylinder --size 24 --re 150 \
--nx 480 --ny 240 --steps 40000 --sponge-len 32 \
--gif wake.gif --gif-field vorticity --verbose full
```
`--help` groups every key by role, under **Russian** headings —
`Физика, Сетка, Тело, Время, Схема, Анимация, Вывод`. Note `--time <seconds>` as an
alternative to `--steps` (the two conflict): a step is not a fixed slice of time
(`dt = u_lat * dx / u_phys`, literally `math.rs`'s `Units::new`), so refining the
cell silently shortens a fixed step count.
Defaults worth knowing beyond the three discussed below: `--backend` is **`cpu`**,
`--collision kbc`, `--outlet extrapolate`, `--case channel`, `--refine 2`.
## File roles — one concern each
`src/` holds exactly these five files — no `lib.rs`, no `benches/`, no integration
tests.
| file | owns |
|---|---|
| `src/math.rs` | **all solver mathematics**, nodewise and pure: D2Q9 lattice, product-form entropic equilibrium, shear projector, γ stabiliser, collision, Zou–He, body SDFs, wall-model closures, unit conversion, spectral diagnostics (`strouhal`). Tests against the papers' formulas live here. `pub type R = f64`. |
| `src/cpu.rs` | CPU backend: AoS layout, rayon, **and the shared topology** — `Geom::build` (masks), Bouzidi link assembly, `Patch` (AMR level coupling), `initial_field`. Also holds the four ignored reference runs. |
| `src/gpu.rs` | GPU backend: wgpu + WGSL (Vulkan/DX12/Metal), SoA layout, f32. Re-implements the physics line-for-line in WGSL but **imports topology from `cpu`** rather than duplicating it. |
| `src/main.rs` | CLI, problem assembly, step loop, live output, report, CSV. Owns the `Spec` / `StepRec` / `FieldKind` contract shared by both backends. |
| `src/gif.rs` | encoding, palettes, normalisation, the HUD, delay dithering, and the physical-time frame timing. |
## Invariants (read before editing)
- **Physics is written twice, topology once.** `gpu.rs` mirrors `math.rs` by hand
in WGSL; masks, Bouzidi links and the patch frame come from `cpu::Geom` /
`cpu::Patch` / `cpu::initial_field`. Keep it that way — a past regression had the
GPU silently running Bouzidi for `--wall staircase`, caught only because two
models produced bit-identical output where they had to differ. Backend parity is
a hard requirement and `bench/parity.py` checks it: CPU f64 vs GPU f32 agree to
4–5 significant digits, and for **each** wall model the two backends agree to
0.007 % on Cd. (That number is *backend* parity, not agreement between wall
models — those differ from one another by ~1 %.)
- **The backends have different step contracts.** `Backend` in `main.rs` is an
enum, not a trait: CPU `step()` returns one `StepRec`, GPU `advance(out)` /
`flush(out)` push a *batch* — results accumulate in a 128-slot ring (`HIST`,
declared identically in Rust and WGSL) and sync once per batch. Adding a per-step
GPU readback outside `StepRec` reintroduces a `map_async` + `poll(Wait)` per step,
which cost ~8× throughput before batching (measured 863 → 6715 steps/s).
- **`GREL = 1e-8` is a relative threshold.** The test is `den > GREL * nrm`, where
`den = ⟨Δh|Δh⟩` and `nrm = ⟨Δ|Δ⟩` — so GREL is a fraction of the **full**
non-equilibrium norm, not of ⟨Δh|Δh⟩ itself. ⟨Δh|Δh⟩ is quadratic in
non-equilibrium and physically tiny (~1e-7…1e-9), so an absolute threshold fires
almost everywhere and silently substitutes γ = 2, i.e. plain LBGK instead of KBC.
The report prints the degenerate-node fraction; on a healthy threshold it must be
~0 (except the very first step).
- **GPU is f32 and cannot be otherwise** — WGSL has no `f64` type at all, so no
hardware helps. Convergence studies therefore run on CPU, everything else on GPU;
below a true error of ~1e-3 f32 diverges from f64 by an order of magnitude.
The **statistics reductions** (ρ, ⟨γ⟩, node and degenerate counts) use compensated
Kahan–Neumaier summation via `kadd`. The **force/torque kernel does not** — it
accumulates in plain f32 and reduces with a naive tree. Both `README.md:366` and
earlier revisions of this file claim otherwise; the code is the authority.
- **GPU dispatch is 2-D with a linear index rebuilt in the shader** (`lin()` /
`wlin()`), lifting the 65535-workgroup limit that capped grids at ~2048². The
workgroup → node mapping stays exactly linear, which is what lets the reductions
keep working; verified to 4096×4096.
- **Population storage has two binding layouts.** Normally two bindings (`f` and
`post`). When the adapter's max binding size is small (dzn/WSL2 caps a binding at
128 MiB while allowing a 2047 MiB buffer), each array is split across nine
bindings — one per direction, **18 in total** — raising the ceiling from 3.73 M to
33.5 M nodes. The choice comes from adapter limits. The split layout is the one
built out of `switch` statements in `fget`/`fset`/`pget`/`pset`; the combined
layout has none, which is the whole reason it is kept. Both give identical numbers;
the split costs 5.7 % bandwidth. Force it with `KBC2D_SPLIT_POPULATIONS` set to
anything other than `0`.
- **`MAX_BODY_BUCKETS = 4`.** Geometry is assembled from any number of bodies, but
per-body force is bucketed — bodies beyond the fourth have their forces merged
(`min(body, MAXB-1)` in three places), and the CLI warns when that happens.
- **Defaults encode measurements, not taste**: `--init uniform` (a rest start pumps
a quarter-wave channel resonance to 75 % of U and wrecks St/Cd/Cl — and the sponge
cannot remove it, since a standing mode has its pressure node exactly where the
sponge sits); `--kbc-model n1` (equal accuracy to `n2`, but ~4× higher bulk
viscosity, which damps longitudinal acoustics); `--wall hrr` (**provisional** —
chosen on scheme structure, pending campaign group C; `bouzidi` resolves geometry
~4× better). Accepted values: `--init uniform|rest`, `--kbc-model n1|n2`,
`--wall hrr|bouzidi|grad|staircase`.
## Validation campaign (`bench/`)
115 runs in nine groups (A 12, B 16, C 20, D 14, E 12, F 9, G 14, H 9, I 9), ≈90
machine-hours — ≈87 GPU-hours over 108 runs plus ≈3 CPU-hours over the 7 CPU runs
of group A. Each run writes `cmd.txt`, `log.txt`, `report.txt`, `series.csv` and
`summary.json` into its own folder under `out/` (untracked — **those are results,
never clean them**). Only **59 of the 115** pass `--gif`; the rest produce no
animation at all.
```sh
cd bench
python preflight.py # start every scenario for two steps — catches typos
./run_campaign.sh --calibrate # measure this machine's MLUPS (hour estimates need it)
./run_campaign.sh --dry-run # cost estimate
./run_campaign.sh --resume # run, skipping what is already done
```
`run_campaign.sh` also takes `--smoke`, `--group`, `--only` and `--budget-hours`;
`run_campaign.ps1` is the Windows twin. Run `preflight.py` for real — it caught
group I failing on a GPU limit and group F's nine runs passing `--body-x` twice,
both of which would otherwise have surfaced twenty hours into a server campaign.
Note the campaign has barely started: `out/` currently holds four finished runs
(`A01`, `A02`, `A05`, `C09`) plus `campaign.log` and `summary.csv`.
## Deployment
Published image **`notbigghost/kbc2d:1.2.0`**; the server needs only
`docker-compose.server.yml` (the only compose file with no `build:` section), not
the sources. `ENTRYPOINT` is the campaign driver and `CMD` defaults to `--dry-run`,
so a stray `docker run` prints an estimate instead of starting a 100-hour job. Check
the card first (`--profile check run --rm vulkan`) — a missing GPU is better
discovered in a minute than in an hour. The `check` profile also carries
`preflight`, `calibrate` and `plan` services (plus `parity` in the WSL file).
Two version caveats: the `1.2.0` tag lives only in the compose files and prose —
`Cargo.toml` still says `version = "0.1.0"` and the Dockerfile defaults
`ARG VERSION=dev`. And `linux/amd64` is asserted in `README.md` only; no compose
file sets `platform:` and the Dockerfile sets no `--platform`.
Two environment traps, both already handled in the image but overridable from
outside: NVIDIA Container Toolkit only injects the Vulkan ICD when
`NVIDIA_DRIVER_CAPABILITIES` contains `graphics` (with `compute` alone wgpu sees no
adapter), and **WSL2 has no NVIDIA Vulkan driver for Linux at all** — the card
arrives over `/dev/dxg`. That path instead uses **dzn** (Mesa's Vulkan→D3D12
translation, why the base image is `archlinux:base` — Debian/Ubuntu do not build
dzn), needs no NVIDIA runtime, and requires
`WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER=1` because dzn reports
`conformanceVersion = 0.0.0.0` and wgpu hides such adapters by default (the variable
is read only because `gpu.rs` builds the instance with `.with_env()`). Use
`docker-compose.wsl.yml` there.
---
# `docs/theory/` — Python prototype (all branches)
The earlier **Python/CuPy** implementation of the same LBM physics — cylinder flow
with a nested AMR patch and SDF+Bouzidi boundaries. `kbc2d` is a deliberate rewrite
of this, not a port. Do not fold any of it into the CMake build, and do not treat it
as dead code — but do not treat it as one program either.
**This directory holds four generations of the same solver.** CLAUDE.md used to name
only the newest, which made the rest look like clutter. Oldest to newest:
| generation | files | status |
|---|---|---|
| the original write-up | `_legacy/kbc_lbm.ipynb` (64 cells, executed, outputs stored) + `_legacy/kbc_lbm.py` | superseded |
| CPU reference prototypes | `_amr_anims.py`, `_amr_anims_sdf.py` (pure NumPy) | superseded, but these are the "verified CPU prototypes" the GPU core is checked against |
| GPU core + runners | `amr_gpu_core.py` (**has a NumPy CPU fallback**) driven by `amr_cylinder_gpu.py` (no SDF) and `amr_cylinder_sdf_gpu.py` (SDF+Bouzidi); both self-titled "ОСНОВНОЙ", three modes: none / 2× / nested 2×+4× | superseded as a model, **still the producer of committed figures 11e–11g and four `*_cyl*.gif` animations** — deleting it orphans notebook images |
| optimised rewrite | `amr_opt/` (`*_opt.py`: no NumPy fallback, `cp.fuse`, precomputed link masks, CUDA Graphs) | dead intermediate — produced nothing that is committed |
| **the current model** | `solver_2x_sdf/` (17 modules + README), demos in `demos_gpu/` | live |
So "**GPU/CuPy only, no CPU fallback**" is true of `solver_2x_sdf/` (`backend.py`
raises outright), `demos_gpu/` and `amr_opt/` — but **not** of `amr_gpu_core.py`,
which silently falls back to NumPy, nor of `_amr_anims*.py` and `_legacy/`, which are
CPU-only.
Also committed and easy to mistake for junk: `figures/` (14 PNG) and `anim/`
(10 GIF, 55 MB) are tracked **deliberately** so the notebook renders on a fresh
clone; `_amr_probe_data*.npz` are probe scalars one runner writes and the other
reads; `factors.csv` is a hand-preserved snapshot of the Experiment-№1 registry
(nothing writes that path — `run_factors.py` writes `solver_2x_sdf/out/factors.csv`,
which is gitignored).
- `solver_2x_sdf/README.md` — maps every file to the physics it owns (17 rows, 17
modules, exact); entry points `run.py`, `run_blockage.py`, `run_factors.py`.
**Two defects in it:** it suggests switching `cfg.collision="bgk"` or registering
a TRT operator in a `get` registry — neither the config field nor the registry
exists, and the same README says two paragraphs later that the operator is fixed;
and it declares **all of its own quantitative results stale** pending a re-run
after the `GREL` fix, which also invalidates notebook §22–24.
- `demos_gpu/` — 8 scripts + README producing 17 of the notebook's artefacts. They
import the model's operators from `solver_2x_sdf` and add only what the model
deliberately lacks: `_common.py` reimplements a polynomial equilibrium, BGK, the
H-function and a γ-field for visualisation. `figures_static.py` is pure matplotlib
and imports no physics at all.
- `kbc_lbm.ipynb` — the write-up. It does not merely "execute nothing": it has **20
cells, all markdown, zero code cells**. Artefacts are relative-path links, not
embedded data, so it is only readable in place — and **4 of its 26 image links are
dead**, all pointing into the gitignored `solver_2x_sdf/out/`.
- `docs/origins/` — the six source PDFs behind both implementations (seven on
`research`, which adds Grad's approximation).
## How kbc2d differs from this prototype
Documented in one paragraph at `docs/theory/2d_solver/README.md:390` — **on branch
`research` only**; nothing under `docs/theory/` mentions the divergence. There are
**three** differences, not two:
1. kbc2d does not collide inside the body (those populations are fictitious; γ
statistics are then gathered strictly over fluid). The Python solver *does*
collide there — `kbc_collide` takes no fluid mask.
2. kbc2d's restriction additionally skips coarse nodes whose *fine source* node lies
inside the body. The Python restriction already gates on the **coarse
destination** mask.
3. kbc2d's CPU backend is f64 and its GPU backend f32; the Python side is fp32 by
default, f64 only via `AMR_FP64`.
+139
View File
@@ -0,0 +1,139 @@
cmake_minimum_required(VERSION 3.26)
project(SimVulcan
VERSION 0.1.0
DESCRIPTION "Vulkan 3D editor: orbit camera, reference grid, OBJ model viewer"
LANGUAGES CXX C
)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")
include(CompilerWarnings)
include(Sanitizers)
include(VulkanSetup)
include(ShaderCompile)
include(FetchContent)
# ----------------------------------------------------------------------------
# External dependencies (FetchContent — pinned commits)
# ----------------------------------------------------------------------------
set(FETCHCONTENT_QUIET OFF)
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
# GLFW
set(GLFW_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(GLFW_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(GLFW_BUILD_DOCS OFF CACHE BOOL "" FORCE)
FetchContent_Declare(glfw
GIT_REPOSITORY https://github.com/glfw/glfw.git
GIT_TAG 3.4
)
# GLM
FetchContent_Declare(glm
GIT_REPOSITORY https://github.com/g-truc/glm.git
GIT_TAG 1.0.1
)
# volk (lazy Vulkan loader)
FetchContent_Declare(volk
GIT_REPOSITORY https://github.com/zeux/volk.git
GIT_TAG 1.3.295
)
# vk-bootstrap
FetchContent_Declare(vk-bootstrap
GIT_REPOSITORY https://github.com/charles-lunarg/vk-bootstrap.git
GIT_TAG v1.3.295
)
# Vulkan Memory Allocator
FetchContent_Declare(VulkanMemoryAllocator
GIT_REPOSITORY https://github.com/GPUOpen-LibrariesAndSDKs/VulkanMemoryAllocator.git
GIT_TAG v3.1.0
)
# Dear ImGui (we wrap it in a small CMake target ourselves below).
FetchContent_Declare(imgui
GIT_REPOSITORY https://github.com/ocornut/imgui.git
GIT_TAG v1.91.5-docking
)
# spdlog
set(SPDLOG_BUILD_SHARED OFF CACHE BOOL "" FORCE)
set(SPDLOG_FMT_EXTERNAL OFF CACHE BOOL "" FORCE)
FetchContent_Declare(spdlog
GIT_REPOSITORY https://github.com/gabime/spdlog.git
GIT_TAG v1.14.1
)
# tinyobjloader (header-only)
FetchContent_Declare(tinyobjloader
GIT_REPOSITORY https://github.com/tinyobjloader/tinyobjloader.git
GIT_TAG v2.0.0rc13
)
# Catch2
FetchContent_Declare(Catch2
GIT_REPOSITORY https://github.com/catchorg/Catch2.git
GIT_TAG v3.7.1
)
FetchContent_MakeAvailable(
glfw glm volk vk-bootstrap VulkanMemoryAllocator
imgui spdlog tinyobjloader Catch2
)
# ----------------------------------------------------------------------------
# ImGui static library (needs explicit target — official repo has none)
# ----------------------------------------------------------------------------
add_library(imgui STATIC
${imgui_SOURCE_DIR}/imgui.cpp
${imgui_SOURCE_DIR}/imgui_demo.cpp
${imgui_SOURCE_DIR}/imgui_draw.cpp
${imgui_SOURCE_DIR}/imgui_tables.cpp
${imgui_SOURCE_DIR}/imgui_widgets.cpp
${imgui_SOURCE_DIR}/backends/imgui_impl_glfw.cpp
${imgui_SOURCE_DIR}/backends/imgui_impl_vulkan.cpp
)
target_include_directories(imgui PUBLIC
${imgui_SOURCE_DIR}
${imgui_SOURCE_DIR}/backends
)
target_link_libraries(imgui PUBLIC glfw Vulkan::Headers volk)
# Have ImGui's Vulkan backend pick up function pointers from volk directly,
# instead of asking us to load them via ImGui_ImplVulkan_LoadFunctions.
target_compile_definitions(imgui PUBLIC
IMGUI_IMPL_VULKAN_NO_PROTOTYPES
IMGUI_IMPL_VULKAN_USE_VOLK
VK_NO_PROTOTYPES
)
# Catch2 helpers (catch_discover_tests).
include(CTest)
include(${Catch2_SOURCE_DIR}/extras/Catch.cmake)
# ----------------------------------------------------------------------------
# Project targets
# ----------------------------------------------------------------------------
add_subdirectory(shaders) # produces simv_shaders custom target
add_subdirectory(src/core)
add_subdirectory(src/vk)
add_subdirectory(src/mesh)
add_subdirectory(src/editor)
add_subdirectory(src/app)
enable_testing()
add_subdirectory(tests)
message(STATUS "")
message(STATUS "SimVulcan ${PROJECT_VERSION} configured.")
message(STATUS " Build type: ${CMAKE_BUILD_TYPE}")
message(STATUS " C++ std: ${CMAKE_CXX_STANDARD}")
message(STATUS " Vulkan: ${Vulkan_VERSION}")
message(STATUS " Generator: ${CMAKE_GENERATOR}")
message(STATUS "")
+67
View File
@@ -0,0 +1,67 @@
{
"version": 6,
"cmakeMinimumRequired": { "major": 3, "minor": 26, "patch": 0 },
"configurePresets": [
{
"name": "base",
"hidden": true,
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/${presetName}",
"cacheVariables": {
"CMAKE_EXPORT_COMPILE_COMMANDS": "ON"
}
},
{
"name": "windows-msvc-debug",
"inherits": "base",
"cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" },
"condition": { "type": "equals", "lhs": "${hostSystemName}", "rhs": "Windows" }
},
{
"name": "windows-msvc-release",
"inherits": "base",
"cacheVariables": { "CMAKE_BUILD_TYPE": "Release" },
"condition": { "type": "equals", "lhs": "${hostSystemName}", "rhs": "Windows" }
},
{
"name": "linux-gcc-release",
"inherits": "base",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"CMAKE_C_COMPILER": "gcc",
"CMAKE_CXX_COMPILER": "g++"
},
"condition": { "type": "equals", "lhs": "${hostSystemName}", "rhs": "Linux" }
},
{
"name": "linux-clang-release",
"inherits": "base",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"CMAKE_C_COMPILER": "clang",
"CMAKE_CXX_COMPILER": "clang++"
},
"condition": { "type": "equals", "lhs": "${hostSystemName}", "rhs": "Linux" }
},
{
"name": "macos-arm64-release",
"inherits": "base",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"CMAKE_OSX_ARCHITECTURES": "arm64"
},
"condition": { "type": "equals", "lhs": "${hostSystemName}", "rhs": "Darwin" }
}
],
"buildPresets": [
{ "name": "windows-msvc-debug", "configurePreset": "windows-msvc-debug" },
{ "name": "windows-msvc-release", "configurePreset": "windows-msvc-release" },
{ "name": "linux-gcc-release", "configurePreset": "linux-gcc-release" },
{ "name": "linux-clang-release", "configurePreset": "linux-clang-release" },
{ "name": "macos-arm64-release", "configurePreset": "macos-arm64-release" }
],
"testPresets": [
{ "name": "windows-msvc-debug", "configurePreset": "windows-msvc-debug",
"output": { "outputOnFailure": true } }
]
}
+61
View File
@@ -0,0 +1,61 @@
# SimVulcan
A minimal Vulkan 1.3 / C++20 **3D model editor**: an orbit camera over three
reference grid planes through the origin, with `.obj` model loading and display.
## Features
* Orbit camera — drag to rotate around the centre, wheel to zoom, and Front / Top
/ Side buttons to align the view to the principal planes.
* A 3D editor space: three grid planes (XY, XZ, YZ) and coloured X/Y/Z axes meeting
at the origin.
* `.obj` loading with three display modes — solid (flat-shaded), wireframe, and
solid + wireframe overlay.
* ImGui interface (Viewport controls + Mesh load panel).
All Vulkan code is isolated in `src/vk/`; the rest of the app is Vulkan-free.
## Targets
* Windows x64 (MSVC / clang-cl)
* Linux x64 (gcc / clang)
* macOS arm64 (Apple Silicon, via MoltenVK)
## Prerequisites
* Vulkan SDK 1.3.290+ (`https://vulkan.lunarg.com/`)
* CMake 3.26+
* Ninja
* C++20 compiler (MSVC 19.36+, gcc 11+, clang 14+)
## Build
```sh
cmake --preset windows-msvc-release
cmake --build --preset windows-msvc-release
```
Run from the executable's own directory (`build/<preset>/src/app/`) so it finds
the `spirv/` and `assets/` trees copied next to it.
## Tests
```sh
cmake --build --preset windows-msvc-debug --target simv_tests
ctest --preset windows-msvc-debug
```
## Layout
```
src/
core/ Logger, Window, App (orchestration, Vulkan-free)
vk/ All Vulkan: Context, Swapchain, Buffer, Image, Shader, GpuMesh,
Renderer (frame loop + depth + ImGui), MeshRenderer, GridRenderer
mesh/ CPU mesh: tinyobjloader + welding (no Vulkan)
editor/ Camera, mouse input, EditorUI + MeshLoadPanel (ImGui, no Vulkan)
app/ main.cpp
shaders/
editor/ mesh + grid vertex/fragment shaders (GLSL → SPIR-V at build time)
tests/ Catch2 unit tests (mesh math)
```
+70
View File
@@ -0,0 +1,70 @@
mtllib C:\Users\ivan\Downloads\Cube.mtl
o Solid_1
v -0.50000000 0.50000000 1
v -0.50000000 -0.50000000 1
v 0.50000000 -0.50000000 1
v 0.50000000 0.50000000 1
v -0.50000000 -0.50000000 0
v 0.50000000 -0.50000000 0
v -0.50000000 0.50000000 0
v 0.50000000 0.50000000 0
vt 0 1
vt 0 0
vt 1 0
vt 1 1
vt 1 0
vt 1 1
vt 0 1
vt 0 0
vt 0 1
vt 0 0
vt 1 0
vt 1 1
vt 0 1
vt 0 0
vt 1 0
vt 1 1
vt 1 1
vt 0 1
vt 0 0
vt 1 0
vt 0 0
vt 1 0
vt 1 1
vt 0 1
vn 0 0 1
vn 0 0 1
vn 0 0 1
vn 0 0 1
vn 0 -1 0
vn 0 -1 0
vn 0 -1 0
vn 0 -1 0
vn -1 0 0
vn -1 0 0
vn -1 0 0
vn -1 0 0
vn 0 1 0
vn 0 1 0
vn 0 1 0
vn 0 1 0
vn 0 0 -1
vn 0 0 -1
vn 0 0 -1
vn 0 0 -1
vn 1 0 0
vn 1 0 0
vn 1 0 0
vn 1 0 0
f 1/1/1 2/2/2 3/3/3
f 3/3/3 4/4/4 1/1/1
f 5/5/5 6/6/6 3/7/7
f 3/7/7 2/8/8 5/5/5
f 7/9/9 5/10/10 2/11/11
f 2/11/11 1/12/12 7/9/9
f 8/13/13 7/14/14 1/15/15
f 1/15/15 4/16/16 8/13/13
f 7/17/17 8/18/18 6/19/19
f 6/19/19 5/20/20 7/17/17
f 3/21/21 6/22/22 8/23/23
f 8/23/23 4/24/24 3/21/21
+70
View File
@@ -0,0 +1,70 @@
mtllib C:\Users\ivan\Downloads\Cube.mtl
o Solid_1
v -0.50000000 0.50000000 1
v -0.50000000 -0.50000000 1
v 0.50000000 -0.50000000 1
v 0.50000000 0.50000000 1
v -0.50000000 -0.50000000 0
v 0.50000000 -0.50000000 0
v -0.50000000 0.50000000 0
v 0.50000000 0.50000000 0
vt 0 1
vt 0 0
vt 1 0
vt 1 1
vt 1 0
vt 1 1
vt 0 1
vt 0 0
vt 0 1
vt 0 0
vt 1 0
vt 1 1
vt 0 1
vt 0 0
vt 1 0
vt 1 1
vt 1 1
vt 0 1
vt 0 0
vt 1 0
vt 0 0
vt 1 0
vt 1 1
vt 0 1
vn 0 0 1
vn 0 0 1
vn 0 0 1
vn 0 0 1
vn 0 -1 0
vn 0 -1 0
vn 0 -1 0
vn 0 -1 0
vn -1 0 0
vn -1 0 0
vn -1 0 0
vn -1 0 0
vn 0 1 0
vn 0 1 0
vn 0 1 0
vn 0 1 0
vn 0 0 -1
vn 0 0 -1
vn 0 0 -1
vn 0 0 -1
vn 1 0 0
vn 1 0 0
vn 1 0 0
vn 1 0 0
f 1/1/1 2/2/2 3/3/3
f 3/3/3 4/4/4 1/1/1
f 5/5/5 6/6/6 3/7/7
f 3/7/7 2/8/8 5/5/5
f 7/9/9 5/10/10 2/11/11
f 2/11/11 1/12/12 7/9/9
f 8/13/13 7/14/14 1/15/15
f 1/15/15 4/16/16 8/13/13
f 7/17/17 8/18/18 6/19/19
f 6/19/19 5/20/20 7/17/17
f 3/21/21 6/22/22 8/23/23
f 8/23/23 4/24/24 3/21/21
+19993
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
function(simv_set_warnings TARGET)
if(MSVC)
target_compile_options(${TARGET} PRIVATE
/W4 /permissive- /Zc:preprocessor /Zc:__cplusplus
/wd4100 # unused parameter (interfaces have many)
)
target_compile_definitions(${TARGET} PRIVATE
_CRT_SECURE_NO_WARNINGS
NOMINMAX
WIN32_LEAN_AND_MEAN
)
else()
target_compile_options(${TARGET} PRIVATE
-Wall -Wextra -Wpedantic
-Wno-unused-parameter
-Wshadow -Wnon-virtual-dtor -Wold-style-cast
-Wcast-align -Wunused -Woverloaded-virtual
)
endif()
endfunction()
+11
View File
@@ -0,0 +1,11 @@
function(simv_enable_sanitizers TARGET)
if(NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
return()
endif()
if(MSVC)
target_compile_options(${TARGET} PRIVATE /fsanitize=address)
else()
target_compile_options(${TARGET} PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer)
target_link_options (${TARGET} PRIVATE -fsanitize=address,undefined)
endif()
endfunction()
+50
View File
@@ -0,0 +1,50 @@
# Compile GLSL (.comp/.vert/.frag) into SPIR-V at build time.
#
# Usage:
# simv_compile_shaders(simv_shaders
# shaders/editor/mesh.vert
# shaders/editor/mesh.frag
# INCLUDES shaders
# )
#
# Produces ${CMAKE_BINARY_DIR}/spirv/<relative_dir>/<name>.spv as a custom target.
function(simv_compile_shaders TARGET)
cmake_parse_arguments(SCS "" "" "INCLUDES" ${ARGN})
set(SOURCES ${SCS_UNPARSED_ARGUMENTS})
set(SPIRV_OUT_DIR "${CMAKE_BINARY_DIR}/spirv")
file(MAKE_DIRECTORY "${SPIRV_OUT_DIR}")
set(SPV_OUTPUTS "")
foreach(SRC IN LISTS SOURCES)
get_filename_component(SHADER_NAME "${SRC}" NAME)
file(RELATIVE_PATH REL_PATH "${CMAKE_CURRENT_SOURCE_DIR}" "${SRC}")
get_filename_component(REL_DIR "${REL_PATH}" DIRECTORY)
set(OUT_PATH "${SPIRV_OUT_DIR}/${REL_DIR}/${SHADER_NAME}.spv")
get_filename_component(OUT_DIR "${OUT_PATH}" DIRECTORY)
file(MAKE_DIRECTORY "${OUT_DIR}")
set(INCLUDE_FLAGS "")
foreach(INC IN LISTS SCS_INCLUDES)
# glslangValidator requires -I<dir> with NO whitespace.
list(APPEND INCLUDE_FLAGS "-I${INC}")
endforeach()
add_custom_command(
OUTPUT "${OUT_PATH}"
COMMAND "${Vulkan_GLSLANG_VALIDATOR_EXECUTABLE}" -V --target-env vulkan1.3
${INCLUDE_FLAGS} -o "${OUT_PATH}" "${SRC}"
DEPENDS "${SRC}"
COMMENT "GLSL → SPIR-V: ${REL_PATH}"
VERBATIM
)
list(APPEND SPV_OUTPUTS "${OUT_PATH}")
endforeach()
add_custom_target(${TARGET} ALL DEPENDS ${SPV_OUTPUTS})
set_target_properties(${TARGET} PROPERTIES
SIMV_SPIRV_DIR "${SPIRV_OUT_DIR}"
)
endfunction()
+17
View File
@@ -0,0 +1,17 @@
# Locate Vulkan SDK + glslangValidator (needed for offline shader compilation).
# On macOS this expects MoltenVK to be present (provided by Vulkan SDK 1.3.290+).
find_package(Vulkan REQUIRED COMPONENTS glslangValidator)
if(NOT Vulkan_glslangValidator_FOUND)
message(FATAL_ERROR "glslangValidator not found. Install Vulkan SDK from https://vulkan.lunarg.com/")
endif()
message(STATUS "Vulkan version: ${Vulkan_VERSION}")
message(STATUS "Vulkan include dir: ${Vulkan_INCLUDE_DIRS}")
message(STATUS "glslangValidator: ${Vulkan_GLSLANG_VALIDATOR_EXECUTABLE}")
if(APPLE)
# MoltenVK ships with the SDK; it's loaded as ICD at runtime, no link change needed,
# but we set MVK_CONFIG_USE_METAL_PRIVATE_API=0 in env when running.
message(STATUS "macOS detected — Vulkan via MoltenVK (Apple Silicon arm64 only)")
endif()
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+14
View File
@@ -0,0 +1,14 @@
# Каталог сборки cargo — сотни мегабайт, внутри образа он собирается заново.
target/
# Результаты прогонов: гифки и ряды монтируются с хоста, в образ не кладутся.
bench/out/
*.gif
*.csv
*.json
!bench/scenarios.json
# Мусор редакторов и ОС
.vs/
.vscode/
.idea/
.DS_Store
Thumbs.db
+1308
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
[package]
name = "kbc2d"
version = "0.1.0"
edition = "2021"
rust-version = "1.75"
description = "Двумерный решатель LBM D2Q9 с энтропийным оператором столкновения KBC (модель D / N1)"
[[bin]]
name = "kbc2d"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
rayon = "1"
gif = "0.13"
bytemuck = { version = "1", features = ["derive"] }
wgpu = { version = "22", optional = true }
pollster = { version = "0.3", optional = true }
[features]
default = ["gpu"]
# GPU-бэкенд на wgpu (Vulkan/DX12/Metal). Отключается: --no-default-features
gpu = ["dep:wgpu", "dep:pollster"]
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
panic = "abort"
# Отдельный профиль для прогонов с проверками переполнения индексов
[profile.release-dbg]
inherits = "release"
debug = true
panic = "unwind"
+79
View File
@@ -0,0 +1,79 @@
# Образ решателя и валидационной кампании.
#
# Поддерживает два способа добраться до видеокарты, и выбирать между ними не нужно —
# доступным окажется ровно один:
#
# 1. НАСТОЯЩИЙ LINUX-СЕРВЕР с драйвером NVIDIA. Vulkan-ICD подкладывает внутрь
# NVIDIA Container Toolkit, и только если в NVIDIA_DRIVER_CAPABILITIES есть
# `graphics` — с одним `compute` wgpu не увидит ни одного адаптера. Запуск через
# docker-compose.server.yml.
#
# 2. WSL2. Драйвер NVIDIA для Linux там отсутствует как класс: карта отдаётся через
# /dev/dxg по протоколу WDDM, а нативный libGLX_nvidia про него не знает и
# enumerate возвращает ноль устройств. Зато с /dev/dxg умеет говорить dzn (Dozen) —
# драйвер Mesa, транслирующий Vulkan в D3D12. Он в образе и лежит. NVIDIA Container
# Toolkit при этом НЕ НУЖЕН: нужен проброс устройства и монтирование /usr/lib/wsl,
# где Microsoft держит libd3d12.so. Запуск через docker-compose.wsl.yml.
#
# Сборка и быстрая проверка:
# docker build -t kbc2d docs/theory/2d_solver
# docker compose -f docker-compose.wsl.yml --profile check run --rm vulkan
# ── сборка ───────────────────────────────────────────────────────────────────
# Версия тулчейна закреплена: та же, на которой решатель собирался и проверялся.
FROM rust:1.97-bookworm AS build
WORKDIR /src
# Сначала только манифесты — тогда слой с зависимостями переиспользуется, пока они не менялись.
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release --locked 2>/dev/null || true && rm -rf src
COPY src ./src
# touch нужен, чтобы cargo не спутал новые исходники с заглушкой из слоя выше
RUN touch src/main.rs && cargo build --release --locked
# ── исполнение ───────────────────────────────────────────────────────────────
# База Arch, а не debian-slim, по одной причине: dzn собирают не все дистрибутивы.
# В mesa-vulkan-drivers Debian и Ubuntu его нет (проверено по списку файлов пакета),
# в Arch он лежит отдельным пакетом vulkan-dzn той же версии Mesa, что и на хосте WSL.
FROM archlinux:base
ARG VERSION=dev
ARG REVISION=unknown
LABEL org.opencontainers.image.title="kbc2d"
LABEL org.opencontainers.image.description="Двумерный решатель LBM D2Q9 с энтропийным столкновением KBC"
LABEL org.opencontainers.image.version="${VERSION}"
LABEL org.opencontainers.image.revision="${REVISION}"
LABEL org.opencontainers.image.source="https://gitea.arseniev.info/NotBigGhost/TurbulenceCAD"
# Справка, руководства и переводы занимают в базе Arch заметно больше, чем сам
# драйвер, а в контейнере не нужны никому. NoExtract прописывается ДО установки.
RUN { echo 'NoExtract = usr/share/man/*'; \
echo 'NoExtract = usr/share/doc/*'; \
echo 'NoExtract = usr/share/info/*'; \
echo 'NoExtract = usr/share/locale/*'; \
echo 'NoExtract = usr/share/i18n/*'; } >> /etc/pacman.conf \
&& pacman -Syu --noconfirm --needed \
vulkan-icd-loader vulkan-dzn vulkan-tools python \
&& pacman -Scc --noconfirm \
&& rm -rf /var/cache/pacman/pkg/* /var/lib/pacman/sync/* \
/usr/share/man /usr/share/doc /usr/share/info /usr/share/locale
# Проверка на этапе сборки: без dzn образ бесполезен для WSL, и узнать об этом надо
# здесь, а не через час после старта кампании.
RUN ls /usr/share/vulkan/icd.d/ && ls /usr/share/vulkan/icd.d/dzn_icd*.json > /dev/null
# Для пути 1 (настоящий сервер). На WSL эти переменные ни на что не влияют.
ENV NVIDIA_VISIBLE_DEVICES=all
ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility,graphics
WORKDIR /work
COPY --from=build /src/target/release/kbc2d /work/target/release/kbc2d
COPY bench /work/bench
RUN chmod +x /work/bench/run_campaign.sh
# Кампания идёт десятки часов, поэтому по умолчанию образ НЕ запускает её: случайный
# `docker run` покажет смету и выйдет.
WORKDIR /work/bench
ENTRYPOINT ["./run_campaign.sh"]
CMD ["--dry-run"]
+609
View File
@@ -0,0 +1,609 @@
# kbc2d — двумерный решатель LBM D2Q9 с энтропийным столкновением KBC
Переписанный на Rust решатель обтекания тела в канале. Физика — та же, что в
`docs/theory/solver_2x_sdf` (python/CuPy), но собранная заново: с тестами против формул
первоисточников, двумя взаимозаменяемыми бэкендами и анимацией, привязанной к физическому
времени потока.
Оператор столкновения — энтропийный KBC по работам Bösch, Chikatamarla, Karlin: *Entropic
Multi-Relaxation Models for Simulation of Fluid Turbulence* (arXiv:1507.02509, двумерная
реализация) и *Entropic multi-relaxation lattice Boltzmann scheme for turbulent flows* (2024,
трёхмерная). Состав сдвиговой части выбирается ключом `--kbc-model`: `n1` — только девиатор
{N, Π_xy} (в 2D-статье KBC D), `n2` — девиатор со следом {N, Π_xy, T} (KBC C). Статьи лежат в
`docs/origins/`; ссылки на формулы в коде и ниже даны по нумерации 2D-статьи.
## Устройство
Пять файлов по ролям — каждый отвечает ровно за одно:
| файл | роль |
|---|---|
| `src/math.rs` | **математика решателя.** Решётка D2Q9, энтропийное равновесие в product-form, проектор на сдвиг, стабилизатор γ, столкновение, Zou–He, геометрия тел через SDF, перевод единиц, спектральная диагностика. Всё узловое и чистое; здесь же тесты против формул статьи. |
| `src/cpu.rs` | **бэкенд под процессор.** Раскладка AoS, обход сетки, rayon, сборка Bouzidi-линков, связка уровней AMR. Физику берёт из `math`. |
| `src/gpu.rs` | **бэкенд под видеокарту.** wgpu + WGSL (Vulkan/DX12/Metal), раскладка SoA. Физика построчно повторяет `math.rs` на f32; топологию (маски, линки, рамка патча) не дублирует, а берёт из `cpu`. |
| `src/main.rs` | **запуск и оркестрирование.** Разбор параметров, сборка постановки, цикл по шагам, живой вывод и итоговый отчёт, выгрузка рядов в CSV. Здесь же контракт `Spec` / `StepRec` / `FieldKind`, общий для обоих бэкендов. |
| `src/gif.rs` | **создание гифок.** Тайминг относительно физического времени, палитры, нормировка, служебная надпись, кодирование. |
Рядом, вне этого разделения: `bench/` — валидационная кампания (список сценариев и драйверы под
Linux и Windows), `Dockerfile` с `docker-compose.yml` — образ для развёртывания на сервере.
## Сборка и запуск
Нужен Rust 1.75+.
```sh
cargo build --release # с GPU-бэкендом
cargo build --release --no-default-features # только CPU (без wgpu)
cargo test --release # 29 быстрых тестов
cargo test --release -- --include-ignored # плюс 4 эталона статьи (~18 с)
```
Сборка без GPU кладёт бинарь по тому же пути, поэтому собирать её ПОСЛЕДНЕЙ нельзя: `kbc2d`
окажется перезаписан вариантом без wgpu, и `--backend gpu` будет отказывать. Порядок —
сначала `--no-default-features`, потом обычная.
На сервер удобнее ставить образом — см. [Развёртывание](#развёртывание-docker).
Пример: цилиндр Re=150, гифка завихренности в реальном времени.
```sh
./target/release/kbc2d --shape cylinder --size 24 --re 150 \
--nx 480 --ny 240 --steps 40000 --sponge-len 32 \
--gif wake.gif --gif-field vorticity --verbose full
```
`--help` показывает все ключи, разбитые по группам: Физика, Сетка, Тело, Время, Схема,
Анимация, Вывод.
## Синхронизация анимации с физическим временем
Требование: гифка идёт с той же скоростью, что и настоящий поток, независимо от того, с какой
скоростью считает машина. Реальная производительность в тайминг не входит вообще.
В LBM скорость самой решётки жёстко равна c = δx/δt = 1, поэтому шаг по времени однозначно
определяется тем, какую **решёточную** скорость `u_lat` мы назначаем физическому потоку:
```
δt = u_lat · δx / u_phys [с/шаг]
шагов в секунду = u_phys / (u_lat · δx)
задержка кадра = (шагов на кадр) · δt / playback
```
**Про пример из постановки.** «30 м/с, ячейка 0.1 м ⇒ 300 шагов/с» — это арифметика
δt = δx/u_phys, то есть `u_lat = 1`: поток проходит ровно ячейку за шаг. Формула
воспроизводится буквально ключом `--u-lat 1.0` (тест `units_time_scaling` это проверяет), но
физически такой режим негоден: Ma = u_lat/c_s = √3 ≈ 1.73, сверхзвук, разложение
Чепмена–Энскога не работает. Поэтому по умолчанию `u_lat = 0.05` (Ma ≈ 0.087), и те же 30 м/с
при ячейке 0.1 м дают 6000 шагов/с. Синхронность гифки выдерживается в обоих случаях — меняется
только, сколько шагов приходится на кадр.
**Две тонкости формата GIF**, обе разобраны:
1. *Задержка хранится в сотых долях секунды.* Точная физическая задержка почти никогда не целая:
30 кадр/с — это 3⅓ сотых. Покадровое округление до 3 дало бы анимацию на 11% быстрее
реальности, и уход копился бы линейно (к тысячному кадру — 3.3 секунды). Поэтому задержки
выдаёт накопитель `DelayDither`: суммарное время кадров отслеживает точное физическое с
точностью до одной сотой (3, 3, 4, 3, 3, 4, …), ошибка ограничена ±5 мс и не растёт.
2. *Меньше одной сотой не бывает.* При физически корректном `u_lat` кадр каждые 10 шагов — это
600 кадр/с, чего формат не умеет. Поэтому режим по умолчанию `--gif-every auto` подбирает
шаг сам под `--gif-fps` (30) так, чтобы получилось ровно реальное время. Если шаг задан
жёстко и задержка не представима, программа печатает фактический коэффициент расхождения и
конкретный совет, как починить.
`--gif-speed 0.1` даёт замедление в 10 раз (тоже точно, через тот же накопитель).
## Шаг — не фиксированная порция времени
Физическая длительность шага привязана к размеру клетки: δt = u_lat·δx/u_phys. Уменьшили клетку
вдвое — вдвое уменьшился и δt, и то же число шагов покроет вдвое меньше физического времени.
Отсюда `--time`: задаёте длительность в секундах, число шагов считается само.
Измельчение стоит дважды: клеток становится (1/δx)², а шагов на ту же секунду — 1/δx, итого
работы ~(1/δx)³ в двумерии. И помните, что `--size` задаётся В КЛЕТКАХ: уменьшив клетку и не
тронув `--size`, вы уменьшите тело физически.
Судить о длительности удобнее всего по **конвективным временам D/U** — их печатает шапка. Это
единственная мера, не зависящая ни от сетки, ни от выбора u_lat.
## Что параметризовано
- **поток**: скорость (м/с), направление, число Рейнольдса, решёточная скорость (число Маха);
- **постановка**: `--case channel` — обтекание тела; `taylor-green`, `shear-layer`,
`decaying-turbulence` — периодические эталоны из статей, без тела и граничных условий;
- **сетка**: размеры домена и размер ячейки в метрах (плотность сетки), коэффициент измельчения
вложенного патча и его границы;
- **тело**: восемь форм на выбор — `cylinder`, `square`, `diamond`, `ellipse`, `naca`,
`triangle`, `plate`, `polygon` — плюс характерный размер, относительная толщина, угол атаки и
положение. Профиль задаётся четырёхзначным кодом (`--naca 4412`, с кривизной), произвольный
контур — списком вершин (`--poly "x,y;x,y;…"`), **несколько тел сразу** — списком
`--bodies "cylinder:d=24,x=120,y=120; naca:d=48,x=260,y=120,a=8"` (сила считается по каждому
телу отдельно, до четырёх);
- **время**: длительность прогона — либо числом шагов (`--steps`), либо прямо в СЕКУНДАХ
физического времени (`--time`, число шагов считается как time/δt); начальное поле (однородный
поток либо покой с разгоном), длина разгона, амплитуда и длительность стартового возмущения;
- **схема**: оператор столкновения (`kbc`/`bgk`), состав сдвиговой части (`n1`/`n2`), модель
стенки на теле (`hrr`/`grad`/`bouzidi`/`staircase`), режим выхода, поглощающие губки перед
выходом и после входа, бэкенд, число потоков;
- **анимация**: файл, поле (`speed`/`vorticity`/`density`/`gamma`), палитра, масштаб, шаг кадра,
частота, скорость воспроизведения, диапазон нормировки, усреднение k×k клеток в пиксель
(`--gif-downsample`, без него кадр с сетки 4096×2048 неподъёмен);
- **вывод**: период живых строк, три уровня подробности, число окон в отчёте о сходимости, CSV
рядов с прореживанием (`--series-every`), x–t диаграмма осевой линии (`--xt`), метрики
эталонных течений (`--case-csv`) и машиночитаемая сводка всего прогона (`--summary`).
## Что печатает отчёт
*Шапка* — вся постановка с производными величинами: δt, шагов на секунду, Ma, τ, физическая
вязкость, блокировка канала, геометрия патча, полный план тайминга анимации.
*Живой вывод* — шаг, физическое время, ⟨ρ⟩, max|u|, Cd, Cl, скорость счёта и ETA; на уровне
`full` дополнительно ⟨γ⟩ с размахом, доля вырожденных узлов, доля узлов с ξ < 0, MLUPS и
отношение скорости счёта к реальному времени.
*Итог* — установившийся режим (St, ⟨Cd⟩, rms Cl, ⟨Cm⟩) сырой и с поправкой на блокировку, рядом
литературные значения для цилиндра; таблица сходимости по окнам с вердиктом о дрейфе массы и
насыщении; разбор стабилизатора γ; производительность.
## Состояние проверки
Опора — статьи авторов метода из `docs/origins/`, а не сторонние реализации. Ссылки на формулы
даны по нумерации 2D-статьи (arXiv:1507.02509); там, где полезнее формулировка из работы 2024
года по трёхмерной реализации, это отмечено отдельно.
### Оператор столкновения сверен с листингом статьи
Работа 2024 года приводит оператор явным пошаговым листингом (разд. IV). Реализация повторяет
его дословно: ρ, u → f^eq → s и s^eq → Δs = s − s^eq → **Δh = h − h^eq = f − f^eq − Δs** →
γ по замкнутой оценке → **f′ = f − β(2Δs + γΔh)**.
Проверено тестами (`cargo test`, 29 быстрых + 4 длинных):
- **проектор Δs** совпадает с матричным `M⁻¹·D·M` в базисе натуральных моментов (6)–(7) до 1e-13
— для обоих составов сдвиговой части; идемпотентен, не несёт ни массы, ни импульса;
- **γ из замкнутой оценки** (ур. 17 / ур. 25 работы 2024) — корень условия максимума энтропии
(ур. 15 / 23): невязка при γ\* более чем в 20 раз меньше, чем при γ\*±1;
- **при γ = 2 схема совпадает с LBGK** поточечно — как и заявлено под ур. (14);
- **сдвиговые моменты релаксируют ровно с 2β при любой γ** (β = 0.3, 0.6, 0.95, обе модели) —
именно это гарантирует, что стабилизатор не трогает вязкость;
- **вязкость по ур. (5)** воспроизводится затуханием сдвиговой волны точнее 1% (τ = 0.6 и 1.0);
- **объёмная вязкость по ур. (57)**: ξ = ν при следе в сдвиговой части и ξ = c_s²(1/(γβ) − ½)
без него;
- равновесие в product-form сохраняет ρ и ρu до 1e-13; Zou–He ставит ровно заданные скорость
на входе и плотность на выходе; SDF всех восьми форм даёт верный знак и |∇φ| = 1 ± 0.05
(у эллипса для этого пришлось считать ближайшую точку итеративно: дешёвое приближение давало
|∇φ| = 0.57 вдали от поверхности);
- **HRR-сборка сохраняет моменты**, ради которых затевалась: ρ, ρu и Π восстановленной функции
распределения совпадают с целевыми, а при нулевой скорости она совпадает с Градовой (все
коэффициенты 3-го порядка рекурсивно обращаются в ноль).
### Вихрь Тейлора–Грина: второй порядок сходимости (разд. VI)
Единственное из трёх эталонных течений статьи с ТОЧНЫМ аналитическим решением, поэтому
проверяется не «похоже на чужой прогон», а прямое совпадение с формулой:
u = ∇×[(u₀/k₂)cos(k₁x)cos(k₂y)·exp(−ν(k₁²+k₂²)t)], k₁ = 1, k₂ = 4,
область 0 < x,y < 2π на N×N, Re = u₀N/ν, полураспад t_c = ln2/[ν(k₁²+k₂²)].
Старт — приближением Града (ур. 58), как в статье. Метрика — как на рис. 1:
Σ|u_x − u_x^точн| / Σ|u_x^точн| в момент t_c.
| N | u₀ | Re | полная | амплитуда | форма |
|---|---|---|---|---|---|
| 64 | 0.03 | 100 | 6.68e-3 | 6.49e-3 | 8.65e-4 |
| 128 | 0.015 | 100 | 1.61e-3 | 1.57e-3 | 2.07e-4 |
| 256 | 0.0075 | 100 | 4.00e-4 | 3.89e-4 | 4.36e-5 |
**Порядок 2.05 и 2.01** — второй порядок статьи воспроизведён, причём отдельно по амплитуде
(скорость затухания) и по форме.
Два места, где пришлось разобраться, и оба поучительны:
1. **Давление в начальных условиях.** Течение несёт собственное поле давления порядка ρu₀²,
находимое из ∇²p = 2ρ(ψ_xx·ψ_yy − ψ_xy²):
`p = −(ρu₀²/4)[cos(2k₁x) + (k₁²/k₂²)cos(2k₂y)]`. Старт с ρ ≡ 1 сбрасывает эту разницу в
акустику, которая в периодическом ящике почти не затухает и садится полкой на ошибку. Работа
2024 года делает то же самое явно: там начальные ρ и старшие моменты получают, решая
∂ρ/∂t + ∇·(ρu₀) = D∇²ρ до стационара.
2. **Способ измельчения.** При фиксированном u₀ ошибка упирается в полку O(Ma²), от сетки не
зависящую (измерено: относительная ошибка формы ∝ u₀¹·⁰⁷, то есть абсолютная ∝ Ma²).
Второй порядок виден целиком только при диффузионном измельчении — ν фиксирована, u₀ ∝ 1/N,
тогда Re сохраняется, а Маха падает вместе с сеткой. Это свойство слабо-сжимаемого метода,
а не реализации: **LBGK на том же тесте даёт ту же полку** (1.39/1.37/1.36e-3 против
0.86/1.05/1.23e-3 у KBC), что согласуется с утверждением статьи «все модели работают
практически одинаково».
### Дважды периодический сдвиговый слой (разд. VII)
Второй эталон статьи: N = 128, Re = 30000, u₀ = 0.04, κ = 80, δ = 0.05, одно конвективное
время. Отношение энстрофии к начальной сходится с fp64-значением 0.6035. Тест чувствителен
именно к тому, что важно: на испорченном (абсолютном) пороге вырожденности γ тот же прогон
даёт 0.6599, то есть +9.3%, а чистый LBGK при этих параметрах разваливается.
### Порог вырожденности γ
`GREL = 1e-8` — **относительный** порог, доля от ⟨Δ|Δ⟩, а не абсолютный. Знаменатель ⟨Δh|Δh⟩
квадратичен по неравновесию и физически мал (~1e-7…1e-9 в развитом следе), поэтому абсолютный
порог срабатывает на подавляющем большинстве узлов и молча подменяет γ на 2 — то есть гонит
чистый LBGK вместо KBC. Работа 2024 года прямо об этом: γ «далеко не постоянна», её эволюция
тесно связана с состоянием потока, и «любой MRT с γ = const не достигнет той же устойчивости».
Доля вырожденных узлов печатается в отчёте; на исправном пороге она обязана быть ~0.
Единственное исключение — самый первый шаг: поле в точности равно равновесию, Δ ≡ 0, и порог
честно срабатывает везде. На результат это не влияет: γ умножается на Δh = 0.
### Выбор состава сдвиговой части (табл. I)
Ключ `--kbc-model`:
- `n1` (умолчание) — s = {N, Π_xy}, только девиатор. 2D-статья: KBC D; 3D: KBC-N1.
- `n2` — s = {N, Π_xy, T}, девиатор со следом. 2D-статья: KBC C; 3D: KBC-N2.
По точности они неразличимы, как и заявляет статья: на одной постановке St 0.1828 у обоих,
⟨Cd⟩ 1.4479 против 1.4468, rms Cl 0.383 против 0.388.
Разница — в объёмной вязкости (ур. 57). У `n2` она фиксирована: ξ = ν. У `n1` она равна
c_s²(1/(γβ) − ½) и, поскольку измеренная ⟨γ⟩ ≈ 1.73 < 2, в среднем оказывается примерно
вчетверо БОЛЬШЕ ν. Отрицательной она бывает лишь в долях процента узлов. Практический вывод
против ожидания: `n1` демпфирует продольную акустику сильнее, и в специально испорченной
постановке (старт из покоя, губка выключена) `n1` доживает до конца с пульсацией 75% от U,
а `n2` разваливается. Поэтому умолчание — `n1`.
### Модель стенки на теле: насколько она субсеточная
Ключ `--wall`:
- `hrr` (умолчание) — восстановление по целевым моментам с **рекурсивной регуляризацией**
(Malaspinas 2015; Coreixas и др., PRE 96, 033306): ряд Эрмита продолжен до 3-го порядка, а
коэффициенты 3-го порядка не считаются по популяциям, а выражаются через 2-й рекурсивно:
`a₃_xxy = 2u_x·a₂_xy + u_y·a₂_xx`, `a₃_xyy = 2u_y·a₂_xy + u_x·a₂_yy`. В D2Q9 `a₃_xxx` и
`a₃_yyy` решёткой не поддерживаются и отбрасываются; множитель 1/2c_s⁶ (а не 1/6c_s⁶) учитывает
три перестановки индексов;
- `grad` — то же самое с обрывом ряда на тензоре давлений: условие Града (Dorschner, Bösch,
Chikatamarla, Boulouchos, Karlin, JFM 801 (2016), разд. 2.1 и прил. B). Задаются не популяции,
а целевые моменты — ρ, u и Π, — после чего недостающие популяции собираются приближением
Града (2.13);
- `bouzidi` — интерполированный отскок: доля пересечения q входит в КАЖДУЮ восстанавливаемую
популяцию, полинково;
- `staircase` — простой отскок, q игнорируется. Не для счёта: это база сравнения, показывающая,
сколько именно даёт субсеточность.
Целевые моменты у `hrr` и `grad` одни и те же — (B 1) и (B 3) прил. B JFM 801; отличается только
то, до какого порядка восстанавливается функция распределения по этим моментам.
**Субсеточность — измеренная.** Прямой тест: сдвигаем тело внутри клетки и смотрим, насколько
поедет Cd. У по-настоящему субсеточной границы ответ не должен зависеть от того, где тело стоит
относительно узлов (Re = 20, D = 16, стационар, пять положений на полклетки, GPU):
| модель | разброс Cd | Cd |
|---|---|---|
| `staircase` | 0.98% | 2.482–2.506 |
| `grad` | 0.62% | 2.449–2.464 |
| `hrr` | 0.61% | 2.450–2.464 |
| `bouzidi` | **0.14%** | 2.459–2.463 |
**HRR не улучшает разрешение геометрии и не должен** — 0.61% против 0.62% у Града. Это следует
из устройства обеих схем: третий порядок Эрмита уточняет ВОССТАНОВЛЕНИЕ популяций по моментам, а
положение стенки входит в моментные схемы совсем другим местом. Обе моментные схемы оказываются
ровно между ступенькой и Bouzidi, и это тоже следует из их устройства: положение
стенки входит туда ТОЛЬКО через целевую скорость (B 1) — одну усреднённую по узлу величину.
Целевая плотность (B 3) — обычная сумма отскочивших и известных популяций, без q вовсе; тензор
давлений — конечные разности по решётке, тоже без q. Плюс все недостающие популяции узла
собираются из ОДНОГО набора моментов, так что полинковая направленность теряется. Bouzidi же
подставляет свою q в каждую популяцию отдельно. Ступенчатой поверхность у моментных схем не
становится, но геометрия у них разрешена заметно грубее.
**Сходимость по разрешению тела.** Физическая постановка фиксирована (домен 15D × 10D,
блокировка 0.1, Re = 20), меняется только число клеток на диаметр:
| D | `bouzidi` | `grad` |
|---|---|---|
| 8 | 2.581 | 2.618 |
| 16 | 2.529 | 2.534 |
| 32 | **2.521** | **2.521** |
Обе модели состоятельны и сходятся к одному пределу с наблюдаемым порядком ≈2.7; к D = 32 они
неразличимы. Но на грубой сетке Град заметно хуже: ошибка при D = 8 равна 0.097 против 0.060.
Для сравнения, `staircase` при D = 16 даёт 2.69 — то есть +6.7% к пределу, тогда как обе
субсеточные модели держатся в пределах +0.4%.
**Зачем тогда моментные схемы.** Их преимущество в статье — не геометрическая точность, а
устойчивость на турбулентных режимах (авторы пишут, что интерполяционные схемы «ограничены
низкими числами Рейнольдса, поскольку на границе возникают паразитные скачки») и естественная
форма для подвижных стенок: скорость стенки входит в целевые значения, а не отдельной поправкой.
В здешней канальной постановке преимущества по устойчивости воспроизвести не удалось: при росте
Re обе модели теряют счёт на одном и том же значении (Re ≈ 5·10⁴ при теле в 16 клеток), то есть
ограничивает не стенка, а что-то другое — вероятнее всего Zou–He при τ → ½.
**Умолчание — `hrr`, и это решение временное.** Оно принято по устройству схемы (третий порядок
Эрмита фильтрует высокочастотный мусор у стенки, чего обрыв на Π не делает), а не по здешним
измерениям: на стационарном цилиндре при Re = 20 отличить `hrr` от `grad` нельзя вовсе. Вопрос
ставит ребром группа C кампании — там обе моментные схемы и Bouzidi гоняются на Re = 20, 150 и
2000. Если данные не подтвердят преимущества HRR на турбулентном режиме, умолчанием станет
`bouzidi`, у которого измеренное разрешение геометрии вчетверо лучше.
Все четыре модели работают на обоих бэкендах и совпадают между ними до 0.007% по Cd. Это
специально проверяется: раньше GPU при `--wall staircase` молча считал по Bouzidi, и обнаружилось
это только потому, что две модели дали побитово одинаковый результат там, где обязаны были
разойтись.
### Паритет бэкендов и согласованность уровней
CPU (f64) и GPU (f32) на одной постановке совпадают до 4–5 значащих цифр шаг в шаг: ⟨ρ⟩
1.04933 против 1.04934, Cd 2.339 против 2.338, ⟨γ⟩ 1.2645 против 1.2646. На Intel Iris Xe GPU
даёт ≈195 MLUPS против ≈18 MLUPS у процессора.
Один и тот же случай с патчем ×2 и вовсе без измельчения (`--refine 1`) даёт St 0.1951 против
0.1970 и ⟨Cd⟩ 1.956 против 1.943 — связка уровней систематики не вносит.
**Предел на размер сетки снят.** У GPU есть жёсткий предел `maxComputeWorkgroupsPerDimension`
= 65535, а диспетчеризация была одномерной: при 64 узлах на рабочую группу это упирало сетку в
4.2 миллиона узлов, то есть примерно 2048×2048. Всё, что крупнее, падало ошибкой валидации —
не считало медленно, а не запускалось вовсе. Теперь диспетчеризация двумерная, а линейный
индекс собирается в шейдере (`lin()`/`wlin()`); отображение «рабочая группа → узлы» при этом
остаётся ровно линейным, поэтому редукции ничего не заметили. Проверено до 4096×4096, паритет
с CPU не сдвинулся ни в одной цифре.
**Где именно кончается f32.** Прямой замер на Тейлоре–Грине, где ошибка известна точно:
| N | CPU, f64 | GPU, f32 |
|---|---|---|
| 64 | 9.83e-3 | 9.80e-3 |
| 128 | 2.40e-3 | 3.29e-3 |
| 256 | 5.97e-4 | 2.37e-2 |
Пока истинная ошибка выше ~10⁻³, f32 идёт с f64 вровень; ниже — промахивается на порядок и
больше. Практический вывод, заложенный в кампанию: исследования сходимости считаются на CPU,
всё остальное — на GPU. Двойной точности на GPU здесь быть не может в принципе: **в WGSL типа
`f64` не существует**, поэтому wgpu не выразит её ни на каком железе; локальная Iris Xe вдобавок
сообщает `shaderFloat64 = false`, а на потребительских NVIDIA f64 идёт в 1/64 от f32 — то есть
медленнее, чем CPU.
Что удалось выжать вместо точности — производительность. Два изменения:
1. **Батчинг чтения.** Раньше после каждого шага делался `map_async` + `poll(Wait)` ради 48 байт
статистики: на 240×120 счёт упирался в 863 шаг/с при том, что сам счёт занимал 0.27 мс из
1.16. Теперь итоги копятся в кольце на 128 слотов, синхронизация — раз в батч: **6715 шаг/с**,
в 7.8 раза быстрее, при неизменном пошаговом интерфейсе снаружи.
2. **Компенсированное суммирование** (Кэхена–Ноймайера) в редукциях и в сумме сил. Наивная сумма
по 10⁵–10⁷ узлам съедает ~log₂N бит мантиссы — именно там f32 терял основную точность.
### Обтекание цилиндра против литературы
Постановка 480×240, D = 24, Re = 150, блокировка β = D/Ny = 0.1, умолчания решателя:
| величина | сырое | с поправкой на блокировку | литература (безгранич. цилиндр) |
|---|---|---|---|
| St | 0.1828 | 0.1645 | 0.183 |
| ⟨Cd⟩ | 1.448 | 1.173 | 1.33 |
| rms Cl | 0.383 | 0.310 | ~0.30 |
| ⟨Cm⟩ | 0.00002 | — | 0 (симметрия) |
Поправка: St×(1−β), Cd и rms Cl ×(1−β)². Сырое St и скорректированный rms Cl ложатся на
литературу; Cd после поправки ниже на 12%. ⟨Cm⟩ ≈ 0 — контроль симметрии считывания силы.
**Формы тел ведут себя физично.** Прогон на каждую форму (260×130, размер 20, угол атаки 12°)
даёт ожидаемый порядок сопротивления: профиль 0.44, эллипс 0.45, пластина 0.60, цилиндр 1.33,
ромб 1.65, квадрат 2.38, треугольник 2.72. ⟨Cm⟩ ≈ 0 **только** у круга (−0.0002), которому
угол атаки безразличен, а у несимметричных под углом тел он ненулевой (профиль +0.31,
эллипс +0.15, пластина +0.13).
### Мелкие отличия от питоновского прототипа
Решатель писался заново, не как порт, но пара мест разошлась с `solver_2x_sdf` намеренно:
внутри тела здесь не считается столкновение (эти популяции фиктивны — Bouzidi перекрывает всё,
что могло бы прийти из тела в жидкость; побочно статистика γ собирается строго по жидкости), а
рестрикция дополнительно пропускает узлы, у которых тонкий узел-источник лежит внутри тела.
Процессорный бэкенд работает в f64, GPU-бэкенд — в f32.
## Продольная акустика канала: почему поток может «дышать»
Самая заметная ловушка этой постановки, и её стоит понимать до первого запуска.
**Граничное условие с заданной скоростью на входе акустически есть жёсткий поршень**: оно
отражает продольные волны с коэффициентом +1. Выход по давлению — наоборот, открытый конец.
Вместе они делают из канала четвертьволновый резонатор с пучностью давления на входе и узлом
на выходе:
период основной моды = 4·Nx/c_s шагов
затухание вязкостью ~ ν(π/2Nx)² — на длинном домене практически ноль
Измерено на 480×240: период пульсации ⟨ρ⟩ **3332 шага** против расчётных 4·Nx/c_s = 3325
(0.2%), первый ноль автокорреляции на 827 шагах = ровно Nx/c_s (четверть периода). За 30000
шагов амплитуда упала на 3.8% — то есть мода не гаснет вообще. Ничто её не подкачивает; это
звон от старта, запертый в почти без потерь резонаторе.
Отсюда умолчания:
- **`--init uniform`** — домен сразу заполнен набегающим потоком, вход включён на полную.
Старт из покоя (`--init rest`) разгоняет весь столб жидкости и закачивает моду; при разгоне
за 1000 шагов против акустического пробега 831 шаг это для звука удар.
- **губка перед выходом включена и подобрана по домену** (`nx/12`, не меньше 16 столбцов,
с запасом до патча). Отключается `--sponge-len 0`. Вклад у неё скромный — см. таблицу ниже,
— но она бесплатна для сил и снимает остаточную пульсацию примерно вдвое при утроении длины.
- **`--outlet extrapolate`** — нуль-градиент поперечной скорости; жёсткий ноль отражает вихри
дорожки обратно к телу.
**Что именно помогает — разделено измерением** (480×240, D=24, Re=150, 30 000 шагов):
| старт | губка, столбцов | пульсация u′/U | St | ⟨Cd⟩ | rms Cl |
|---|---|---|---|---|---|
| uniform | 0 | 1.12% | 0.1839 | 1.483 | 0.404 |
| uniform | 40 | 1.03% | 0.1840 | 1.483 | 0.404 |
| uniform | 120 | 0.83% | 0.1839 | 1.482 | 0.403 |
| rest | 0 | **74.91%** | 0.1534 | 2.009 | 0.807 |
| rest | 40 | **74.90%** | 0.1539 | 2.010 | 0.808 |
| rest | 120 | **74.99%** | 0.1542 | 2.008 | 0.808 |
Читается однозначно: **весь эффект даёт однородный старт**, 74.9% → 1.12%, причём при
полностью выключенной губке. Губка снимает только остаток — 1.12% → 1.03% → 0.83%, — и на
силы с частотой схода не влияет вовсе (St 0.1839 во всех трёх строках).
**При старте из покоя губка не помогает совсем.** Это не осечка реализации, а свойство моды:
губка поднимает вязкость на последних столбцах, а там у стоячей четвертьволновой моды **узел
давления и пучность скорости** — то самое место, где повышенная вязкость её почти не трогает.
Бегущую волну такая губка съедает, стоячую — нет. Убрать моду можно только не возбуждая её.
Заодно видно, ЧЕМ платит неверный старт: St 0.153 вместо 0.184, ⟨Cd⟩ 2.01 вместо 1.48,
rms Cl 0.81 вместо 0.40 — то есть 75-процентная продольная пульсация ломает не косметику, а
все три величины, ради которых постановка и считается.
Отчёт печатает период моды, время её вязкого затухания и измеренную пульсацию в конце прогона,
с явным предупреждением, если она превысила 5% от U.
**Инструмент разбора — `--xt`.** Каждые `--xt-every` шагов пишется срез ⟨ρ⟩ и u_x вдоль осевой
линии, по строке на срез. Наклон полос на такой диаграмме прямо даёт скорость распространения:
звук (±c_s) или конвекция (U). Стоячие узлы видны как вертикальные линии постоянной фазы, и это
сразу отличает резонанс от неустойчивости самого граничного условия, привязанной к столбцу x = 0
и никуда не бегущей.
Есть и **губка после входа** — `--sponge-in <столбцов>`, по умолчанию выключена. Гасит продольные
волны до того, как они отразятся от входа-поршня. Осмысленна на высоких Re, где стартовая волна
перестаёт быть безобидной полоской; цена — искажение профиля прямо на входе, поэтому включать её
надо осознанно, а не «на всякий случай».
Что **не помогает** и оставлено только для повторной проверки — `--init-taper`. Замерено:
сглаживание стартовой скорости у тела давит возмущение плотности на первом шаге восьмикратно
(2.54·10⁻² → 3.13·10⁻³), но пик ЗА ПРОГОН при этом даже подрастает (до 3.22·10⁻²). Возмущение
просто переносится во времени: поток всё равно обязан разогнаться вокруг тела, и энергия этого
переходного процесса задана физикой, а не гладкостью начального поля. Умолчание — 0.
## Валидационная кампания
`bench/` — 115 прогонов на ≈90 часов GPU, разложенных по девяти группам: эталоны первоисточников,
цилиндр против литературы, модели стенки, профили крыла, сложная и множественная геометрия,
границы домена, старт и время жизни, внутренние инварианты, сверхмелкие сетки до 4096×2048.
Каждый прогон кладёт логи, ряды, машиночитаемую сводку и гифку на всю свою длительность в
собственную папку.
```sh
cd bench
python preflight.py # каждый сценарий стартует на два шага: ловит опечатки
./run_campaign.sh --calibrate # замерить MLUPS этой машины: оценки в часах иначе гадание
./run_campaign.sh --dry-run # смета: что, сколько шагов, сколько часов
./run_campaign.sh --resume # считать, пропуская уже готовое
```
Предполётную проверку стоит гонять всерьёз: она поймала, что вся группа сверхмелких сеток
падала на пределе GPU (см. ниже), а девять прогонов передавали `--body-x` дважды. Оба отказа
проявились бы только на сервере, часов через двадцать после старта кампании.
Подробности — в `bench/README.md`: раскладка выходных файлов, таблица групп, модель стоимости и
список того, что известно заранее (какие прогоны обязаны развалиться и почему).
## Развёртывание (Docker)
Собранный образ опубликован: **`notbigghost/kbc2d:1.2.0`** (он же `latest`, платформа `linux/amd64`).
Исходники на сервере не нужны — достаточно перенести туда один файл
`docker-compose.server.yml`:
```sh
mkdir -p ~/kbc2d && cd ~/kbc2d # сюда же ляжет ./out с результатами
# перенести docker-compose.server.yml
docker compose -f docker-compose.server.yml --profile check run --rm vulkan # карта видна?
docker compose -f docker-compose.server.yml --profile check run --rm preflight # сценарии стартуют?
docker compose -f docker-compose.server.yml --profile check run --rm calibrate # сколько MLUPS?
docker compose -f docker-compose.server.yml up -d # кампания
docker compose -f docker-compose.server.yml logs -f
```
Порядок именно такой: узнать, что карта не видна, лучше через минуту, чем через час. Замеренные
`--calibrate` числа подставляются переменными `KBC2D_GPU_MLUPS` / `KBC2D_CPU_MLUPS` — только на
оценки в часах, на счёт они не влияют.
Собрать образ самому (`docker-compose.yml` рядом делает то же самое с `build:`):
```sh
docker build -t kbc2d docs/theory/2d_solver
docker run --rm --gpus all kbc2d --calibrate
```
`ENTRYPOINT` — драйвер кампании, `CMD` по умолчанию `--dry-run`: случайный `docker run` покажет
смету и выйдет, а не запустит сточасовую задачу. В серверном compose политика перезапуска —
`on-failure`, а не `unless-stopped`: кампания завершается штатно с кодом 0, и «перезапускать
всегда» крутило бы контейнер вхолостую по кругу, тогда как падение или перезагрузку хоста
`on-failure` подхватывает, а `--resume` продолжает с места.
**Главная тонкость — Vulkan внутри контейнера.** NVIDIA Container Toolkit подкладывает
Vulkan-ICD (`nvidia_icd.json`) только если в `NVIDIA_DRIVER_CAPABILITIES` есть `graphics`;
с одним `compute` wgpu не увидит ни одного адаптера. В образе это прописано, но может быть
переопределено снаружи, поэтому `vulkan-tools` лежит внутрь: первым делом на сервере стоит
выполнить `docker run --rm --gpus all --entrypoint vulkaninfo kbc2d --summary`.
Проверено локально: образ собирается, кампания внутри него проходит смоук с монтированием
результатов на хост, физика совпадает с хостовой до последней цифры (ошибка Тейлора–Грина
9.829e-3 при N=64 и 2.401e-3 при N=128 — те же значения, что вне контейнера), а `--backend gpu`
без проброшенной карты отказывает явным сообщением, а не считает молча.
### WSL2 — отдельный путь
В WSL2 всё вышеописанное не работает, и не из-за настроек. **Драйвера Vulkan для Linux у
NVIDIA там нет**: карта отдаётся через `/dev/dxg` по протоколу WDDM, нативный
`libGLX_nvidia` про него не знает и перечисляет ноль устройств. Container Toolkit
подкладывать внутрь нечего, отсюда `could not select device driver "nvidia"`.
Работает другое: **dzn** (Dozen) — драйвер Mesa, транслирующий Vulkan в D3D12, он умеет
говорить с `/dev/dxg` напрямую. Он положен в образ (ради него база сменена с
`debian:bookworm-slim` на `archlinux:base`: в пакетах Mesa у Debian и Ubuntu dzn не
собирают). NVIDIA-runtime для этого пути не нужен вовсе — нужны проброс устройства и
монтирование `/usr/lib/wsl`. Запуск через `docker-compose.wsl.yml`, подробности в
[`bench/README.md`](bench/README.md).
Три вещи, которые надо знать про этот путь.
**wgpu по умолчанию прячет несоответствующие адаптеры.** dzn сообщает о себе
`conformanceVersion = 0.0.0.0`, и wgpu молча его отбрасывает — решатель докладывает, что
GPU не найден. Согласие даётся явно, переменной `WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER=1`;
чтобы она вообще читалась, в `gpu.rs` при создании инстанса стоит
`InstanceFlags::from_build_config().with_env()`. Поведение по умолчанию не изменилось: без
переменной несоответствующие адаптеры по-прежнему скрыты.
**У dzn мал предел на размер одной привязки — 128 МиБ** против гигабайтов у нативных
драйверов. Массив популяций занимает `nx · ny · 9 · 4` байта, поэтому при одной общей
привязке потолок выходил 3.73 млн узлов, и сетки от 2048×2048 не запускались вовсе. Но
ограничена именно привязка, а не буфер (`max_buffer_size` у dzn 2047 МиБ), так что тот же
буфер теперь показывается **девятью привязками по одному направлению**: потолок
поднимается до 33.5 млн узлов. Вариант выбирается по возможностям адаптера; где предела
нет, собирается прежний общий, без `switch` в аксессорах. Оба дают одинаковые числа
(Cd 2.39486 в обоих), раздельный стоит 5.7% пропускной способности. Принудительно
включается переменной `KBC2D_SPLIT_POPULATIONS=1` — она нужна для сверки двух вариантов
на одной карте.
**Точность трансляция не портит.** Замерено на Intel Iris Xe одним и тем же прогоном
(`bench/parity.py`, цилиндр Re=20, 8000 шагов):
| | Cd | energy_end (Тейлор–Грин) |
|---|---|---|
| CPU, f64 | 2.39490 | 5.74754e-05 |
| нативный Vulkan, f32 | 2.39486 | 5.74813e-05 |
| dzn в контейнере, f32 | 2.39486 | 5.74810e-05 |
Числа dzn и нативного драйвера сходятся до 5–6 значащих цифр, и разница между ними меньше,
чем между любым из них и f64. Считает трансляция то же самое.
**Скорость она портит, но тем меньше, чем крупнее сетка** — плата почти вся приходится на
трансляцию вызова, а не счёта:
| сетка | узлов | нативно | dzn в контейнере | плата |
|---|---|---|---|---|
| 320×192 | 61 тыс. | 188.8 MLUPS | 44.5 MLUPS | 4.2× |
| 960×480 | 461 тыс. | 125.3 MLUPS | 86.0 MLUPS | 1.46× |
| 1920×960 | 1.84 млн | 128.7 MLUPS | 98.7 MLUPS | 1.30× |
Для кампании это решает дело: 95.1% её стоимости приходится на сетки крупнее 600 тыс.
узлов, а на сетки мельче 150 тыс. — 0.0%. Ожидаемое удорожание всей кампании в контейнере —
около трети, а не в разы. Проверяется профилем `calibrate`, который меряет в том числе
1920×960.
## Дальше
- σ·n-кросс-чек силы (интеграл тензора напряжений по контуру) как независимая проверка GMEM;
- согласованные начальные условия по образцу работы 2024 года: там ρ и старшие моменты
получают, решая ∂ρ/∂t + ∇·(ρu₀) = D∇²ρ до стационара, что убрало бы и остаточный
стартовый импульс от появления тела в потоке;
- подвижные и вращающиеся тела: GMEM уже записан в галилей-инвариантной форме и принимает
скорость стенки на линке, но подача этой скорости не подключена;
- разбор нерешённых 12% по Cd и предела устойчивости Re ≈ 5·10⁴ — обе задачи вынесены в
кампанию (группы B и G), выводы делать по её данным;
- больше четырёх тел в домене: сейчас сила считается по четырём вёдрам (`MAX_BODY_BUCKETS`),
геометрия при этом собирается из любого числа тел, но силы сверх четвёртого сливаются вместе.
+248
View File
@@ -0,0 +1,248 @@
# Валидационная кампания
115 прогонов, ≈90 часов на RTX 4070 Ti. Проверяет решатель по трём независимым линиям:
эталонам из статей авторов метода, литературе по обтеканию тел и внутренним инвариантам самой
схемы. Каждый прогон кладёт логи, ряды, машиночитаемую сводку и гифку в собственную папку.
## Быстрый старт на сервере
Образ опубликован, собирать ничего не нужно: **`notbigghost/kbc2d:1.2.0`**. Исходники на
сервере тоже не нужны — переносится один файл `docker-compose.server.yml`.
```sh
mkdir -p ~/kbc2d && cd ~/kbc2d # сюда же ляжет ./out с результатами
# перенести сюда 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 preflight # 2. все 115 сценариев стартуют?
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 up -d # 5. кампания
docker compose -f $C logs -f
```
Порядок не случайный: узнать, что карта не видна, лучше на первом шаге, чем через час счёта.
Замеренные калибровкой числа подставляются переменными окружения — они влияют только на
оценки в часах, не на счёт:
```sh
KBC2D_GPU_MLUPS=1450 KBC2D_CPU_MLUPS=40 docker compose -f $C --profile check run --rm plan
```
**Если `vulkaninfo` показывает ноль адаптеров** — почти наверняка дело в
`NVIDIA_DRIVER_CAPABILITIES`: Container Toolkit подкладывает Vulkan-ICD только при наличии
`graphics` в списке. В образе и в compose это прописано, но может быть переопределено снаружи.
Проверить, что хост вообще умеет отдавать карту:
```sh
docker run --rm --gpus all nvidia/cuda:12.4.0-base-ubuntu22.04 nvidia-smi
```
## Запуск в WSL2
Отдельный путь, потому что в WSL2 **драйвера Vulkan для Linux у NVIDIA не существует**.
Карта отдаётся через `/dev/dxg` по протоколу WDDM; нативный `libGLX_nvidia` про него не
знает и возвращает ноль устройств — загрузчик его выбрасывает. Отсюда и `could not select
device driver "nvidia"`: NVIDIA Container Toolkit тут не поможет, потому что подкладывать
внутрь нечего.
Зато с `/dev/dxg` умеет говорить **dzn** (Dozen) — драйвер Mesa, транслирующий Vulkan в
D3D12. Он лежит в образе. NVIDIA-runtime при этом **не нужен вовсе**: достаточно проброса
устройства и монтирования `/usr/lib/wsl`, где Microsoft держит `libd3d12.so`.
```sh
C=docker-compose.wsl.yml
docker compose -f $C --profile check run --rm vulkan # 1. карта видна?
docker compose -f $C --profile check run --rm parity # 2. считает ли она правильно?
docker compose -f $C --profile check run --rm calibrate # 3. и с какой скоростью?
docker compose -f $C --profile check run --rm preflight # 4. все сценарии стартуют?
docker compose -f $C --profile check run --rm plan # 5. смета в часах
docker compose -f $C up -d # 6. кампания
```
Если образа нет ни локально, ни в реестре — `docker compose -f $C build`, доступ к Docker
Hub не обязателен.
### Шаг parity обязателен
dzn сообщает о себе `conformanceVersion = 0.0.0.0`: набор тестов соответствия Vulkan он не
проходил. wgpu по этой причине по умолчанию **прячет** такие адаптеры, и решатель сообщает,
что GPU не найден. Согласие считать на непроверенном драйвере даётся явно — переменной
`WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER=1`, она прописана в `docker-compose.wsl.yml`.
Раз соответствие не проверено вендором, его проверяем сами. `bench/parity.py` гоняет два
коротких эталона на CPU в f64 и на GPU и сверяет числа: вихрь Тейлора–Грина (есть точное
решение) и стационарное обтекание цилиндра при Re=20. Течения выбраны намеренно не
хаотические — там расхождение f32 и f64 не нарастает, поэтому заметное различие означает
проблему драйвера, а не разрядности.
Замерено на Intel Iris Xe: **dzn совпадает с нативным драйвером той же карты до 5–6
значащих цифр** (`energy_end` 3.02277e-07 против 3.02283e-07, `cd` 2.39486 против 2.39486).
Точность трансляция не портит. Но это замер на Intel; на своей карте прогоните сами — две
минуты.
### Предел 128 МиБ на привязку и как он снят
dzn объявляет `max_storage_buffer_binding_size = 128 МиБ` (2²⁷ байт), тогда как нативные
драйверы дают гигабайты. Массив популяций занимает `nx · ny · 9 · 4` байта, так что при
одной общей привязке потолок выходил **3.73 млн узлов** (примерно 1920×1920), и 14
прогонов кампании из 115 падали на создании bind group:
```
Buffer binding 0 range 150994944 exceeds `max_*_buffer_binding_size` limit 134217728
```
На эти 14 приходилось 70.7% стоимости кампании — дорогие прогоны как раз крупносеточные.
Существенно, что ограничена только **привязка**: `max_buffer_size` у dzn 2047 МиБ, то есть
буфер держать разрешено, нельзя лишь показать шейдеру его целиком. Поэтому решатель теперь
умеет показывать тот же буфер **девятью привязками**, по одному направлению в каждой.
Потолок поднимается в девять раз — до 33.5 млн узлов, чего хватает всей кампании с запасом
(самая крупная сетка в ней 4096×4096 — 16.8 млн узлов).
Вариант выбирается сам, по `max_storage_buffer_binding_size` адаптера: где предела нет,
собирается прежний общий вариант без `switch` в аксессорах. Проверить оба на одной карте
можно переменной `KBC2D_SPLIT_POPULATIONS=1` — она включает раздельные привязки
принудительно.
Замерено на Intel Iris Xe, один драйвер, два варианта привязки:
| | Cd | energy_end | MLUPS (цилиндр) |
|---|---|---|---|
| общая привязка | 2.39486 | 5.74813e-05 | 169.7 |
| девять привязок | 2.39486 | 5.74813e-05 | 160.0 |
Числа совпадают полностью; раздельный вариант стоит **5.7%** пропускной способности, и
включается только там, где без него счёт вообще невозможен.
### Чего трансляция стоит по скорости
Плата есть, но она почти вся — накладные расходы на вызов, а не на счёт, и потому падает
с ростом сетки. Замерено на Intel Iris Xe, один и тот же решатель:
| сетка | узлов | нативный Vulkan | dzn в контейнере | плата |
|---|---|---|---|---|
| 320×192 | 61 тыс. | 188.8 MLUPS | 44.5 MLUPS | **4.2×** |
| 960×480 | 461 тыс. | 125.3 MLUPS | 86.0 MLUPS | **1.46×** |
| 1920×960 | 1.84 млн | 128.7 MLUPS | 98.7 MLUPS | **1.30×** |
| 2048×2048 | 4.19 млн | 111.8 MLUPS | 67.4 MLUPS | **1.66×** |
Последняя строка стоит особняком: там уже раздельные привязки (см. ниже), и в плату
входит их `switch` в аксессорах. Ожидаемый разброс по кампании — от 1.3× до 1.7×.
Каждый шаг решателя — несколько отправок в очередь; на мелкой сетке трансляция вызова
стоит дороже самого счёта, на крупной размазывается. Для кампании это решающее
обстоятельство, потому что дорогие прогоны в ней как раз крупные:
| узлов в сетке | прогонов | доля стоимости кампании |
|---|---|---|
| < 150 тыс. | 18 | 0.0% |
| 150–600 тыс. | 47 | 4.9% |
| > 600 тыс. | 50 | **95.1%** |
То есть 95% времени кампания проводит там, где плата 1.3–1.5×, и почти не бывает там, где
она четырёхкратна. Ожидаемое удорожание по всей кампании — около трети: 90 часов
превращаются примерно в 115–120, а не в 400.
Проверьте на своей карте: профиль `calibrate` меряет три сетки, и ориентироваться надо на
**1920×960** — она представительна для кампании, а 240×120 показывает худший случай.
## Своя сборка образа
Если нужен образ из текущего состояния репозитория, а не опубликованный:
```sh
docker build -t kbc2d docs/theory/2d_solver --build-arg VERSION=dev --build-arg REVISION=$(git rev-parse --short HEAD)
```
Рядом лежит `docker-compose.yml` — то же самое через `build:`, для локальной отладки.
## Без Docker
```sh
cargo build --release # из docs/theory/2d_solver
cd bench
python preflight.py # каждый сценарий стартует на два шага
./run_campaign.sh --calibrate # замерить MLUPS этой машины
KBC2D_GPU_MLUPS=1400 ./run_campaign.sh --dry-run
./run_campaign.sh --resume
```
Под Windows то же самое делает `run_campaign.ps1` (тот же `scenarios.json`); он нужен для
локальной отладки обвязки, целевая машина — Linux.
## Ключи драйвера
| ключ | что делает |
|---|---|
| `--dry-run` | печатает смету: что, сколько шагов, сколько часов. Ничего не считает |
| `--calibrate` | три коротких прогона, замер фактических MLUPS этой машины |
| `--smoke` | три самых дешёвых прогона: проверить обвязку, а не физику |
| `--resume` | пропускает прогоны, у которых уже есть `summary.json` |
| `--group A,B` | только выбранные группы |
| `--only cyl_re150` | по подстроке идентификатора |
| `--budget-hours 24` | остановиться, когда время выйдет |
Прогон, который упал или развалился, помечается в сводке и **не останавливает кампанию**:
группы E и G специально ищут предел устойчивости, там развал — ожидаемый результат.
## Что где лежит
```
out/summary.csv сводная таблица: id, группа, статус, секунды, стоимость
out/campaign.log журнал с отметками времени
out/<id>/cmd.txt точная команда, которой прогон был запущен
out/<id>/log.txt полный вывод
out/<id>/report.txt только итоговый отчёт
out/<id>/series.csv временные ряды по шагам
out/<id>/summary.json ключевые метрики машиночитаемо — с этого удобно начинать разбор
out/<id>/*.gif анимация
out/<id>/*_case.csv энергия, энстрофия, палинстрофия (эталонные течения)
out/<id>/xt_*.csv x–t диаграммы (группа G)
```
## Группы
| | прогонов | что проверяется |
|---|---|---|
| **A** | 12 | эталоны первоисточников: Тейлор–Грин (второй порядок сходимости; при фиксированном u₀ — полка O(Ma²)), сдвиговый слой Re=3·10⁴, затухающая турбулентность |
| **B** | 16 | цилиндр против литературы: стационар Re=20/40, дорожка Re=100…300, разрешение D=16…128, блокировка Ny/D=6…32 с экстраполяцией к бесконечной среде |
| **C** | 20 | модели стенки и субсеточность: hrr / grad / bouzidi / staircase при Re=150 и 2000, плюс сдвиг тела внутри клетки на 0 / ¼ / ½ для всех четырёх |
| **D** | 14 | профили крыла: поляра NACA 0012, Re-серия, изгиб 4412, сходимость по хорде |
| **E** | 12 | сложная и множественная геометрия: тандем, решётка, перфорация, многоэлементный профиль, сверхтонкая пластина, клин, зазубренная кромка |
| **F** | 9 | поле влияния и границы домена: отступы до входа и выхода, вложенный патч |
| **G** | 14 | старт, акустика, время жизни: x–t диаграммы, губки, предел по Re, прогон на 10⁷ шагов |
| **H** | 9 | инварианты: симметрия, зеркальность, зависимость от числа Маха, расхождение f32 против f64 |
| **I** | 9 | сверхмелкие сетки 4096×2048 по всем формам и композиции из трёх тел |
## Стоимость и время
Стоимость каждого прогона хранится в **обновлениях узлов** (`nodes_per_step × steps`) — это
единственная мера, переносимая между машинами. Часы драйвер получает, поделив её на MLUPS.
Оценки по умолчанию исходят из 1200 MLUPS на GPU и 22 на CPU; **`--calibrate` обязателен**,
потому что эти числа взяты с другой машины.
Пересобрать список с другой длительностью:
```sh
python gen_scenarios.py --scale 3 # все прогоны в полтора раза длиннее (≈135 ч)
```
## Что известно заранее
- **Точность f32.** Замерено на Тейлоре–Грине: GPU совпадает с CPU/f64, пока истинная ошибка
выше ~10⁻³, и промахивается в 40 раз, когда она ниже. Поэтому исследования сходимости из
группы A идут на CPU — это указано в самих сценариях.
- **Развалы ожидаемы** в группе G (предел по Re) и у прогона `A09_shear_n512_lbgk`: LBGK при
Re=3·10⁴ обязан развалиться там, где KBC доживает — это и есть проверяемое утверждение.
- **Гифки** пишутся на всю длительность прогона, в реальном времени, 10 кадр/с. Число кадров
этим задано жёстко (у самого длинного прогона их 16 666), поэтому единственный рычаг —
размер кадра. Замерено: 0.103 байта на пиксель после LZW; отсюда бюджет
кадры×пиксели ≤ 1.5·10⁹ на гифку, но ширина не опускается ниже 480 пикселей. Итог по
кампании: **≈4.4 ГБ**, самый тяжёлый файл 226 МБ.
- **Перед запуском** имеет смысл прогнать предполётную проверку: каждый сценарий стартует на
два шага, что ловит опечатки в ключах и несовместимые сочетания до того, как кампания уйдёт
считать на девяносто часов. Именно она поймала, что вся группа I падала на пределе GPU в
65535 рабочих групп на измерение.
@@ -0,0 +1,518 @@
#!/usr/bin/env python3
# Генератор списка прогонов кампании -> scenarios.json
#
# Список порождается кодом, а не правится руками: серии по Re, по разрешению и по углу атаки
# получаются циклами, а стоимость каждого прогона считается тут же и суммируется. Запуск:
# python gen_scenarios.py # перезаписать scenarios.json и напечатать смету
# python gen_scenarios.py --scale 2 # растянуть все длительности вдвое
#
# Стоимость меряется в ОБНОВЛЕНИЯХ УЗЛОВ (nodes_per_step * steps) — единственная переносимая
# между машинами мера. Часы драйвер получает из неё, поделив на фактические MLUPS, которые
# замеряет на месте (--calibrate).
import argparse
import json
import math
import os
RUNS = []
U_LAT = 0.05 # решёточная скорость по умолчанию
DX = 0.1 # размер клетки, м (умолчание решателя)
U_PHYS = 30.0 # скорость потока, м/с (умолчание решателя)
GIF_MAX_W = 1280 # предельная ширина кадра после прореживания
GIF_MIN_W = 480 # ниже не опускаемся: нечитаемая гифка бесполезнее большой
GIF_PX_BUDGET = 1.5e9 # кадры×пиксели на гифку; при 0.103 байта на пиксель это ~150 МБ
def conv_steps(d_cells, n_conv, u_lat=U_LAT):
"""Сколько шагов нужно на n_conv конвективных времён D/U."""
return int(round(n_conv * d_cells / u_lat))
def add(rid, group, title, expect, args, nodes, steps, backend="gpu",
gif=None, nx=None, extra_out=()):
"""Записать прогон. `nodes` — обновлений узлов за шаг, `steps` — сколько шагов."""
a = list(args) + ["--steps", str(steps), "--backend", backend]
# ряды прореживаем так, чтобы их осталось порядка 200 тысяч записей
if steps > 400_000:
a += ["--series-every", str(max(1, steps // 200_000))]
if "case_csv" in extra_out:
a += ["--case-csv", f"{rid}_case.csv"]
if gif:
# Гифка идёт на всю длительность прогона в реальном времени, 10 кадр/с — число кадров
# задано физикой прогона и не обсуждается. Единственный рычаг — пиксели в кадре, и он
# нужен: замерено 0.103 байта на пиксель после LZW, так что прогон на 16 тысяч кадров
# при 960×576 весит 900 МБ, а такой файл уже нечем открыть. Поэтому кроме ограничения
# по ширине действует бюджет ПРОИЗВЕДЕНИЯ кадры×пиксели, но ширина не опускается ниже
# GIF_MIN_W: нечитаемая гифка бесполезнее большой.
ny = max(1, int(nodes) // max(1, nx or 1))
u_lat = float(a[a.index("--u-lat") + 1]) if "--u-lat" in a else U_LAT
frames = max(1, int(steps * (u_lat * DX / U_PHYS) * 10))
down = max(1, math.ceil((nx or 0) / GIF_MAX_W))
while nx and frames * (nx // down) * (ny // down) > GIF_PX_BUDGET \
and nx // (down + 1) >= GIF_MIN_W:
down += 1
a += ["--gif", gif, "--gif-field", "vorticity", "--gif-every", "auto",
"--gif-fps", "10", "--gif-speed", "1",
"--gif-downsample", str(down), "--gif-scale", "1"]
RUNS.append({
"id": rid, "group": group, "title": title, "expect": expect,
"backend": backend, "args": a,
"nodes_per_step": int(nodes), "steps": int(steps),
"cost": int(nodes) * int(steps),
"outputs": list(extra_out),
})
# ══════════════════════════════════════════════════════════════════════════════
# A. Эталоны первоисточников: периодические течения, тела и ГУ нет вовсе
# ══════════════════════════════════════════════════════════════════════════════
def group_a(scale):
NU = 0.0192
for n in (64, 128, 256, 512):
# диффузионное измельчение: nu фиксирована, u0 ~ 1/N, поэтому Re сохраняется, а
# число Маха падает вместе с сеткой — только так виден второй порядок целиком
u0 = 0.03 * 64 / n
tc = int(round(math.log(2.0) / (NU * (2 * math.pi / n) ** 2 * 17)))
add(f"A{len(RUNS)+1:02d}_tg_diff_n{n}", "A",
f"Тейлор–Грин, диффузионное измельчение, N={n} (CPU/f64)",
"порядок сходимости 2 по ряду N=64..512; ошибка на N=512 ниже 2e-4",
["--case", "taylor-green", "--nx", str(n), "--ny", str(n), "--refine", "1",
"--u-lat", f"{u0:.6f}", "--re", "100", "--case-every", str(max(1, tc // 20))],
n * n, tc, backend="cpu", extra_out=("case_csv",))
for n in (128, 256):
# при ФИКСИРОВАННОМ u0 ошибка упирается в полку O(Ma^2) и от сетки не зависит
tc = int(round(math.log(2.0) / (NU * (2 * math.pi / n) ** 2 * 17)))
add(f"A{len(RUNS)+1:02d}_tg_fixed_n{n}", "A",
f"Тейлор–Грин при фиксированном u₀, N={n} — демонстрация полки O(Ma²)",
"ошибка перестаёт падать с ростом N: это свойство слабо-сжимаемого метода",
["--case", "taylor-green", "--nx", str(n), "--ny", str(n), "--refine", "1",
"--u-lat", "0.03", "--re", f"{0.03 * n / NU:.1f}",
"--case-every", str(max(1, tc // 20))],
n * n, tc, backend="cpu", extra_out=("case_csv",))
for n in (512,):
for op, km, tag in (("kbc", "n1", "kbc_n1"), ("kbc", "n2", "kbc_n2"), ("bgk", "n1", "lbgk")):
steps = int(4 * n / 0.04 * scale)
add(f"A{len(RUNS)+1:02d}_shear_n{n}_{tag}", "A",
f"Сдвиговый слой Re=3·10⁴, N={n}, {tag}",
"KBC доживает до конца, LBGK обязан развалиться (разд. VII статьи)",
["--case", "shear-layer", "--nx", str(n), "--ny", str(n), "--refine", "1",
"--u-lat", "0.04", "--re", "30000", "--collision", op, "--kbc-model", km,
"--case-every", str(max(1, steps // 200))],
n * n, steps, gif=f"shear_{tag}.gif", nx=n, extra_out=("case_csv",))
for n, tag, ops in ((2048, "n2048", ("kbc", "bgk")), (4096, "n4096", ("kbc",))):
for op in ops:
steps = int(200_000 * scale * (2048 / n))
add(f"A{len(RUNS)+1:02d}_turb_{tag}_{op}", "A",
f"Затухающая турбулентность N={n}, {op}",
"энстрофия и палинстрофия падают монотонно; KBC держится там, где LBGK нет",
["--case", "decaying-turbulence", "--nx", str(n), "--ny", str(n), "--refine", "1",
"--u-lat", "0.02", "--re", f"{0.02 * n / 8.16e-5:.0f}", "--collision", op,
"--case-every", str(max(1, steps // 300))],
n * n, steps, gif=f"turb_{tag}_{op}.gif", nx=n, extra_out=("case_csv",))
# ══════════════════════════════════════════════════════════════════════════════
# B. Цилиндр против литературы
# ══════════════════════════════════════════════════════════════════════════════
def flatten(d):
"""Словарь ключей в плоский argv. Именно словарь, а не список: clap отвергает
повторённый ключ, а сценарии сплошь и рядом переопределяют умолчания сборщика."""
a = []
for k, v in d.items():
a += [k, str(v)]
return a
def cyl_args(d, re, nx, ny, **kw):
a = {"--shape": "cylinder", "--size": d, "--nx": nx, "--ny": ny,
"--body-x": nx // 5, "--re": re, "--refine": 1}
a.update({"--" + k.replace("_", "-"): v for k, v in kw.items()})
return flatten(a)
def group_b(scale):
# стационарные режимы: дорожки нет, Cd — одно число
for re in (20, 40):
d, nx, ny = 48, 960, 576
st = int(conv_steps(d, 60) * scale)
add(f"B{len(RUNS)-len(RUNS)+len([r for r in RUNS if r['group']=='B'])+1:02d}_cyl_re{re}",
"B", f"Цилиндр Re={re}, стационар, D=48",
"Cd выходит на постоянную; сравнение с литературой по стационарному обтеканию",
cyl_args(d, re, nx, ny, pert_amp=0), nx * ny, st,
gif=f"cyl_re{re}.gif", nx=nx)
# дорожка Кармана: длинное осреднение, это главный заход на литературу
for re in (100, 150, 200, 300):
d, nx, ny = 64, 1280, 768
st = int(conv_steps(d, 1500) * scale)
add(f"B{len([r for r in RUNS if r['group']=='B'])+1:02d}_cyl_re{re}", "B",
f"Цилиндр Re={re}, дорожка Кармана, D=64, 1500 конв. времён",
"St≈0.183 и ⟨Cd⟩≈1.33 при Re=150 после поправки на блокировку",
cyl_args(d, re, nx, ny), nx * ny, st, gif=f"cyl_re{re}.gif", nx=nx)
# разрешение тела: сходимость Cd
for d in (16, 32, 64, 128):
nx, ny = 20 * d, 12 * d
st = int(conv_steps(d, 800) * scale)
add(f"B{len([r for r in RUNS if r['group']=='B'])+1:02d}_cyl_d{d}", "B",
f"Цилиндр Re=150, разрешение D={d}",
"Cd сходится по D; экстраполяция даёт сеточно-независимое значение",
cyl_args(d, 150, nx, ny), nx * ny, st, gif=f"cyl_d{d}.gif", nx=nx)
# блокировка канала: экстраполяция к бесконечной среде — прямой заход на нерешённые 12%
for k in (6, 8, 12, 16, 24, 32):
d, nx, ny = 48, 960, k * 48
st = int(conv_steps(d, 1000) * scale)
add(f"B{len([r for r in RUNS if r['group']=='B'])+1:02d}_cyl_block{k}", "B",
f"Цилиндр Re=150, блокировка Ny/D={k}",
"экстраполяция Cd и St к нулевой блокировке; ожидание — сходимость к 1.33 и 0.183",
cyl_args(d, 150, nx, ny), nx * ny, st, gif=None, nx=nx)
# ══════════════════════════════════════════════════════════════════════════════
# C. Модели стенки и субсеточность
# ══════════════════════════════════════════════════════════════════════════════
def group_c(scale):
for wall in ("hrr", "grad", "bouzidi", "staircase"):
for re in (150, 2000):
d, nx, ny = 32, 640, 384
st = int(conv_steps(d, 400 if re > 20 else 60) * scale)
add(f"C{len([r for r in RUNS if r['group']=='C'])+1:02d}_wall_{wall}_re{re}", "C",
f"Модель стенки {wall}, Re={re}",
"все субсеточные модели обязаны сойтись к одному пределу; staircase — база",
cyl_args(d, re, nx, ny, wall=wall), nx * ny, st,
gif=f"wall_{wall}_re{re}.gif" if re == 150 else None, nx=nx)
# Чувствительность к положению тела ВНУТРИ клетки — прямая проверка субсеточности, и
# единственное измерение, которое вообще разделяет модели стенки. Двух положений для этого
# мало: по двум точкам не отличить систематический разброс от совпадения, поэтому берём
# четверти, и все четыре модели. Прогоны короткие (стационар при Re=20), вся серия — минуты.
for wall in ("hrr", "grad", "bouzidi", "staircase"):
for off in (0.0, 0.25, 0.5):
d, nx, ny = 16, 320, 192
st = int(conv_steps(d, 60) * scale)
add(f"C{len([r for r in RUNS if r['group']=='C'])+1:02d}_sub_{wall}_{int(off*100):02d}",
"C", f"Субклеточный сдвиг тела {off} клетки, стенка {wall}",
"разброс Cd по сдвигам: чем субсеточнее модель, тем он меньше",
cyl_args(d, 20, nx, ny, body_y=96 + off, wall=wall, pert_amp=0),
nx * ny, st)
# ══════════════════════════════════════════════════════════════════════════════
# D. Профили крыла
# ══════════════════════════════════════════════════════════════════════════════
def foil_args(chord, alpha, re, nx, ny, naca="0012", **kw):
a = {"--shape": "naca", "--naca": naca, "--size": chord,
"--body-angle": alpha, "--nx": nx, "--ny": ny,
"--body-x": nx // 4, "--re": re, "--refine": 1}
a.update({"--" + k.replace("_", "-"): v for k, v in kw.items()})
return flatten(a)
def group_d(scale):
# поляра: наклон Cl(alpha) и положение сваливания
for al in (0, 4, 8, 12, 16):
c, nx, ny = 128, 2048, 1024
st = int(conv_steps(c, 400) * scale)
add(f"D{len([r for r in RUNS if r['group']=='D'])+1:02d}_naca0012_a{al:02d}", "D",
f"NACA 0012, α={al}°, Re=1000, хорда 128",
"линейный участок Cl(α) и срыв; сравнение с низкорейнольдсовой литературой",
foil_args(c, al, 1000, nx, ny), nx * ny, st,
gif=f"naca0012_a{al:02d}.gif", nx=nx)
for re in (500, 2000, 10000):
c, nx, ny = 128, 2048, 1024
st = int(conv_steps(c, 400) * scale)
add(f"D{len([r for r in RUNS if r['group']=='D'])+1:02d}_naca0012_re{re}", "D",
f"NACA 0012, α=8°, Re={re}",
"перестройка следа с ростом Re; проверка устойчивости на профиле",
foil_args(c, 8, re, nx, ny), nx * ny, st,
gif=f"naca0012_re{re}.gif", nx=nx)
for al in (0, 6, 12):
c, nx, ny = 128, 2048, 1024
st = int(conv_steps(c, 400) * scale)
add(f"D{len([r for r in RUNS if r['group']=='D'])+1:02d}_naca4412_a{al:02d}", "D",
f"NACA 4412 (с изгибом), α={al}°, Re=1000",
"ненулевой Cl при α=0 — прямая проверка средней линии профиля",
foil_args(c, al, 1000, nx, ny, naca="4412"), nx * ny, st,
gif=f"naca4412_a{al:02d}.gif", nx=nx)
for c in (48, 96, 192):
nx, ny = 16 * c, 8 * c
st = int(conv_steps(c, 300) * scale)
add(f"D{len([r for r in RUNS if r['group']=='D'])+1:02d}_naca_chord{c}", "D",
f"NACA 0012, α=8°, сходимость по хорде {c}",
"Cl и Cd сходятся по разрешению хорды",
foil_args(c, 8, 1000, nx, ny), nx * ny, st, nx=nx)
# ══════════════════════════════════════════════════════════════════════════════
# E. Сложная и множественная геометрия
# ══════════════════════════════════════════════════════════════════════════════
def group_e(scale):
# тандем: литература даёт переключение режимов около L/D 3.5–4
for ld in (1.5, 3.0, 5.0):
d, nx, ny = 48, 1440, 720
gap = int(ld * d)
bodies = f"cylinder:d={d},x=360,y=360; cylinder:d={d},x={360 + gap},y=360"
st = int(conv_steps(d, 800) * scale)
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_tandem_ld{int(ld*10)}", "E",
f"Тандем цилиндров, зазор L/D={ld}",
"при малом зазоре у заднего тела ОТРИЦАТЕЛЬНОЕ сопротивление; переход около 3.5–4",
["--bodies", bodies, "--nx", str(nx), "--ny", str(ny), "--re", "150", "--refine", "1"],
nx * ny, st, gif=f"tandem_ld{int(ld*10)}.gif", nx=nx)
# решётка как пористая среда
for step_d, tag in ((3, "sparse"), (2, "dense")):
d, nx, ny = 24, 1440, 720
bodies = "; ".join(
f"cylinder:d={d},x={360 + i * step_d * d},y={360 + (j - 1.5) * step_d * d}"
for i in range(4) for j in range(4))
st = int(conv_steps(d, 600) * scale)
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_array_{tag}", "E",
f"Решётка 4×4 цилиндров, шаг {step_d}D — пористая среда",
"суммарное сопротивление и структура течения сквозь набор тел",
["--bodies", bodies, "--nx", str(nx), "--ny", str(ny), "--re", "150", "--refine", "1"],
nx * ny, st, gif=f"array_{tag}.gif", nx=nx)
# перфорированная пластина: набор коротких пластин со щелями
for open_frac, tag in ((0.2, "open20"), (0.5, "open50")):
nx, ny = 1200, 600
seg, gap = 40, int(40 * open_frac / (1 - open_frac))
bodies = "; ".join(
f"plate:d={seg},t=0.12,a=90,x=300,y={60 + k * (seg + gap)}"
for k in range(max(1, (ny - 120) // (seg + gap))))
st = int(conv_steps(seg, 500) * scale)
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_perf_{tag}", "E",
f"Перфорированная пластина, скважность {open_frac}",
"струи в щелях и общее сопротивление; проверка множества тонких тел",
["--bodies", bodies, "--nx", str(nx), "--ny", str(ny), "--re", "500", "--refine", "1"],
nx * ny, st, gif=f"perf_{tag}.gif", nx=nx)
# многоэлементный профиль: основной + предкрылок
nx, ny = 2048, 1024
bodies = ("naca:naca=4412,d=200,a=6,x=560,y=512; "
"naca:naca=2412,d=70,a=18,x=430,y=470")
st = int(conv_steps(200, 300) * scale)
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_multielem", "E",
"Многоэлементный профиль: основной 4412 плюс предкрылок",
"щель между элементами и раздельные Cl/Cd по телам",
["--bodies", bodies, "--nx", str(nx), "--ny", str(ny), "--re", "2000", "--refine", "1"],
nx * ny, st, gif="multielem.gif", nx=nx)
# сверхтонкая пластина: предел субсеточности, тело тоньше клетки
for t, tag in ((0.02, "t05"), (0.01, "t025")):
nx, ny = 1200, 600
st = int(conv_steps(50, 500) * scale)
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_thin_{tag}", "E",
f"Сверхтонкая пластина, толщина {t*50:.2f} клетки",
"предел субсеточной границы: тело тоньше клетки; ждём откаты на простой отскок",
["--shape", "plate", "--size", "50", "--thickness", str(t), "--body-angle", "20",
"--nx", str(nx), "--ny", str(ny), "--re", "500", "--refine", "1"],
nx * ny, st, gif=f"thin_{tag}.gif", nx=nx)
# клин и зазубренная кромка — субклеточная деталь на многоугольнике
nx, ny = 1200, 600
st = int(conv_steps(60, 500) * scale)
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_wedge", "E",
"Клин остриём против потока", "острая кромка: особая точка геометрии",
["--shape", "polygon", "--poly=-30,-18;36,0;-30,18", "--size", "60",
"--nx", str(nx), "--ny", str(ny), "--re", "500", "--refine", "1"],
nx * ny, st, gif="wedge.gif", nx=nx)
saw = ";".join(f"{-30 + 12 * k},{(-1) ** k * 6}" for k in range(6)) + ";30,-18;-30,-18"
add(f"E{len([r for r in RUNS if r['group']=='E'])+1:02d}_serrated", "E",
"Зазубренная задняя кромка", "субклеточные зубцы: как их видит граница",
["--shape", "polygon", f"--poly={saw}", "--size", "60",
"--nx", str(nx), "--ny", str(ny), "--re", "500", "--refine", "1"],
nx * ny, st, gif="serrated.gif", nx=nx)
# ══════════════════════════════════════════════════════════════════════════════
# F. Поле влияния и границы домена
# ══════════════════════════════════════════════════════════════════════════════
def group_f(scale):
d = 48
for up in (2, 6, 16):
nx, ny = int((up + 20) * d), 12 * d
st = int(conv_steps(d, 600) * scale)
add(f"F{len([r for r in RUNS if r['group']=='F'])+1:02d}_inlet{up}d", "F",
f"Отступ до входа {up}D",
"с какого отступа результат перестаёт зависеть от положения входа",
cyl_args(d, 150, nx, ny, body_x=up * d), nx * ny, st)
for down in (5, 15, 40):
nx, ny = int((6 + down) * d), 12 * d
st = int(conv_steps(d, 600) * scale)
add(f"F{len([r for r in RUNS if r['group']=='F'])+1:02d}_outlet{down}d", "F",
f"Отступ до выхода {down}D",
"с какой длины следа выход перестаёт влиять на силы",
cyl_args(d, 150, nx, ny, body_x=6 * d), nx * ny, st)
for r in (1, 2, 3):
d, nx, ny = 32, 640, 384
st = int(conv_steps(d, 600) * scale)
nodes = nx * ny
if r > 1:
ax, bx = int(nx // 5 - 1.5 * d), int(nx // 5 + 6.25 * d)
ay, by = int(ny / 2 - 2 * d), int(ny / 2 + 2 * d)
nodes += r * (r * (bx - ax) + 1) * (r * (by - ay) + 1)
add(f"F{len([r_ for r_ in RUNS if r_['group']=='F'])+1:02d}_refine{r}", "F",
f"Вложенный патч ×{r}",
"вносит ли связка уровней систематику: Cd и St обязаны совпасть",
cyl_args(d, 150, nx, ny, refine=r), nodes, st)
# ══════════════════════════════════════════════════════════════════════════════
# G. Старт, акустика, время жизни
# ══════════════════════════════════════════════════════════════════════════════
def group_g(scale):
d, nx, ny = 48, 960, 576
for re in (150, 2000, 20000):
for init in ("uniform", "rest"):
st = int(conv_steps(d, 300) * scale)
add(f"G{len([r for r in RUNS if r['group']=='G'])+1:02d}_xt_re{re}_{init}", "G",
f"x–t диаграмма, Re={re}, старт {init}",
"откуда идёт стартовое возмущение и раскачивается ли продольная мода",
cyl_args(d, re, nx, ny, init=init, sponge_len=0,
xt=f"xt_re{re}_{init}.csv", xt_every=200),
nx * ny, st, gif=f"xt_re{re}_{init}.gif", nx=nx, extra_out=("xt",))
for sp in (0, 40):
st = int(conv_steps(d, 400) * scale)
add(f"G{len([r for r in RUNS if r['group']=='G'])+1:02d}_sponge{sp}", "G",
f"Губка выхода {sp} столбцов при Re=5000",
"губка — единственное, что реально ест продольную моду",
cyl_args(d, 5000, nx, ny, sponge_len=sp), nx * ny, st)
for sp in (0, 40):
st = int(conv_steps(d, 400) * scale)
add(f"G{len([r for r in RUNS if r['group']=='G'])+1:02d}_spongein{sp}", "G",
f"Губка входа {sp} столбцов при Re=5000",
"помогает ли гасить волну до отражения от входа",
cyl_args(d, 5000, nx, ny, sponge_in=sp), nx * ny, st)
for re in (10000, 50000, 200000):
st = int(conv_steps(d, 200) * scale)
add(f"G{len([r for r in RUNS if r['group']=='G'])+1:02d}_limit_re{re}", "G",
f"Предел устойчивости, Re={re}",
"где именно гибнет счёт; часть этих прогонов обязана развалиться",
cyl_args(d, re, nx, ny), nx * ny, st)
# тест на время жизни: один очень длинный прогон
st = int(5_000_000 * scale)
add(f"G{len([r for r in RUNS if r['group']=='G'])+1:02d}_lifetime", "G",
"Тест на время жизни: 5·10⁶ шагов",
"не уплывает ли ⟨ρ⟩ и не деградирует ли решение на очень длинной дистанции",
cyl_args(d, 150, nx, ny), nx * ny, st, gif="lifetime.gif", nx=nx)
# ══════════════════════════════════════════════════════════════════════════════
# H. Инварианты и точность
# ══════════════════════════════════════════════════════════════════════════════
def group_h(scale):
d, nx, ny = 32, 640, 384
st = int(conv_steps(d, 400) * scale)
add(f"H{len([r for r in RUNS if r['group']=='H'])+1:02d}_sym_cyl", "H",
"Симметрия: цилиндр при α=0 без возмущения",
"Cl и Cm обязаны остаться ≈0 — это внутреннее свойство схемы, не литература",
cyl_args(d, 40, nx, ny, pert_amp=0), nx * ny, st)
add(f"H{len([r for r in RUNS if r['group']=='H'])+1:02d}_sym_foil", "H",
"Симметрия: NACA 0012 при α=0 без возмущения",
"Cl ≈ 0 у симметричного профиля под нулевым углом",
foil_args(64, 0, 500, 1024, 512, pert_amp=0), 1024 * 512,
int(conv_steps(64, 300) * scale))
for al, tag in ((8, "plus"), (-8, "minus")):
add(f"H{len([r for r in RUNS if r['group']=='H'])+1:02d}_mirror_{tag}", "H",
f"Зеркальность: NACA 0012 при α={al}°",
"ряды обязаны зеркалиться: Cl меняет знак, Cd совпадает",
foil_args(64, al, 500, 1024, 512), 1024 * 512,
int(conv_steps(64, 300) * scale))
for ul in (0.02, 0.05, 0.1):
st2 = int(conv_steps(d, 400, ul) * scale)
add(f"H{len([r for r in RUNS if r['group']=='H'])+1:02d}_mach{int(ul*100):02d}", "H",
f"Зависимость от числа Маха, u_lat={ul}",
"результат обязан сходиться при Ma→0; расхождение и есть сжимаемостная ошибка",
cyl_args(d, 150, nx, ny, u_lat=ul), nx * ny, st2)
# прямой замер расхождения f32 и f64 от длины прогона
for bk in ("cpu", "gpu"):
add(f"H{len([r for r in RUNS if r['group']=='H'])+1:02d}_prec_{bk}", "H",
f"Точность: одинаковая постановка на {bk}",
"расхождение f32 против f64 в зависимости от длины прогона",
cyl_args(d, 150, nx, ny), nx * ny, int(conv_steps(d, 800) * scale), backend=bk)
# ══════════════════════════════════════════════════════════════════════════════
# I. Сверхмелкие сетки по всем формам
# ══════════════════════════════════════════════════════════════════════════════
def group_i(scale):
shapes = [
("cylinder", ["--shape", "cylinder"]),
("square", ["--shape", "square"]),
("diamond", ["--shape", "diamond"]),
("ellipse", ["--shape", "ellipse", "--thickness", "0.35"]),
("naca", ["--shape", "naca", "--naca", "0012", "--body-angle", "8"]),
("triangle", ["--shape", "triangle"]),
("plate", ["--shape", "plate", "--thickness", "0.08", "--body-angle", "25"]),
("wedge", ["--shape", "polygon", "--poly=-128,-72;154,0;-128,72"]),
]
d, nx, ny = 256, 4096, 2048
st = int(conv_steps(d, 300) * scale)
for tag, extra in shapes:
add(f"I{len([r for r in RUNS if r['group']=='I'])+1:02d}_fine_{tag}", "I",
f"Сверхмелкая сетка 4096×2048, {tag}, D=256",
"держится ли субсеточная граница на предельном разрешении для этой формы",
extra + ["--size", str(d), "--nx", str(nx), "--ny", str(ny),
"--body-x", "820", "--re", "1000", "--refine", "1"],
nx * ny, st, gif=f"fine_{tag}.gif", nx=nx)
bodies = ("cylinder:d=200,x=800,y=1024; cylinder:d=200,x=1400,y=1024; "
"naca:naca=4412,d=300,a=10,x=2200,y=1024")
add(f"I{len([r for r in RUNS if r['group']=='I'])+1:02d}_fine_multi", "I",
"Сверхмелкая сетка, композиция из трёх тел",
"взаимодействие следов на предельном разрешении",
["--bodies", bodies, "--nx", str(nx), "--ny", str(ny), "--re", "1000", "--refine", "1"],
nx * ny, st, gif="fine_multi.gif", nx=nx)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--scale", type=float, default=2.0,
help="общий множитель длительности всех прогонов")
ap.add_argument("--out", default=os.path.join(os.path.dirname(__file__), "scenarios.json"))
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):
g(args.scale)
doc = {
"meta": {
"note": "Стоимость в обновлениях узлов — единственная переносимая между машинами "
"мера. Часы драйвер получает, поделив её на фактические MLUPS, которые "
"замеряет на месте (--calibrate).",
"gif_policy": "вся длительность прогона, реальная скорость, 10 кадр/с; кадр "
"прореживается до ширины не больше 1280 пикселей",
"scale": args.scale,
"assumed_gpu_mlups": 1200,
"assumed_cpu_mlups": 22,
},
"runs": RUNS,
}
with open(args.out, "w", encoding="utf-8") as f:
json.dump(doc, f, ensure_ascii=False, indent=1)
print(f"прогонов: {len(RUNS)} файл: {args.out}\n")
print(f"{'гр':>3}{'прогонов':>10}{'обновлений узлов':>20}{'часов GPU':>12}{'часов CPU':>12}")
tot_gpu = tot_cpu = 0.0
for g in sorted({r["group"] for r in RUNS}):
rs = [r for r in RUNS if r["group"] == g]
cost = sum(r["cost"] for r in rs)
hg = sum(r["cost"] / 1.2e9 / 3600 for r in rs if r["backend"] == "gpu")
hc = sum(r["cost"] / 2.2e7 / 3600 for r in rs if r["backend"] == "cpu")
tot_gpu += hg
tot_cpu += hc
print(f"{g:>3}{len(rs):>10}{cost:>20.3e}{hg:>12.1f}{hc:>12.1f}")
print(f"{'—':>3}{len(RUNS):>10}{sum(r['cost'] for r in RUNS):>20.3e}"
f"{tot_gpu:>12.1f}{tot_cpu:>12.1f}")
print(f"\nвсего ≈ {tot_gpu + tot_cpu:.1f} ч при 1200 MLUPS на GPU и 22 на CPU")
if __name__ == "__main__":
main()
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""Сверка GPU-пути с эталонным CPU-путём.
Зачем. На настоящем сервере GPU-путь идёт через драйвер NVIDIA. В WSL такого драйвера
для Linux нет, и единственный доступный Vulkan — dzn (Dozen), транслирующий вызовы в
D3D12. Он честно сообщает о себе `conformanceVersion = 0.0.0.0`, то есть набор тестов
соответствия не проходил. Арифметика f32 в обоих API описана одним и тем же IEEE 754,
но точность трансцендентных функций (exp, log, sqrt — а они в энтропийном равновесии
на каждом узле) стандартами задаётся с допуском в несколько ULP, и допуски эти разные.
Плюс энтропийный стабилизатор сравнивает знаменатель с относительным порогом GREL=1e-8:
достаточно мелкого расхождения, чтобы решение «вырожден или нет» поменялось.
Поэтому пригодность dzn нельзя принимать на веру — её надо мерить. Скрипт гоняет два
коротких эталона на CPU (f64) и на GPU и сверяет числа из summary.json.
Прогоны подобраны намеренно НЕ хаотические: вихрь Тейлора–Грина затухает гладко и имеет
точное решение, обтекание цилиндра при Re=20 стационарно. В таких течениях расхождение
f32 и f64 не нарастает, поэтому любое заметное различие означает проблему драйвера, а не
разрядности. На развитой дорожке (Re >= 100) сверять бессмысленно: там разойдётся любая
пара запусков с разной арифметикой.
Порог взят с запасом от факта: на нативном Vulkan разница Cd между CPU-f64 и GPU-f32
измерена и составила 0.007%.
python3 parity.py # сверка
python3 parity.py --tol 2e-3 # свой порог
python3 parity.py --keep out/ # оставить сводки для разбора
Код возврата 1, если хоть одно поле вышло за порог или прогон развалился.
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
BIN = os.environ.get("KBC2D_BIN") or os.path.join(
HERE, "..", "target", "release", "kbc2d" + (".exe" if os.name == "nt" else ""))
# Поля сводки: "fields" сверяются с порогом, "report" только печатаются.
#
# * energy_end, enstrophy_end — интегралы поля, ловят расхождение самого счёта;
# * cd — интегральная сила, накапливает ошибку по всей границе тела;
# * gamma_mean и degenerate_frac — поведение энтропийного стабилизатора, самый чуткий
# индикатор расхождений в exp/log;
# * rho_mean — сохранение массы, обязано совпадать почти побитово.
#
# Пороги откалиброваны по замеру на НАТИВНОМ драйвере Vulkan (Intel Iris Xe, Windows):
# там же, где f64 и f32 сравниваются в отсутствие какой-либо трансляции, отклонения
# составили cd 1.7e-5, rho_mean 1.5e-6, gamma_mean 6.5e-4, energy_end 3.4e-3 (вихрь
# доведён до затухания). Пороги ниже взяты с запасом к этим числам, но заметно ниже
# того, что дал бы по-настоящему сломанный драйвер.
#
# Прогоны намеренно НЕ доводятся до глубокого затухания: когда энергия падает до 1e-7,
# f32 упирается в собственный шум, и сверять становится нечего — это свойство
# разрядности, а не драйвера.
CASES = [
{
"name": "Тейлор-Грин 128x128, 500 шагов",
"args": ["--case", "taylor-green", "--nx", "128", "--ny", "128",
"--refine", "1", "--steps", "500", "--case-every", "100"],
# taylor_green_error намеренно НЕ в списке сверяемых, только в отчётных: это
# разность почти равных величин, и её относительное отклонение f32 от f64
# достигает десятков процентов на любом, в том числе нативном, драйвере.
# Замерено на нативном Vulkan: 0.0116 (f64) против 0.0218 (f32). Сверять по
# ней бессмысленно, а видеть её полезно.
"fields": {"energy_end": 1.0, "enstrophy_end": 1.0, "gamma_mean": 1.0},
"report": ["taylor_green_error"],
},
{
"name": "цилиндр Re=20, стационар, 8000 шагов",
"args": ["--shape", "cylinder", "--size", "16", "--nx", "320", "--ny", "192",
"--re", "20", "--refine", "1", "--steps", "8000"],
"fields": {"cd": 1.0, "rho_mean": 0.05, "gamma_mean": 2.0,
"degenerate_frac": 5.0},
"report": ["strouhal"],
},
]
def run(backend, args, summary_path):
"""Один прогон. Возвращает разобранную сводку либо строку с ошибкой."""
cmd = [BIN] + args + ["--backend", backend, "--verbose", "quiet",
"--report-every", "0", "--summary", summary_path]
proc = subprocess.run(cmd, capture_output=True, text=True, errors="replace")
if proc.returncode != 0:
tail = (proc.stderr or proc.stdout or "").strip().splitlines()
return None, "\n".join(tail[-6:]) or f"код возврата {proc.returncode}"
try:
with open(summary_path, encoding="utf-8") as f:
return json.load(f), None
except Exception as e:
return None, f"сводка не читается: {e}"
def deviation(ref, got):
"""Относительное отклонение; для околонулевых величин — абсолютное."""
if abs(ref) > 1e-12:
return abs(got - ref) / abs(ref)
return abs(got - ref)
def main():
ap = argparse.ArgumentParser(description="Сверка GPU-пути с CPU-эталоном")
ap.add_argument("--tol", type=float, default=1e-3,
help="базовый относительный порог (по умолчанию 1e-3)")
ap.add_argument("--keep", metavar="DIR", help="куда положить сводки прогонов")
args = ap.parse_args()
if not os.path.exists(BIN):
print(f"не найден бинарь решателя: {BIN}")
return 1
workdir = args.keep or tempfile.mkdtemp(prefix="kbc2d-parity-")
os.makedirs(workdir, exist_ok=True)
failures = []
speeds = []
for case in CASES:
print(f"\n=== {case['name']} ===")
summaries = {}
for backend in ("cpu", "gpu"):
path = os.path.join(workdir, f"{backend}.json")
data, err = run(backend, case["args"], path)
if err:
print(f" {backend}: ПРОГОН НЕ УДАЛСЯ\n {err}")
failures.append(f"{case['name']}: {backend} не отработал")
break
if data.get("blew_up"):
print(f" {backend}: счёт РАЗВАЛИЛСЯ")
failures.append(f"{case['name']}: {backend} развалился")
break
summaries[backend] = data
if len(summaries) != 2:
continue
cpu, gpu = summaries["cpu"], summaries["gpu"]
m_cpu, m_gpu = cpu.get("mlups", 0.0), gpu.get("mlups", 0.0)
speeds.append((case["name"], m_cpu, m_gpu))
ratio = f", ускорение x{m_gpu / m_cpu:.1f}" if m_cpu > 0 else ""
print(f" скорость: CPU {m_cpu:.1f} MLUPS, GPU {m_gpu:.1f} MLUPS{ratio}")
print(f" {'поле':<22}{'CPU (f64)':>16}{'GPU':>16}{'откл.':>12} порог")
for field in case.get("report", []):
if field in cpu and field in gpu:
ref, got = float(cpu[field]), float(gpu[field])
print(f" {field:<22}{ref:>16.6g}{got:>16.6g}{deviation(ref, got):>11.2e}"
f" (только к сведению)")
for field, mult in case["fields"].items():
if field not in cpu or field not in gpu:
print(f" {field:<22}{'нет в сводке':>16}")
continue
ref, got = float(cpu[field]), float(gpu[field])
dev = deviation(ref, got)
tol = args.tol * mult
mark = "" if dev <= tol else " <-- ВЫШЕ ПОРОГА"
print(f" {field:<22}{ref:>16.6g}{got:>16.6g}{dev:>11.2e} {tol:.1e}{mark}")
if dev > tol:
failures.append(f"{case['name']}: {field} отклонилось на {dev:.2e} "
f"при пороге {tol:.1e}")
print()
if speeds:
print("Скорость по прогонам:")
for name, m_cpu, m_gpu in speeds:
print(f" {name}: CPU {m_cpu:.1f} MLUPS, GPU {m_gpu:.1f} MLUPS")
print()
if failures:
print("СВЕРКА НЕ ПРОЙДЕНА:")
for f in failures:
print(f" * {f}")
print("\nGPU-путь на этом драйвере доверия не заслуживает — кампанию на нём "
"запускать нельзя.")
else:
print("Сверка пройдена: GPU считает то же, что CPU в двойной точности.")
if not args.keep:
shutil.rmtree(workdir, ignore_errors=True)
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
# Предполётная проверка кампании: каждый сценарий запускается на два шага.
#
# python preflight.py # проверить все
# python preflight.py --group I # только группу
#
# Ловит опечатки в ключах, несовместимые сочетания и пределы железа ДО того, как кампания
# уйдёт считать на девяносто часов. Не бесплатная привычка, а окупившаяся: именно так
# выяснилось, что вся группа сверхмелких сеток падала на пределе GPU в 65535 рабочих групп
# на измерение, а девять прогонов группы F передавали --body-x дважды.
#
# Физику проверка не трогает вовсе: два шага — это ровно «запустилось и не упало».
import argparse
import json
import os
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
BIN = os.environ.get("KBC2D_BIN") or os.path.join(
HERE, "..", "target", "release", "kbc2d" + (".exe" if os.name == "nt" else ""))
# Всё, что пишет файлы, выкидываем: проверяем запуск, а не вывод. Ключ идёт со значением,
# поэтому следующий за ним аргумент тоже пропускается.
DROP = {"--gif", "--xt", "--case-csv", "--gif-field", "--gif-downsample", "--gif-scale",
"--gif-every", "--gif-fps", "--series-every"}
# Строки, которые ничего не объясняют и только вытесняют полезные.
NOISE = (
"note: run with `RUST_BACKTRACE",
"note: Some details are omitted",
"WARNING: dzn is not a conformant",
"stack backtrace",
"Caused by:",
)
def explain(output):
"""Достать из вывода упавшего прогона то, ради чего его вообще читают.
Раньше бралась последняя строка вывода — а у паники Rust последняя строка это
«note: run with RUST_BACKTRACE=1», то есть ровно ноль сведений о причине.
Полезное лежит либо в собственном сообщении решателя, либо на строке ПОСЛЕ
«panicked at»: там текст самой паники.
"""
out = [l.rstrip() for l in output.splitlines() if l.strip()]
out = [l for l in out if not any(n in l for n in NOISE)]
if not out:
return "(без вывода)"
for l in out:
if l.lstrip().startswith("ОШИБКА"):
return l.strip()
for i, l in enumerate(out):
if "panicked at" in l:
rest = [x.strip() for x in out[i + 1:]]
return " | ".join(rest[:4]) if rest else l.strip()
return out[-1].strip()
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--group", help="только эта группа, например I")
ap.add_argument("--scenarios", default=os.path.join(HERE, "scenarios.json"))
args = ap.parse_args()
runs = json.load(open(args.scenarios, encoding="utf-8"))["runs"]
if args.group:
runs = [r for r in runs if r["group"].upper() == args.group.upper()]
if not runs:
print("под выборку не попал ни один прогон")
return 0
if not os.path.exists(BIN):
print(f"не найден бинарь решателя: {BIN}")
return 1
bad = []
for i, r in enumerate(runs, 1):
argv, skip = [], False
for a in r["args"]:
if skip:
skip = False
continue
if a in DROP:
skip = True
continue
argv.append(a)
if "--steps" in argv:
argv[argv.index("--steps") + 1] = "2"
else:
argv += ["--steps", "2"]
p = subprocess.run([BIN] + argv + ["--verbose", "quiet", "--report-every", "0"],
capture_output=True, cwd=HERE)
ok = p.returncode == 0
print("." if ok else "X", end="", flush=True)
if not ok:
out = (p.stdout + p.stderr).decode("utf-8", "replace")
bad.append((r["id"], explain(out)))
if i % 40 == 0:
print(f" {i}/{len(runs)}", flush=True)
print(f"\n\nпроверено сценариев: {len(runs)}, не запустились: {len(bad)}")
for id_, msg in bad:
print(f" {id_}")
print(f" {msg}")
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,110 @@
# Драйвер валидационной кампании для Windows. Делает ровно то же, что run_campaign.sh, и
# читает тот же scenarios.json — целевая машина Linux, а этот вариант нужен для локальной
# отладки обвязки.
#
# .\run_campaign.ps1 -DryRun
# .\run_campaign.ps1 -Calibrate
# .\run_campaign.ps1 -Smoke
# .\run_campaign.ps1 -Resume -Group A,B
# .\run_campaign.ps1 -Only cyl_re150 -BudgetHours 6
[CmdletBinding()]
param(
[switch]$DryRun,
[switch]$Resume,
[switch]$Calibrate,
[switch]$Smoke,
[string[]]$Group,
[string]$Only,
[double]$BudgetHours = 0,
[string]$Bin = "..\target\release\kbc2d.exe",
[string]$Scenarios = "scenarios.json",
[string]$Out = "out",
[double]$GpuMlups = 1200,
[double]$CpuMlups = 22
)
Set-Location $PSScriptRoot
if (-not (Test-Path $Bin)) { throw "не найден бинарь решателя: $Bin (собери cargo build --release)" }
if (-not (Test-Path $Scenarios)) { throw "не найден список сценариев: $Scenarios" }
if ($Calibrate) {
"Калибровка на этой машине (три коротких прогона)…"
# 1920x960 добавлена не для красоты: на 95% стоимости кампании сетки крупнее
# 600 тыс. узлов, и оценивать по мелким - значит занижать пропускную способность.
foreach ($c in @(@(240,120,'gpu'), @(960,480,'gpu'), @(1920,960,'gpu'), @(480,240,'cpu'))) {
$o = & $Bin --nx $c[0] --ny $c[1] --size 16 --refine 1 --steps 3000 `
--report-every 3000 --verbose full --backend $c[2] 2>$null
$m = ($o | Select-String 'MLUPS' | Select-Object -Last 1)
" $($c[0])x$($c[1]) на $($c[2]): $(if ($m) { ($m -replace '.*?([0-9.]+) MLUPS.*','$1') } else { 'не измерено' })"
}
""
"Подставь замеренное: .\run_campaign.ps1 -DryRun -GpuMlups <число> -CpuMlups <число>"
return
}
$runs = (Get-Content $Scenarios -Raw -Encoding UTF8 | ConvertFrom-Json).runs
if ($Group) { $g = $Group | ForEach-Object { $_.ToUpper() }; $runs = $runs | Where-Object { $g -contains $_.group.ToUpper() } }
if ($Only) { $runs = $runs | Where-Object { $_.id -like "*$Only*" } }
if ($Smoke) { $runs = $runs | Sort-Object cost | Select-Object -First 3 }
if (-not $runs) { "под выборку не попал ни один прогон"; return }
$totalCost = ($runs | Measure-Object -Property cost -Sum).Sum
$estH = $totalCost / ($GpuMlups * 1e6) / 3600
"прогонов: $($runs.Count) обновлений узлов: {0:e3} ≈ {1:F1} ч при $GpuMlups MLUPS" -f $totalCost, $estH
"результаты: $Out\<id>\"
""
if ($DryRun) {
"{0,-28} {1,-3} {2,-4} {3,12} {4,8} {5}" -f 'id','гр','бэк','шагов','часов','название'
foreach ($r in $runs) {
$m = if ($r.backend -eq 'cpu') { $CpuMlups } else { $GpuMlups }
"{0,-28} {1,-3} {2,-4} {3,12} {4,8:F2} {5}" -f $r.id, $r.group, $r.backend, $r.steps, ($r.cost/($m*1e6)/3600), $r.title
}
return
}
New-Item -ItemType Directory -Force $Out | Out-Null
$summary = Join-Path $Out 'summary.csv'
$log = Join-Path $Out 'campaign.log'
if (-not (Test-Path $summary)) { 'id,group,backend,status,seconds,steps,cost,title' | Out-File $summary -Encoding utf8 }
$started = Get-Date
foreach ($r in $runs) {
$dir = Join-Path $Out $r.id
if ($Resume -and (Test-Path (Join-Path $dir 'summary.json'))) { "· $($r.id) — уже посчитан, пропускаю"; continue }
if ($BudgetHours -gt 0 -and ((Get-Date) - $started).TotalHours -ge $BudgetHours) {
"бюджет $BudgetHours ч исчерпан, останавливаюсь"; break
}
New-Item -ItemType Directory -Force $dir | Out-Null
# относительные имена файлов из сценария кладём внутрь папки прогона
$argv = @()
for ($i = 0; $i -lt $r.args.Count; $i++) {
$v = $r.args[$i]
if ($i -gt 0 -and @('--gif','--xt','--case-csv') -contains $r.args[$i-1]) { $v = Join-Path $dir $v }
$argv += $v
}
$argv += @('--summary', (Join-Path $dir 'summary.json'),
'--csv', (Join-Path $dir 'series.csv'),
'--verbose','full','--report-every','5000')
"$Bin $($argv -join ' ')" | Out-File (Join-Path $dir 'cmd.txt') -Encoding utf8
"▶ $($r.id) ($($r.group), $($r.backend)) $($r.title)"
$t0 = Get-Date
& $Bin @argv 2>&1 | Out-File (Join-Path $dir 'log.txt') -Encoding utf8
$st = if ($LASTEXITCODE -eq 0) { 'ok' } else { 'fail' }
$dt = [int]((Get-Date) - $t0).TotalSeconds
if (Select-String -Path (Join-Path $dir 'log.txt') -Pattern 'РАЗВАЛИЛСЯ' -Quiet) { $st = 'blewup' }
$rep = Get-Content (Join-Path $dir 'log.txt') -Encoding utf8
$k = ($rep | Select-String 'ИТОГОВЫЙ ОТЧЁТ' | Select-Object -First 1).LineNumber
if ($k) { $rep[($k-1)..($rep.Count-1)] | Out-File (Join-Path $dir 'report.txt') -Encoding utf8 }
'{0},{1},{2},{3},{4},{5},{6},"{7}"' -f $r.id,$r.group,$r.backend,$st,$dt,$r.steps,$r.cost,$r.title |
Out-File $summary -Append -Encoding utf8
"$(Get-Date -Format o) $($r.id) $st ${dt}s" | Out-File $log -Append -Encoding utf8
" → $st за $dt с"
}
""
"готово. сводная таблица: $summary"
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env bash
# Драйвер валидационной кампании. Читает scenarios.json, гоняет прогоны по одному, кладёт
# логи, ряды, сводки и гифки каждого в собственную папку и ведёт общую таблицу.
#
# ./run_campaign.sh --dry-run смета: что и сколько будет считаться
# ./run_campaign.sh --calibrate замерить фактические MLUPS этой машины
# ./run_campaign.sh --smoke три коротких прогона: проверить обвязку
# ./run_campaign.sh --resume считать, пропуская уже готовое
# ./run_campaign.sh --group A,B только выбранные группы
# ./run_campaign.sh --only cyl_re150 по подстроке идентификатора
# ./run_campaign.sh --budget-hours 24 остановиться, когда время выйдет
#
# Прогон, который упал или развалился, помечается в сводке и НЕ останавливает кампанию:
# группы E и G специально ищут предел устойчивости.
set -u
cd "$(dirname "$0")"
BIN="${KBC2D_BIN:-../target/release/kbc2d}"
SCEN="${KBC2D_SCENARIOS:-scenarios.json}"
OUT="${KBC2D_OUT:-out}"
# ВНИМАНИЕ: имя GROUPS занято самим bash (список групп пользователя), присваивание в неё
# молча игнорируется, а "$GROUPS" под root разворачивается в 0 — и выборка съедает всю
# кампанию, не сказав ни слова. Отсюда GRP_SEL.
DRY=0; RESUME=0; CALIB=0; SMOKE=0; GRP_SEL=""; ONLY=""; BUDGET=""
GPU_MLUPS="${KBC2D_GPU_MLUPS:-1200}"
CPU_MLUPS="${KBC2D_CPU_MLUPS:-22}"
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) DRY=1 ;;
--resume) RESUME=1 ;;
--calibrate) CALIB=1 ;;
--smoke) SMOKE=1 ;;
--group) GRP_SEL="$2"; shift ;;
--only) ONLY="$2"; shift ;;
--budget-hours) BUDGET="$2"; shift ;;
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
*) echo "неизвестный ключ: $1" >&2; exit 2 ;;
esac
shift
done
command -v python3 >/dev/null 2>&1 && PY=python3 || PY=python
[ -x "$BIN" ] || { echo "не найден бинарь решателя: $BIN (собери cargo build --release)" >&2; exit 1; }
[ -f "$SCEN" ] || { echo "не найден список сценариев: $SCEN" >&2; exit 1; }
# ── калибровка: три коротких прогона на месте, чтобы оценки в часах были не гаданием ──
if [ "$CALIB" = 1 ]; then
echo "Калибровка на этой машине (три коротких прогона)…"
# 1920x960 добавлена не для красоты: на 95% стоимости кампании сетки крупнее
# 600 тыс. узлов, и оценивать по мелким — значит занижать пропускную способность.
for spec in "240 120 gpu" "960 480 gpu" "1920 960 gpu" "480 240 cpu"; do
set -- $spec
m=$("$BIN" --nx "$1" --ny "$2" --size 16 --refine 1 --steps 3000 --report-every 3000 \
--verbose full --backend "$3" 2>/dev/null | grep -o '[0-9.]* MLUPS' | tail -1)
echo " $1x$2 на $3: ${m:-не измерено}"
done
echo
echo "Подставь замеренное в переменные окружения и запусти смету:"
echo " KBC2D_GPU_MLUPS=<число> KBC2D_CPU_MLUPS=<число> ./run_campaign.sh --dry-run"
exit 0
fi
mkdir -p "$OUT"
SUMMARY="$OUT/summary.csv"
LOG="$OUT/campaign.log"
[ -f "$SUMMARY" ] || echo "id,group,backend,status,seconds,steps,cost,title" > "$SUMMARY"
# Выборку и порядок считает python: разбирать JSON башем — верный способ ошибиться.
PLAN=$("$PY" - "$SCEN" "$GRP_SEL" "$ONLY" "$SMOKE" <<'PYEOF'
import json, sys
scen, groups, only, smoke = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] == "1"
runs = json.load(open(scen, encoding="utf-8"))["runs"]
if groups:
keep = {g.strip().upper() for g in groups.split(",")}
runs = [r for r in runs if r["group"].upper() in keep]
if only:
runs = [r for r in runs if only in r["id"]]
if smoke:
# три самых дешёвых прогона: обвязку проверяем, а не физику
runs = sorted(runs, key=lambda r: r["cost"])[:3]
for r in runs:
print("\t".join([r["id"], r["group"], r["backend"], str(r["cost"]),
str(r["steps"]), r["title"], "\x1f".join(r["args"])]))
PYEOF
)
[ -n "$PLAN" ] || { echo "под выборку не попал ни один прогон"; exit 0; }
total_cost=0; n=0
while IFS=$'\t' read -r id grp bk cost steps title args; do
n=$((n + 1)); total_cost=$((total_cost + cost))
done <<< "$PLAN"
est_h=$("$PY" -c "
import sys
print('%.1f' % (float(sys.argv[1]) / (float(sys.argv[2]) * 1e6) / 3600))" "$total_cost" "$GPU_MLUPS")
echo "прогонов: $n обновлений узлов: $total_cost ≈ $est_h ч при $GPU_MLUPS MLUPS"
echo "результаты: $OUT/<id>/"
echo
if [ "$DRY" = 1 ]; then
printf '%-28s %-3s %-4s %12s %10s %s\n' id гр бэк шагов часов название
while IFS=$'\t' read -r id grp bk cost steps title args; do
m=$GPU_MLUPS; [ "$bk" = cpu ] && m=$CPU_MLUPS
h=$("$PY" -c "import sys;print('%.2f'%(float(sys.argv[1])/(float(sys.argv[2])*1e6)/3600))" "$cost" "$m")
printf '%-28s %-3s %-4s %12s %10s %s\n' "$id" "$grp" "$bk" "$steps" "$h" "$title"
done <<< "$PLAN"
exit 0
fi
started=$(date +%s)
while IFS=$'\t' read -r id grp bk cost steps title args; do
dir="$OUT/$id"
if [ "$RESUME" = 1 ] && [ -f "$dir/summary.json" ]; then
echo "· $id — уже посчитан, пропускаю"
continue
fi
if [ -n "$BUDGET" ]; then
spent=$(( ($(date +%s) - started) ))
limit=$("$PY" -c "import sys;print(int(float(sys.argv[1])*3600))" "$BUDGET")
[ "$spent" -ge "$limit" ] && { echo "бюджет $BUDGET ч исчерпан, останавливаюсь"; break; }
fi
mkdir -p "$dir"
# аргументы разделены символом 0x1f: в них есть пробелы (список тел) и запятые
IFS=$'\x1f' read -r -a ARGV <<< "$args"
ARGV+=(--summary "$dir/summary.json" --csv "$dir/series.csv" --verbose full --report-every 5000)
# относительные имена файлов из сценария кладём внутрь папки прогона
for i in "${!ARGV[@]}"; do
case "${ARGV[$((i-1))]:-}" in
--gif|--xt|--case-csv) ARGV[$i]="$dir/${ARGV[$i]}" ;;
esac
done
printf '%s\n' "$BIN ${ARGV[*]}" > "$dir/cmd.txt"
echo "▶ $id ($grp, $bk) $title"
t0=$(date +%s)
if "$BIN" "${ARGV[@]}" > "$dir/log.txt" 2>&1; then st=ok; else st=fail; fi
t1=$(date +%s); dt=$((t1 - t0))
grep -q "РАЗВАЛИЛСЯ" "$dir/log.txt" 2>/dev/null && st=blewup
sed -n '/ИТОГОВЫЙ ОТЧЁТ/,$p' "$dir/log.txt" > "$dir/report.txt" 2>/dev/null
printf '%s,%s,%s,%s,%s,%s,%s,"%s"\n' "$id" "$grp" "$bk" "$st" "$dt" "$steps" "$cost" "$title" >> "$SUMMARY"
echo "$(date -Is) $id $st ${dt}s" >> "$LOG"
echo " → $st за ${dt} с"
done <<< "$PLAN"
echo
echo "готово. сводная таблица: $SUMMARY"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
# Развёртывание кампании на сервере с NVIDIA. Этот файл САМОДОСТАТОЧЕН: он тянет готовый образ
# из реестра и исходников репозитория не требует. Скопировать на сервер достаточно его одного.
#
# mkdir -p ~/kbc2d && cd ~/kbc2d
# curl -O <ссылка на этот файл> # либо просто перенести файл руками
#
# docker compose -f docker-compose.server.yml --profile check run --rm vulkan
# docker compose -f docker-compose.server.yml --profile check run --rm preflight
# docker compose -f docker-compose.server.yml --profile check run --rm calibrate
# docker compose -f docker-compose.server.yml --profile check run --rm plan
# docker compose -f docker-compose.server.yml up -d
# docker compose -f docker-compose.server.yml logs -f
#
# Порядок именно такой. Сначала vulkan: если карта не видна, кампания молча уйдёт считать
# ничего — точнее, откажется стартовать на первом же прогоне, но узнать об этом через час
# обиднее, чем через минуту. Потом preflight (каждый сценарий стартует на два шага), потом
# calibrate (замер MLUPS этой машины), и только потом сама кампания.
#
# Результаты складываются в ./out на хосте — около 4.4 ГБ гифок и рядов за полный проход.
name: kbc2d
# ── общая часть всех сервисов ────────────────────────────────────────────────
x-kbc2d: &kbc2d
image: notbigghost/kbc2d:1.2.0
pull_policy: missing
volumes:
- ./out:/work/bench/out
environment:
# ГЛАВНОЕ МЕСТО ВСЕГО ФАЙЛА. NVIDIA Container Toolkit подкладывает внутрь Vulkan-ICD
# (nvidia_icd.json) только если в этом списке есть `graphics`. С одним `compute` wgpu не
# увидит НИ ОДНОГО адаптера, и решатель откажется стартовать с --backend gpu. В образе
# значение уже прописано, здесь оно продублировано явно — чтобы его было видно тому, кто
# читает compose, а не Dockerfile.
NVIDIA_DRIVER_CAPABILITIES: compute,utility,graphics
NVIDIA_VISIBLE_DEVICES: all
# Оценки в часах драйвер считает из этих чисел. Умолчания взяты с другой машины —
# подставьте сюда то, что напечатает профиль calibrate.
KBC2D_GPU_MLUPS: ${KBC2D_GPU_MLUPS:-1200}
KBC2D_CPU_MLUPS: ${KBC2D_CPU_MLUPS:-22}
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
services:
# ── сама кампания: единственный сервис, который поднимается по `up -d` ──────
campaign:
<<: *kbc2d
container_name: kbc2d-campaign
# --resume пропускает всё, у чего уже есть summary.json: перезапуск продолжает с места,
# а не начинает заново. Именно поэтому перезапуск здесь безопасен и дёшев.
command: ["--resume"]
# on-failure, а НЕ unless-stopped: кампания завершается штатно с кодом 0, и политика
# «перезапускать всегда» после её окончания крутила бы контейнер вхолостую по кругу.
# Падение (OOM, перезагрузка хоста) даёт ненулевой код и будет подхвачено.
restart: on-failure:5
stop_grace_period: 30s
# Девяносто часов вывода — это сотни мегабайт журнала; без ограничения он съест диск.
# Полные логи каждого прогона всё равно лежат в out/<id>/log.txt.
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
# ── проверки перед запуском (профиль check, сами не поднимаются) ────────────
vulkan:
<<: *kbc2d
profiles: ["check"]
entrypoint: ["vulkaninfo"]
command: ["--summary"]
preflight:
<<: *kbc2d
profiles: ["check"]
entrypoint: ["python3"]
command: ["preflight.py"]
calibrate:
<<: *kbc2d
profiles: ["check"]
command: ["--calibrate"]
plan:
<<: *kbc2d
profiles: ["check"]
command: ["--dry-run"]
# ── если результаты нужны не под root ────────────────────────────────────────
# Контейнер работает от root, поэтому файлы в ./out окажутся root:root. Чтобы они
# принадлежали вам, добавьте в x-kbc2d строку
# user: "${UID}:${GID}"
# и запускайте как UID=$(id -u) GID=$(id -g) docker compose -f … up -d
# (каталог ./out при этом должен быть создан заранее и принадлежать вам).
@@ -0,0 +1,114 @@
# Запуск решателя и кампании в WSL2 — там, где драйвера NVIDIA для Linux не существует.
#
# ЧЕМ ЭТОТ ФАЙЛ ОТЛИЧАЕТСЯ ОТ docker-compose.server.yml. Тот рассчитан на настоящий
# Linux-сервер: просит у Docker устройство через драйвер `nvidia`, а Vulkan-ICD внутрь
# подкладывает NVIDIA Container Toolkit. В WSL2 этого сделать нельзя в принципе —
# у NVIDIA там нет Linux-библиотеки Vulkan, карта отдаётся по протоколу WDDM через
# /dev/dxg. Отсюда и ошибка `could not select device driver "nvidia"`.
#
# Здесь NVIDIA-runtime не запрашивается ВООБЩЕ. Нужно ровно две вещи:
# * проброс /dev/dxg — сама видеокарта;
# * монтирование /usr/lib/wsl — там Microsoft держит libd3d12.so и libdxcore.so.
# Дальше работает dzn (Dozen) из образа: драйвер Mesa, транслирующий Vulkan в D3D12.
# Ни nvidia-container-toolkit, ни nvidia-ctk, ни правки в daemon.json не требуются.
#
# ВАЖНО, ПРОЧТИТЕ ДО ЗАПУСКА КАМПАНИИ. dzn сообщает о себе conformanceVersion 0.0.0.0,
# то есть набор тестов соответствия Vulkan не проходил. Поэтому порядок такой:
#
# docker compose -f docker-compose.wsl.yml --profile check run --rm vulkan # карта видна?
# docker compose -f docker-compose.wsl.yml --profile check run --rm parity # числа те же?
# docker compose -f docker-compose.wsl.yml --profile check run --rm calibrate # скорость какая?
# docker compose -f docker-compose.wsl.yml --profile check run --rm plan
# docker compose -f docker-compose.wsl.yml up -d
# docker compose -f docker-compose.wsl.yml logs -f
#
# Шаг parity — не формальность. Он гоняет вихрь Тейлора-Грина (есть точное решение) и
# стационарное обтекание цилиндра на CPU в f64 и на GPU, и сверяет числа. Если он не
# прошёл, кампанию запускать нельзя: результаты будет нечем защищать.
#
# Результаты складываются в ./out на хосте.
name: kbc2d
# ── общая часть всех сервисов ────────────────────────────────────────────────
x-kbc2d: &kbc2d
image: notbigghost/kbc2d:1.2.0
# Контекст сборки — каталог с этим файлом. Если образа нет ни локально, ни в реестре,
# достаточно `docker compose -f docker-compose.wsl.yml build`: доступ к Docker Hub
# для запуска не обязателен.
build:
context: .
args:
VERSION: "1.2.0"
pull_policy: missing
devices:
- /dev/dxg:/dev/dxg
volumes:
- ./out:/work/bench/out
# Только на чтение: контейнеру нужны отсюда libd3d12.so, libd3d12core.so и
# libdxcore.so. Каталог наполняет сам WSL из C:\Windows\System32\lxss\lib.
- /usr/lib/wsl:/usr/lib/wsl:ro
environment:
# Без этого dzn не найдёт D3D12 и молча не даст ни одного адаптера.
LD_LIBRARY_PATH: /usr/lib/wsl/lib
# Осознанное согласие считать на драйвере, не прошедшем тесты соответствия Vulkan.
# Без этой переменной wgpu прячет dzn и решатель сообщает, что адаптер не найден.
# Прежде чем запускать кампанию, обязательно пройдите профиль parity.
WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER: "1"
# Оценки в часах драйвер считает из этих чисел. Умолчания взяты с другой машины —
# подставьте сюда то, что напечатает профиль calibrate.
KBC2D_GPU_MLUPS: ${KBC2D_GPU_MLUPS:-1200}
KBC2D_CPU_MLUPS: ${KBC2D_CPU_MLUPS:-22}
services:
# ── сама кампания: единственный сервис, который поднимается по `up -d` ──────
campaign:
<<: *kbc2d
container_name: kbc2d-campaign
# --resume пропускает всё, у чего уже есть summary.json: перезапуск продолжает с
# места, а не начинает заново.
command: ["--resume"]
# on-failure, а НЕ unless-stopped: кампания завершается штатно с кодом 0, и политика
# «перезапускать всегда» после её окончания крутила бы контейнер вхолостую.
restart: on-failure:5
stop_grace_period: 30s
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
# ── проверки перед запуском (профиль check, сами не поднимаются) ────────────
vulkan:
<<: *kbc2d
profiles: ["check"]
entrypoint: ["vulkaninfo"]
command: ["--summary"]
parity:
<<: *kbc2d
profiles: ["check"]
entrypoint: ["python3"]
command: ["parity.py"]
preflight:
<<: *kbc2d
profiles: ["check"]
entrypoint: ["python3"]
command: ["preflight.py"]
calibrate:
<<: *kbc2d
profiles: ["check"]
command: ["--calibrate"]
plan:
<<: *kbc2d
profiles: ["check"]
command: ["--dry-run"]
# ── если результаты нужны не под root ────────────────────────────────────────
# Контейнер работает от root, поэтому файлы в ./out окажутся root:root. Чтобы они
# принадлежали вам, добавьте в x-kbc2d строку
# user: "${UID}:${GID}"
# и запускайте как UID=$(id -u) GID=$(id -g) docker compose -f ... up -d
# (каталог ./out при этом должен быть создан заранее и принадлежать вам).
+27
View File
@@ -0,0 +1,27 @@
# Запуск кампании на сервере с NVIDIA одной командой:
# docker compose run --rm kbc2d --calibrate # сначала убедиться, что GPU виден
# docker compose up -d # кампания в фоне
# docker compose logs -f # смотреть ход
#
# Результаты складываются в ./out на хосте.
services:
kbc2d:
build: .
image: kbc2d
container_name: kbc2d
volumes:
- ./out:/work/bench/out
environment:
# Без graphics NVIDIA Container Toolkit не подложит Vulkan-ICD и wgpu не увидит карту.
NVIDIA_DRIVER_CAPABILITIES: compute,utility,graphics
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
# Кампания идёт десятки часов и переживает перезапуск: --resume пропускает готовое.
command: ["--resume"]
restart: "no"
File diff suppressed because it is too large Load Diff
+632
View File
@@ -0,0 +1,632 @@
//! БЛОК СОЗДАНИЯ ГИФОК: перевод поля в индексированный кадр, палитры, служебная надпись и —
//! главное — СИНХРОНИЗАЦИЯ АНИМАЦИИ С ФИЗИЧЕСКИМ ВРЕМЕНЕМ ПОТОКА.
//!
//! Требование: гифка идёт с той же скоростью, что и настоящий поток, независимо от того, с
//! какой скоростью считает машина. Реальная производительность (шагов в секунду wall-clock)
//! в тайминг не входит вообще — берётся только физическая длительность шага δt из `Units`:
//!
//! δt = u_lat·δx/u_phys с/шаг
//! кадр каждые S шагов ⇒ между кадрами S·δt секунд физического времени
//! задержка кадра = S·δt / playback с (playback = 1 ⇒ реальное время)
//!
//! Ограничение формата: задержка в GIF хранится в СОТЫХ долях секунды (u16), т.е. сетка 10 мс,
//! и почти все декодеры считают задержку 0–1 сс как «как можно быстрее». Поэтому реальное время
//! достижимо не при любом S: при δt = 1.7e-4 с (30 м/с, ячейка 0.1 м, u_lat = 0.05) кадр каждые
//! 10 шагов — это 600 кадр/с, чего GIF не умеет. Отсюда два режима:
//!
//! * `--gif-every N` — шаг задан жёстко; если реальное время не представимо, задержка
//! зажимается и в отчёт печатается фактический коэффициент замедления;
//! * `--gif-every auto` — шаг подбирается так, чтобы реальное время получилось точно при
//! целевой частоте `--gif-fps` (по умолчанию 30). Это режим по
//! умолчанию: синхронность важнее, чем конкретное число шагов на кадр.
use std::fs::File;
use std::io::BufWriter;
use crate::math::{Units, R};
// ─────────────────────────────────────────────────────────────────────────────
// Тайминг
// ─────────────────────────────────────────────────────────────────────────────
/// Как кадры анимации ложатся на физическое время.
#[derive(Clone, Copy, Debug)]
pub struct GifPlan {
/// Кадр каждые столько шагов симуляции.
pub stride: u64,
/// ТОЧНАЯ задержка кадра в сотых долях секунды — вообще говоря дробная.
pub exact_delay_cs: R,
/// Физическое время между соседними кадрами, с.
pub frame_dt: R,
/// Запрошенная скорость воспроизведения (1 = реальное время, 0.1 = 10× замедление).
pub requested_playback: R,
/// Фактическая, в среднем по анимации.
pub actual_playback: R,
/// Средняя частота кадров получившейся гифки.
pub fps: R,
/// Шаг подбирался автоматически.
pub auto: bool,
/// Точная задержка нецелая ⇒ задержки кадров чередуются (см. `DelayDither`).
pub dithered: bool,
/// Точная задержка меньше одной сотой ⇒ формат физически не тянет, время сжато.
pub clamped: bool,
}
impl GifPlan {
/// `stride`: Some(N) — жёстко N шагов на кадр; None — подобрать под `target_fps`.
pub fn new(units: &Units, stride: Option<u64>, target_fps: R, playback: R) -> GifPlan {
let dt = units.dt;
let playback = if playback > 0.0 { playback } else { 1.0 };
let auto = stride.is_none();
let stride = match stride {
Some(s) => s.max(1),
None => {
// хотим кадр раз в 1/target_fps секунды экранного времени, что при скорости
// playback отвечает playback/target_fps секундам физического времени
((playback / target_fps / dt).round() as i64).max(1) as u64
}
};
let frame_dt = stride as R * dt;
let exact_delay_cs = 100.0 * frame_dt / playback;
// 0 сотых большинство декодеров трактует как «как можно быстрее», поэтому пол — единица
let clamped = exact_delay_cs < 1.0;
let mean_delay_cs = if clamped { 1.0 } else { exact_delay_cs };
GifPlan {
stride,
exact_delay_cs,
frame_dt,
requested_playback: playback,
actual_playback: frame_dt / (mean_delay_cs / 100.0),
fps: 100.0 / mean_delay_cs,
auto,
dithered: !clamped && (exact_delay_cs - exact_delay_cs.round()).abs() > 1e-9,
clamped,
}
}
/// Отношение фактической скорости воспроизведения к запрошенной (1.0 = точно).
pub fn sync_error(&self) -> R {
self.actual_playback / self.requested_playback
}
/// Синхронность выдержана: средняя задержка совпадает с точной.
pub fn is_synced(&self) -> bool {
(self.sync_error() - 1.0).abs() < 0.02
}
/// Совет, как починить рассинхрон, если формат его не тянет.
pub fn advice(&self, units: &Units) -> Option<String> {
// разойтись с запрошенной скоростью можно только одним способом: точная задержка
// не влезла в минимальную единицу формата и была зажата
if !self.clamped {
return None;
}
let good = ((0.01 * self.requested_playback / units.dt).ceil() as i64).max(1);
Some(format!(
"кадр требует задержки {:.4} сотых — меньше минимальной единицы формата, поэтому \
анимация идёт в {:.2}× быстрее запрошенного. Починить: --gif-every auto (подберёт \
шаг сам), либо --gif-every {} (минимальный шаг с представимой задержкой), либо \
--gif-speed {:.4} (замедление вместо реального времени)",
self.exact_delay_cs,
self.sync_error(),
good,
self.requested_playback / self.sync_error()
))
}
}
/// Раскладка дробной задержки по целым сотым без накопления ошибки.
///
/// Формат GIF хранит задержку кадра целым числом сотых долей секунды. Точная физическая
/// задержка почти никогда не целая: 30 кадр/с — это 3⅓ сотых. Округление каждого кадра
/// по отдельности даёт систематический уход (3 вместо 3.333 — анимация на 11% быстрее
/// реальности, и расхождение копится линейно). Поэтому задержки выдаются так, чтобы
/// НАКОПЛЕННОЕ время кадров всегда совпадало с накопленным физическим с точностью до одной
/// сотой: для 3.333 получается 3, 3, 4, 3, 3, 4, … Ошибка ограничена ±5 мс и не растёт.
#[derive(Clone, Copy, Debug, Default)]
pub struct DelayDither {
exact_cum: R,
emitted_cum: i64,
}
impl DelayDither {
/// Задержка очередного кадра в сотых долях секунды.
pub fn next(&mut self, exact_delay_cs: R) -> u16 {
self.exact_cum += exact_delay_cs;
let want = self.exact_cum.round() as i64;
let d = (want - self.emitted_cum).max(1);
self.emitted_cum += d;
d.clamp(1, u16::MAX as i64) as u16
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Палитры
// ─────────────────────────────────────────────────────────────────────────────
/// Индексы служебных цветов в палитре (после градиента).
const NCOLORS: usize = 250;
const IDX_BODY: u8 = 250;
const IDX_PATCH: u8 = 251;
const IDX_TEXT: u8 = 252;
const IDX_TEXT_BG: u8 = 253;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ColorMap {
/// Последовательная, воспринимаемо-равномерная — для |u| и ρ.
Viridis,
/// Яркая последовательная с широким динамическим диапазоном.
Turbo,
/// Расходящаяся сине-бело-красная — для знакопеременных полей (завихренность, γ−2).
CoolWarm,
}
impl ColorMap {
pub fn from_str(s: &str) -> Option<Self> {
Some(match s.to_ascii_lowercase().as_str() {
"viridis" => ColorMap::Viridis,
"turbo" => ColorMap::Turbo,
"coolwarm" | "rdbu" => ColorMap::CoolWarm,
_ => return None,
})
}
pub const ALL: [&'static str; 3] = ["viridis", "turbo", "coolwarm"];
/// Опорные точки градиента; между ними линейная интерполяция в sRGB.
fn anchors(&self) -> &'static [[u8; 3]] {
match self {
ColorMap::Viridis => &[
[68, 1, 84],
[72, 40, 120],
[62, 74, 137],
[49, 104, 142],
[38, 130, 142],
[31, 158, 137],
[53, 183, 121],
[109, 205, 89],
[180, 222, 44],
[253, 231, 37],
],
ColorMap::Turbo => &[
[48, 18, 59],
[70, 107, 227],
[40, 187, 236],
[49, 242, 153],
[138, 252, 61],
[210, 226, 27],
[254, 165, 45],
[239, 89, 17],
[180, 27, 1],
[122, 4, 3],
],
// Расходящаяся палитра Морланда. Опорных точек НЕЧЁТНОЕ число: только тогда
// середина диапазона (для знакопеременного поля — ноль) попадает ровно на
// нейтральный серый, а не между двумя соседними точками.
ColorMap::CoolWarm => &[
[59, 76, 192],
[98, 130, 234],
[141, 176, 254],
[184, 208, 249],
[221, 221, 221],
[245, 196, 173],
[244, 154, 123],
[222, 96, 77],
[180, 4, 38],
],
}
}
/// Палитра GIF: NCOLORS ступеней градиента плюс служебные цвета.
fn palette(&self) -> Vec<u8> {
let a = self.anchors();
let mut p = vec![0u8; 256 * 3];
for k in 0..NCOLORS {
let t = k as R / (NCOLORS - 1) as R * (a.len() - 1) as R;
let i = (t.floor() as usize).min(a.len() - 2);
let fr = t - i as R;
for c in 0..3 {
p[k * 3 + c] = (a[i][c] as R * (1.0 - fr) + a[i + 1][c] as R * fr).round() as u8;
}
}
let set = |p: &mut Vec<u8>, idx: u8, rgb: [u8; 3]| {
let o = idx as usize * 3;
p[o] = rgb[0];
p[o + 1] = rgb[1];
p[o + 2] = rgb[2];
};
set(&mut p, IDX_BODY, [25, 25, 28]);
set(&mut p, IDX_PATCH, [255, 255, 255]);
set(&mut p, IDX_TEXT, [255, 255, 255]);
set(&mut p, IDX_TEXT_BG, [0, 0, 0]);
p
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Микрошрифт 5×7 для служебной надписи
// ─────────────────────────────────────────────────────────────────────────────
/// Битовая маска глифа: 7 строк по 5 бит (старший бит — левый пиксель).
fn glyph(c: char) -> [u8; 7] {
match c.to_ascii_uppercase() {
'0' => [0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E],
'1' => [0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E],
'2' => [0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F],
'3' => [0x1F, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0E],
'4' => [0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02],
'5' => [0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E],
'6' => [0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E],
'7' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08],
'8' => [0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E],
'9' => [0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C],
'A' => [0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
'B' => [0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E],
'C' => [0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E],
'D' => [0x1C, 0x12, 0x11, 0x11, 0x11, 0x12, 0x1C],
'E' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F],
'F' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10],
'G' => [0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0F],
'H' => [0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
'I' => [0x0E, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E],
'K' => [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11],
'L' => [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F],
'M' => [0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11],
'N' => [0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11],
'O' => [0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
'P' => [0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10],
'R' => [0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11],
'S' => [0x0F, 0x10, 0x10, 0x0E, 0x01, 0x01, 0x1E],
'T' => [0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04],
'U' => [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
'V' => [0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04],
'W' => [0x11, 0x11, 0x11, 0x15, 0x15, 0x1B, 0x11],
'X' => [0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11],
'Y' => [0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04],
'Z' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F],
'.' => [0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C],
',' => [0x00, 0x00, 0x00, 0x00, 0x0C, 0x04, 0x08],
':' => [0x00, 0x0C, 0x0C, 0x00, 0x0C, 0x0C, 0x00],
'-' => [0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00],
'+' => [0x00, 0x04, 0x04, 0x1F, 0x04, 0x04, 0x00],
'=' => [0x00, 0x00, 0x1F, 0x00, 0x1F, 0x00, 0x00],
'/' => [0x01, 0x02, 0x02, 0x04, 0x08, 0x08, 0x10],
'%' => [0x19, 0x1A, 0x02, 0x04, 0x08, 0x0B, 0x13],
'(' => [0x02, 0x04, 0x08, 0x08, 0x08, 0x04, 0x02],
')' => [0x08, 0x04, 0x02, 0x02, 0x02, 0x04, 0x08],
'Ч' => [0x11, 0x11, 0x11, 0x0F, 0x01, 0x01, 0x01],
_ => [0; 7], // пробел и всё незнакомое
}
}
/// Нарисовать строку в индексированный буфер (координаты — левый верхний угол).
fn draw_text(buf: &mut [u8], w: usize, h: usize, x0: usize, y0: usize, s: &str, scale: usize) {
let gw = 6 * scale; // 5 пикселей глифа + 1 пробел
// подложка, чтобы текст читался на любом фоне
let tw = s.chars().count() * gw;
for yy in y0.saturating_sub(scale)..(y0 + 7 * scale + scale).min(h) {
for xx in x0.saturating_sub(scale)..(x0 + tw + scale).min(w) {
buf[yy * w + xx] = IDX_TEXT_BG;
}
}
for (k, ch) in s.chars().enumerate() {
let g = glyph(ch);
for (row, bits) in g.iter().enumerate() {
for col in 0..5 {
if bits & (1 << (4 - col)) == 0 {
continue;
}
for sy in 0..scale {
for sx in 0..scale {
let px = x0 + k * gw + col * scale + sx;
let py = y0 + row * scale + sy;
if px < w && py < h {
buf[py * w + px] = IDX_TEXT;
}
}
}
}
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Диапазон нормировки
// ─────────────────────────────────────────────────────────────────────────────
/// Диапазон значений, отображаемый на палитру. Фиксируется на весь прогон: плавающая
/// автонормировка делает анимацию нечитаемой — цвет перестаёт что-либо значить.
#[derive(Clone, Copy, Debug)]
pub struct Range {
pub lo: R,
pub hi: R,
}
impl Range {
#[inline]
fn index(&self, v: R) -> u8 {
if !v.is_finite() {
return 0;
}
let t = ((v - self.lo) / (self.hi - self.lo)).clamp(0.0, 1.0);
(t * (NCOLORS - 1) as R).round() as u8
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Запись гифки
// ─────────────────────────────────────────────────────────────────────────────
/// Что подписывать на кадре.
pub struct Hud {
pub u_phys: R,
pub field: &'static str,
pub show: bool,
}
pub struct GifWriter {
enc: gif::Encoder<BufWriter<File>>,
pub plan: GifPlan,
/// Размеры ИСХОДНОЙ сетки.
src_nx: usize,
src_ny: usize,
/// Во сколько раз клетки усредняются в пиксель перед отрисовкой.
down: usize,
/// Размеры картинки после прореживания.
nx: usize,
ny: usize,
scale: usize,
w: usize,
h: usize,
range: Range,
hud: Hud,
patch: Option<(usize, usize, usize, usize)>,
dither: DelayDither,
pub frames: u32,
}
impl GifWriter {
#[allow(clippy::too_many_arguments)]
pub fn create(
path: &str,
nx: usize,
ny: usize,
scale: usize,
down: usize,
cmap: ColorMap,
range: Range,
plan: GifPlan,
hud: Hud,
patch: Option<(usize, usize, usize, usize)>,
) -> std::io::Result<GifWriter> {
let scale = scale.max(1);
let down = down.max(1);
let (src_nx, src_ny) = (nx, ny);
// прореживание с округлением вверх: последний блок может быть неполным
let nx = nx.div_ceil(down);
let ny = ny.div_ceil(down);
let w = nx * scale;
let h = ny * scale;
let file = BufWriter::new(File::create(path)?);
let mut enc = gif::Encoder::new(file, w as u16, h as u16, &cmap.palette())
.map_err(std::io::Error::other)?;
enc.set_repeat(gif::Repeat::Infinite).map_err(std::io::Error::other)?;
Ok(GifWriter {
enc,
plan,
src_nx,
src_ny,
down,
nx,
ny,
scale,
w,
h,
range,
hud,
patch,
dither: DelayDither::default(),
frames: 0,
})
}
/// Добавить кадр. `field` — значения по узлам L0, `solid` — маска тела,
/// `t_phys` — физическое время этого кадра (с), `cd` — текущий коэффициент сопротивления.
pub fn push(
&mut self,
field: &[R],
solid: &[bool],
t_phys: R,
cd: Option<R>,
) -> std::io::Result<()> {
// Прореживание: блок down×down усредняется в один пиксель, а телом пиксель считается,
// если тело занимает хотя бы половину блока. Без этого кадр с сетки 4096×2048 весит
// столько, что гифка становится непригодной.
let (field, solid) = if self.down == 1 {
(field.to_vec(), solid.to_vec())
} else {
let d = self.down;
let mut fv = vec![0.0; self.nx * self.ny];
let mut sv = vec![false; self.nx * self.ny];
for gy in 0..self.ny {
for gx in 0..self.nx {
let (mut acc, mut cnt, mut sol) = (0.0, 0usize, 0usize);
for yy in gy * d..((gy + 1) * d).min(self.src_ny) {
for xx in gx * d..((gx + 1) * d).min(self.src_nx) {
let k = yy * self.src_nx + xx;
acc += field[k];
sol += solid[k] as usize;
cnt += 1;
}
}
let g = gy * self.nx + gx;
fv[g] = if cnt > 0 { acc / cnt as R } else { 0.0 };
sv[g] = cnt > 0 && 2 * sol >= cnt;
}
}
(fv, sv)
};
let (field, solid) = (&field[..], &solid[..]);
let mut buf = vec![0u8; self.w * self.h];
// строка 0 изображения — это ВЕРХ, а y = 0 решётки — низ канала: переворачиваем
for y in 0..self.ny {
let src = (self.ny - 1 - y) * self.nx;
for x in 0..self.nx {
let idx = if solid[src + x] { IDX_BODY } else { self.range.index(field[src + x]) };
for sy in 0..self.scale {
let row = (y * self.scale + sy) * self.w + x * self.scale;
for sx in 0..self.scale {
buf[row + sx] = idx;
}
}
}
}
if let Some((ax, bx, ay, by)) = self.patch {
let d = self.down;
self.draw_patch_outline(&mut buf, ax / d, bx / d, ay / d, by / d);
}
if self.hud.show {
let line = match cd {
Some(c) => format!(
"T={:.4}S U={:.1}M/S CD={:.2}",
t_phys, self.hud.u_phys, c
),
None => format!("T={:.4}S U={:.1}M/S", t_phys, self.hud.u_phys),
};
let sc = (self.scale.min(3)).max(1);
draw_text(&mut buf, self.w, self.h, 2 * sc, 2 * sc, &line, sc);
draw_text(&mut buf, self.w, self.h, 2 * sc, self.h - 9 * sc, self.hud.field, sc);
}
let mut frame = gif::Frame::from_indexed_pixels(self.w as u16, self.h as u16, buf, None);
// задержка берётся из накопителя: сумма задержек кадров отслеживает точное
// физическое время, а не округляется покадрово (см. DelayDither)
frame.delay = self.dither.next(self.plan.exact_delay_cs.max(1.0));
self.enc.write_frame(&frame).map_err(std::io::Error::other)?;
self.frames += 1;
Ok(())
}
/// Контур области измельчения — видно, где считает тонкая сетка.
fn draw_patch_outline(&self, buf: &mut [u8], ax: usize, bx: usize, ay: usize, by: usize) {
let flip = |y: usize| self.ny - 1 - y.min(self.ny - 1);
let mut put = |x: usize, y: usize| {
if x < self.nx && y < self.ny {
let py = flip(y) * self.scale;
let px = x * self.scale;
for sy in 0..self.scale {
for sx in 0..self.scale {
let o = (py + sy) * self.w + px + sx;
if o < buf.len() {
buf[o] = IDX_PATCH;
}
}
}
}
};
for x in ax..=bx.min(self.nx - 1) {
put(x, ay);
put(x, by);
}
for y in ay..=by.min(self.ny - 1) {
put(ax, y);
put(bx, y);
}
}
pub fn finish(self) -> std::io::Result<u32> {
Ok(self.frames)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Пример из постановки: 30 м/с, ячейка 0.1 м, u_lat = 1 ⇒ 300 шагов/с; кадр каждые
/// 10 шагов ⇒ 30 кадр/с. Точная задержка 3⅓ сотых — нецелая, поэтому включается дизеринг,
/// и в среднем гифка идёт ровно в реальном времени.
#[test]
fn spec_example_is_realtime() {
let u = Units::new(0.1, 30.0, 1.0, 150.0, 16.0);
assert!((u.steps_per_second() - 300.0).abs() < 1e-9);
let p = GifPlan::new(&u, Some(10), 30.0, 1.0);
assert!((p.exact_delay_cs - 10.0 / 3.0).abs() < 1e-9, "{}", p.exact_delay_cs);
assert!(p.dithered && !p.clamped);
assert!((p.fps - 30.0).abs() < 1e-9);
assert!(p.is_synced(), "коэффициент {}", p.sync_error());
}
/// Дизеринг обязан вести НАКОПЛЕННОЕ время кадров вплотную к физическому, без ухода.
/// Покадровое округление 3.333 → 3 дало бы к тысячному кадру уход в 3.3 секунды.
#[test]
fn dither_tracks_exact_time_without_drift() {
let exact = 10.0 / 3.0;
let mut d = DelayDither::default();
let mut sum = 0i64;
for k in 1..=1000 {
sum += d.next(exact) as i64;
let want = exact * k as R;
assert!(
(sum as R - want).abs() <= 0.5 + 1e-9,
"кадр {k}: накоплено {sum} сотых против точных {want:.3}"
);
}
// а наивное округление к этому моменту отстало бы на треть сотой на кадр
assert!((sum as R - exact * 1000.0).abs() < 1.0);
assert!((3000 - sum).abs() > 300, "дизеринг обязан отличаться от покадрового округления");
}
/// Тайминг обязан зависеть ТОЛЬКО от физики, не от того, как быстро считает машина:
/// один и тот же δt даёт одну и ту же задержку, вдвое больший шаг — вдвое большую.
#[test]
fn timing_ignores_wall_clock() {
let u = Units::new(0.1, 30.0, 1.0, 150.0, 16.0);
let a = GifPlan::new(&u, Some(10), 30.0, 1.0);
let b = GifPlan::new(&u, Some(10), 30.0, 1.0);
assert!((a.exact_delay_cs - b.exact_delay_cs).abs() < 1e-12);
let c = GifPlan::new(&u, Some(20), 30.0, 1.0);
assert!((c.exact_delay_cs - 2.0 * a.exact_delay_cs).abs() < 1e-12);
assert!(c.is_synced() && a.is_synced());
}
/// Авто-режим обязан вытянуть реальное время при физически корректном u_lat, где жёсткий
/// шаг «каждые 10» потребовал бы 600 кадр/с и формат бы этого не вытянул.
#[test]
fn auto_stride_restores_realtime() {
let u = Units::new(0.1, 30.0, 0.05, 150.0, 16.0);
assert!((u.steps_per_second() - 6000.0).abs() < 1e-6);
let fixed = GifPlan::new(&u, Some(10), 30.0, 1.0);
assert!(fixed.clamped, "0.167 сотых обязаны упереться в пол формата");
assert!(!fixed.is_synced(), "жёсткий шаг не должен был попасть в реальное время");
assert!(fixed.advice(&u).is_some(), "в таком режиме обязан быть совет");
let auto = GifPlan::new(&u, None, 30.0, 1.0);
assert_eq!(auto.stride, 200); // 1/30 с при 6000 шаг/с
assert!(auto.is_synced(), "коэффициент {}", auto.sync_error());
assert!((auto.fps - 30.0).abs() < 1e-9);
}
/// Замедление: playback = 0.1 растягивает то же физическое время ровно в 10 раз.
#[test]
fn slow_motion_scales_delay() {
let u = Units::new(0.1, 30.0, 1.0, 150.0, 16.0);
let fast = GifPlan::new(&u, Some(10), 30.0, 1.0);
let slow = GifPlan::new(&u, Some(10), 30.0, 0.1);
assert!((slow.exact_delay_cs - 10.0 * fast.exact_delay_cs).abs() < 1e-9);
assert!((slow.actual_playback - 0.1).abs() < 1e-9);
assert!(slow.is_synced());
}
/// Палитра обязана быть ровно 256 цветов по 3 байта и не затирать служебные индексы.
#[test]
fn palette_layout() {
for cm in [ColorMap::Viridis, ColorMap::Turbo, ColorMap::CoolWarm] {
let p = cm.palette();
assert_eq!(p.len(), 768);
let o = IDX_BODY as usize * 3;
assert_eq!(&p[o..o + 3], &[25, 25, 28]);
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+241
View File
@@ -0,0 +1,241 @@
# AMR на цилиндре: полный домен 150x72, три версии (без AMR / 2x / вложенный 2x+4x).
# Встроенные зонды: сила на теле (обмен импульсом) -> Cd/Cl; u_y в следе -> Струхаль.
# Визуализация: inferno по |omega|, ТОЛЬКО рамки зон (без линий сетки), подписи осей.
# После прогона — анализ и сравнение трёх версий на реально собранных данных.
import sys, os, io as _io, numpy as np, matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt, matplotlib.patches as mpatches
from PIL import Image
try: sys.stdout.reconfigure(encoding="utf-8")
except Exception: pass
HERE = os.path.dirname(os.path.abspath(__file__))
ANIM = os.path.join(HERE, "anim"); os.makedirs(ANIM, exist_ok=True)
FIGS = os.path.join(HERE, "figures"); os.makedirs(FIGS, exist_ok=True)
C=np.array([[0,0],[1,0],[0,1],[-1,0],[0,-1],[1,1],[-1,1],[-1,-1],[1,-1]],float)
W=np.array([4/9,1/9,1/9,1/9,1/9,1/36,1/36,1/36,1/36]); CS2=1/3; Q=9
CX,CY=C[:,0],C[:,1]; OPP=np.array([0,3,4,1,2,7,8,5,6])
M=np.zeros((Q,Q)); M[0]=1;M[1]=CX;M[2]=CY;M[3]=3*(CX**2+CY**2)-2
M[4]=CX**2-CY**2;M[5]=CX*CY;M[6]=CX**2*CY;M[7]=CX*CY**2;M[8]=CX**2*CY**2
Dm=np.zeros((Q,Q)); Dm[4,4]=Dm[5,5]=1.0; Ps=np.linalg.inv(M)@Dm@M
def feq(rho,u):
ux=np.clip(u[0],-0.95,0.95); uy=np.clip(u[1],-0.95,0.95)
sx=np.sqrt(1+3*ux**2); sy=np.sqrt(1+3*uy**2); base=rho*(2-sx)*(2-sy)
Bx=((2*ux+sx)/(1-ux))[None]**CX[:,None,None]; By=((2*uy+sy)/(1-uy))[None]**CY[:,None,None]
return W[:,None,None]*base[None]*Bx*By
def macros(f):
r=f.sum(0); return r,np.stack([(CX[:,None,None]*f).sum(0)/r,(CY[:,None,None]*f).sum(0)/r])
def collide(f,fe,beta):
dfn=f-fe; ds=np.einsum("ij,jab->iab",Ps,dfn); dh=dfn-ds; inv=1.0/fe
num=(ds*dh*inv).sum(0); den=(dh*dh*inv).sum(0)
with np.errstate(divide="ignore",invalid="ignore"):
g=np.where(den>1e-14,1/beta-(2-1/beta)*num/den,2.0)
return f-beta*(2*ds+g[None]*dh)
def stream(f):
fs=np.empty_like(f)
for i in range(Q): fs[i]=np.roll(f[i],(int(CY[i]),int(CX[i])),(0,1))
return fs
def smoothstep(x): x=min(max(x,0.0),1.0); return x*x*(3-2*x)
def tauc(tp,r): return r*tp-(r-1)/2
def vort(u):
return (np.roll(u[1],-1,1)-np.roll(u[1],1,1))*0.5-(np.roll(u[0],-1,0)-np.roll(u[0],1,0))*0.5
def patch(pNx,pNy,ax,bx,ay,by,r):
Wx,Wy=bx-ax,by-ay; Nfx,Nfy=r*Wx+1,r*Wy+1
FX,FY=np.meshgrid(ax+np.arange(Nfx)/r, ay+np.arange(Nfy)/r)
x0=np.floor(FX).astype(int); y0=np.floor(FY).astype(int)
x1=np.minimum(x0+1,pNx-1); y1=np.minimum(y0+1,pNy-1)
return dict(Nfx=Nfx,Nfy=Nfy,ax=ax,bx=bx,ay=ay,by=by,r=r,x0=x0,y0=y0,x1=x1,y1=y1,
tx=FX-x0,ty=FY-y0,slx=slice(r,r*Wx,r),sly=slice(r,r*Wy,r))
def pint(fld,P):
return (fld[...,P["y0"],P["x0"]]*(1-P["tx"])*(1-P["ty"])+fld[...,P["y0"],P["x1"]]*P["tx"]*(1-P["ty"])
+fld[...,P["y1"],P["x0"]]*(1-P["tx"])*P["ty"]+fld[...,P["y1"],P["x1"]]*P["tx"]*P["ty"])
def ghost(pf,P,Rcf):
r,u=macros(pf); neq=pf-feq(r,u)
return feq(pint(r,P),np.stack([pint(u[0],P),pint(u[1],P)]))+Rcf*pint(neq,P)
def fill(cf,gh):
cf[:,0,:]=gh[:,0,:];cf[:,-1,:]=gh[:,-1,:];cf[:,:,0]=gh[:,:,0];cf[:,:,-1]=gh[:,:,-1]; return cf
def restrict(cf,pf,P,Rfc,fluid):
r,u=macros(cf); neq=cf-feq(r,u); slx,sly=P["slx"],P["sly"]
nv=feq(r[sly,slx],u[:,sly,slx])+Rfc*neq[:,sly,slx]
cur=pf[:,P["ay"]+1:P["by"],P["ax"]+1:P["bx"]]
pf[:,P["ay"]+1:P["by"],P["ax"]+1:P["bx"]]=np.where(fluid[None],nv,cur); return pf
def bb(pre,f,solid):
for i in range(Q): f[i,solid]=pre[OPP[i],solid]
return f
def cyl(NX,NY,ccx,ccy,rad,walls=True):
Y,X=np.mgrid[0:NY,0:NX]; m=(X-ccx)**2+(Y-ccy)**2<=rad*rad
if walls: m[0,:]=True; m[-1,:]=True
return m
def force_on(fpost,solid_body):
# обмен импульсом (Mei et al. 2002): по линкам жидкий-узел -> твёрдый сосед,
# F = sum c_i (f_i + f_ī)^post — корректная передача импульса на стенку.
fluid=~solid_body; Fx=0.0; Fy=0.0
for i in range(1,Q):
nb=np.roll(solid_body,(-int(CY[i]),-int(CX[i])),(0,1)) # nb[x]=solid[x+c_i]
link=fluid & nb; s=(fpost[i]+fpost[OPP[i]])[link].sum()
Fx+=CX[i]*s; Fy+=CY[i]*s
return Fx,Fy
# --- полный домен 150x72 (вся сетка в кадре, измельчение внутри) ---
Nx,Ny=150,72; D=16; cx,cy=40,36; U=0.07; Re=150.0; STEPS=8000; ramp=1000; FE=34
D0=D; nu=U*D/Re; tau0=nu/CS2+0.5; b0=1/(2*tau0)
px,py=cx+3*D,cy # зонд в следе
solid0=cyl(Nx,Ny,cx,cy,D/2) # коарс: цилиндр + стенки
solid0_cyl=cyl(Nx,Ny,cx,cy,D/2,walls=False)
# уровень1 (2×, зелёный): тело+след
ax1,bx1,ay1,by1=16,118,8,64; P1=patch(Nx,Ny,ax1,bx1,ay1,by1,2)
t1=tauc(tau0,2); b1=1/(2*t1); R01=t1/(2*tau0); Rf01=1/R01
solid1=cyl(P1["Nfx"],P1["Nfy"],(cx-ax1)*2,(cy-ay1)*2,D,walls=False) # D_L=32
fl1=~solid0[ay1+1:by1,ax1+1:bx1]
# уровень2 (4×, оранжевый): у тела (в коарс-координатах [26:64]x[18:54])
cx2a,cx2b,cy2a,cy2b=26,64,18,54
P2=patch(P1["Nfx"],P1["Nfy"],(cx2a-ax1)*2,(cx2b-ax1)*2,(cy2a-ay1)*2,(cy2b-ay1)*2,2)
t2=tauc(t1,2); b2=1/(2*t2); R12=t2/(2*t1); Rf12=1/R12
solid2=cyl(P2["Nfx"],P2["Nfy"],(cx-cx2a)*4,(cy-cy2a)*4,2*D,walls=False) # D_L=64
fl2=np.ones((P2["by"]-P2["ay"]-1,P2["bx"]-P2["ax"]-1),bool)
onecol=np.ones((Ny,1))
print(f"домен {Nx}x{Ny}; L1(2×) {P1['Nfx']}x{P1['Nfy']}; L2(4×) {P2['Nfx']}x{P2['Nfy']}; "
f"Re={Re} D0={D0} steps={STEPS}")
def run(mode): # "none" | "amr2x" | "nested"
use1=mode in ("amr2x","nested"); use2=(mode=="nested")
DL={"none":D0,"amr2x":2*D0,"nested":4*D0}[mode]
f0=feq(np.ones((Ny,Nx)),np.zeros((2,Ny,Nx)))
f1=feq(np.ones((P1["Nfy"],P1["Nfx"])),np.zeros((2,P1["Nfy"],P1["Nfx"]))) if use1 else None
f2=feq(np.ones((P2["Nfy"],P2["Nfx"])),np.zeros((2,P2["Nfy"],P2["Nfx"]))) if use2 else None
Fx=np.zeros(STEPS+1); Fy=np.zeros(STEPS+1); uy=np.zeros(STEPS+1); frames=[]
for t in range(STEPS+1):
rho,u0=macros(f0)
if not np.all(np.isfinite(u0)): print("BLEW UP",mode,t); Fx=Fx[:t];Fy=Fy[:t];uy=uy[:t]; break
pre=f0.copy(); post0=collide(f0,feq(rho,u0),b0)
if mode=="none": Fx[t],Fy[t]=force_on(post0,solid0_cyl)
f0=stream(bb(pre,post0,solid0))
uin=np.zeros((2,Ny,1)); uin[0,:,0]=U*smoothstep(t/ramp)
f0[:,1:-1,0]=feq(onecol,uin)[:,1:-1,0]; f0[:,1:-1,-1]=f0[:,1:-1,-2]
if use1:
g1o=ghost(pre,P1,R01); g1n=ghost(f0,P1,R01)
for s1 in range(2):
pre1=f1.copy(); r1,u1=macros(f1); post1=collide(f1,feq(r1,u1),b1)
if mode=="amr2x" and s1==1: Fx[t],Fy[t]=force_on(post1,solid1)
f1=stream(bb(pre1,post1,solid1)); f1=fill(f1,(1-(s1+1)/2)*g1o+((s1+1)/2)*g1n)
if use2:
g2o=ghost(pre1,P2,R12); g2n=ghost(f1,P2,R12)
for s2 in range(2):
pre2=f2.copy(); r2,u2=macros(f2); post2=collide(f2,feq(r2,u2),b2)
if s1==1 and s2==1: Fx[t],Fy[t]=force_on(post2,solid2)
f2=stream(bb(pre2,post2,solid2)); f2=fill(f2,(1-(s2+1)/2)*g2o+((s2+1)/2)*g2n)
f1=restrict(f2,f1,P2,Rf12,fl2)
f0=restrict(f1,f0,P1,Rf01,fl1)
uc=macros(f0)[1]; uy[t]=uc[1,py,px]
if use1 and t%FE==0:
frames.append((t,uc.copy(),macros(f1)[1].copy(),(macros(f2)[1].copy() if use2 else None)))
return dict(mode=mode,DL=DL,Fx=Fx,Fy=Fy,uy=uy,frames=frames)
def save_gif(res,name,fps=24):
nested=(res["mode"]=="nested"); frames=res["frames"]
mid=frames[len(frames)//2][1]
vm=np.nanpercentile(np.abs(np.where(solid0,np.nan,vort(mid))),99.0)
cm=plt.get_cmap("inferno").copy(); cm.set_bad("white")
pil=[]
for (t,u0,u1,u2) in frames:
fig=plt.figure(figsize=(11.2,5.7),dpi=96); ax=fig.add_subplot(111)
ax.imshow(np.abs(np.where(solid0,np.nan,vort(u0))),origin="lower",cmap=cm,vmin=0,vmax=vm,
extent=(0,Nx,0,Ny),interpolation="nearest")
o1=np.abs(np.where(solid1,np.nan,vort(u1)))[1:-1,1:-1]
ax.imshow(o1,origin="lower",cmap=cm,vmin=0,vmax=vm,
extent=(ax1+0.5,bx1-0.5,ay1+0.5,by1-0.5),interpolation="nearest")
if nested and u2 is not None:
o2=np.abs(np.where(solid2,np.nan,vort(u2)))[1:-1,1:-1]
ax.imshow(o2,origin="lower",cmap=cm,vmin=0,vmax=vm,
extent=(cx2a+0.25,cx2b-0.25,cy2a+0.25,cy2b-0.25),interpolation="nearest")
# ТОЛЬКО рамки зон (без линий сетки)
ax.add_patch(mpatches.Rectangle((0.2,0.2),Nx-0.4,Ny-0.4,fill=False,edgecolor="#4fc3f7",lw=1.6))
ax.text(1.5,Ny-4,"уровень 0: Δx (крупный)",color="#4fc3f7",fontsize=9,weight="bold")
ax.add_patch(mpatches.Rectangle((ax1,ay1),bx1-ax1,by1-ay1,fill=False,edgecolor="#aed581",lw=2.2))
ax.text(ax1+1,by1-3.5,"уровень 1: Δx/2 (тело+след)",color="#aed581",fontsize=9,weight="bold")
if nested:
ax.add_patch(mpatches.Rectangle((cx2a,cy2a),cx2b-cx2a,cy2b-cy2a,fill=False,edgecolor="#ff8a65",lw=2.2))
ax.text(cx2a+1,cy2b-3.5,"уровень 2: Δx/4 (у тела)",color="#ff8a65",fontsize=9,weight="bold")
ttl="Вложенный AMR на цилиндре: 2× (след) + 4× (у тела)" if nested else "AMR на цилиндре: одиночный блок 2×"
ax.set_title(f"{ttl} · поверх $|\\omega|$ · t={t}",fontsize=11)
ax.set_xlabel("x [lu]"); ax.set_ylabel("y [lu]")
ax.set_xlim(0,Nx); ax.set_ylim(0,Ny)
buf=_io.BytesIO(); fig.savefig(buf,format="png",bbox_inches="tight"); buf.seek(0); plt.close(fig)
pil.append(Image.open(buf).convert("RGB").convert("P",palette=Image.ADAPTIVE))
path=os.path.join(ANIM,name)
pil[0].save(path,save_all=True,append_images=pil[1:],duration=int(1000/fps),loop=0,optimize=True)
print("saved",name,len(frames),"кадров")
def analyze(res):
Fx,Fy,uy,DL=res["Fx"],res["Fy"],res["uy"],res["DL"]
n=len(uy); h=slice(n//2,n) # статистически установившаяся половина
Cd=2*Fx/(U**2*DL); Cl=2*Fy/(U**2*DL)
sig=uy[h]-uy[h].mean(); npad=8192
freqs=np.fft.rfftfreq(npad,1.0); amp=np.abs(np.fft.rfft(sig,n=npad))
fpk=freqs[1+int(np.argmax(amp[1:]))] if len(amp)>2 else 0.0
St=fpk*D0/U
return dict(Cd=float(Cd[h].mean()),Clrms=float(Cl[h].std()),St=float(St),
uyrms=float(uy[h].std()),Cd_s=Cd,Cl_s=Cl,uy=uy,freqs=freqs,amp=amp,h0=n//2)
# ===== прогон трёх версий =====
print("=== none ==="); R0=run("none")
print("=== amr2x ==="); R1=run("amr2x")
print("=== nested ==="); R2=run("nested")
save_gif(R1,"amr2x_cyl.gif"); save_gif(R2,"amr_nested_cyl.gif")
A0,A1,A2=analyze(R0),analyze(R1),analyze(R2)
LABELS=["без AMR (D=16)","AMR 2× (D=32)","AMR 2×+4× (D=64)"]
AS=[A0,A1,A2]
print("\n=== СРАВНЕНИЕ НА СОБРАННЫХ ДАННЫХ (установившийся режим) ===")
print(f"{'версия':22}{'D у тела':>9}{'St':>8}{'<Cd>':>9}{'rms Cl':>9}{'rms u_y':>9}")
for lab,DL,a in zip(LABELS,[16,32,64],AS):
print(f"{lab:22}{DL:>9}{a['St']:>8.3f}{a['Cd']:>9.3f}{a['Clrms']:>9.4f}{a['uyrms']:>9.4f}")
print(f"{'литература Re≈150':22}{'—':>9}{0.183:>8.3f}{1.33:>9.3f}{'~0.3':>9}")
print("(лит.: St≈0.18, Cd≈1.3 для цилиндра при Re≈150; Williamson 1996, Henderson 1995)")
# ===== фигура сравнения =====
np.savez(os.path.join(HERE,"_amr_probe_data.npz"),
St=np.array([a['St'] for a in AS]), Cd=np.array([a['Cd'] for a in AS]),
Clrms=np.array([a['Clrms'] for a in AS]), uyrms=np.array([a['uyrms'] for a in AS]),
Cd0=A0['Cd_s'],Cd1=A1['Cd_s'],Cd2=A2['Cd_s'],
Cl0=A0['Cl_s'],Cl1=A1['Cl_s'],Cl2=A2['Cl_s'],
uy0=R0['uy'],uy1=R1['uy'],uy2=R2['uy'])
cols=["#9e9e9e","#1f6feb","#d1495b"]
fig=plt.figure(figsize=(13.5,8.2))
gs=fig.add_gridspec(2,2,hspace=0.32,wspace=0.22)
# поле |omega| вложенного AMR
axA=fig.add_subplot(gs[0,0])
mid=R2["frames"][len(R2["frames"])//2]; vmf=np.nanpercentile(np.abs(np.where(solid0,np.nan,vort(mid[1]))),99)
cmf=plt.get_cmap("inferno").copy(); cmf.set_bad("white")
axA.imshow(np.abs(np.where(solid0,np.nan,vort(mid[1]))),origin="lower",cmap=cmf,vmin=0,vmax=vmf,
extent=(0,Nx,0,Ny),interpolation="nearest")
axA.add_patch(mpatches.Rectangle((ax1,ay1),bx1-ax1,by1-ay1,fill=False,edgecolor="#aed581",lw=1.8))
axA.add_patch(mpatches.Rectangle((cx2a,cy2a),cx2b-cx2a,cy2b-cy2a,fill=False,edgecolor="#ff8a65",lw=1.8))
axA.set_title("Вложенный AMR: $|\\omega|$ + рамки зон 2×/4×"); axA.set_xlabel("x [lu]"); axA.set_ylabel("y [lu]")
# Cl(t)
axB=fig.add_subplot(gs[0,1])
for a,lab,c in zip(AS,LABELS,cols):
axB.plot(a['Cl_s'][a['h0']:],lw=0.8,color=c,label=lab)
axB.set_title("Подъёмная сила $C_l(t)$ (зонд силы, обмен импульсом)")
axB.set_xlabel("шаг (установившийся режим)"); axB.set_ylabel("$C_l$"); axB.legend(fontsize=8); axB.grid(alpha=0.3)
# спектры u_y
axC=fig.add_subplot(gs[1,0])
for a,lab,c in zip(AS,LABELS,cols):
sp=a['amp']/a['amp'][1:].max(); axC.plot(a['freqs']*D0/U,sp,lw=1.0,color=c,label=lab)
axC.axvline(0.183,color="k",ls="--",alpha=0.6,label="лит. St≈0.18")
axC.set_xlim(0,0.6); axC.set_title("Спектр $u_y$ в следе → число Струхаля")
axC.set_xlabel("St = f·D/U"); axC.set_ylabel("нормир. амплитуда"); axC.legend(fontsize=8); axC.grid(alpha=0.3)
# столбики St и Cd
axD=fig.add_subplot(gs[1,1]); x=np.arange(3); w=0.35
axD.bar(x-w/2,[a['St'] for a in AS],w,color="#1f6feb",label="St")
axD.bar(x+w/2,[a['Cd'] for a in AS],w,color="#fb8c00",label="<Cd>")
axD.axhline(0.183,color="#1f6feb",ls="--",alpha=0.6); axD.axhline(1.33,color="#fb8c00",ls="--",alpha=0.6)
axD.set_xticks(x); axD.set_xticklabels(["без AMR","2×","2×+4×"],fontsize=9)
axD.set_title("St и <Cd> vs разрешение у тела (пунктир — лит.)"); axD.legend(fontsize=8); axD.grid(alpha=0.3,axis="y")
for i,a in enumerate(AS):
axD.text(i-w/2,a['St']+0.005,f"{a['St']:.3f}",ha="center",fontsize=7)
axD.text(i+w/2,a['Cd']+0.02,f"{a['Cd']:.2f}",ha="center",fontsize=7)
fig.suptitle("AMR на цилиндре: сравнение трёх версий на реально собранных данных зондов",fontweight="bold")
fig.savefig(os.path.join(FIGS,"11e_amr_probe_comparison.png"),dpi=110,bbox_inches="tight"); plt.close(fig)
print("saved figures/11e_amr_probe_comparison.png")
print("DONE")
+244
View File
@@ -0,0 +1,244 @@
# СЛЕДУЮЩАЯ ИТЕРАЦИЯ решателя: тот же AMR (без / 2× / вложенный 2×+4×), но граница тела
# задана ПОЛНОЦЕННЫМ SDF (знаковое поле расстояний) + интерполированное отражение Bouzidi
# (Bouzidi-Firdaouss-Lallemand 2001) — сглаживает ступенчатость криволинейной границы.
# Геометрия/Re/шаги ИДЕНТИЧНЫ _amr_anims.py — отличие ТОЛЬКО в граничном условии, поэтому
# результаты прямо сравнимы. В конце — сводное сравнение с версиями без SDF.
import sys, os, io as _io, numpy as np, matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt, matplotlib.patches as mpatches
from PIL import Image
try: sys.stdout.reconfigure(encoding="utf-8")
except Exception: pass
HERE = os.path.dirname(os.path.abspath(__file__))
ANIM = os.path.join(HERE, "anim"); os.makedirs(ANIM, exist_ok=True)
FIGS = os.path.join(HERE, "figures"); os.makedirs(FIGS, exist_ok=True)
# ===== проверенное ядро D2Q9 KBC (идентично _amr_anims.py) =====
C=np.array([[0,0],[1,0],[0,1],[-1,0],[0,-1],[1,1],[-1,1],[-1,-1],[1,-1]],float)
W=np.array([4/9,1/9,1/9,1/9,1/9,1/36,1/36,1/36,1/36]); CS2=1/3; Q=9
CX,CY=C[:,0],C[:,1]; OPP=np.array([0,3,4,1,2,7,8,5,6])
M=np.zeros((Q,Q)); M[0]=1;M[1]=CX;M[2]=CY;M[3]=3*(CX**2+CY**2)-2
M[4]=CX**2-CY**2;M[5]=CX*CY;M[6]=CX**2*CY;M[7]=CX*CY**2;M[8]=CX**2*CY**2
Dm=np.zeros((Q,Q)); Dm[4,4]=Dm[5,5]=1.0; Ps=np.linalg.inv(M)@Dm@M
def feq(rho,u):
ux=np.clip(u[0],-0.95,0.95); uy=np.clip(u[1],-0.95,0.95)
sx=np.sqrt(1+3*ux**2); sy=np.sqrt(1+3*uy**2); base=rho*(2-sx)*(2-sy)
Bx=((2*ux+sx)/(1-ux))[None]**CX[:,None,None]; By=((2*uy+sy)/(1-uy))[None]**CY[:,None,None]
return W[:,None,None]*base[None]*Bx*By
def macros(f):
r=f.sum(0); return r,np.stack([(CX[:,None,None]*f).sum(0)/r,(CY[:,None,None]*f).sum(0)/r])
def collide(f,fe,beta):
dfn=f-fe; ds=np.einsum("ij,jab->iab",Ps,dfn); dh=dfn-ds; inv=1.0/fe
num=(ds*dh*inv).sum(0); den=(dh*dh*inv).sum(0)
with np.errstate(divide="ignore",invalid="ignore"):
g=np.where(den>1e-14,1/beta-(2-1/beta)*num/den,2.0)
return f-beta*(2*ds+g[None]*dh)
def stream(f):
fs=np.empty_like(f)
for i in range(Q): fs[i]=np.roll(f[i],(int(CY[i]),int(CX[i])),(0,1))
return fs
def smoothstep(x): x=min(max(x,0.0),1.0); return x*x*(3-2*x)
def tauc(tp,r): return r*tp-(r-1)/2
def vort(u):
return (np.roll(u[1],-1,1)-np.roll(u[1],1,1))*0.5-(np.roll(u[0],-1,0)-np.roll(u[0],1,0))*0.5
def patch(pNx,pNy,ax,bx,ay,by,r):
Wx,Wy=bx-ax,by-ay; Nfx,Nfy=r*Wx+1,r*Wy+1
FX,FY=np.meshgrid(ax+np.arange(Nfx)/r, ay+np.arange(Nfy)/r)
x0=np.floor(FX).astype(int); y0=np.floor(FY).astype(int)
x1=np.minimum(x0+1,pNx-1); y1=np.minimum(y0+1,pNy-1)
return dict(Nfx=Nfx,Nfy=Nfy,ax=ax,bx=bx,ay=ay,by=by,r=r,x0=x0,y0=y0,x1=x1,y1=y1,
tx=FX-x0,ty=FY-y0,slx=slice(r,r*Wx,r),sly=slice(r,r*Wy,r))
def pint(fld,P):
return (fld[...,P["y0"],P["x0"]]*(1-P["tx"])*(1-P["ty"])+fld[...,P["y0"],P["x1"]]*P["tx"]*(1-P["ty"])
+fld[...,P["y1"],P["x0"]]*(1-P["tx"])*P["ty"]+fld[...,P["y1"],P["x1"]]*P["tx"]*P["ty"])
def ghost(pf,P,Rcf):
r,u=macros(pf); neq=pf-feq(r,u)
return feq(pint(r,P),np.stack([pint(u[0],P),pint(u[1],P)]))+Rcf*pint(neq,P)
def fill(cf,gh):
cf[:,0,:]=gh[:,0,:];cf[:,-1,:]=gh[:,-1,:];cf[:,:,0]=gh[:,:,0];cf[:,:,-1]=gh[:,:,-1]; return cf
def restrict(cf,pf,P,Rfc,fluid):
r,u=macros(cf); neq=cf-feq(r,u); slx,sly=P["slx"],P["sly"]
nv=feq(r[sly,slx],u[:,sly,slx])+Rfc*neq[:,sly,slx]
cur=pf[:,P["ay"]+1:P["by"],P["ax"]+1:P["bx"]]
pf[:,P["ay"]+1:P["by"],P["ax"]+1:P["bx"]]=np.where(fluid[None],nv,cur); return pf
# ===== ПОЛНОЦЕННЫЙ SDF + интерполированное отражение Bouzidi =====
def sdf_circle(NX,NY,ccx,ccy,R):
# знаковое расстояние до окружности: >0 жидкость, <0 внутри тела
Y,X=np.mgrid[0:NY,0:NX]; return np.sqrt((X-ccx)**2+(Y-ccy)**2)-R
def sdf_from_mask(mask):
# общий SDF из произвольной бинарной маски (для не-круглых тел), через EDT
from scipy import ndimage
din=ndimage.distance_transform_edt(mask); dout=ndimage.distance_transform_edt(~mask)
return dout-din-0.5 # >0 жидкость, <0 тело; -0.5 — поверхность между узлами
def add_walls(phi):
NY=phi.shape[0]; Y=np.arange(NY)[:,None]*np.ones((1,phi.shape[1]))
return np.minimum(phi, np.minimum(Y-0.5, (NY-1.5)-Y)) # стенки: поверхность на y=0.5 и Ny-1.5
def build_bc(solid, phi, cyl_mask):
# для каждого направления: линки жидкий->твёрдый, доля стенки q (по SDF),
# есть ли второй жидкий узел (для интерполяции), и принадлежит ли сосед телу (для силы)
fluid=~solid; bc=[]
for i in range(1,Q):
cy_,cx_=int(CY[i]),int(CX[i])
nb_solid=np.roll(solid,(-cy_,-cx_),(0,1)) # solid[x+c_i]
mask=fluid & nb_solid
cl=mask & np.roll(cyl_mask,(-cy_,-cx_),(0,1)) # сосед — тело (не стенка)
phinb=np.roll(phi,(-cy_,-cx_),(0,1))
with np.errstate(divide="ignore",invalid="ignore"):
qq=phi/(phi-phinb) # доля линка до стенки (phi=0)
q=np.where(mask, np.clip(qq,0.02,0.98), 0.5)
xff=mask & (~np.roll(solid,(cy_,cx_),(0,1))) # x_f - c_i — жидкий?
bc.append((i,int(OPP[i]),mask,q,xff,cl))
return bc
def apply_bc(f, fpost, bc):
# Bouzidi (линейный): пристеночный жидкий узел получает корректно отражённую популяцию
for (i,ib,mask,q,xff,cl) in bc:
cy_,cx_=int(CY[i]),int(CX[i])
fi=fpost[i]; fib=fpost[ib]; fiback=np.roll(fpost[i],(cy_,cx_),(0,1)) # f_i в x_f-c_i
near=mask&(q<0.5)&xff; bad=mask&(q<0.5)&(~xff); far=mask&(q>=0.5)
qn=q[near]; f[ib][near]=2*qn*fi[near]+(1-2*qn)*fiback[near]
f[ib][bad]=fi[bad] # запас: нет второго узла -> halfway
qf=q[far]; f[ib][far]=(1/(2*qf))*fi[far]+(1-1/(2*qf))*fib[far]
return f
def force_bc(fpost, f, bc):
# обмен импульсом (Mei et al. 2002) для интерполированной границы: F=sum c_i (f_i^post + f_ī^bc)
Fx=0.0; Fy=0.0
for (i,ib,mask,q,xff,cl) in bc:
s=(fpost[i]+f[ib])[cl].sum(); Fx+=CX[i]*s; Fy+=CY[i]*s
return Fx,Fy
# ===== геометрия (идентична версии без SDF) =====
Nx,Ny=150,72; D=16; cx,cy=40,36; U=0.07; Re=150.0; STEPS=8000; ramp=1000; FE=34
D0=D; nu=U*D/Re; tau0=nu/CS2+0.5; b0=1/(2*tau0); px,py=cx+3*D,cy
# коарс: цилиндр (SDF) + стенки
phi0=add_walls(sdf_circle(Nx,Ny,cx,cy,D/2)); solid0=phi0<0
cyl0=sdf_circle(Nx,Ny,cx,cy,D/2)<0
bc0=build_bc(solid0,phi0,cyl0)
# уровень1 (2×): тело+след
ax1,bx1,ay1,by1=16,118,8,64; P1=patch(Nx,Ny,ax1,bx1,ay1,by1,2)
t1=tauc(tau0,2); b1=1/(2*t1); R01=t1/(2*tau0); Rf01=1/R01
phi1=sdf_circle(P1["Nfx"],P1["Nfy"],(cx-ax1)*2,(cy-ay1)*2,D); solid1=phi1<0
bc1=build_bc(solid1,phi1,solid1); fl1=~solid0[ay1+1:by1,ax1+1:bx1]
# уровень2 (4×): у тела
cx2a,cx2b,cy2a,cy2b=26,64,18,54
P2=patch(P1["Nfx"],P1["Nfy"],(cx2a-ax1)*2,(cx2b-ax1)*2,(cy2a-ay1)*2,(cy2b-ay1)*2,2)
t2=tauc(t1,2); b2=1/(2*t2); R12=t2/(2*t1); Rf12=1/R12
phi2=sdf_circle(P2["Nfx"],P2["Nfy"],(cx-cx2a)*4,(cy-cy2a)*4,2*D); solid2=phi2<0
bc2=build_bc(solid2,phi2,solid2); fl2=np.ones((P2["by"]-P2["ay"]-1,P2["bx"]-P2["ax"]-1),bool)
onecol=np.ones((Ny,1))
print(f"[SDF] домен {Nx}x{Ny}; L1(2×) {P1['Nfx']}x{P1['Nfy']}; L2(4×) {P2['Nfx']}x{P2['Nfy']}; "
f"Re={Re} D0={D0} steps={STEPS}")
def run(mode): # "none" | "amr2x" | "nested"
use1=mode in ("amr2x","nested"); use2=(mode=="nested")
f0=feq(np.ones((Ny,Nx)),np.zeros((2,Ny,Nx)))
f1=feq(np.ones((P1["Nfy"],P1["Nfx"])),np.zeros((2,P1["Nfy"],P1["Nfx"]))) if use1 else None
f2=feq(np.ones((P2["Nfy"],P2["Nfx"])),np.zeros((2,P2["Nfy"],P2["Nfx"]))) if use2 else None
Fx=np.zeros(STEPS+1); Fy=np.zeros(STEPS+1); uy=np.zeros(STEPS+1); frames=[]
for t in range(STEPS+1):
rho,u0=macros(f0)
if not np.all(np.isfinite(u0)): print("BLEW UP",mode,t); Fx=Fx[:t];Fy=Fy[:t];uy=uy[:t]; break
pre=f0.copy(); post0=collide(f0,feq(rho,u0),b0); f0=stream(post0); f0=apply_bc(f0,post0,bc0)
if mode=="none": Fx[t],Fy[t]=force_bc(post0,f0,bc0)
uin=np.zeros((2,Ny,1)); uin[0,:,0]=U*smoothstep(t/ramp)
f0[:,1:-1,0]=feq(onecol,uin)[:,1:-1,0]; f0[:,1:-1,-1]=f0[:,1:-1,-2]
if use1:
g1o=ghost(pre,P1,R01); g1n=ghost(f0,P1,R01)
for s1 in range(2):
pre1=f1.copy(); r1,u1=macros(f1); post1=collide(f1,feq(r1,u1),b1)
f1=stream(post1); f1=apply_bc(f1,post1,bc1)
if mode=="amr2x" and s1==1: Fx[t],Fy[t]=force_bc(post1,f1,bc1)
f1=fill(f1,(1-(s1+1)/2)*g1o+((s1+1)/2)*g1n)
if use2:
g2o=ghost(pre1,P2,R12); g2n=ghost(f1,P2,R12)
for s2 in range(2):
pre2=f2.copy(); r2,u2=macros(f2); post2=collide(f2,feq(r2,u2),b2)
f2=stream(post2); f2=apply_bc(f2,post2,bc2)
if s1==1 and s2==1: Fx[t],Fy[t]=force_bc(post2,f2,bc2)
f2=fill(f2,(1-(s2+1)/2)*g2o+((s2+1)/2)*g2n)
f1=restrict(f2,f1,P2,Rf12,fl2)
f0=restrict(f1,f0,P1,Rf01,fl1)
uc=macros(f0)[1]; uy[t]=uc[1,py,px]
if use1 and t%FE==0:
frames.append((t,uc.copy(),macros(f1)[1].copy(),(macros(f2)[1].copy() if use2 else None)))
return dict(mode=mode,Fx=Fx,Fy=Fy,uy=uy,frames=frames,DL={"none":D0,"amr2x":2*D0,"nested":4*D0}[mode])
def save_gif(res,name,fps=24):
nested=(res["mode"]=="nested"); frames=res["frames"]
vm=np.nanpercentile(np.abs(np.where(solid0,np.nan,vort(frames[len(frames)//2][1]))),99.0)
cm=plt.get_cmap("inferno").copy(); cm.set_bad("white")
pil=[]
for (t,u0,u1,u2) in frames:
fig=plt.figure(figsize=(11.2,5.7),dpi=96); ax=fig.add_subplot(111)
ax.imshow(np.abs(np.where(solid0,np.nan,vort(u0))),origin="lower",cmap=cm,vmin=0,vmax=vm,
extent=(0,Nx,0,Ny),interpolation="nearest")
ax.imshow(np.abs(np.where(solid1,np.nan,vort(u1)))[1:-1,1:-1],origin="lower",cmap=cm,vmin=0,vmax=vm,
extent=(ax1+0.5,bx1-0.5,ay1+0.5,by1-0.5),interpolation="nearest")
if nested and u2 is not None:
ax.imshow(np.abs(np.where(solid2,np.nan,vort(u2)))[1:-1,1:-1],origin="lower",cmap=cm,vmin=0,vmax=vm,
extent=(cx2a+0.25,cx2b-0.25,cy2a+0.25,cy2b-0.25),interpolation="nearest")
ax.add_patch(mpatches.Rectangle((0.2,0.2),Nx-0.4,Ny-0.4,fill=False,edgecolor="#4fc3f7",lw=1.6))
ax.text(1.5,Ny-4,"уровень 0: Δx (крупный)",color="#4fc3f7",fontsize=9,weight="bold")
ax.add_patch(mpatches.Rectangle((ax1,ay1),bx1-ax1,by1-ay1,fill=False,edgecolor="#aed581",lw=2.2))
ax.text(ax1+1,by1-3.5,"уровень 1: Δx/2 (тело+след)",color="#aed581",fontsize=9,weight="bold")
if nested:
ax.add_patch(mpatches.Rectangle((cx2a,cy2a),cx2b-cx2a,cy2b-cy2a,fill=False,edgecolor="#ff8a65",lw=2.2))
ax.text(cx2a+1,cy2b-3.5,"уровень 2: Δx/4 (у тела)",color="#ff8a65",fontsize=9,weight="bold")
ttl="Вложенный AMR + SDF: 2×(след)+4×(тело)" if nested else "AMR + SDF: одиночный блок 2×"
ax.set_title(f"{ttl} · граница тела по SDF (Bouzidi) · поверх $|\\omega|$ · t={t}",fontsize=11)
ax.set_xlabel("x [lu]"); ax.set_ylabel("y [lu]"); ax.set_xlim(0,Nx); ax.set_ylim(0,Ny)
buf=_io.BytesIO(); fig.savefig(buf,format="png",bbox_inches="tight"); buf.seek(0); plt.close(fig)
pil.append(Image.open(buf).convert("RGB").convert("P",palette=Image.ADAPTIVE))
pil[0].save(os.path.join(ANIM,name),save_all=True,append_images=pil[1:],duration=int(1000/fps),loop=0,optimize=True)
print("saved",name,len(frames),"кадров")
def analyze(res):
Fx,Fy,uy,DL=res["Fx"],res["Fy"],res["uy"],res["DL"]
n=len(uy); h=slice(n//2,n); Cd=2*Fx/(U**2*DL); Cl=2*Fy/(U**2*DL)
sig=uy[h]-uy[h].mean(); npad=8192
freqs=np.fft.rfftfreq(npad,1.0); amp=np.abs(np.fft.rfft(sig,n=npad))
St=(freqs[1+int(np.argmax(amp[1:]))] if len(amp)>2 else 0.0)*D0/U
return dict(Cd=float(Cd[h].mean()),Clrms=float(Cl[h].std()),St=float(St),uyrms=float(uy[h].std()),
Cl_s=Cl,uy=uy,freqs=freqs,amp=amp,h0=n//2)
print("=== SDF none ==="); R0=run("none")
print("=== SDF amr2x ==="); R1=run("amr2x")
print("=== SDF nested ==="); R2=run("nested")
save_gif(R1,"amr2x_cyl_sdf.gif"); save_gif(R2,"amr_nested_cyl_sdf.gif")
AS=[analyze(R0),analyze(R1),analyze(R2)]; LAB=["без AMR (D=16)","AMR 2× (D=32)","AMR 2×+4× (D=64)"]
print("\n=== SDF: СРАВНЕНИЕ НА СОБРАННЫХ ДАННЫХ ===")
print(f"{'версия':22}{'D у тела':>9}{'St':>8}{'<Cd>':>9}{'rms Cl':>9}{'rms u_y':>9}")
for lab,DL,a in zip(LAB,[16,32,64],AS):
print(f"{lab:22}{DL:>9}{a['St']:>8.3f}{a['Cd']:>9.3f}{a['Clrms']:>9.4f}{a['uyrms']:>9.4f}")
np.savez(os.path.join(HERE,"_amr_probe_data_sdf.npz"),
St=np.array([a['St'] for a in AS]),Cd=np.array([a['Cd'] for a in AS]),
Clrms=np.array([a['Clrms'] for a in AS]),uyrms=np.array([a['uyrms'] for a in AS]))
# ===== сводное сравнение SDF vs без SDF (если есть данные без SDF) =====
nosdf_path=os.path.join(HERE,"_amr_probe_data.npz")
if os.path.exists(nosdf_path) and ("St" in np.load(nosdf_path).files):
N=np.load(nosdf_path); St_n,Cd_n,Cl_n=N["St"],N["Cd"],N["Clrms"]
St_s=np.array([a['St'] for a in AS]); Cd_s=np.array([a['Cd'] for a in AS]); Cl_s=np.array([a['Clrms'] for a in AS])
print("\n=== СВОДНО: SDF vs без SDF ===")
print(f"{'версия':14}{'St(noSDF)':>11}{'St(SDF)':>9}{'Cd(noSDF)':>11}{'Cd(SDF)':>9}")
for k,lab in enumerate(["без AMR","2×","2×+4×"]):
print(f"{lab:14}{St_n[k]:>11.3f}{St_s[k]:>9.3f}{Cd_n[k]:>11.3f}{Cd_s[k]:>9.3f}")
print(f"{'лит. Re≈150':14}{0.183:>11.3f}{0.183:>9.3f}{1.330:>11.3f}{1.330:>9.3f}")
x=np.arange(3); w=0.38
fig,axs=plt.subplots(1,2,figsize=(13.5,4.6))
axs[0].bar(x-w/2,Cd_n,w,color="#9e9e9e",label="без SDF (ступенч.)")
axs[0].bar(x+w/2,Cd_s,w,color="#1f6feb",label="с SDF (Bouzidi)")
axs[0].axhline(1.33,color="k",ls="--",alpha=0.6,label="лит. Cd≈1.33")
axs[0].set_xticks(x); axs[0].set_xticklabels(["без AMR","2×","2×+4×"]); axs[0].set_ylabel("<Cd>")
axs[0].set_title("Сопротивление: SDF убирает завышение от ступенчатой границы"); axs[0].legend(fontsize=8); axs[0].grid(alpha=0.3,axis="y")
axs[1].bar(x-w/2,St_n,w,color="#9e9e9e",label="без SDF")
axs[1].bar(x+w/2,St_s,w,color="#1f6feb",label="с SDF")
axs[1].axhline(0.183,color="k",ls="--",alpha=0.6,label="лит. St≈0.18")
axs[1].set_xticks(x); axs[1].set_xticklabels(["без AMR","2×","2×+4×"]); axs[1].set_ylabel("St")
axs[1].set_title("Число Струхаля"); axs[1].legend(fontsize=8); axs[1].grid(alpha=0.3,axis="y")
fig.suptitle("Цилиндр: SDF (Bouzidi) против ступенчатого bounce-back, на реально собранных данных",fontweight="bold")
fig.savefig(os.path.join(FIGS,"11f_sdf_vs_nosdf.png"),dpi=110,bbox_inches="tight"); plt.close(fig)
print("saved figures/11f_sdf_vs_nosdf.png")
else:
print("\n(нет свежего _amr_probe_data.npz со скалярами — сначала запусти _amr_anims.py)")
print("DONE")
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
# ОСНОВНОЙ GPU-решатель (без SDF): цилиндр, три версии (без AMR / 2× / вложенный 2×+4×).
# Математика идентична CPU-прототипу _amr_anims.py; backend — CuPy (GPU) с откатом на numpy.
# Запуск: python amr_cylinder_gpu.py (fp32 по умолчанию)
# AMR_FP64=1 python amr_cylinder_gpu.py (двойная точность)
# AMR_STEPS=2000 python amr_cylinder_gpu.py (короткий прогон)
import os, time, numpy as np
import amr_gpu_core as core
HERE = os.path.dirname(os.path.abspath(__file__))
ANIM = os.path.join(HERE, "anim"); os.makedirs(ANIM, exist_ok=True)
FIGS = os.path.join(HERE, "figures"); os.makedirs(FIGS, exist_ok=True)
print(f"[ОСНОВНОЙ · без SDF] backend={core.BACKEND}; домен {core.Nx}x{core.Ny}; steps={core.STEPS}; "
f"L1(2×) {core.P1['Nfx']}x{core.P1['Nfy']}; L2(4×) {core.P2['Nfx']}x{core.P2['Nfy']}")
t0 = time.perf_counter()
R0 = core.run("none", False); print(" none :", round(time.perf_counter()-t0, 1), "s")
R1 = core.run("amr2x", False); print(" amr2x :", round(time.perf_counter()-t0, 1), "s")
R2 = core.run("nested", False); print(" nested :", round(time.perf_counter()-t0, 1), "s")
core.save_gif(R1, "amr2x_cyl.gif", ANIM, " (без SDF)")
core.save_gif(R2, "amr_nested_cyl.gif", ANIM, " (без SDF)")
AS = [core.analyze(R0), core.analyze(R1), core.analyze(R2)]
core.print_table(AS, "БЕЗ SDF — сравнение на реально собранных данных (установившийся режим)")
np.savez(os.path.join(HERE, "_amr_probe_data.npz"),
St=np.array([a["St"] for a in AS]), Cd=np.array([a["Cd"] for a in AS]),
Clrms=np.array([a["Clrms"] for a in AS]), uyrms=np.array([a["uyrms"] for a in AS]),
Cl0=AS[0]["Cl_s"], Cl1=AS[1]["Cl_s"], Cl2=AS[2]["Cl_s"],
uy0=AS[0]["uy"], uy1=AS[1]["uy"], uy2=AS[2]["uy"])
core.probe_figure(AS, R2, os.path.join(FIGS, "11e_amr_probe_comparison.png"),
"AMR на цилиндре (без SDF): сравнение трёх версий на данных зондов")
print("DONE (без SDF)")
+75
View File
@@ -0,0 +1,75 @@
# ОСНОВНОЙ GPU-решатель (с SDF + Bouzidi): цилиндр, три версии (без AMR / 2× / 2×+4×).
# Граница тела — по знаковому полю расстояний + интерполированное отражение (Bouzidi);
# стенки канала — тот же механизм с q=0.5 (обычный halfway). Математика идентична _amr_anims_sdf.py.
# backend — CuPy (GPU) с откатом на numpy. В конце — сводное сравнение с версией без SDF.
# Запуск: python amr_cylinder_sdf_gpu.py (после amr_cylinder_gpu.py — для сводного сравнения)
import os, time, numpy as np
import amr_gpu_core as core
HERE = os.path.dirname(os.path.abspath(__file__))
ANIM = os.path.join(HERE, "anim"); os.makedirs(ANIM, exist_ok=True)
FIGS = os.path.join(HERE, "figures"); os.makedirs(FIGS, exist_ok=True)
print(f"[ОСНОВНОЙ · SDF+Bouzidi] backend={core.BACKEND}; домен {core.Nx}x{core.Ny}; steps={core.STEPS}; "
f"L1(2×) {core.P1['Nfx']}x{core.P1['Nfy']}; L2(4×) {core.P2['Nfx']}x{core.P2['Nfy']}")
def make_progress(label, width=32, every=0.1):
"""Колбэк-индикатор: рисует ASCII-шкалу по выполненным шагам (t из STEPS), обновляется на месте."""
st = {"t0": time.perf_counter(), "last": 0.0}
def cb(t, total):
now = time.perf_counter(); done = (t >= total)
if not done and (now - st["last"] < every): # троттлинг, чтобы не спамить консоль
return
st["last"] = now; frac = (t + 1) / (total + 1)
filled = int(round(width * frac)); bar = "#" * filled + "-" * (width - filled)
el = now - st["t0"]; eta = (el / frac - el) if frac > 0 else 0.0
print(f"\r {label:7}[{bar}] {frac*100:5.1f}% ({t}/{total}) {el:5.1f}s ETA {eta:5.1f}s",
end=("\n" if done else ""), flush=True)
return cb
t0 = time.perf_counter()
R0 = core.run("none", True, make_progress("none")); print(" none :", round(time.perf_counter()-t0, 1), "s")
R1 = core.run("amr2x", True, make_progress("amr2x")); print(" amr2x :", round(time.perf_counter()-t0, 1), "s")
R2 = core.run("nested", True, make_progress("nested")); print(" nested :", round(time.perf_counter()-t0, 1), "s")
core.save_gif(R1, "amr2x_cyl_sdf.gif", ANIM, " + SDF (Bouzidi)")
core.save_gif(R2, "amr_nested_cyl_sdf.gif", ANIM, " + SDF (Bouzidi)")
AS = [core.analyze(R0), core.analyze(R1), core.analyze(R2)]
core.print_table(AS, "С SDF — сравнение на реально собранных данных (установившийся режим)")
np.savez(os.path.join(HERE, "_amr_probe_data_sdf.npz"),
St=np.array([a["St"] for a in AS]), Cd=np.array([a["Cd"] for a in AS]),
Clrms=np.array([a["Clrms"] for a in AS]), uyrms=np.array([a["uyrms"] for a in AS]),
Cl0=AS[0]["Cl_s"], Cl1=AS[1]["Cl_s"], Cl2=AS[2]["Cl_s"],
uy0=AS[0]["uy"], uy1=AS[1]["uy"], uy2=AS[2]["uy"])
core.probe_figure(AS, R2, os.path.join(FIGS, "11f_amr_sdf_probe_comparison.png"),
"AMR на цилиндре (SDF+Bouzidi): сравнение трёх версий на данных зондов")
# ---- сводное сравнение: SDF против без-SDF (если есть свежие данные без SDF) ----
nosdf = os.path.join(HERE, "_amr_probe_data.npz")
if os.path.exists(nosdf) and ("St" in np.load(nosdf).files):
import matplotlib; matplotlib.use("Agg"); import matplotlib.pyplot as plt
N = np.load(nosdf); Stn, Cdn = N["St"], N["Cd"]
Sts = np.array([a["St"] for a in AS]); Cds = np.array([a["Cd"] for a in AS])
print("\n=== СВОДНО: SDF vs без SDF (Cd, St) ===")
print(f"{'версия':14}{'St(noSDF)':>11}{'St(SDF)':>9}{'Cd(noSDF)':>11}{'Cd(SDF)':>9}")
for k, lab in enumerate(["без AMR", "2×", "2×+4×"]):
print(f"{lab:14}{Stn[k]:>11.3f}{Sts[k]:>9.3f}{Cdn[k]:>11.3f}{Cds[k]:>9.3f}")
print(f"{'лит. Re≈150':14}{0.183:>11.3f}{0.183:>9.3f}{1.330:>11.3f}{1.330:>9.3f}")
x = np.arange(3); w = 0.38
fig, axs = plt.subplots(1, 2, figsize=(13.5, 4.6))
axs[0].bar(x-w/2, Cdn, w, color="#9e9e9e", label="без SDF (ступенч.)")
axs[0].bar(x+w/2, Cds, w, color="#1f6feb", label="с SDF (Bouzidi)")
axs[0].axhline(1.33, color="k", ls="--", alpha=0.6, label="лит. Cd≈1.33")
axs[0].set_xticks(x); axs[0].set_xticklabels(["без AMR", "2×", "2×+4×"]); axs[0].set_ylabel("<Cd>")
axs[0].set_title("Сопротивление: SDF убирает завышение от ступенчатой границы")
axs[0].legend(fontsize=8); axs[0].grid(alpha=0.3, axis="y")
axs[1].bar(x-w/2, Stn, w, color="#9e9e9e", label="без SDF"); axs[1].bar(x+w/2, Sts, w, color="#1f6feb", label="с SDF")
axs[1].axhline(0.183, color="k", ls="--", alpha=0.6, label="лит. St≈0.18")
axs[1].set_xticks(x); axs[1].set_xticklabels(["без AMR", "2×", "2×+4×"]); axs[1].set_ylabel("St")
axs[1].set_title("Число Струхаля"); axs[1].legend(fontsize=8); axs[1].grid(alpha=0.3, axis="y")
fig.suptitle("Цилиндр: SDF (Bouzidi) против ступенчатого bounce-back — на реальных данных", fontweight="bold")
fig.savefig(os.path.join(FIGS, "11g_sdf_vs_nosdf.png"), dpi=110, bbox_inches="tight"); plt.close(fig)
print("saved 11g_sdf_vs_nosdf.png")
else:
print("\n(нет свежего _amr_probe_data.npz — сначала запусти amr_cylinder_gpu.py)")
print("DONE (SDF)")
+309
View File
@@ -0,0 +1,309 @@
# Общее GPU-ядро для основных AMR-решателей (D2Q9 KBC) на цилиндре.
# Математика ДОСЛОВНО та же, что в проверенных CPU-прототипах (_amr_anims*.py):
# - энтропийное равновесие (product-form), KBC-N1 столкновение f<-f-β(2Δs+γΔh),
# - AMR-связка τ_f=2τ_c-½, R_cf=τ_f/(2τ_c), m подшагов, временна́я интерполяция,
# - сила по Mei (обмен импульсом), вход/выход/стенки, опционально SDF+Bouzidi.
# Оптимизация под GPU (CuPy): всё векторно на device; проектор через matmul; ГУ и силы
# через where (без gather/scatter); БЕЗ пер-шаговых синхронизаций host↔device.
import os, io as _io, numpy as np
# ---- backend: CuPy (GPU) с откатом на numpy (CPU) ----
try:
import cupy as cp
_ = (cp.zeros(2) + 1).sum() # реальная операция на device — ловит "cupy есть, GPU нет"
xp = cp; GPU = True
except Exception:
import numpy as cp # noqa
xp = np; GPU = False
def to_cpu(a):
return cp.asnumpy(a) if GPU else np.asarray(a)
DTYPE = xp.float64 if os.environ.get("AMR_FP64") else xp.float32
GREL = 1e-8 # ОТНОСИТЕЛЬНЫЙ порог вырожденности знаменателя γ (доля от ‖Δ‖²); см. solver_2x_sdf/backend.py.
# Прежний абсолютный порог (1e-6 в fp32) срабатывал на большинстве узлов и подменял KBC на LBGK.
BACKEND = f"{'CuPy/GPU' if GPU else 'numpy/CPU'} dtype={'float64' if DTYPE == xp.float64 else 'float32'}"
# ---- решётка D2Q9 ----
Cx = [0, 1, 0, -1, 0, 1, -1, -1, 1] # python-инты для roll/индексации
Cy = [0, 0, 1, 0, -1, 1, 1, -1, -1]
OPP = [0, 3, 4, 1, 2, 7, 8, 5, 6]
Q = 9
_W = np.array([4/9, 1/9, 1/9, 1/9, 1/9, 1/36, 1/36, 1/36, 1/36])
_CXn = np.array(Cx, float); _CYn = np.array(Cy, float)
# моментный базис и проектор на сдвиг {cx²−cy², cxcy} (KBC-N1) — считаем в numpy, грузим на device
_M = np.zeros((Q, Q)); _M[0] = 1; _M[1] = _CXn; _M[2] = _CYn; _M[3] = 3*(_CXn**2+_CYn**2)-2
_M[4] = _CXn**2-_CYn**2; _M[5] = _CXn*_CYn; _M[6] = _CXn**2*_CYn; _M[7] = _CXn*_CYn**2; _M[8] = _CXn**2*_CYn**2
_Dm = np.zeros((Q, Q)); _Dm[4, 4] = _Dm[5, 5] = 1.0
_Psn = np.linalg.inv(_M) @ _Dm @ _M
# device-константы
Wa = xp.asarray(_W, dtype=DTYPE)
CXa = xp.asarray(_CXn, dtype=DTYPE); CYa = xp.asarray(_CYn, dtype=DTYPE)
Ps = xp.asarray(_Psn, dtype=DTYPE)
CS2 = 1.0/3.0
# ---- ядро LBM/KBC (идентичная математика) ----
def feq(rho, u):
ux = xp.clip(u[0], -0.95, 0.95); uy = xp.clip(u[1], -0.95, 0.95)
sx = xp.sqrt(1 + 3*ux*ux); sy = xp.sqrt(1 + 3*uy*uy); base = rho*(2-sx)*(2-sy)
Bx = ((2*ux+sx)/(1-ux))[None]**CXa[:, None, None]
By = ((2*uy+sy)/(1-uy))[None]**CYa[:, None, None]
return Wa[:, None, None]*base[None]*Bx*By
def macros(f):
r = f.sum(0)
return r, xp.stack([(CXa[:, None, None]*f).sum(0)/r, (CYa[:, None, None]*f).sum(0)/r])
def collide(f, fe, beta):
dfn = f - fe
ds = (Ps @ dfn.reshape(Q, -1)).reshape(dfn.shape) # проектор на сдвиг (matmul, GPU-friendly)
dh = dfn - ds; inv = 1.0/fe
num = (ds*dh*inv).sum(0); den = (dh*dh*inv).sum(0)
nrm = (dfn*dfn*inv).sum(0) # ‖Δ‖² — масштаб неравновесия узла
ok = den > GREL*nrm
den_safe = xp.where(ok, den, xp.asarray(1.0, DTYPE)) # избегаем 0/0 (обе ветки where считаются)
g = xp.where(ok, 1/beta - (2 - 1/beta)*num/den_safe, xp.asarray(2.0, DTYPE))
return f - beta*(2*ds + g[None]*dh)
def stream(f):
fs = xp.empty_like(f)
for i in range(Q):
fs[i] = xp.roll(f[i], (Cy[i], Cx[i]), (0, 1))
return fs
# ---- AMR-связка ----
def patch(pNx, pNy, ax, bx, ay, by, r):
Wx, Wy = bx-ax, by-ay; Nfx, Nfy = r*Wx+1, r*Wy+1
FX, FY = np.meshgrid(ax+np.arange(Nfx)/r, ay+np.arange(Nfy)/r)
x0 = np.floor(FX).astype(np.int64); y0 = np.floor(FY).astype(np.int64)
x1 = np.minimum(x0+1, pNx-1); y1 = np.minimum(y0+1, pNy-1)
return dict(Nfx=Nfx, Nfy=Nfy, ax=ax, bx=bx, ay=ay, by=by, r=r,
x0=xp.asarray(x0), y0=xp.asarray(y0), x1=xp.asarray(x1), y1=xp.asarray(y1),
tx=xp.asarray(FX-x0, DTYPE), ty=xp.asarray(FY-y0, DTYPE),
slx=slice(r, r*Wx, r), sly=slice(r, r*Wy, r))
def pint(fld, P):
return (fld[..., P["y0"], P["x0"]]*(1-P["tx"])*(1-P["ty"]) + fld[..., P["y0"], P["x1"]]*P["tx"]*(1-P["ty"])
+ fld[..., P["y1"], P["x0"]]*(1-P["tx"])*P["ty"] + fld[..., P["y1"], P["x1"]]*P["tx"]*P["ty"])
def ghost(pf, P, Rcf):
r, u = macros(pf); neq = pf - feq(r, u)
return feq(pint(r, P), xp.stack([pint(u[0], P), pint(u[1], P)])) + Rcf*pint(neq, P)
def fill(cf, gh):
cf[:, 0, :] = gh[:, 0, :]; cf[:, -1, :] = gh[:, -1, :]; cf[:, :, 0] = gh[:, :, 0]; cf[:, :, -1] = gh[:, :, -1]
return cf
def restrict(cf, pf, P, Rfc, fluid):
r, u = macros(cf); neq = cf - feq(r, u); slx, sly = P["slx"], P["sly"]
nv = feq(r[sly, slx], u[:, sly, slx]) + Rfc*neq[:, sly, slx]
cur = pf[:, P["ay"]+1:P["by"], P["ax"]+1:P["bx"]]
pf[:, P["ay"]+1:P["by"], P["ax"]+1:P["bx"]] = xp.where(fluid[None], nv, cur)
return pf
# ---- граница: (1) узловой bounce-back (без SDF); (2) SDF+Bouzidi ----
def bb_set(post, pre, solid): # узлы тела <- развёрнутые до-столкновения (как в CPU-версии)
for i in range(Q):
post[i] = xp.where(solid, pre[OPP[i]], post[i])
return post
def force_on(fpost, cyl, fluid): # Mei для узловой границы: F=Σ c_i(f_i+f_ī) по линкам жидк.->тело
Fx = 0.0; Fy = 0.0
for i in range(1, Q):
nb = xp.roll(cyl, (-Cy[i], -Cx[i]), (0, 1)); link = fluid & nb
s = xp.where(link, fpost[i] + fpost[OPP[i]], xp.asarray(0.0, DTYPE)).sum()
Fx = Fx + Cx[i]*s; Fy = Fy + Cy[i]*s
return Fx, Fy
def build_bc(solid_np, phi_np, cyl_np): # SDF-границу строим на host один раз, переносим на device
fluid = ~solid_np; bc = []
for i in range(1, Q):
nb_solid = np.roll(solid_np, (-Cy[i], -Cx[i]), (0, 1)); mask = fluid & nb_solid
cl = mask & np.roll(cyl_np, (-Cy[i], -Cx[i]), (0, 1))
phinb = np.roll(phi_np, (-Cy[i], -Cx[i]), (0, 1))
with np.errstate(divide="ignore", invalid="ignore"):
qq = phi_np/(phi_np - phinb)
q = np.where(mask, np.clip(qq, 0.02, 0.98), 0.5)
xff = mask & (~np.roll(solid_np, (Cy[i], Cx[i]), (0, 1)))
bc.append((i, OPP[i], xp.asarray(mask), xp.asarray(q, DTYPE), xp.asarray(xff), xp.asarray(cl)))
return bc
def apply_bc(f, fpost, bc): # Bouzidi (линейный), полностью через where (GPU)
for (i, ib, mask, q, xff, cl) in bc:
fi = fpost[i]; fib = fpost[ib]; fiback = xp.roll(fpost[i], (Cy[i], Cx[i]), (0, 1))
near = mask & (q < 0.5) & xff; bad = mask & (q < 0.5) & (~xff); far = mask & (q >= 0.5)
new = f[ib]
new = xp.where(near, 2*q*fi + (1-2*q)*fiback, new)
new = xp.where(far, (1/(2*q))*fi + (1-1/(2*q))*fib, new)
new = xp.where(bad, fi, new)
f[ib] = new
return f
def force_bc(fpost, f, bc): # Mei для интерполированной границы
Fx = 0.0; Fy = 0.0
for (i, ib, mask, q, xff, cl) in bc:
s = xp.where(cl, fpost[i] + f[ib], xp.asarray(0.0, DTYPE)).sum()
Fx = Fx + Cx[i]*s; Fy = Fy + Cy[i]*s
return Fx, Fy
# ---- геометрия (ИДЕНТИЧНА CPU-версиям) ----
Nx, Ny, D, cx, cy = 150, 72, 16, 40, 36
U, Re, STEPS, ramp, FE = 0.07, 150.0, int(os.environ.get("AMR_STEPS", 8000)), 1000, 34
D0 = D; nu = U*D/Re; tau0 = nu/CS2 + 0.5
ax1, bx1, ay1, by1 = 16, 118, 8, 64
cx2a, cx2b, cy2a, cy2b = 26, 64, 18, 54
px, py = cx + 3*D, cy
def _cmask(NX, NY, ccx, ccy, R):
Y, X = np.meshgrid(np.arange(NY), np.arange(NX), indexing="ij"); return (X-ccx)**2 + (Y-ccy)**2 <= R*R
def _csdf(NX, NY, ccx, ccy, R):
Y, X = np.meshgrid(np.arange(NY), np.arange(NX), indexing="ij"); return np.sqrt((X-ccx)**2.0 + (Y-ccy)**2.0) - R
# host-маски/SDF
_walls = np.zeros((Ny, Nx), bool); _walls[0, :] = True; _walls[-1, :] = True
cyl0_np = _cmask(Nx, Ny, cx, cy, D/2); solid0_np = cyl0_np | _walls
_Yg = np.arange(Ny)[:, None]*np.ones((1, Nx))
phi0_np = np.minimum(_csdf(Nx, Ny, cx, cy, D/2), np.minimum(_Yg-0.5, (Ny-1.5)-_Yg))
P1 = patch(Nx, Ny, ax1, bx1, ay1, by1, 2); P2 = patch(P1["Nfx"], P1["Nfy"], (cx2a-ax1)*2, (cx2b-ax1)*2, (cy2a-ay1)*2, (cy2b-ay1)*2, 2)
solid1_np = _cmask(P1["Nfx"], P1["Nfy"], (cx-ax1)*2, (cy-ay1)*2, D); phi1_np = _csdf(P1["Nfx"], P1["Nfy"], (cx-ax1)*2, (cy-ay1)*2, D)
solid2_np = _cmask(P2["Nfx"], P2["Nfy"], (cx-cx2a)*4, (cy-cy2a)*4, 2*D); phi2_np = _csdf(P2["Nfx"], P2["Nfy"], (cx-cx2a)*4, (cy-cy2a)*4, 2*D)
# device-маски (без SDF)
g_solid0 = xp.asarray(solid0_np); g_cyl0 = xp.asarray(cyl0_np); g_fl0 = ~g_solid0
g_solid1 = xp.asarray(solid1_np); g_fl1 = ~g_solid1
g_solid2 = xp.asarray(solid2_np); g_fl2 = ~g_solid2
# SDF-ГУ
BC0 = build_bc(solid0_np, phi0_np, cyl0_np); BC1 = build_bc(solid1_np, phi1_np, solid1_np); BC2 = build_bc(solid2_np, phi2_np, solid2_np)
# рестрикция: жидкие узлы
fl1 = xp.asarray(~solid0_np[ay1+1:by1, ax1+1:bx1])
fl2 = xp.ones((P2["by"]-P2["ay"]-1, P2["bx"]-P2["ax"]-1), bool); fl2 = xp.asarray(fl2)
# τ / масштабы
t1 = 2*tau0 - 0.5; t2 = 2*t1 - 0.5
b0 = 1/(2*tau0); b1 = 1/(2*t1); b2 = 1/(2*t2)
R01 = t1/(2*tau0); Rf01 = 1/R01; R12 = t2/(2*t1); Rf12 = 1/R12
def smoothstep(x):
x = min(max(x, 0.0), 1.0); return x*x*(3-2*x)
def run(mode, use_sdf, progress=None):
"""mode: none|amr2x|nested. Возвращает {Fx,Fy,uy (на host), frames (host), DL, mode}.
progress(t, STEPS) — необязательный колбэк прогресса (вызывается каждый шаг)."""
use1 = mode in ("amr2x", "nested"); use2 = (mode == "nested")
DL = {"none": D0, "amr2x": 2*D0, "nested": 4*D0}[mode]
f0 = feq(xp.ones((Ny, Nx), DTYPE), xp.zeros((2, Ny, Nx), DTYPE))
f1 = feq(xp.ones((P1["Nfy"], P1["Nfx"]), DTYPE), xp.zeros((2, P1["Nfy"], P1["Nfx"]), DTYPE)) if use1 else None
f2 = feq(xp.ones((P2["Nfy"], P2["Nfx"]), DTYPE), xp.zeros((2, P2["Nfy"], P2["Nfx"]), DTYPE)) if use2 else None
Fx = xp.zeros(STEPS+1, DTYPE); Fy = xp.zeros(STEPS+1, DTYPE); uy = xp.zeros(STEPS+1, DTYPE)
onecol = xp.ones((Ny, 1), DTYPE); frames = []
for t in range(STEPS+1):
rho, u0 = macros(f0)
if t % 500 == 0 and not bool(xp.isfinite(rho).all()):
print("BLEW UP", mode, t); Fx = Fx[:t]; Fy = Fy[:t]; uy = uy[:t]; break
pre = f0.copy(); post0 = collide(f0, feq(rho, u0), b0)
if not use_sdf:
post0b = bb_set(post0.copy(), pre, g_solid0); f0 = stream(post0b)
else:
f0 = stream(post0); f0 = apply_bc(f0, post0, BC0)
if mode == "none":
Fx[t], Fy[t] = (force_bc(post0, f0, BC0) if use_sdf else force_on(post0, g_cyl0, g_fl0))
uin = xp.zeros((2, Ny, 1), DTYPE); uin[0, :, 0] = U*smoothstep(t/ramp)
f0[:, 1:-1, 0] = feq(onecol, uin)[:, 1:-1, 0]; f0[:, 1:-1, -1] = f0[:, 1:-1, -2]
if use1:
g1o = ghost(pre, P1, R01); g1n = ghost(f0, P1, R01)
for s1 in range(2):
pre1 = f1.copy(); r1, u1 = macros(f1); post1 = collide(f1, feq(r1, u1), b1)
if not use_sdf:
f1 = stream(bb_set(post1.copy(), pre1, g_solid1))
else:
f1 = stream(post1); f1 = apply_bc(f1, post1, BC1)
if mode == "amr2x" and s1 == 1:
Fx[t], Fy[t] = (force_bc(post1, f1, BC1) if use_sdf else force_on(post1, g_solid1, g_fl1))
f1 = fill(f1, (1-(s1+1)/2)*g1o + ((s1+1)/2)*g1n)
if use2:
g2o = ghost(pre1, P2, R12); g2n = ghost(f1, P2, R12)
for s2 in range(2):
pre2 = f2.copy(); r2, u2 = macros(f2); post2 = collide(f2, feq(r2, u2), b2)
if not use_sdf:
f2 = stream(bb_set(post2.copy(), pre2, g_solid2))
else:
f2 = stream(post2); f2 = apply_bc(f2, post2, BC2)
if s1 == 1 and s2 == 1:
Fx[t], Fy[t] = (force_bc(post2, f2, BC2) if use_sdf else force_on(post2, g_solid2, g_fl2))
f2 = fill(f2, (1-(s2+1)/2)*g2o + ((s2+1)/2)*g2n)
f1 = restrict(f2, f1, P2, Rf12, fl2)
f0 = restrict(f1, f0, P1, Rf01, fl1)
col = f0[:, py, px]; uy[t] = (CYa*col).sum()/col.sum() # зонд следа (без full-macros)
if progress is not None: progress(t, STEPS)
if use1 and t % FE == 0:
frames.append((t, to_cpu(macros(f0)[1]), to_cpu(macros(f1)[1]),
(to_cpu(macros(f2)[1]) if use2 else None)))
return dict(mode=mode, DL=DL, Fx=to_cpu(Fx), Fy=to_cpu(Fy), uy=to_cpu(uy), frames=frames)
# ---- анализ (на host: FFT малых рядов) ----
def analyze(res):
Fx, Fy, uy, DL = res["Fx"], res["Fy"], res["uy"], res["DL"]
n = len(uy); h = slice(n//2, n); Cd = 2*Fx/(U**2*DL); Cl = 2*Fy/(U**2*DL)
sig = uy[h] - uy[h].mean(); npad = 8192
freqs = np.fft.rfftfreq(npad, 1.0); amp = np.abs(np.fft.rfft(sig, n=npad))
St = (freqs[1+int(np.argmax(amp[1:]))] if len(amp) > 2 else 0.0)*D0/U
return dict(Cd=float(Cd[h].mean()), Clrms=float(Cl[h].std()), St=float(St), uyrms=float(uy[h].std()),
Cl_s=Cl, uy=uy, freqs=freqs, amp=amp, h0=n//2)
# ---- визуализация (matplotlib на CPU; кадры уже host) ----
def _vort_np(u):
return (np.roll(u[1], -1, 1)-np.roll(u[1], 1, 1))*0.5 - (np.roll(u[0], -1, 0)-np.roll(u[0], 1, 0))*0.5
def save_gif(res, name, anim_dir, sdf_tag, fps=24):
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt, matplotlib.patches as mpatches
from PIL import Image
nested = (res["mode"] == "nested"); frames = res["frames"]
vm = np.nanpercentile(np.abs(np.where(solid0_np, np.nan, _vort_np(frames[len(frames)//2][1]))), 99.0)
cm = plt.get_cmap("inferno").copy(); cm.set_bad("white"); pil = []
for (t, u0, u1, u2) in frames:
fig = plt.figure(figsize=(11.2, 5.7), dpi=96); ax = fig.add_subplot(111)
ax.imshow(np.abs(np.where(solid0_np, np.nan, _vort_np(u0))), origin="lower", cmap=cm, vmin=0, vmax=vm,
extent=(0, Nx, 0, Ny), interpolation="nearest")
ax.imshow(np.abs(np.where(solid1_np, np.nan, _vort_np(u1)))[1:-1, 1:-1], origin="lower", cmap=cm, vmin=0, vmax=vm,
extent=(ax1+0.5, bx1-0.5, ay1+0.5, by1-0.5), interpolation="nearest")
if nested and u2 is not None:
ax.imshow(np.abs(np.where(solid2_np, np.nan, _vort_np(u2)))[1:-1, 1:-1], origin="lower", cmap=cm, vmin=0, vmax=vm,
extent=(cx2a+0.25, cx2b-0.25, cy2a+0.25, cy2b-0.25), interpolation="nearest")
ax.add_patch(mpatches.Rectangle((0.2, 0.2), Nx-0.4, Ny-0.4, fill=False, edgecolor="#4fc3f7", lw=1.6))
ax.text(1.5, Ny-4, "уровень 0: Δx (крупный)", color="#4fc3f7", fontsize=9, weight="bold")
ax.add_patch(mpatches.Rectangle((ax1, ay1), bx1-ax1, by1-ay1, fill=False, edgecolor="#aed581", lw=2.2))
ax.text(ax1+1, by1-3.5, "уровень 1: Δx/2 (тело+след)", color="#aed581", fontsize=9, weight="bold")
if nested:
ax.add_patch(mpatches.Rectangle((cx2a, cy2a), cx2b-cx2a, cy2b-cy2a, fill=False, edgecolor="#ff8a65", lw=2.2))
ax.text(cx2a+1, cy2b-3.5, "уровень 2: Δx/4 (у тела)", color="#ff8a65", fontsize=9, weight="bold")
base = "Вложенный AMR" if nested else "AMR (одиночный блок 2×)"
ax.set_title(f"{base}{sdf_tag} · поверх $|\\omega|$ · t={t}", fontsize=11)
ax.set_xlabel("x [lu]"); ax.set_ylabel("y [lu]"); ax.set_xlim(0, Nx); ax.set_ylim(0, Ny)
buf = _io.BytesIO(); fig.savefig(buf, format="png", bbox_inches="tight"); buf.seek(0); plt.close(fig)
pil.append(Image.open(buf).convert("RGB").convert("P", palette=Image.ADAPTIVE))
path = os.path.join(anim_dir, name)
pil[0].save(path, save_all=True, append_images=pil[1:], duration=int(1000/fps), loop=0, optimize=True)
print("saved", name, len(frames), "кадров")
LAB = ["без AMR (D=16)", "AMR 2× (D=32)", "AMR 2×+4× (D=64)"]
def print_table(AS, header):
print(f"\n=== {header} ===")
print(f"{'версия':22}{'D у тела':>9}{'St':>8}{'<Cd>':>9}{'rms Cl':>9}{'rms u_y':>9}")
for lab, DL, a in zip(LAB, [16, 32, 64], AS):
print(f"{lab:22}{DL:>9}{a['St']:>8.3f}{a['Cd']:>9.3f}{a['Clrms']:>9.4f}{a['uyrms']:>9.4f}")
print(f"{'литература Re≈150':22}{'—':>9}{0.183:>8.3f}{1.330:>9.3f}{'~0.3':>9}{'':>9}")
print("(лит.: St≈0.18, Cd≈1.3 для цилиндра при Re≈150; Williamson 1996, Henderson 1995)")
def probe_figure(AS, R2, path, suptitle):
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt, matplotlib.patches as mpatches
cols = ["#9e9e9e", "#1f6feb", "#d1495b"]
fig = plt.figure(figsize=(13.5, 8.2)); gs = fig.add_gridspec(2, 2, hspace=0.32, wspace=0.22)
axA = fig.add_subplot(gs[0, 0]); mid = R2["frames"][len(R2["frames"])//2]
vmf = np.nanpercentile(np.abs(np.where(solid0_np, np.nan, _vort_np(mid[1]))), 99)
cmf = plt.get_cmap("inferno").copy(); cmf.set_bad("white")
axA.imshow(np.abs(np.where(solid0_np, np.nan, _vort_np(mid[1]))), origin="lower", cmap=cmf, vmin=0, vmax=vmf,
extent=(0, Nx, 0, Ny), interpolation="nearest")
axA.add_patch(mpatches.Rectangle((ax1, ay1), bx1-ax1, by1-ay1, fill=False, edgecolor="#aed581", lw=1.8))
axA.add_patch(mpatches.Rectangle((cx2a, cy2a), cx2b-cx2a, cy2b-cy2a, fill=False, edgecolor="#ff8a65", lw=1.8))
axA.set_title("$|\\omega|$ + рамки зон 2×/4×"); axA.set_xlabel("x [lu]"); axA.set_ylabel("y [lu]")
axB = fig.add_subplot(gs[0, 1])
for a, lab, c in zip(AS, LAB, cols): axB.plot(a["Cl_s"][a["h0"]:], lw=0.8, color=c, label=lab)
axB.set_title("Подъёмная сила $C_l(t)$ (зонд силы)"); axB.set_xlabel("шаг"); axB.set_ylabel("$C_l$")
axB.legend(fontsize=8); axB.grid(alpha=0.3)
axC = fig.add_subplot(gs[1, 0])
for a, lab, c in zip(AS, LAB, cols):
sp = a["amp"]/a["amp"][1:].max(); axC.plot(a["freqs"]*D0/U, sp, lw=1.0, color=c, label=lab)
axC.axvline(0.183, color="k", ls="--", alpha=0.6, label="лит. St≈0.18"); axC.set_xlim(0, 0.6)
axC.set_title("Спектр $u_y$ в следе → St"); axC.set_xlabel("St = f·D/U"); axC.set_ylabel("норм. ампл.")
axC.legend(fontsize=8); axC.grid(alpha=0.3)
axD = fig.add_subplot(gs[1, 1]); x = np.arange(3); w = 0.35
axD.bar(x-w/2, [a["St"] for a in AS], w, color="#1f6feb", label="St")
axD.bar(x+w/2, [a["Cd"] for a in AS], w, color="#fb8c00", label="<Cd>")
axD.axhline(0.183, color="#1f6feb", ls="--", alpha=0.6); axD.axhline(1.33, color="#fb8c00", ls="--", alpha=0.6)
axD.set_xticks(x); axD.set_xticklabels(["без AMR", "2×", "2×+4×"]); axD.set_title("St и <Cd> (пунктир — лит.)")
axD.legend(fontsize=8); axD.grid(alpha=0.3, axis="y")
fig.suptitle(suptitle, fontweight="bold"); fig.savefig(path, dpi=110, bbox_inches="tight"); plt.close(fig)
print("saved", os.path.basename(path))
@@ -0,0 +1,36 @@
# ОПТИМИЗИРОВАННЫЙ GPU-решатель (без SDF): цилиндр, три версии (без AMR / 2× / вложенный 2×+4×).
# То же, что amr_cylinder_gpu.py, но: GPU-only ядро _opt (фикс силового зонда + ускорение),
# прогресс-бар на каждый прогон, гифки для ВСЕХ трёх режимов (none/amr2x/nested).
# Запуск: python amr_cylinder_gpu_opt.py
# AMR_FP64=1 ... (двойная точность)
# AMR_STEPS=2000 ... (короткий прогон)
# AMR_CUDAGRAPH=0 ... (выключить CUDA Graphs; по умолчанию ВКЛ, с рантайм-самопроверкой)
import os, time, numpy as np
import amr_gpu_core_opt as core
HERE = os.path.dirname(os.path.abspath(__file__))
ANIM = os.path.join(HERE, "anim"); os.makedirs(ANIM, exist_ok=True)
FIGS = os.path.join(HERE, "figures"); os.makedirs(FIGS, exist_ok=True)
print(f"[ОПТ · без SDF] backend={core.BACKEND}; домен {core.Nx}x{core.Ny}; steps={core.STEPS}; "
f"graphs={'on' if os.environ.get('AMR_CUDAGRAPH','1') != '0' else 'off'}; "
f"L1(2×) {core.P1['Nfx']}x{core.P1['Nfy']}; L2(4×) {core.P2['Nfx']}x{core.P2['Nfy']}")
t0 = time.perf_counter()
R0 = core.run("none", False, core.make_progress("none")); print(" none :", round(time.perf_counter()-t0, 1), "s")
R1 = core.run("amr2x", False, core.make_progress("amr2x")); print(" amr2x :", round(time.perf_counter()-t0, 1), "s")
R2 = core.run("nested", False, core.make_progress("nested")); print(" nested :", round(time.perf_counter()-t0, 1), "s")
core.save_gif(R0, "none_cyl.gif", ANIM, " (без SDF)")
core.save_gif(R1, "amr2x_cyl.gif", ANIM, " (без SDF)")
core.save_gif(R2, "amr_nested_cyl.gif", ANIM, " (без SDF)")
AS = [core.analyze(R0), core.analyze(R1), core.analyze(R2)]
core.print_table(AS, "БЕЗ SDF — сравнение на реально собранных данных (установившийся режим)")
np.savez(os.path.join(HERE, "_amr_probe_data.npz"),
St=np.array([a["St"] for a in AS]), Cd=np.array([a["Cd"] for a in AS]),
Clrms=np.array([a["Clrms"] for a in AS]), uyrms=np.array([a["uyrms"] for a in AS]),
Cl0=AS[0]["Cl_s"], Cl1=AS[1]["Cl_s"], Cl2=AS[2]["Cl_s"],
uy0=AS[0]["uy"], uy1=AS[1]["uy"], uy2=AS[2]["uy"])
core.probe_figure(AS, R2, os.path.join(FIGS, "11e_amr_probe_comparison.png"),
"AMR на цилиндре (без SDF): сравнение трёх версий на данных зондов")
print("DONE (без SDF)")
@@ -0,0 +1,63 @@
# ОПТИМИЗИРОВАННЫЙ GPU-решатель (с SDF + Bouzidi): цилиндр, три версии (без AMR / 2× / 2×+4×).
# То же, что amr_cylinder_sdf_gpu.py, но: GPU-only ядро _opt (ускорение), прогресс-бар на
# каждый прогон, гифки для ВСЕХ трёх режимов (none/amr2x/nested). В конце — сводное сравнение с без-SDF.
# Запуск: python amr_cylinder_sdf_gpu_opt.py (после amr_cylinder_gpu_opt.py — для сводного сравнения)
# AMR_CUDAGRAPH=0 ... (выключить CUDA Graphs; по умолчанию ВКЛ, с рантайм-самопроверкой)
import os, time, numpy as np
import amr_gpu_core_opt as core
HERE = os.path.dirname(os.path.abspath(__file__))
ANIM = os.path.join(HERE, "anim"); os.makedirs(ANIM, exist_ok=True)
FIGS = os.path.join(HERE, "figures"); os.makedirs(FIGS, exist_ok=True)
print(f"[ОПТ · SDF+Bouzidi] backend={core.BACKEND}; домен {core.Nx}x{core.Ny}; steps={core.STEPS}; "
f"graphs={'on' if os.environ.get('AMR_CUDAGRAPH','1') != '0' else 'off'}; "
f"L1(2×) {core.P1['Nfx']}x{core.P1['Nfy']}; L2(4×) {core.P2['Nfx']}x{core.P2['Nfy']}")
t0 = time.perf_counter()
R0 = core.run("none", True, core.make_progress("none")); print(" none :", round(time.perf_counter()-t0, 1), "s")
R1 = core.run("amr2x", True, core.make_progress("amr2x")); print(" amr2x :", round(time.perf_counter()-t0, 1), "s")
R2 = core.run("nested", True, core.make_progress("nested")); print(" nested :", round(time.perf_counter()-t0, 1), "s")
core.save_gif(R0, "none_cyl_sdf.gif", ANIM, " + SDF (Bouzidi)")
core.save_gif(R1, "amr2x_cyl_sdf.gif", ANIM, " + SDF (Bouzidi)")
core.save_gif(R2, "amr_nested_cyl_sdf.gif", ANIM, " + SDF (Bouzidi)")
AS = [core.analyze(R0), core.analyze(R1), core.analyze(R2)]
core.print_table(AS, "С SDF — сравнение на реально собранных данных (установившийся режим)")
np.savez(os.path.join(HERE, "_amr_probe_data_sdf.npz"),
St=np.array([a["St"] for a in AS]), Cd=np.array([a["Cd"] for a in AS]),
Clrms=np.array([a["Clrms"] for a in AS]), uyrms=np.array([a["uyrms"] for a in AS]),
Cl0=AS[0]["Cl_s"], Cl1=AS[1]["Cl_s"], Cl2=AS[2]["Cl_s"],
uy0=AS[0]["uy"], uy1=AS[1]["uy"], uy2=AS[2]["uy"])
core.probe_figure(AS, R2, os.path.join(FIGS, "11f_amr_sdf_probe_comparison.png"),
"AMR на цилиндре (SDF+Bouzidi): сравнение трёх версий на данных зондов")
# ---- сводное сравнение: SDF против без-SDF (если есть свежие данные без SDF) ----
nosdf = os.path.join(HERE, "_amr_probe_data.npz")
if os.path.exists(nosdf) and ("St" in np.load(nosdf).files):
import matplotlib; matplotlib.use("Agg"); import matplotlib.pyplot as plt
N = np.load(nosdf); Stn, Cdn = N["St"], N["Cd"]
Sts = np.array([a["St"] for a in AS]); Cds = np.array([a["Cd"] for a in AS])
print("\n=== СВОДНО: SDF vs без SDF (Cd, St) ===")
print(f"{'версия':14}{'St(noSDF)':>11}{'St(SDF)':>9}{'Cd(noSDF)':>11}{'Cd(SDF)':>9}")
for k, lab in enumerate(["без AMR", "2×", "2×+4×"]):
print(f"{lab:14}{Stn[k]:>11.3f}{Sts[k]:>9.3f}{Cdn[k]:>11.3f}{Cds[k]:>9.3f}")
print(f"{'лит. Re≈150':14}{0.183:>11.3f}{0.183:>9.3f}{1.330:>11.3f}{1.330:>9.3f}")
x = np.arange(3); w = 0.38
fig, axs = plt.subplots(1, 2, figsize=(13.5, 4.6))
axs[0].bar(x-w/2, Cdn, w, color="#9e9e9e", label="без SDF (ступенч.)")
axs[0].bar(x+w/2, Cds, w, color="#1f6feb", label="с SDF (Bouzidi)")
axs[0].axhline(1.33, color="k", ls="--", alpha=0.6, label="лит. Cd≈1.33")
axs[0].set_xticks(x); axs[0].set_xticklabels(["без AMR", "2×", "2×+4×"]); axs[0].set_ylabel("<Cd>")
axs[0].set_title("Сопротивление: SDF убирает завышение от ступенчатой границы")
axs[0].legend(fontsize=8); axs[0].grid(alpha=0.3, axis="y")
axs[1].bar(x-w/2, Stn, w, color="#9e9e9e", label="без SDF"); axs[1].bar(x+w/2, Sts, w, color="#1f6feb", label="с SDF")
axs[1].axhline(0.183, color="k", ls="--", alpha=0.6, label="лит. St≈0.18")
axs[1].set_xticks(x); axs[1].set_xticklabels(["без AMR", "2×", "2×+4×"]); axs[1].set_ylabel("St")
axs[1].set_title("Число Струхаля"); axs[1].legend(fontsize=8); axs[1].grid(alpha=0.3, axis="y")
fig.suptitle("Цилиндр: SDF (Bouzidi) против ступенчатого bounce-back — на реальных данных", fontweight="bold")
fig.savefig(os.path.join(FIGS, "11g_sdf_vs_nosdf.png"), dpi=110, bbox_inches="tight"); plt.close(fig)
print("saved 11g_sdf_vs_nosdf.png")
else:
print("\n(нет свежего _amr_probe_data.npz — сначала запусти amr_cylinder_gpu_opt.py)")
print("DONE (SDF)")
+448
View File
@@ -0,0 +1,448 @@
# Оптимизированное GPU-ЯДРО (D2Q9 KBC) для AMR-решателей на цилиндре. GPU-only (CuPy).
# Отличия от amr_gpu_core.py:
# * УБРАН numpy-фолбэк: backend всегда CuPy/GPU (иначе жёсткая ошибка). numpy остаётся
# только для host-задач (маски/SDF/базис один раз, FFT в analyze, matplotlib/PIL).
# * ФИКС силового зонда force_on: второй член обмена импульсом берётся из поля ПОСЛЕ
# стриминга (как в корректном force_bc), а не из post-collision. Раньше это давало
# нефизичный отрицательный Cd и крошечный rms Cl.
# * feq без pow (показатели c∈{-1,0,1}) + cp.fuse; фьюзинг хвоста collide.
# * Предвычисленные маски линков для силы (без per-step roll статических масок).
# * run(): один шаг = замкнутая _step() над persistent-буферами; опционально CUDA Graphs
# (AMR_CUDAGRAPH=1, по умолчанию вкл) с автоматическим откатом на eager.
# * Кадры для всех трёх режимов (none/amr2x/nested); save_gif рисует и режим none.
# * make_progress — общий прогресс-бар в ядре.
# Запуск через раннеры: amr_cylinder_gpu_opt.py (без SDF) и amr_cylinder_sdf_gpu_opt.py (SDF).
import os, io as _io, numpy as np
# ---- backend: ТОЛЬКО CuPy/GPU (фолбэк удалён) ----
import cupy as cp
try:
_ = (cp.zeros(2) + 1).sum(); cp.cuda.Device().synchronize() # реальная операция на device
except Exception as e:
raise RuntimeError("Этому решателю нужна рабочая GPU + CuPy: numpy-фолбэк удалён.") from e
xp = cp; GPU = True
def to_cpu(a):
return cp.asnumpy(a)
DTYPE = cp.float64 if os.environ.get("AMR_FP64") else cp.float32
GREL = 1e-8 # ОТНОСИТЕЛЬНЫЙ порог вырожденности знаменателя γ (доля от ‖Δ‖²); см. solver_2x_sdf/backend.py.
# Прежний абсолютный порог (1e-6 в fp32) срабатывал на большинстве узлов и подменял KBC на LBGK.
BACKEND = f"CuPy/GPU dtype={'float64' if DTYPE == cp.float64 else 'float32'}"
# ---- решётка D2Q9 ----
Cx = [0, 1, 0, -1, 0, 1, -1, -1, 1] # python-инты для roll/индексации
Cy = [0, 0, 1, 0, -1, 1, 1, -1, -1]
OPP = [0, 3, 4, 1, 2, 7, 8, 5, 6]
Q = 9
_W = np.array([4/9, 1/9, 1/9, 1/9, 1/9, 1/36, 1/36, 1/36, 1/36])
_CXn = np.array(Cx, float); _CYn = np.array(Cy, float)
# моментный базис и проектор на сдвиг {cx²−cy², cxcy} (KBC-N1) — считаем в numpy, грузим на device
_M = np.zeros((Q, Q)); _M[0] = 1; _M[1] = _CXn; _M[2] = _CYn; _M[3] = 3*(_CXn**2+_CYn**2)-2
_M[4] = _CXn**2-_CYn**2; _M[5] = _CXn*_CYn; _M[6] = _CXn**2*_CYn; _M[7] = _CXn*_CYn**2; _M[8] = _CXn**2*_CYn**2
_Dm = np.zeros((Q, Q)); _Dm[4, 4] = _Dm[5, 5] = 1.0
_Psn = np.linalg.inv(_M) @ _Dm @ _M
# device-константы
Wa = xp.asarray(_W, dtype=DTYPE)
CXa = xp.asarray(_CXn, dtype=DTYPE); CYa = xp.asarray(_CYn, dtype=DTYPE)
Ps = xp.asarray(_Psn, dtype=DTYPE)
CS2 = 1.0/3.0
# device-скаляры (чтобы не делать host→device asarray на горячем пути и не ломать graph-capture)
_Z0 = xp.zeros((), DTYPE); _ONE = xp.asarray(1.0, DTYPE); _TWO = xp.asarray(2.0, DTYPE)
# ---- ядро LBM/KBC (математика идентична; feq без pow) ----
def _feq9_body(rho, ux, uy):
# равновесие product-form для D2Q9: показатели c∈{-1,0,1} → используем qx/iqx вместо pow.
# clip входит в ТЕЛО ядра через min/max c литералами (запекаются в кернел → нет H2D при capture).
ux = cp.minimum(cp.maximum(ux, -0.95), 0.95); uy = cp.minimum(cp.maximum(uy, -0.95), 0.95)
sx = cp.sqrt(1.0 + 3.0*ux*ux); sy = cp.sqrt(1.0 + 3.0*uy*uy)
base = rho*(2.0 - sx)*(2.0 - sy)
qx = (2.0*ux + sx)/(1.0 - ux); qy = (2.0*uy + sy)/(1.0 - uy)
ix = 1.0/qx; iy = 1.0/qy
return (base*(4.0/9.0), # ( 0, 0)
base*(1.0/9.0)*qx, base*(1.0/9.0)*qy, # (+1,0) (0,+1)
base*(1.0/9.0)*ix, base*(1.0/9.0)*iy, # (-1,0) (0,-1)
base*(1.0/36.0)*qx*qy, base*(1.0/36.0)*ix*qy, # (+1,+1) (-1,+1)
base*(1.0/36.0)*ix*iy, base*(1.0/36.0)*qx*iy) # (-1,-1) (+1,-1)
_feq9 = cp.fuse()(_feq9_body) # одно слитное ядро (если cp.fuse доступен)
_FUSE_OK = True
def feq(rho, u):
global _FUSE_OK
if _FUSE_OK:
try:
return xp.stack(_feq9(rho, u[0], u[1]), axis=0)
except Exception as e: # откат на не-fused (тоже без pow)
print(f"[feq] cp.fuse отключён ({type(e).__name__}: {e}); обычный путь без pow")
_FUSE_OK = False
return xp.stack(_feq9_body(rho, u[0], u[1]), axis=0)
def macros(f):
r = f.sum(0)
return r, xp.stack([(CXa[:, None, None]*f).sum(0)/r, (CYa[:, None, None]*f).sum(0)/r])
def collide(f, fe, beta):
dfn = f - fe
# проектор на сдвиг БЕЗ cuBLAS (gemm при capture тащит alpha/beta host→device → H2D):
dfn2d = dfn.reshape(Q, -1) # (Q, N)
ds = (Ps[:, :, None] * dfn2d[None]).sum(1).reshape(dfn.shape) # Σ_k Ps[i,k]·dfn[k] — чистые ядра
dh = dfn - ds; inv = 1.0/fe
num = (ds*dh*inv).sum(0); den = (dh*dh*inv).sum(0)
nrm = (dfn*dfn*inv).sum(0) # ‖Δ‖² — масштаб неравновесия узла
ok = den > GREL*nrm
den_safe = xp.where(ok, den, _ONE) # избегаем 0/0
binv = 1.0/beta
g = xp.where(ok, binv - (2.0 - binv)*num/den_safe, _TWO)
return f - beta*(2.0*ds + g[None]*dh)
def stream(f):
fs = xp.empty_like(f)
for i in range(Q):
fs[i] = xp.roll(f[i], (Cy[i], Cx[i]), (0, 1))
return fs
# ---- AMR-связка ----
def patch(pNx, pNy, ax, bx, ay, by, r):
Wx, Wy = bx-ax, by-ay; Nfx, Nfy = r*Wx+1, r*Wy+1
FX, FY = np.meshgrid(ax+np.arange(Nfx)/r, ay+np.arange(Nfy)/r)
x0 = np.floor(FX).astype(np.int64); y0 = np.floor(FY).astype(np.int64)
x1 = np.minimum(x0+1, pNx-1); y1 = np.minimum(y0+1, pNy-1)
return dict(Nfx=Nfx, Nfy=Nfy, ax=ax, bx=bx, ay=ay, by=by, r=r,
x0=xp.asarray(x0), y0=xp.asarray(y0), x1=xp.asarray(x1), y1=xp.asarray(y1),
tx=xp.asarray(FX-x0, DTYPE), ty=xp.asarray(FY-y0, DTYPE),
slx=slice(r, r*Wx, r), sly=slice(r, r*Wy, r))
def pint(fld, P):
return (fld[..., P["y0"], P["x0"]]*(1-P["tx"])*(1-P["ty"]) + fld[..., P["y0"], P["x1"]]*P["tx"]*(1-P["ty"])
+ fld[..., P["y1"], P["x0"]]*(1-P["tx"])*P["ty"] + fld[..., P["y1"], P["x1"]]*P["tx"]*P["ty"])
def ghost(pf, P, Rcf):
r, u = macros(pf); neq = pf - feq(r, u)
return feq(pint(r, P), xp.stack([pint(u[0], P), pint(u[1], P)])) + Rcf*pint(neq, P)
def fill(cf, gh):
cf[:, 0, :] = gh[:, 0, :]; cf[:, -1, :] = gh[:, -1, :]; cf[:, :, 0] = gh[:, :, 0]; cf[:, :, -1] = gh[:, :, -1]
return cf
def restrict(cf, pf, P, Rfc, fluid):
r, u = macros(cf); neq = cf - feq(r, u); slx, sly = P["slx"], P["sly"]
nv = feq(r[sly, slx], u[:, sly, slx]) + Rfc*neq[:, sly, slx]
cur = pf[:, P["ay"]+1:P["by"], P["ax"]+1:P["bx"]]
pf[:, P["ay"]+1:P["by"], P["ax"]+1:P["bx"]] = xp.where(fluid[None], nv, cur)
return pf
# ---- граница: (1) узловой bounce-back (без SDF); (2) SDF+Bouzidi ----
def bb_set(post, pre, solid): # узлы тела <- развёрнутые до-столкновения
for i in range(Q):
post[i] = xp.where(solid, pre[OPP[i]], post[i])
return post
def build_links(solid_np, body_np): # предвычисление масок линков жидкость->тело (статичны)
fluid = ~solid_np; links = []
for i in range(1, Q):
nb = np.roll(body_np, (-Cy[i], -Cx[i]), (0, 1)); mask = fluid & nb
links.append((i, OPP[i], Cx[i], Cy[i], xp.asarray(mask, DTYPE)))
return links
def force_on(fpost, fnew, links): # Mei (узловая граница): F=Σ c_i (f_i^после-столкн. + f_ī^после-стриминга)
Fx = _Z0; Fy = _Z0
for (i, ib, cx_i, cy_i, maskf) in links:
s = (maskf*(fpost[i] + fnew[ib])).sum()
Fx = Fx + cx_i*s; Fy = Fy + cy_i*s
return Fx, Fy
def build_bc(solid_np, phi_np, cyl_np): # SDF-границу строим на host один раз, переносим на device
fluid = ~solid_np; bc = []
for i in range(1, Q):
nb_solid = np.roll(solid_np, (-Cy[i], -Cx[i]), (0, 1)); mask = fluid & nb_solid
cl = mask & np.roll(cyl_np, (-Cy[i], -Cx[i]), (0, 1))
phinb = np.roll(phi_np, (-Cy[i], -Cx[i]), (0, 1))
with np.errstate(divide="ignore", invalid="ignore"):
qq = phi_np/(phi_np - phinb)
q = np.where(mask, np.clip(qq, 0.02, 0.98), 0.5)
xff = mask & (~np.roll(solid_np, (Cy[i], Cx[i]), (0, 1)))
bc.append((i, OPP[i], xp.asarray(mask), xp.asarray(q, DTYPE), xp.asarray(xff), xp.asarray(cl)))
return bc
def apply_bc(f, fpost, bc): # Bouzidi (линейный), полностью через where (GPU)
for (i, ib, mask, q, xff, cl) in bc:
fi = fpost[i]; fib = fpost[ib]; fiback = xp.roll(fpost[i], (Cy[i], Cx[i]), (0, 1))
near = mask & (q < 0.5) & xff; bad = mask & (q < 0.5) & (~xff); far = mask & (q >= 0.5)
new = f[ib]
new = xp.where(near, 2*q*fi + (1-2*q)*fiback, new)
new = xp.where(far, (1/(2*q))*fi + (1-1/(2*q))*fib, new)
new = xp.where(bad, fi, new)
f[ib] = new
return f
def force_bc(fpost, f, bc): # Mei для интерполированной границы (уже корректно: f[ib] — после стриминга)
Fx = _Z0; Fy = _Z0
for (i, ib, mask, q, xff, cl) in bc:
s = xp.where(cl, fpost[i] + f[ib], _Z0).sum()
Fx = Fx + Cx[i]*s; Fy = Fy + Cy[i]*s
return Fx, Fy
# ---- геометрия (ИДЕНТИЧНА базовым версиям) ----
Nx, Ny, D, cx, cy = 150, 72, 16, 40, 36
U, Re, STEPS, ramp, FE = 0.07, 150.0, int(os.environ.get("AMR_STEPS", 8000)), 1000, 34
D0 = D; nu = U*D/Re; tau0 = nu/CS2 + 0.5
ax1, bx1, ay1, by1 = 16, 118, 8, 64
cx2a, cx2b, cy2a, cy2b = 26, 64, 18, 54
px, py = cx + 3*D, cy
def _cmask(NX, NY, ccx, ccy, R):
Y, X = np.meshgrid(np.arange(NY), np.arange(NX), indexing="ij"); return (X-ccx)**2 + (Y-ccy)**2 <= R*R
def _csdf(NX, NY, ccx, ccy, R):
Y, X = np.meshgrid(np.arange(NY), np.arange(NX), indexing="ij"); return np.sqrt((X-ccx)**2.0 + (Y-ccy)**2.0) - R
# host-маски/SDF
_walls = np.zeros((Ny, Nx), bool); _walls[0, :] = True; _walls[-1, :] = True
cyl0_np = _cmask(Nx, Ny, cx, cy, D/2); solid0_np = cyl0_np | _walls
_Yg = np.arange(Ny)[:, None]*np.ones((1, Nx))
phi0_np = np.minimum(_csdf(Nx, Ny, cx, cy, D/2), np.minimum(_Yg-0.5, (Ny-1.5)-_Yg))
P1 = patch(Nx, Ny, ax1, bx1, ay1, by1, 2); P2 = patch(P1["Nfx"], P1["Nfy"], (cx2a-ax1)*2, (cx2b-ax1)*2, (cy2a-ay1)*2, (cy2b-ay1)*2, 2)
solid1_np = _cmask(P1["Nfx"], P1["Nfy"], (cx-ax1)*2, (cy-ay1)*2, D); phi1_np = _csdf(P1["Nfx"], P1["Nfy"], (cx-ax1)*2, (cy-ay1)*2, D)
solid2_np = _cmask(P2["Nfx"], P2["Nfy"], (cx-cx2a)*4, (cy-cy2a)*4, 2*D); phi2_np = _csdf(P2["Nfx"], P2["Nfy"], (cx-cx2a)*4, (cy-cy2a)*4, 2*D)
# device-маски (без SDF)
g_solid0 = xp.asarray(solid0_np); g_cyl0 = xp.asarray(cyl0_np); g_fl0 = ~g_solid0
g_solid1 = xp.asarray(solid1_np); g_fl1 = ~g_solid1
g_solid2 = xp.asarray(solid2_np); g_fl2 = ~g_solid2
# предвычисленные маски линков для силы (узловая граница)
L0_links = build_links(solid0_np, cyl0_np)
L1_links = build_links(solid1_np, solid1_np)
L2_links = build_links(solid2_np, solid2_np)
# SDF-ГУ
BC0 = build_bc(solid0_np, phi0_np, cyl0_np); BC1 = build_bc(solid1_np, phi1_np, solid1_np); BC2 = build_bc(solid2_np, phi2_np, solid2_np)
# рестрикция: жидкие узлы
fl1 = xp.asarray(~solid0_np[ay1+1:by1, ax1+1:bx1])
fl2 = xp.ones((P2["by"]-P2["ay"]-1, P2["bx"]-P2["ax"]-1), bool); fl2 = xp.asarray(fl2)
# τ / масштабы
t1 = 2*tau0 - 0.5; t2 = 2*t1 - 0.5
b0 = 1/(2*tau0); b1 = 1/(2*t1); b2 = 1/(2*t2)
R01 = t1/(2*tau0); Rf01 = 1/R01; R12 = t2/(2*t1); Rf12 = 1/R12
# ---- прогресс-бар (ASCII-шкала по выполненным шагам из заданных) ----
import time as _time
def make_progress(label, width=32, every=0.1):
st = {"t0": _time.perf_counter(), "last": 0.0}
def cb(t, total):
now = _time.perf_counter(); done = (t >= total)
if not done and (now - st["last"] < every): # троттлинг, чтобы не спамить консоль
return
st["last"] = now; frac = (t + 1) / (total + 1)
filled = int(round(width * frac)); bar = "#" * filled + "-" * (width - filled)
el = now - st["t0"]; eta = (el / frac - el) if frac > 0 else 0.0
print(f"\r {label:7}[{bar}] {frac*100:5.1f}% ({t}/{total}) {el:5.1f}s ETA {eta:5.1f}s",
end=("\n" if done else ""), flush=True)
return cb
# ---- захват CUDA-графа одного шага ----
def _capture(step_fn):
cp.cuda.Device().synchronize()
s = cp.cuda.Stream(non_blocking=True)
with s:
s.begin_capture()
try:
step_fn()
except Exception:
try: s.end_capture()
except Exception: pass
raise
g = s.end_capture()
return g
def run(mode, use_sdf, progress=None):
"""mode: none|amr2x|nested. Возвращает {Fx,Fy,uy (host), frames (host), DL, mode}.
progress(t, STEPS) — необязательный колбэк. CUDA Graphs: env AMR_CUDAGRAPH (по умолч. вкл)."""
use1 = mode in ("amr2x", "nested"); use2 = (mode == "nested")
DL = {"none": D0, "amr2x": 2*D0, "nested": 4*D0}[mode]
# ---- persistent-состояние (фиксированные буферы для replay графа) ----
F0 = feq(xp.ones((Ny, Nx), DTYPE), xp.zeros((2, Ny, Nx), DTYPE)).copy()
F1 = feq(xp.ones((P1["Nfy"], P1["Nfx"]), DTYPE), xp.zeros((2, P1["Nfy"], P1["Nfx"]), DTYPE)).copy() if use1 else None
F2 = feq(xp.ones((P2["Nfy"], P2["Nfx"]), DTYPE), xp.zeros((2, P2["Nfy"], P2["Nfx"]), DTYPE)).copy() if use2 else None
onecol = xp.ones((Ny, 1), DTYPE)
uin_dev = xp.zeros((2, Ny, 1), DTYPE) # вход; обновляется СНАРУЖИ захвата
Fx_s = xp.zeros((), DTYPE); Fy_s = xp.zeros((), DTYPE); uy_s = xp.zeros((), DTYPE)
# разгон входа на device: U*smoothstep(t/ramp)
_ts = np.minimum(np.arange(STEPS+1)/ramp, 1.0)
ramp_u = xp.asarray(U*(_ts*_ts*(3-2*_ts)), DTYPE)
Fx = xp.zeros(STEPS+1, DTYPE); Fy = xp.zeros(STEPS+1, DTYPE); uy = xp.zeros(STEPS+1, DTYPE)
frames = []
def _step():
# ----- уровень 0 -----
rho, u0 = macros(F0)
pre = F0.copy()
post0 = collide(F0, feq(rho, u0), b0)
if not use_sdf:
f0 = stream(bb_set(post0.copy(), pre, g_solid0))
else:
f0 = stream(post0); f0 = apply_bc(f0, post0, BC0)
if mode == "none":
fx, fy = (force_bc(post0, f0, BC0) if use_sdf else force_on(post0, f0, L0_links))
Fx_s[...] = fx; Fy_s[...] = fy
# вход/выход (разгон читаем из uin_dev)
f0[:, 1:-1, 0] = feq(onecol, uin_dev)[:, 1:-1, 0]; f0[:, 1:-1, -1] = f0[:, 1:-1, -2]
# ----- уровень 1 -----
if use1:
g1o = ghost(pre, P1, R01); g1n = ghost(f0, P1, R01)
f1 = F1
for s1 in range(2):
pre1 = f1.copy(); r1, u1 = macros(f1); post1 = collide(f1, feq(r1, u1), b1)
if not use_sdf:
f1 = stream(bb_set(post1.copy(), pre1, g_solid1))
else:
f1 = stream(post1); f1 = apply_bc(f1, post1, BC1)
if mode == "amr2x" and s1 == 1:
fx, fy = (force_bc(post1, f1, BC1) if use_sdf else force_on(post1, f1, L1_links))
Fx_s[...] = fx; Fy_s[...] = fy
f1 = fill(f1, (1-(s1+1)/2)*g1o + ((s1+1)/2)*g1n)
# ----- уровень 2 -----
if use2:
g2o = ghost(pre1, P2, R12); g2n = ghost(f1, P2, R12)
f2 = F2
for s2 in range(2):
pre2 = f2.copy(); r2, u2 = macros(f2); post2 = collide(f2, feq(r2, u2), b2)
if not use_sdf:
f2 = stream(bb_set(post2.copy(), pre2, g_solid2))
else:
f2 = stream(post2); f2 = apply_bc(f2, post2, BC2)
if s1 == 1 and s2 == 1:
fx, fy = (force_bc(post2, f2, BC2) if use_sdf else force_on(post2, f2, L2_links))
Fx_s[...] = fx; Fy_s[...] = fy
f2 = fill(f2, (1-(s2+1)/2)*g2o + ((s2+1)/2)*g2n)
f1 = restrict(f2, f1, P2, Rf12, fl2)
F2[...] = f2
f0 = restrict(f1, f0, P1, Rf01, fl1)
F1[...] = f1
# зонд скорости в следе + запись состояния
col = f0[:, py, px]; uy_s[...] = (CYa*col).sum()/col.sum()
F0[...] = f0
use_graph = (os.environ.get("AMR_CUDAGRAPH", "1") != "0") # CUDA Graphs по умолчанию ВКЛ (AMR_CUDAGRAPH=0 — выкл)
graph = None; graph_failed = False
def _snap():
return (F0.copy(), F1.copy() if use1 else None, F2.copy() if use2 else None)
def _restore(s):
F0[...] = s[0]
if use1: F1[...] = s[1]
if use2: F2[...] = s[2]
def _maxdiff(s):
d = float(xp.abs(s[0]-F0).max())
if use1: d = max(d, float(xp.abs(s[1]-F1).max()))
if use2: d = max(d, float(xp.abs(s[2]-F2).max()))
return d
for t in range(STEPS+1):
uin_dev[0, :, 0] = ramp_u[t]
did = False
if t == 1 and use_graph and not graph_failed:
# захват графа + РАНТАЙМ-САМОПРОВЕРКА: один шаг графом vs eager; при расхождении — откат
try:
graph = _capture(_step) # запись (без исполнения)
before = _snap() # состояние после t=0
graph.launch() # граф -> результат t=1
gres = _snap()
_restore(before); _step() # eager -> доверенный результат t=1
d = _maxdiff(gres)
if d > 1e-3:
print(f"\n[graph] self-check разошёлся (Δ={d:.2e}) — отключаю графы, работаю eager")
graph = None; graph_failed = True
else:
print(f"\n[graph] self-check OK (Δ={d:.2e}) — CUDA graphs активны")
did = True # шаг t=1 уже выполнен (eager-результат — доверенный)
except Exception as e:
print(f"\n[graph] недоступны ({type(e).__name__}: {e}) — работаю eager")
import traceback as _tb; _tb.print_exc() # точная строка H2D/несовместимости
graph = None; graph_failed = True
try: cp.cuda.Device().synchronize()
except Exception: pass
if not did:
if graph is not None and t >= 1:
graph.launch()
else:
_step()
Fx[t] = Fx_s; Fy[t] = Fy_s; uy[t] = uy_s
if progress is not None:
progress(t, STEPS)
if t % 500 == 0 and not bool(xp.isfinite(F0).all()):
print("BLEW UP", mode, t); Fx = Fx[:t]; Fy = Fy[:t]; uy = uy[:t]; break
if t % FE == 0:
frames.append((t, to_cpu(macros(F0)[1]),
to_cpu(macros(F1)[1]) if use1 else None,
to_cpu(macros(F2)[1]) if use2 else None))
return dict(mode=mode, DL=DL, Fx=to_cpu(Fx), Fy=to_cpu(Fy), uy=to_cpu(uy), frames=frames)
# ---- анализ (на host: FFT малых рядов) ----
def analyze(res):
Fx, Fy, uy, DL = res["Fx"], res["Fy"], res["uy"], res["DL"]
n = len(uy); h = slice(n//2, n); Cd = 2*Fx/(U**2*DL); Cl = 2*Fy/(U**2*DL)
sig = uy[h] - uy[h].mean(); npad = 8192
freqs = np.fft.rfftfreq(npad, 1.0); amp = np.abs(np.fft.rfft(sig, n=npad))
St = (freqs[1+int(np.argmax(amp[1:]))] if len(amp) > 2 else 0.0)*D0/U
return dict(Cd=float(Cd[h].mean()), Clrms=float(Cl[h].std()), St=float(St), uyrms=float(uy[h].std()),
Cl_s=Cl, uy=uy, freqs=freqs, amp=amp, h0=n//2)
# ---- визуализация (matplotlib на CPU; кадры уже host) ----
def _vort_np(u):
return (np.roll(u[1], -1, 1)-np.roll(u[1], 1, 1))*0.5 - (np.roll(u[0], -1, 0)-np.roll(u[0], 1, 0))*0.5
def save_gif(res, name, anim_dir, sdf_tag, fps=24):
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt, matplotlib.patches as mpatches
from PIL import Image
nested = (res["mode"] == "nested"); has1 = res["mode"] in ("amr2x", "nested"); frames = res["frames"]
vm = np.nanpercentile(np.abs(np.where(solid0_np, np.nan, _vort_np(frames[len(frames)//2][1]))), 99.0)
cm = plt.get_cmap("inferno").copy(); cm.set_bad("white"); pil = []
for (t, u0, u1, u2) in frames:
fig = plt.figure(figsize=(11.2, 5.7), dpi=96); ax = fig.add_subplot(111)
ax.imshow(np.abs(np.where(solid0_np, np.nan, _vort_np(u0))), origin="lower", cmap=cm, vmin=0, vmax=vm,
extent=(0, Nx, 0, Ny), interpolation="nearest")
if has1 and u1 is not None:
ax.imshow(np.abs(np.where(solid1_np, np.nan, _vort_np(u1)))[1:-1, 1:-1], origin="lower", cmap=cm, vmin=0, vmax=vm,
extent=(ax1+0.5, bx1-0.5, ay1+0.5, by1-0.5), interpolation="nearest")
if nested and u2 is not None:
ax.imshow(np.abs(np.where(solid2_np, np.nan, _vort_np(u2)))[1:-1, 1:-1], origin="lower", cmap=cm, vmin=0, vmax=vm,
extent=(cx2a+0.25, cx2b-0.25, cy2a+0.25, cy2b-0.25), interpolation="nearest")
ax.add_patch(mpatches.Rectangle((0.2, 0.2), Nx-0.4, Ny-0.4, fill=False, edgecolor="#4fc3f7", lw=1.6))
ax.text(1.5, Ny-4, "уровень 0: Δx (крупный)", color="#4fc3f7", fontsize=9, weight="bold")
if has1:
ax.add_patch(mpatches.Rectangle((ax1, ay1), bx1-ax1, by1-ay1, fill=False, edgecolor="#aed581", lw=2.2))
ax.text(ax1+1, by1-3.5, "уровень 1: Δx/2 (тело+след)", color="#aed581", fontsize=9, weight="bold")
if nested:
ax.add_patch(mpatches.Rectangle((cx2a, cy2a), cx2b-cx2a, cy2b-cy2a, fill=False, edgecolor="#ff8a65", lw=2.2))
ax.text(cx2a+1, cy2b-3.5, "уровень 2: Δx/4 (у тела)", color="#ff8a65", fontsize=9, weight="bold")
base = ("Вложенный AMR" if nested else "AMR (одиночный блок 2×)") if has1 else "Без AMR (D=16)"
ax.set_title(f"{base}{sdf_tag} · поверх $|\\omega|$ · t={t}", fontsize=11)
ax.set_xlabel("x [lu]"); ax.set_ylabel("y [lu]"); ax.set_xlim(0, Nx); ax.set_ylim(0, Ny)
buf = _io.BytesIO(); fig.savefig(buf, format="png", bbox_inches="tight"); buf.seek(0); plt.close(fig)
pil.append(Image.open(buf).convert("RGB").convert("P", palette=Image.ADAPTIVE))
path = os.path.join(anim_dir, name)
pil[0].save(path, save_all=True, append_images=pil[1:], duration=int(1000/fps), loop=0, optimize=True)
print("saved", name, len(frames), "кадров")
LAB = ["без AMR (D=16)", "AMR 2× (D=32)", "AMR 2×+4× (D=64)"]
def print_table(AS, header):
print(f"\n=== {header} ===")
print(f"{'версия':22}{'D у тела':>9}{'St':>8}{'<Cd>':>9}{'rms Cl':>9}{'rms u_y':>9}")
for lab, DL, a in zip(LAB, [16, 32, 64], AS):
print(f"{lab:22}{DL:>9}{a['St']:>8.3f}{a['Cd']:>9.3f}{a['Clrms']:>9.4f}{a['uyrms']:>9.4f}")
print(f"{'литература Re≈150':22}{'—':>9}{0.183:>8.3f}{1.330:>9.3f}{'~0.3':>9}{'':>9}")
print("(лит.: St≈0.18, Cd≈1.3 для цилиндра при Re≈150; Williamson 1996, Henderson 1995)")
def probe_figure(AS, R2, path, suptitle):
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt, matplotlib.patches as mpatches
cols = ["#9e9e9e", "#1f6feb", "#d1495b"]
fig = plt.figure(figsize=(13.5, 8.2)); gs = fig.add_gridspec(2, 2, hspace=0.32, wspace=0.22)
axA = fig.add_subplot(gs[0, 0]); mid = R2["frames"][len(R2["frames"])//2]
vmf = np.nanpercentile(np.abs(np.where(solid0_np, np.nan, _vort_np(mid[1]))), 99)
cmf = plt.get_cmap("inferno").copy(); cmf.set_bad("white")
axA.imshow(np.abs(np.where(solid0_np, np.nan, _vort_np(mid[1]))), origin="lower", cmap=cmf, vmin=0, vmax=vmf,
extent=(0, Nx, 0, Ny), interpolation="nearest")
axA.add_patch(mpatches.Rectangle((ax1, ay1), bx1-ax1, by1-ay1, fill=False, edgecolor="#aed581", lw=1.8))
axA.add_patch(mpatches.Rectangle((cx2a, cy2a), cx2b-cx2a, cy2b-cy2a, fill=False, edgecolor="#ff8a65", lw=1.8))
axA.set_title("$|\\omega|$ + рамки зон 2×/4×"); axA.set_xlabel("x [lu]"); axA.set_ylabel("y [lu]")
axB = fig.add_subplot(gs[0, 1])
for a, lab, c in zip(AS, LAB, cols): axB.plot(a["Cl_s"][a["h0"]:], lw=0.8, color=c, label=lab)
axB.set_title("Подъёмная сила $C_l(t)$ (зонд силы)"); axB.set_xlabel("шаг"); axB.set_ylabel("$C_l$")
axB.legend(fontsize=8); axB.grid(alpha=0.3)
axC = fig.add_subplot(gs[1, 0])
for a, lab, c in zip(AS, LAB, cols):
sp = a["amp"]/a["amp"][1:].max(); axC.plot(a["freqs"]*D0/U, sp, lw=1.0, color=c, label=lab)
axC.axvline(0.183, color="k", ls="--", alpha=0.6, label="лит. St≈0.18"); axC.set_xlim(0, 0.6)
axC.set_title("Спектр $u_y$ в следе → St"); axC.set_xlabel("St = f·D/U"); axC.set_ylabel("норм. ампл.")
axC.legend(fontsize=8); axC.grid(alpha=0.3)
axD = fig.add_subplot(gs[1, 1]); x = np.arange(3); w = 0.35
axD.bar(x-w/2, [a["St"] for a in AS], w, color="#1f6feb", label="St")
axD.bar(x+w/2, [a["Cd"] for a in AS], w, color="#fb8c00", label="<Cd>")
axD.axhline(0.183, color="#1f6feb", ls="--", alpha=0.6); axD.axhline(1.33, color="#fb8c00", ls="--", alpha=0.6)
axD.set_xticks(x); axD.set_xticklabels(["без AMR", "2×", "2×+4×"]); axD.set_title("St и <Cd> (пунктир — лит.)")
axD.legend(fontsize=8); axD.grid(alpha=0.3, axis="y")
fig.suptitle(suptitle, fontweight="bold"); fig.savefig(path, dpi=110, bbox_inches="tight"); plt.close(fig)
print("saved", os.path.basename(path))
Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 MiB

+36
View File
@@ -0,0 +1,36 @@
# demos_gpu — GPU-прогоны демо для ноутбука kbc_lbm.ipynb
Ноутбук **не исполняет код** — он встраивает готовые артефакты (png/gif) и цитирует числа.
Все симуляции здесь, **только GPU/CuPy** (CPU-фолбэка нет). Физика не дублируется: решётка,
равновесие, KBC-столкновение, перенос и AMR-связка импортируются из `../solver_2x_sdf` —
демо тем самым гоняют **те же компоненты, что и финальная модель**.
## Запуск (на GPU-сервере, из этой папки)
```sh
python checks_gpu.py # самопроверки математики → out/checks.txt
python tgv_gpu.py # Тейлор–Грин: вязкость, H-теорема → figures/07*, anim/tgv_decay.gif
python shear_gpu.py # сдвиговый слой BGK vs KBC → figures/08*, anim/shear_bgk_vs_kbc.gif
python cavity_gpu.py # каверна Re=1000 vs Ghia → figures/09*, anim/cavity_transient.gif
python obstacle_gpu.py # 4 тела, дорожки Кармана → figures/10*, anim/obstacle_shapes.gif
python amr_demo_gpu.py # AMR: демо + бенчмарк + вложенный → figures/11d*, anim/amr_{cylinder,nested}.gif
python figures_static.py # стенсили/блок-схема/схема AMR → figures/01,06,11c,11_d3q27 (после obstacle!)
```
`figures_static.py` — единственный скрипт без GPU (чистый matplotlib, можно гонять локально);
схему `11c` он перерисовывает по фону из `out/obstacle_circle_field.npz`, который пишет
`obstacle_gpu.py` — поэтому его запускать **после** obstacle.
Env: `AMR_FP64=1` — двойная точность (жёстче пороги самопроверок в checks_gpu).
| Скрипт | Время (примерно, совр. GPU) |
|---|---|
| checks_gpu | секунды |
| tgv_gpu | ~1 мин (10k шагов, 96²) |
| shear_gpu | ~1–2 мин (2×8k, 128²) |
| cavity_gpu | ~1–2 мин (до 14k, 110²) |
| obstacle_gpu | ~3–5 мин (4×9.6k, 150×72) |
| amr_demo_gpu | ~5–10 мин (демо + 6 бенчмарков + анимация) |
Артефакты идут в общие каталоги `../figures` и `../anim` под **теми же именами**, что
исторически использовал ноутбук; числовые сводки — в `out/*.txt|npz`.
+128
View File
@@ -0,0 +1,128 @@
# Общая инфраструктура GPU-демо (ноутбук kbc_lbm.ipynb код не исполняет — только встраивает
# артефакты, которые генерят эти скрипты на GPU-сервере).
#
# Физика НЕ дублируется: решётка/равновесие/столкновение/перенос импортируются из
# ../solver_2x_sdf (те же модули, что и в финальной модели) — демо тем самым доказывают,
# что КОМПОНЕНТЫ финальной модели воспроизводят классические бенчмарки.
# Здесь только то, чего в модели нет намеренно: полиномиальное равновесие и BGK
# (для сравнений «BGK vs KBC»), H-функция, γ-поле для визуализации, гифки.
import os
import sys
import io as _io
HERE = os.path.dirname(os.path.abspath(__file__))
THEORY = os.path.dirname(HERE)
sys.path.insert(0, os.path.join(THEORY, "solver_2x_sdf"))
import backend as B # GPU-only: жёсткая ошибка без CuPy (CPU-фолбэка нет намеренно)
import lattice as L
import equilibrium as E
import collision as Coll
import streaming as Strm
import visualization as Viz # make_progress (ASCII-бар)
cp = B.cp; xp = B.xp; DTYPE = B.DTYPE; to_cpu = B.to_cpu
Q = L.Q; CS2 = L.CS2; OPP = L.OPP; Cx = L.Cx; Cy = L.Cy
import numpy as np
# пути артефактов: фигуры/гифки — общие каталоги theory, числа — demos_gpu/out
FIGDIR = os.path.join(THEORY, "figures"); os.makedirs(FIGDIR, exist_ok=True)
ANIMDIR = os.path.join(THEORY, "anim"); os.makedirs(ANIMDIR, exist_ok=True)
OUTDIR = os.path.join(HERE, "out"); os.makedirs(OUTDIR, exist_ok=True)
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
try:
sys.stdout.reconfigure(encoding="utf-8") # кириллица/греческие в print при cp1251-консоли
except Exception:
pass
plt.rcParams.update({
"figure.dpi": 110, "savefig.dpi": 120, "font.size": 11,
"axes.titlesize": 13, "axes.titleweight": "bold",
"axes.grid": True, "grid.alpha": 0.25, "axes.axisbelow": True,
"figure.facecolor": "white", "axes.facecolor": "#fbfbfd",
})
# пороги самопроверок под точность бэкенда (демо обычно гоняются в float32)
FP64 = (DTYPE == cp.float64)
TOL_EXACT = 1e-12 if FP64 else 2e-5 # «точные» тождества (моменты равновесия и т.п.)
def finish(fig, name):
"""Сохранить фигуру в figures/ под тем же именем, что использовал ноутбук."""
fig.savefig(os.path.join(FIGDIR, name), bbox_inches="tight")
plt.close(fig)
print("PNG:", name)
def write_out(name, lines):
"""Дописать результаты в demos_gpu/out/<name> (ноутбук цитирует эти числа в markdown)."""
with open(os.path.join(OUTDIR, name), "w", encoding="utf-8") as fh:
fh.write("\n".join(lines) + "\n")
print("OUT:", name)
# ----- дополнения к модели (нужны только демо) ------------------------------------------------
def feq_poly(rho, u):
"""Полиномиальное равновесие O(u²) — стандарт BGK (в модели НЕ используется; здесь —
для демонстрации «BGK+poly разрушается там, где KBC+entropic держит»)."""
CXa = L.CXa[:, None, None]; CYa = L.CYa[:, None, None]; Wa = L.Wa[:, None, None]
cu = CXa*u[0] + CYa*u[1]
usq = u[0]**2 + u[1]**2
return Wa * rho * (1.0 + cu/CS2 + cu**2/(2.0*CS2*CS2) - usq/(2.0*CS2))
def bgk_collide(f, fe, beta):
"""Классический BGK: f − ω(f−feq), ω=2β. Эквивалент KBC при γ=2."""
return f - 2.0*beta*(f - fe)
def kbc_gamma(f, fe, beta):
"""Поле энтропийного стабилизатора γ (для визуализации; сам оператор — collision.kbc_collide,
формула та же: γ = 1/β − (2−1/β)·⟨Δs|Δh⟩/⟨Δh|Δh⟩)."""
dfn = f - fe
ds = Coll._project_shift(dfn)
dh = dfn - ds; inv = 1.0/fe
num = (ds*dh*inv).sum(0); den = (dh*dh*inv).sum(0)
nrm = (dfn*dfn*inv).sum(0) # тот же относительный порог, что в collision.py
ok = den > B.GREL*nrm
return xp.where(ok, 1.0/beta - (2.0 - 1.0/beta)*num/xp.where(ok, den, B.ONE), B.TWO)
def nu_to_beta(nu):
return 1.0 / (2.0 * (nu / CS2 + 0.5))
def H_function(f):
"""Дискретная H-функция H = Σ f_i ln(f_i/w_i) (поле)."""
fp = xp.maximum(f, 1e-30)
return (fp * xp.log(fp / L.Wa[:, None, None])).sum(0)
def smoothstep(x):
x = min(max(x, 0.0), 1.0)
return x*x*(3.0 - 2.0*x)
# ----- host-постобработка (numpy: кадры уже перенесены на хост) --------------------------------
def vorticity(u):
"""ω = ∂x u_y − ∂y u_x центральными разностями (host, по кадру)."""
duy_dx = (np.roll(u[1], -1, 1) - np.roll(u[1], 1, 1)) * 0.5
dux_dy = (np.roll(u[0], -1, 0) - np.roll(u[0], 1, 0)) * 0.5
return duy_dx - dux_dy
def render_gif(n_frames, draw_fn, name, figsize, fps=18, dpi=80):
"""Отрисовать n_frames кадров (draw_fn(fig, i)) и сохранить зацикленный GIF в anim/."""
from PIL import Image
path = os.path.join(ANIMDIR, name)
fig = plt.figure(figsize=figsize, dpi=dpi)
pil = []
for i in range(n_frames):
fig.clf()
draw_fn(fig, i)
buf = _io.BytesIO()
fig.savefig(buf, format="png")
buf.seek(0)
pil.append(Image.open(buf).convert("RGB").convert("P", palette=Image.ADAPTIVE))
plt.close(fig)
pil[0].save(path, save_all=True, append_images=pil[1:],
duration=int(1000 / fps), loop=0, optimize=True)
print("GIF:", name, f"({n_frames} кадров)")
return path
+265
View File
@@ -0,0 +1,265 @@
# Демо-5: блочное измельчение (AMR) на компонентах solver_2x_sdf (amr.patch/ghost/fill/restrict —
# ТЕ ЖЕ функции, что в финальной модели). Три части:
# 1) минимальный 2-уровневый AMR на цилиндре (без временно́й интерполяции) → anim/amr_cylinder.gif
# 2) бенчмарк 5 вариантов против эталона uniform-fine 4× (тест-вихрь) → figures/11d_amr_comparison.png
# 3) анимация полного вложенного 2×+4× с временно́й интерполяцией → anim/amr_nested.gif
# Числа → out/amr_bench.txt|npz. Запуск: python amr_demo_gpu.py
import os
import time as _time
import numpy as np
import _common as cm
import amr as Amr # из solver_2x_sdf (sys.path настроен в _common)
cp = cm.cp; xp = cm.xp; E = cm.E; Coll = cm.Coll; Strm = cm.Strm
plt = cm.plt; vorticity = cm.vorticity; OPP = cm.OPP; Q = cm.Q; CS2 = cm.CS2
import matplotlib.patches as mpatches
def _sync():
cp.cuda.Device().synchronize()
# ===== часть 1: минимальный 2-уровневый AMR на цилиндре =========================================
def run_amr_cylinder(Nx=120, Ny=60, D=10, U=0.06, Re=120.0, steps=5400,
ramp=1000, frame_every=22):
cx, cy = Nx//4, Ny//2
nu = U*D/Re
tau_c = nu/CS2 + 0.5; beta_c = cm.nu_to_beta(nu)
tau_f = 2*tau_c - 0.5; beta_f = 1.0/(2*tau_f)
Rcf = tau_f/(2*tau_c); Rfc = 1.0/Rcf
bx0 = max(cx - 2*D, 2); bx1 = min(cx + 3*D, Nx - 2)
by0 = max(cy - 2*D, 2); by1 = min(cy + 2*D, Ny - 2)
P = Amr.patch(Nx, Ny, bx0, bx1, by0, by1, 2)
Nfx, Nfy = P["Nfx"], P["Nfy"]
# маски тела (host → device); стенки канала входят в solid_c
Yc_, Xc_ = np.mgrid[0:Ny, 0:Nx]
solid_c_np = (Xc_ - cx)**2 + (Yc_ - cy)**2 <= (D/2)**2
solid_c_np[0, :] = True; solid_c_np[-1, :] = True
fcx, fcy = 2*(cx - bx0), 2*(cy - by0)
Yf_, Xf_ = np.mgrid[0:Nfy, 0:Nfx]
solid_f_np = (Xf_ - fcx)**2 + (Yf_ - fcy)**2 <= float(D)**2
solid_c = cp.asarray(solid_c_np); solid_f = cp.asarray(solid_f_np)
block_fluid = cp.asarray(~solid_c_np[by0+1:by1, bx0+1:bx1])
onecol = cp.ones((Ny, 1), cm.DTYPE)
fc = E.feq(cp.ones((Ny, Nx), cm.DTYPE), cp.zeros((2, Ny, Nx), cm.DTYPE))
ff = E.feq(cp.ones((Nfy, Nfx), cm.DTYPE), cp.zeros((2, Nfy, Nfx), cm.DTYPE))
frames = []
prog = cm.Viz.make_progress("amr-cyl")
for t in range(steps + 1):
rho, u = E.macros(fc)
pre = fc.copy()
fc = Coll.kbc_collide(fc, E.feq(rho, u), beta_c)
for i in range(Q):
fc[i, solid_c] = pre[OPP[i], solid_c]
fc = Strm.stream(fc)
uin = cp.zeros((2, Ny, 1), cm.DTYPE); uin[0, :, 0] = U*cm.smoothstep(t/ramp)
fc[:, 1:-1, 0] = E.feq(onecol, uin)[:, 1:-1, 0]
fc[:, 1:-1, -1] = fc[:, 1:-1, -2]
# МИНИМАЛЬНЫЙ AMR: один ghost на оба подшага (без временно́й интерполяции)
gh = Amr.ghost(fc, P, Rcf)
for _ in range(2):
rf, uf = E.macros(ff); pref = ff.copy()
ff = Coll.kbc_collide(ff, E.feq(rf, uf), beta_f)
for i in range(Q):
ff[i, solid_f] = pref[OPP[i], solid_f]
ff = Strm.stream(ff)
ff = Amr.fill(ff, gh)
fc = Amr.restrict(ff, fc, P, Rfc, block_fluid)
if t % frame_every == 0:
frames.append((t, cm.to_cpu(E.macros(fc)[1]), cm.to_cpu(E.macros(ff)[1])))
prog(t, steps)
return dict(frames=frames, solid_c=solid_c_np, solid_f=solid_f_np, Nx=Nx, Ny=Ny,
bx0=bx0, bx1=bx1, by0=by0, by1=by1, tau_c=tau_c, tau_f=tau_f, Rcf=Rcf)
amr = run_amr_cylinder()
print(f"AMR-цилиндр: крупная {amr['Nx']}x{amr['Ny']} + мелкий блок 2x, "
f"tau_c={amr['tau_c']:.3f} tau_f={amr['tau_f']:.3f} Rcf={amr['Rcf']:.3f}, "
f"кадров={len(amr['frames'])} (устойчиво)")
_af = amr["frames"]
_vmaxA = np.nanpercentile(np.abs(np.where(amr["solid_c"], np.nan,
vorticity(_af[len(_af)//2][1]))), 99.0)
_cmapA = plt.get_cmap("RdBu_r").copy(); _cmapA.set_bad("#1b1b1b")
def _draw_amr(fig, i):
t, uc, uf = _af[i]
ax = fig.add_subplot(111)
ax.imshow(np.where(amr["solid_c"], np.nan, vorticity(uc)), origin="lower",
cmap=_cmapA, vmin=-_vmaxA, vmax=_vmaxA, extent=(0, amr["Nx"], 0, amr["Ny"]),
interpolation="nearest")
omf = np.where(amr["solid_f"], np.nan, vorticity(uf))[1:-1, 1:-1]
ax.imshow(omf, origin="lower", cmap=_cmapA, vmin=-_vmaxA, vmax=_vmaxA,
extent=(amr["bx0"]+0.5, amr["bx1"]-0.5, amr["by0"]+0.5, amr["by1"]-0.5),
interpolation="nearest")
ax.add_patch(mpatches.Rectangle((amr["bx0"], amr["by0"]),
amr["bx1"]-amr["bx0"], amr["by1"]-amr["by0"],
fill=False, edgecolor="#ffe600", lw=3.0))
ax.text(amr["bx0"]+1, amr["by1"]-4, "мелкий блок 2×", color="#ffe600", fontsize=9, weight="bold")
ax.set_title(f"AMR: крупная сетка + мелкий блок 2× (жёлтая рамка) · t={t}", fontsize=10)
ax.set_xticks([]); ax.set_yticks([])
fig.tight_layout()
cm.render_gif(len(_af), _draw_amr, "amr_cylinder.gif", (9.2, 4.7), fps=24)
# ===== часть 2: бенчмарк точность/работа (тест-вихрь, без тел) ==================================
_Nc = 48; _xc = _yc = 24.0; _sig = 1.4; _A = 0.115
_nu_c = 0.008; _tau_c = _nu_c/CS2 + 0.5; _BSTEPS = 400
def _vfield(X, Y):
rr = (X - _xc)**2 + (Y - _yc)**2; e = cp.exp(-rr/(2*_sig**2))
return cm.B.pack(((-_A*(Y - _yc)/_sig**2*e).astype(cm.DTYPE),
(_A*(X - _xc)/_sig**2*e).astype(cm.DTYPE)))
def _grid_v(N, h):
xs = cp.arange(N, dtype=cm.DTYPE)*h
Xg, Yg = cp.meshgrid(xs, xs)
return _vfield(Xg, Yg)
def _adv(f, beta):
r, u = E.macros(f)
return Strm.stream(Coll.kbc_collide(f, E.feq(r, u), beta))
def _sq_patch(pN, a, b, r):
return Amr.patch(pN, pN, a, b, a, b, r)
def _allfluid(P):
return cp.ones((P["by"]-P["ay"]-1, P["bx"]-P["ax"]-1), bool)
def _tauc(tp, r): return r*tp - (r - 1)/2
def _init_patch(P, a_phys, h_par):
pos = a_phys + cp.arange(P["Nfx"], dtype=cm.DTYPE)*(h_par/P["r"])
X, Y = cp.meshgrid(pos, pos)
return E.feq(cp.ones((P["Nfy"], P["Nfx"]), cm.DTYPE), _vfield(X, Y))
def _run_uniform(N, h, tau, nsteps):
beta = 1/(2*tau); f = E.feq(cp.ones((N, N), cm.DTYPE), _grid_v(N, h))
_sync(); t0 = _time.perf_counter()
for _ in range(nsteps):
f = _adv(f, beta)
_sync()
return E.macros(f)[1], _time.perf_counter() - t0, N*N*nsteps
def _run_single(r, a, b, temporal):
P = _sq_patch(_Nc, a, b, r); tau_f = _tauc(_tau_c, r)
b0, bf = 1/(2*_tau_c), 1/(2*tau_f); Rcf = tau_f/(r*_tau_c); Rfc = 1/Rcf
fl = _allfluid(P)
f0 = E.feq(cp.ones((_Nc, _Nc), cm.DTYPE), _grid_v(_Nc, 1.0)); ff = _init_patch(P, a, 1.0)
_sync(); t0 = _time.perf_counter()
for t in range(_BSTEPS):
f0o = f0.copy(); f0 = _adv(f0, b0)
gho = Amr.ghost(f0o, P, Rcf); ghn = Amr.ghost(f0, P, Rcf)
for s in range(r):
ff = _adv(ff, bf)
w = (s + 1)/r
ff = Amr.fill(ff, (1 - w)*gho + w*ghn if temporal else ghn)
f0 = Amr.restrict(ff, f0, P, Rfc, fl)
_sync()
return E.macros(f0)[1], _time.perf_counter() - t0, _Nc*_Nc*_BSTEPS + P["Nfx"]**2*r*_BSTEPS
def _run_nested(temporal, steps=_BSTEPS, frame_every=0):
P1 = _sq_patch(_Nc, 8, 40, 2); P2 = _sq_patch(P1["Nfx"], 16, 48, 2)
t1 = _tauc(_tau_c, 2); t2 = _tauc(t1, 2)
b0, b1, b2 = 1/(2*_tau_c), 1/(2*t1), 1/(2*t2)
R01 = t1/(2*_tau_c); Rf01 = 1/R01; R12 = t2/(2*t1); Rf12 = 1/R12
fl1 = _allfluid(P1); fl2 = _allfluid(P2)
f0 = E.feq(cp.ones((_Nc, _Nc), cm.DTYPE), _grid_v(_Nc, 1.0))
f1 = _init_patch(P1, 8, 1.0); f2 = _init_patch(P2, 16.0, 0.5)
frames = []
_sync(); t0 = _time.perf_counter()
for t in range(steps + (1 if frame_every else 0)):
f0o = f0.copy(); f0 = _adv(f0, b0)
g1o = Amr.ghost(f0o, P1, R01); g1n = Amr.ghost(f0, P1, R01)
for s1 in range(2):
f1o = f1.copy(); f1 = _adv(f1, b1)
w1 = (s1 + 1)/2
f1 = Amr.fill(f1, (1 - w1)*g1o + w1*g1n if temporal else g1n)
g2o = Amr.ghost(f1o, P2, R12); g2n = Amr.ghost(f1, P2, R12)
for s2 in range(2):
f2 = _adv(f2, b2)
w2 = (s2 + 1)/2
f2 = Amr.fill(f2, (1 - w2)*g2o + w2*g2n if temporal else g2n)
f1 = Amr.restrict(f2, f1, P2, Rf12, fl2)
f0 = Amr.restrict(f1, f0, P1, Rf01, fl1)
if frame_every and t % frame_every == 0:
frames.append((t, cm.to_cpu(E.macros(f0)[1]), cm.to_cpu(E.macros(f1)[1]),
cm.to_cpu(E.macros(f2)[1])))
_sync()
cells = _Nc*_Nc*steps + P1["Nfx"]**2*2*steps + P2["Nfx"]**2*4*steps
if frame_every:
return frames
return E.macros(f0)[1], _time.perf_counter() - t0, cells
print("\nБенчмарк AMR (эталон uniform-fine 4×, физ. время одинаково)...")
_uref, _tref, _cref = _run_uniform(_Nc*4, 0.25, _tauc(_tau_c, 4), 4*_BSTEPS)
_uref = _uref[:, ::4, ::4]
_rms = float(cp.sqrt((_uref[0]**2 + _uref[1]**2).mean()))
def _err(u):
return float(cp.sqrt(((u[0] - _uref[0])**2 + (u[1] - _uref[1])**2).mean()))/_rms
_bench = []
u, t, c = _run_uniform(_Nc, 1.0, _tau_c, _BSTEPS); _bench.append(("uniform-coarse", _err(u), t, c))
u, t, c = _run_single(2, 12, 36, False); _bench.append(("AMR 2x (мин., без вр.инт.)", _err(u), t, c))
u, t, c = _run_single(4, 12, 36, True); _bench.append(("AMR 4x single (вр.инт.)", _err(u), t, c))
u, t, c = _run_nested(False); _bench.append(("AMR 2x+4x nested (без вр.инт.)", _err(u), t, c))
u, t, c = _run_nested(True); _bench.append(("AMR 2x+4x nested ПОЛНЫЙ", _err(u), t, c))
out_lines = [f"reference uniform-fine 4x (192^2): time={_tref:.2f}s cell-updates={_cref:.3e}"]
print(f"Эталон uniform-fine 4× (192²): время={_tref:.1f}с, cell-updates={_cref:.2e}")
print(f"{'метод':32}{'ε,%':>8}{'cell-upd':>11}{'η=1/(εW)':>10}{'×fine(работа)':>14}")
for name, e, t, c in _bench:
eta = 1.0/(e*c/1e6)
print(f"{name:32}{e*100:>8.2f}{c:>11.2e}{eta:>10.2f}{_cref/c:>14.1f}")
out_lines.append(f"{name}: eps_pct={e*100:.3f} cells={c:.3e} eta={eta:.2f} work_vs_fine={_cref/c:.1f}")
np.savez(os.path.join(cm.OUTDIR, "amr_bench.npz"),
names=np.array([b[0] for b in _bench]),
eps=np.array([b[1] for b in _bench]),
wall=np.array([b[2] for b in _bench]),
cells=np.array([b[3] for b in _bench]),
cref=_cref, tref=_tref)
# фигура: ошибка по методам + фронт «точность–работа»
names = [b[0] for b in _bench]; errs = np.array([b[1]*100 for b in _bench])
cells = np.array([b[3] for b in _bench]); cols = ["#9e9e9e", "#1f6feb", "#2e7d32", "#fb8c00", "#d1495b"]
fig, axs = plt.subplots(1, 2, figsize=(13.5, 4.4))
axs[0].barh(range(len(names)), errs, color=cols)
axs[0].set_yticks(range(len(names))); axs[0].set_yticklabels(names, fontsize=8); axs[0].invert_yaxis()
axs[0].set_xlabel("отн. L2-ошибка к эталону, %"); axs[0].set_title("Точность (меньше — лучше)")
for i, e in enumerate(errs):
axs[0].text(e + 0.05, i, f"{e:.2f}", va="center", fontsize=8)
axs[1].scatter(cells, errs, c=cols, s=90, zorder=3, edgecolors="k")
for n, c2, e in zip(names, cells, errs):
axs[1].annotate(n, (c2, e), fontsize=7, xytext=(5, 4), textcoords="offset points")
axs[1].axvline(_cref, color="#d1495b", ls="--", alpha=0.6)
axs[1].text(_cref, errs.max()*0.9, " работа эталона", color="#d1495b", fontsize=8)
axs[1].set_xscale("log"); axs[1].set_xlabel("cell-updates (работа, лог)"); axs[1].set_ylabel("ошибка, %")
axs[1].set_title("Фронт «точность–работа»: AMR ближе к низ-лево (точно и дёшево)")
fig.suptitle("AMR: численное сравнение точности и производительности (KBC, тест-вихрь)", fontweight="bold")
cm.finish(fig, "11d_amr_comparison.png")
# ===== часть 3: анимация полного вложенного 2×+4× ===============================================
_naf = _run_nested(True, steps=750, frame_every=3)
_vmaxN = np.nanpercentile(np.abs(vorticity(_naf[0][1])), 99.0)
_cN = plt.get_cmap("RdBu_r").copy(); _cN.set_bad("#1b1b1b")
def _draw_nested(fig, i):
t, u0, u1, u2 = _naf[i]
ax = fig.add_subplot(111)
ax.imshow(vorticity(u0), origin="lower", cmap=_cN, vmin=-_vmaxN, vmax=_vmaxN,
extent=(0, _Nc, 0, _Nc), interpolation="nearest")
ax.imshow(vorticity(u1)[1:-1, 1:-1], origin="lower", cmap=_cN, vmin=-_vmaxN, vmax=_vmaxN,
extent=(8.5, 39.5, 8.5, 39.5), interpolation="nearest")
ax.imshow(vorticity(u2)[1:-1, 1:-1], origin="lower", cmap=_cN, vmin=-_vmaxN, vmax=_vmaxN,
extent=(16.25, 31.75, 16.25, 31.75), interpolation="nearest")
ax.add_patch(mpatches.Rectangle((8, 8), 32, 32, fill=False, edgecolor="#7CFC00", lw=2.5))
ax.add_patch(mpatches.Rectangle((16, 16), 16, 16, fill=False, edgecolor="#ff8a00", lw=2.5))
ax.text(8.3, 40.4, "2× блок", color="#7CFC00", fontsize=9, weight="bold")
ax.text(16.3, 32.4, "4× блок", color="#ff8a00", fontsize=9, weight="bold")
ax.set_title(f"Полный вложенный AMR: коарс + 2× + 4× (видны размеры ячеек) · t={t}", fontsize=10)
ax.set_xticks([]); ax.set_yticks([]); fig.tight_layout()
cm.render_gif(len(_naf), _draw_nested, "amr_nested.gif", (6.2, 6.2), fps=24)
out_lines.append("PASS AMR: вложенный 2x+4x точнее и дешевле; временная интерполяция снижает ошибку")
cm.write_out("amr_bench.txt", out_lines)
print("DONE (amr demo)")
+122
View File
@@ -0,0 +1,122 @@
# Демо-3: каверна с движущейся крышкой (Re=1000) против эталона Ghia, Ghia & Shin (1982).
# Стенки — bounce-back; крышка — bounce-back с поправкой импульса −2wᵢρ(cᵢ·u_w)/cs².
# Сетка намеренно грубая, BB первого порядка → совпадение качественное.
# Артефакты: figures/09_cavity_ghia.png, anim/cavity_transient.gif, out/cavity.txt|npz.
# Запуск: python cavity_gpu.py
import numpy as np
import _common as cm
cp = cm.cp; xp = cm.xp; E = cm.E; Coll = cm.Coll; Strm = cm.Strm
plt = cm.plt; OPP = cm.OPP; Q = cm.Q; CS2 = cm.CS2
UP = [2, 5, 6] # c_iy = +1
DOWN = [4, 7, 8] # c_iy = -1
RIGHT = [1, 5, 8] # c_ix = +1
LEFT = [3, 6, 7] # c_ix = -1
def run_cavity(N=110, Re=1000.0, U=0.05, steps=14000, tol=1e-7, check=500, frame_every=58):
nu = U*N/Re
beta = cm.nu_to_beta(nu)
rho = cp.ones((N, N), cm.DTYPE)
u = cp.zeros((2, N, N), cm.DTYPE)
f = E.feq(rho, u)
W = cm.to_cpu(cm.L.Wa).astype(float)
prev = None
frames = []
prog = cm.Viz.make_progress("cavity")
t = 0
for t in range(steps):
rho, u = E.macros(f)
if t % frame_every == 0:
frames.append((t, cm.to_cpu(u)))
fcol = Coll.kbc_collide(f, E.feq(rho, u), beta)
f = Strm.stream(fcol)
# bounce-back: на каждой стенке достраиваем входящие в область направления
for i in UP: # низ (y=0)
f[i, 0, :] = fcol[OPP[i], 0, :]
for i in RIGHT: # лево (x=0)
f[i, :, 0] = fcol[OPP[i], :, 0]
for i in LEFT: # право (x=N-1)
f[i, :, -1] = fcol[OPP[i], :, -1]
for i in DOWN: # верх (крышка) + поправка подвижной стенки
io = OPP[i]
corr = 2.0*W[io]*(cm.Cx[io]*U)/CS2 # u_w=(U,0): c_y-член равен нулю
f[i, -1, :] = fcol[io, -1, :] - corr
if t % check == 0 and t > 0:
cur = u.copy()
if prev is not None and float(xp.abs(cur - prev).max()) < tol:
print(f"\ncavity: сошлось по полю скорости на шаге {t}")
break
prev = cur
prog(t, steps - 1)
rho, u = E.macros(f)
return dict(N=N, Re=Re, U=U, u=cm.to_cpu(u), steps=t, frames=frames)
cav = run_cavity()
print(f"Каверна: Re={cav['Re']:.0f}, N={cav['N']}, остановлено на шаге {cav['steps']}")
# Эталон Ghia, Ghia & Shin (1982), Re=1000.
ghia_y = np.array([0.0000, 0.0547, 0.0625, 0.0703, 0.1016, 0.1719, 0.2813, 0.4531,
0.5000, 0.6172, 0.7344, 0.8516, 0.9531, 0.9609, 0.9688, 0.9766, 1.0000])
ghia_u = np.array([0.0000, -0.18109, -0.20196, -0.22220, -0.29730, -0.38289, -0.27805,
-0.10648, -0.06080, 0.05702, 0.18719, 0.33304, 0.46604, 0.51117,
0.57492, 0.65928, 1.0000])
ghia_x = np.array([0.0000, 0.0625, 0.0703, 0.0781, 0.0938, 0.1563, 0.2266, 0.2344,
0.5000, 0.8047, 0.8594, 0.9063, 0.9453, 0.9531, 0.9609, 0.9688, 1.0000])
ghia_v = np.array([0.0000, 0.27485, 0.29012, 0.30353, 0.32627, 0.37095, 0.33075, 0.32235,
0.02526, -0.31966, -0.42665, -0.51550, -0.39188, -0.33714, -0.27669,
-0.21388, 0.0000])
Ncav = cav["N"]
yc = (np.arange(Ncav) + 0.5)/Ncav
xc = (np.arange(Ncav) + 0.5)/Ncav
u_centerline = cav["u"][0, :, Ncav//2]/cav["U"]
v_centerline = cav["u"][1, Ncav//2, :]/cav["U"]
fig, axs = plt.subplots(1, 3, figsize=(14, 4.4))
spd = np.sqrt(cav["u"][0]**2 + cav["u"][1]**2)/cav["U"]
axs[0].imshow(spd, origin="lower", cmap="viridis", extent=[0, 1, 0, 1])
sx = np.linspace(0, 1, Ncav)
axs[0].streamplot(sx, sx, cav["u"][0], cav["u"][1], density=1.1, color="white", linewidth=0.6)
axs[0].set_title("Линии тока и $|\\mathbf{u}|/U$"); axs[0].set_xlabel("$x/L$"); axs[0].set_ylabel("$y/L$")
axs[1].plot(u_centerline, yc, "-", color="#1f6feb", label="KBC")
axs[1].plot(ghia_u, ghia_y, "o", ms=5, mfc="none", color="#d1495b", label="Ghia 1982")
axs[1].set_title("$u_x/U$ по вертикали $x=0.5$"); axs[1].set_xlabel("$u_x/U$")
axs[1].set_ylabel("$y/L$"); axs[1].legend()
axs[2].plot(xc, v_centerline, "-", color="#1f6feb", label="KBC")
axs[2].plot(ghia_x, ghia_v, "o", ms=5, mfc="none", color="#d1495b", label="Ghia 1982")
axs[2].set_title("$u_y/U$ по горизонтали $y=0.5$"); axs[2].set_xlabel("$x/L$")
axs[2].set_ylabel("$u_y/U$"); axs[2].legend()
fig.suptitle("Демо-3: каверна с движущейся крышкой, Re=1000 (KBC vs Ghia)",
y=1.04, fontweight="bold")
cm.finish(fig, "09_cavity_ghia.png")
u_interp = np.interp(ghia_y, yc, u_centerline)
rms = float(np.sqrt(np.mean((u_interp - ghia_u)**2)))
print(f"Каверна: RMS-отклонение u_x от Ghia ~ {rms:.3f} (грубое BB → допустимо)")
# --- гифка становления течения -----------------------------------------------------------------
_cf = cav["frames"]
_vmax = max(np.sqrt(u[0]**2 + u[1]**2).max() for _, u in _cf)/cav["U"]
def _draw(fig, i):
t, u = _cf[i]
ax = fig.add_subplot(111)
sp = np.sqrt(u[0]**2 + u[1]**2)/cav["U"]
ax.imshow(sp, origin="lower", cmap="viridis", vmin=0, vmax=_vmax, extent=[0, 1, 0, 1])
ax.set_title(f"Каверна Re=1000 · t={t} · $|\\mathbf{{u}}|/U$", fontsize=10)
ax.set_xlabel("$x/L$"); ax.set_ylabel("$y/L$")
fig.tight_layout()
cm.render_gif(len(_cf), _draw, "cavity_transient.gif", (4.9, 4.5), fps=24)
np.savez(__import__("os").path.join(cm.OUTDIR, "cavity.npz"),
yc=yc, xc=xc, u_centerline=u_centerline, v_centerline=v_centerline,
ghia_y=ghia_y, ghia_u=ghia_u, ghia_x=ghia_x, ghia_v=ghia_v, rms=rms,
Re=cav["Re"], N=cav["N"], steps=cav["steps"])
cm.write_out("cavity.txt", [
f"N={cav['N']} Re={cav['Re']:.0f} U={cav['U']} stopped_at={cav['steps']}",
f"rms_ux_vs_Ghia={rms:.4f}",
"PASS каверна: качественное совпадение с Ghia (BB первого порядка)",
])
print("DONE (cavity)")
+170
View File
@@ -0,0 +1,170 @@
# Самопроверки математики НА КОМПОНЕНТАХ ФИНАЛЬНОЙ МОДЕЛИ (solver_2x_sdf): решётка,
# энтропийное равновесие, KBC-проектор, предел γ=2 ≡ BGK. Результаты → out/checks.txt
# (ноутбук цитирует их в markdown). Запуск: python checks_gpu.py [AMR_FP64=1 — жёстче пороги].
import numpy as np
import _common as cm
cp = cm.cp; xp = cm.xp; L = cm.L; E = cm.E; Coll = cm.Coll
Q = cm.Q; CS2 = cm.CS2; TOL = cm.TOL_EXACT
lines = [f"backend={cm.B.BACKEND}; порог точных тождеств TOL={TOL:g}"]
def check(name, value, tol):
ok = value < tol
lines.append(f"{'PASS' if ok else 'FAIL'} {name}: {value:.3e} (tol {tol:g})")
print(lines[-1])
assert ok, name
return ok
def check_gt(name, value, floor):
"""Для величин, которые обязаны быть НЕ малы (иначе модель выродилась)."""
ok = value > floor
lines.append(f"{'PASS' if ok else 'FAIL'} {name}: {value:.3e} (должно быть > {floor:g})")
print(lines[-1])
assert ok, name
return ok
# --- 1. Решётка D2Q9: тождества весов и изотропии ---------------------------------------------
# Допуск по типу: Wa хранится в DTYPE, и в float32 уже само Σw отличается от 1 на ~7e-9,
# так что жёсткий 1e-12 здесь означал бы падение проверки в режиме по умолчанию.
TOL_W = 1e-12 if cm.FP64 else 1e-6
C = np.array(list(zip(L.Cx, L.Cy)), float)
W = cm.to_cpu(L.Wa).astype(float)
check("Σw − 1", abs(W.sum() - 1.0), TOL_W)
check("|Σ w·c|", np.abs((W[:, None]*C).sum(0)).max(), TOL_W)
check("|Σ w·c⊗c − cs²·I|",
np.abs((W[:, None, None]*C[:, :, None]*C[:, None, :]).sum(0) - CS2*np.eye(2)).max(), TOL_W)
assert np.all(C[list(L.OPP)] == -C), "OPP: c_opp != -c"
lines.append("PASS OPP: c_opp = −c (точно)")
# --- 2. Энтропийное равновесие (product-form) точно воспроизводит ρ и ρu -----------------------
rng = np.random.default_rng(0)
rho_t = xp.asarray(1.0 + 0.1*rng.standard_normal((16, 16)), cm.DTYPE)
u_t = xp.asarray(0.06*rng.standard_normal((2, 16, 16)), cm.DTYPE)
fe = E.feq(rho_t, u_t)
rho_m, u_m = E.macros(fe)
check("feq: |Σf − ρ|", float(xp.abs(rho_m - rho_t).max()), TOL)
check("feq: |u(f) − u|", float(xp.abs(u_m - u_t).max()), TOL)
Pxx = (L.CXa[:, None, None]**2 * fe).sum(0)
check("feq: |Σc_x²f − ρ(cs²+u_x²)| (мал при малом Ma)",
float(xp.abs(Pxx - rho_t*(CS2 + u_t[0]**2)).max()), 5e-3)
# --- 3. KBC-проектор Ps: идемпотентность и состав сдвиг-моментов --------------------------------
Ps = cm.to_cpu(L.Ps).astype(float)
check("Ps идемпотентен: |Ps·Ps − Ps|", np.abs(Ps @ Ps - Ps).max(), 1e-5 if not cm.FP64 else 1e-12)
# моментная матрица (как в lattice._build_projector): Δs несёт ТОЛЬКО {cx²−cy², cx·cy}
cx, cy = C[:, 0], C[:, 1]
M = np.zeros((Q, Q))
M[0] = 1.0; M[1] = cx; M[2] = cy; M[3] = 3*(cx**2 + cy**2) - 2
M[4] = cx**2 - cy**2; M[5] = cx*cy
M[6] = cx**2*cy; M[7] = cx*cy**2; M[8] = cx**2*cy**2
dfn = rng.standard_normal((Q, 5, 7))
ds = np.einsum("ij,jab->iab", Ps, dfn)
mds = np.einsum("ai,ijk->ajk", M, ds)
other = [0, 1, 2, 3, 6, 7, 8]
check("Ps: |моменты Δs вне {N, Πxy}|", np.abs(mds[other]).max(), 1e-4 if not cm.FP64 else 1e-10)
lines.append("PASS состав KBC-N1: s = {Πxx−Πyy, Πxy}, h = остальное (k отщеплён точно)")
# --- 4. Предел γ=2: KBC-форма с γ=2 совпадает с BGK (через РЕАЛЬНЫЙ проектор модели) ------------
f0 = fe + xp.asarray(0.01*rng.standard_normal((Q, 16, 16)), cm.DTYPE)
r0, u0 = E.macros(f0)
fe0 = E.feq(r0, u0)
beta = 0.7
dfn0 = f0 - fe0
ds0 = Coll._project_shift(dfn0)
fk2 = f0 - beta*(2.0*ds0 + 2.0*(dfn0 - ds0)) # KBC с γ=2
fbgk = cm.bgk_collide(f0, fe0, beta)
check("KBC(γ=2) ≡ BGK", float(xp.abs(fk2 - fbgk).max()), TOL)
# --- 5. kbc_collide сохраняет ρ и импульс -------------------------------------------------------
fpost = Coll.kbc_collide(f0, fe0, beta)
r1, u1 = E.macros(fpost)
check("KBC: |Δρ| за столкновение", float(xp.abs(r1 - r0).max()), TOL)
check("KBC: |Δ(ρu)| за столкновение", float(xp.abs(u1*r1[None] - u0*r0[None]).max()), TOL)
# --- 6. Стабилизатор γ ДЕЙСТВИТЕЛЬНО считается на физическом поле ------------------------------
# Регрессия на дефект от 2026-08-14: абсолютный порог знаменателя (1e-6 в fp32) отправлял в откат
# γ←2 77–99% узлов, т.е. модель молча вырождалась в LBGK. Проверяем на РАЗВИТОМ поле (сдвиговый
# слой после прогрева), что откат не срабатывает и γ не залипает на 2.
Strm = cm.Strm
Nsl = 64; u0sl = 0.04; beta_sl = cm.nu_to_beta(u0sl*Nsl/5000.0)
yy = xp.arange(Nsl, dtype=cm.DTYPE)[:, None] * xp.ones((1, Nsl), cm.DTYPE)
xx = xp.ones((Nsl, 1), cm.DTYPE) * xp.arange(Nsl, dtype=cm.DTYPE)[None, :]
uxsl = xp.where(yy <= Nsl/2, u0sl*xp.tanh(40.0*(yy/Nsl - 0.25)), u0sl*xp.tanh(40.0*(0.75 - yy/Nsl)))
uysl = 0.05*u0sl*xp.sin(2.0*np.pi*(xx/Nsl + 0.25))
fsl = E.feq(xp.ones((Nsl, Nsl), cm.DTYPE), cm.B.pack((uxsl, uysl)))
for _ in range(400):
rsl, usl = E.macros(fsl)
fsl = Strm.stream(Coll.kbc_collide(fsl, E.feq(rsl, usl), beta_sl))
rsl, usl = E.macros(fsl); fesl = E.feq(rsl, usl)
dfs = fsl - fesl; dss = Coll._project_shift(dfs); dhs = dfs - dss; invs = 1.0/fesl
den_s = (dhs*dhs*invs).sum(0); nrm_s = (dfs*dfs*invs).sum(0)
frac_fb = float((den_s <= cm.B.GREL*nrm_s).mean())
check("γ: доля узлов в откате γ←2 на развитом поле", frac_fb, 1e-3)
gam = cm.kbc_gamma(fsl, fesl, beta_sl)
# при полном вырождении в LBGK эта величина строго 0, при частичном (90% узлов) — ~0.01,
# наблюдаемое значение ~0.13, поэтому порог 0.05 ловит регресс с запасом и не флапает
check_gt("γ: ⟨|γ−2|⟩ — отличие от LBGK", float(xp.abs(gam - 2.0).mean()), 0.05)
lines.append(f"INFO γ на развитом поле: ⟨γ⟩={float(gam.mean()):.4f} "
f"[{float(gam.min()):.3f}, {float(gam.max()):.3f}], min den/‖Δ‖²="
f"{float((den_s/xp.maximum(nrm_s, 1e-30)).min()):.2e} (порог {cm.B.GREL:g})")
print(lines[-1])
# --- 7. Замкнутая формула γ ур.(17)/(39) против корня точного ур.(15) --------------------------
# Берём один узел развитого поля и решаем Σ Δh·ln(1 + ((1−βγ)Δh − (2β−1)Δs)/feq) = 0 бисекцией.
iy, ix = Nsl//3, Nsl//3
fe_n = cm.to_cpu(fesl[:, iy, ix]).astype(float)
ds_n = cm.to_cpu(dss[:, iy, ix]).astype(float); dh_n = cm.to_cpu(dhs[:, iy, ix]).astype(float)
g_formula = float(cm.to_cpu(gam[iy, ix]))
def _crit(g):
return float((dh_n*np.log(1.0 + ((1.0 - beta_sl*g)*dh_n - (2.0*beta_sl - 1.0)*ds_n)/fe_n)).sum())
lo, hi = g_formula - 2.0, g_formula + 2.0
while _crit(lo)*_crit(hi) > 0 and hi - lo < 40:
lo -= 1.0; hi += 1.0
for _ in range(200):
mid = 0.5*(lo + hi)
if _crit(lo)*_crit(mid) <= 0: hi = mid
else: lo = mid
check("γ: |формула − точный корень ур.(15)|", abs(g_formula - 0.5*(lo + hi)), 5e-2)
# --- 8. Гидродинамический предел: ν по затуханию сдвиговой волны --------------------------------
# Единственная проверка, которая ловит подмену оператора: LBGK и KBC дают одну ν, но неверная
# релаксация сдвиг-моментов сразу видна здесь.
Nw = 48; kw = 2.0*np.pi/Nw; NSW = 1500
sinw = xp.sin(kw*xp.arange(Nw, dtype=cm.DTYPE))
for tau_w in (0.6, 1.0):
beta_w = 1.0/(2.0*tau_w); nu_th = CS2*(tau_w - 0.5)
# амплитуда 1e-2 (Ma≈0.017, режим ещё линейный): в float32 при 1e-4 сигнал тонет в округлении
uxw = 0.01*sinw[:, None]*xp.ones((1, 4), cm.DTYPE)
fw = E.feq(xp.ones((Nw, 4), cm.DTYPE), cm.B.pack((uxw, xp.zeros_like(uxw))))
amps = []
for _ in range(NSW + 1):
rw, uw = E.macros(fw)
amps.append(abs(float((uw[0][:, 0]*sinw).sum()))) # проекция на моду, без abs поэлементно
fw = Strm.stream(Coll.kbc_collide(fw, E.feq(rw, uw), beta_w))
a = np.array(amps); i0 = NSW//3
rate = -np.polyfit(np.arange(i0, NSW + 1), np.log(a[i0:]), 1)[0]
# допуск 1e-2 покрывает решёточную дисперсию O(k²) (k=0.131 → ~3e-3)
check(f"ν(τ={tau_w}): отн. ошибка затухания сдвиговой волны", abs(rate/(kw*kw)/nu_th - 1.0), 1e-2)
# --- 9. ГУ канала: вход и выход не связаны заворотом roll (включая углы) ------------------------
# Регрессия на дефект от 2026-08-14: Zou-He стоял на срезе 1:-1, и в 4 углах stream() через roll
# сносил популяции прямо из столбца выхода в столбец входа.
import boundary as Bnd
Ny_b, Nx_b = 24, 32
fb = E.feq(xp.ones((Ny_b, Nx_b), cm.DTYPE), 0.02*xp.ones((2, Ny_b, Nx_b), cm.DTYPE))
fb = fb*(1.0 + xp.asarray(0.05*rng.standard_normal(fb.shape), cm.DTYPE))
post_b = fb.copy()
uin_b = xp.zeros((2, Ny_b, 1), cm.DTYPE); uin_b[0] = 0.05
fs_b = Bnd.channel_bc(Bnd.free_slip_walls(Strm.stream(post_b)), uin_b, outlet_uy="zero")
r_in = fs_b[:, :, 0].sum(0)
ux_in = (L.CXa[:, None]*fs_b[:, :, 0]).sum(0)/r_in
check("ГУ: |u_x(вход) − заданной| по ВСЕМ рядам, вкл. углы", float(xp.abs(ux_in - 0.05).max()), TOL)
check("ГУ: |ρ(выход) − 1| по ВСЕМ рядам, вкл. углы",
float(xp.abs(fs_b[:, :, -1].sum(0) - 1.0).max()), TOL)
leak = min(float(xp.abs(fs_b[i, row, 0] - post_b[i, row, -1]).max())
for i in (1, 5, 8) for row in (0, -1))
check_gt("ГУ: расхождение углового f с популяцией выхода (заворот подавлен)", leak, 1e-6)
lines.append("=== Все самопроверки пройдены ===")
print(lines[-1])
cm.write_out("checks.txt", lines)
+153
View File
@@ -0,0 +1,153 @@
# Статические иллюстрации (НЕ симуляции): стенсили D2Q9/D3Q27, блок-схема шага KBC,
# схема блочного измельчения. Чистый numpy+matplotlib — НЕ импортирует CuPy и потому
# исполним и локально (единственный скрипт demos_gpu без GPU).
# Схема измельчения использует фон |ω| из out/obstacle_circle_field.npz (его пишет
# obstacle_gpu.py на сервере); без него рисуется нейтральный фон.
# Артефакты: figures/01_stencil_d2q9.png, 06_algorithm_flow.png, 11c_refinement_schematic.png,
# 11_d3q27.png. Запуск: python figures_static.py
import os
import sys
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.lines import Line2D
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
HERE = os.path.dirname(os.path.abspath(__file__))
THEORY = os.path.dirname(HERE)
FIGDIR = os.path.join(THEORY, "figures"); os.makedirs(FIGDIR, exist_ok=True)
OUTDIR = os.path.join(HERE, "out")
plt.rcParams.update({
"figure.dpi": 110, "savefig.dpi": 120, "font.size": 11,
"axes.titlesize": 13, "axes.titleweight": "bold",
"axes.grid": True, "grid.alpha": 0.25, "axes.axisbelow": True,
"figure.facecolor": "white", "axes.facecolor": "#fbfbfd",
})
def finish(fig, name):
fig.savefig(os.path.join(FIGDIR, name), bbox_inches="tight")
plt.close(fig)
print("PNG:", name)
# D2Q9 (та же нумерация, что в solver_2x_sdf/lattice.py)
CX = np.array([0, 1, 0, -1, 0, 1, -1, -1, 1], float)
CY = np.array([0, 0, 1, 0, -1, 1, 1, -1, -1], float)
W = np.array([4/9, 1/9, 1/9, 1/9, 1/9, 1/36, 1/36, 1/36, 1/36])
CS2 = 1.0/3.0
# --- 1. Стенсиль D2Q9 ---------------------------------------------------------------------------
fig, ax = plt.subplots(figsize=(5.2, 5.2))
tier_color = {4/9: "#d1495b", 1/9: "#2e7d32", 1/36: "#1f6feb"}
for i in range(9):
col = tier_color[W[i]]
if i == 0:
ax.plot(0, 0, "o", ms=18, color=col, zorder=3)
ax.annotate(f"$w_0={W[i]:.3f}$", (0, 0), textcoords="offset points",
xytext=(8, 8), fontsize=9)
else:
ax.annotate("", xy=(CX[i], CY[i]), xytext=(0, 0),
arrowprops=dict(arrowstyle="-|>", lw=1.5 + 40*W[i], color=col))
ax.annotate(f"$c_{{{i}}}$", (CX[i], CY[i]), textcoords="offset points",
xytext=(6*np.sign(CX[i] + 0.1), 6*np.sign(CY[i] + 0.1)), fontsize=9)
ax.set_xlim(-1.6, 1.6); ax.set_ylim(-1.6, 1.6); ax.set_aspect("equal")
ax.set_title("Решётка D2Q9: дискретные скорости $\\mathbf{c}_i$ и веса $w_i$")
ax.set_xlabel("$c_x$ [lu/ts]"); ax.set_ylabel("$c_y$ [lu/ts]")
ax.legend(handles=[Line2D([0], [0], color=c, lw=3, label=f"$w={k:.4f}$")
for k, c in tier_color.items()], loc="upper right", fontsize=8)
finish(fig, "01_stencil_d2q9.png")
# --- 2. Блок-схема шага KBC ---------------------------------------------------------------------
fig, ax = plt.subplots(figsize=(4.3, 6.2))
ax.set_axis_off()
steps_txt = [
("Макро-моменты\n$\\rho=\\sum f_i,\\ \\rho\\mathbf{u}=\\sum \\mathbf{c}_i f_i$", "#e8f0fe"),
("Равновесие $f_i^{eq}$\n(энтропийное)", "#e6f4ea"),
("Неравновесие\n$\\Delta s=P_s(f-f^{eq}),\\ \\Delta h=(f-f^{eq})-\\Delta s$", "#fff4e5"),
("Стабилизатор $\\gamma$\n$=1/\\beta-(2-1/\\beta)\\frac{\\langle\\Delta s|\\Delta h\\rangle}{\\langle\\Delta h|\\Delta h\\rangle}$", "#fde7ef"),
("Столкновение\n$f\\leftarrow f-\\beta(2\\Delta s+\\gamma\\Delta h)$", "#f3e8fd"),
("Перенос\n$f_i(\\mathbf{x}+\\mathbf{c}_i)\\leftarrow f_i(\\mathbf{x})$", "#e8f0fe"),
("Граничные условия", "#eceff1"),
]
y = 0.95
for txt, col in steps_txt:
ax.add_patch(plt.Rectangle((0.05, y - 0.1), 0.9, 0.085, transform=ax.transAxes,
facecolor=col, edgecolor="#555", lw=1.2))
ax.text(0.5, y - 0.057, txt, transform=ax.transAxes, ha="center", va="center", fontsize=8.5)
if y > 0.2:
ax.annotate("", xy=(0.5, y - 0.118), xytext=(0.5, y - 0.1), xycoords="axes fraction",
arrowprops=dict(arrowstyle="-|>", color="#555"))
y -= 0.13
ax.set_title("Шаг KBC")
finish(fig, "06_algorithm_flow.png")
# --- 3. Схема блочного измельчения (фон — |ω| цилиндра, если есть артефакт) ----------------------
def _vorticity(u):
duy_dx = (np.roll(u[1], -1, 1) - np.roll(u[1], 1, 1))*0.5
dux_dy = (np.roll(u[0], -1, 0) - np.roll(u[0], 1, 0))*0.5
return duy_dx - dux_dy
field_path = os.path.join(OUTDIR, "obstacle_circle_field.npz")
schematic_path = os.path.join(FIGDIR, "11c_refinement_schematic.png")
if not os.path.exists(field_path) and os.path.exists(schematic_path):
# без серверного фона не затираем уже существующую схему с |ω|-подложкой
print("(нет out/obstacle_circle_field.npz — оставляю существующий 11c_refinement_schematic.png; "
"запустите obstacle_gpu.py на сервере и повторите)")
else:
fig, ax = plt.subplots(figsize=(11, 5.6))
if os.path.exists(field_path):
d = np.load(field_path)
u = d["u"]; solid = d["solid"]
Nx, Ny = int(d["Nx"]), int(d["Ny"]); cx, cy, D = int(d["cx"]), int(d["cy"]), int(d["D"])
vmag = np.where(solid, np.nan, np.abs(_vorticity(u)))
ax.imshow(vmag, origin="lower", cmap="inferno", vmax=np.nanpercentile(vmag, 99))
else:
Nx, Ny, cx, cy, D = 150, 72, 37, 36, 14
ax.add_patch(plt.Circle((cx, cy), D/2, color="#37474f"))
ax.set_facecolor("#10101a")
def _grid_block(x0, y0, w, h, step, color, label):
ax.add_patch(mpatches.Rectangle((x0, y0), w, h, fill=False, edgecolor=color, lw=2.2))
for xx in np.arange(x0, x0 + w + 0.1, step):
ax.plot([xx, xx], [y0, y0 + h], color=color, lw=0.4, alpha=0.45)
for yy in np.arange(y0, y0 + h + 0.1, step):
ax.plot([x0, x0 + w], [yy, yy], color=color, lw=0.4, alpha=0.45)
ax.text(x0 + 1, y0 + h - 4, label, color=color, fontsize=8, weight="bold")
_grid_block(0, 0, Nx - 1, Ny - 1, 16, "#4fc3f7", "уровень 0: Δx (крупный)")
_grid_block(cx - 1.5*D, cy - 1.9*D, 6.5*D, 3.8*D, 8, "#aed581", "уровень 1: Δx/2 (тело+след)")
_grid_block(cx - 1.1*D, cy - 1.3*D, 2.6*D, 2.6*D, 4, "#ff8a65", "уровень 2: Δx/4 (у тела)")
ax.set_xlim(0, Nx - 1); ax.set_ylim(0, Ny - 1)
ax.set_title("Схема блочного измельчения вокруг тела (поверх $|\\omega|$): "
"сетка мельче там, где градиенты резче")
ax.set_xlabel("x [lu]"); ax.set_ylabel("y [lu]")
finish(fig, "11c_refinement_schematic.png")
# --- 4. Стенсиль D3Q27 --------------------------------------------------------------------------
C3 = np.array([[x, y, z] for x in (-1, 0, 1) for y in (-1, 0, 1) for z in (-1, 0, 1)], dtype=float)
nz = (C3 != 0).sum(1)
W3 = np.select([nz == 0, nz == 1, nz == 2, nz == 3], [8/27, 2/27, 1/54, 1/216])
assert np.isclose(W3.sum(), 1.0)
assert np.allclose((W3[:, None, None]*C3[:, :, None]*C3[:, None, :]).sum(0), CS2*np.eye(3))
print("D3Q27: Σw=1 и Σw c⊗c = cs²·I — OK; направлений:", len(C3))
fig = plt.figure(figsize=(6.4, 5.6))
ax = fig.add_subplot(111, projection="3d")
tier3 = {8/27: ("#d1495b", "покой"), 2/27: ("#2e7d32", "оси (×6)"),
1/54: ("#1f6feb", "рёбра (×12)"), 1/216: ("#9c27b0", "углы (×8)")}
for wv, (col, lab) in tier3.items():
m = np.isclose(W3, wv)
ax.scatter(C3[m, 0], C3[m, 1], C3[m, 2], s=70 + 5000*wv, color=col,
label=f"{lab}, w={wv:.4f}", depthshade=True, edgecolors="k", linewidths=0.4)
ax.set_xlabel("$c_x$"); ax.set_ylabel("$c_y$"); ax.set_zlabel("$c_z$")
ax.set_title("Решётка D3Q27 (цель реализации)")
ax.legend(loc="upper left", fontsize=7, bbox_to_anchor=(0.0, 1.0))
finish(fig, "11_d3q27.png")
print("DONE (static figures)")
+118
View File
@@ -0,0 +1,118 @@
# Демо-4: обтекание тел разной формы (цилиндр/квадрат/треугольник/профиль) при Re≈150 —
# вихревые дорожки Кармана на грубой сетке (KBC держит устойчиво). Ступенчатый bounce-back
# (узловой) — именно его «лесенку» позже лечит SDF+Bouzidi в финальной модели.
# Артефакты: figures/10_obstacle_shapes.png, figures/10b_obstacle_probe.png,
# anim/obstacle_shapes.gif, out/obstacle.txt, out/obstacle_circle_field.npz (фон для схемы AMR).
# Запуск: python obstacle_gpu.py
import os
import numpy as np
import _common as cm
cp = cm.cp; xp = cm.xp; E = cm.E; Coll = cm.Coll; Strm = cm.Strm
plt = cm.plt; vorticity = cm.vorticity; OPP = cm.OPP; Q = cm.Q
def solid_mask(Nx, Ny, shape, cx, cy, D):
"""Маска тела + стенки канала (host numpy → device bool)."""
Y, X = np.mgrid[0:Ny, 0:Nx]
if shape == "circle":
m = (X - cx)**2 + (Y - cy)**2 <= (D/2)**2
elif shape == "square":
m = (np.abs(X - cx) <= D/2) & (np.abs(Y - cy) <= D/2)
elif shape == "triangle": # вершиной против потока
tt = (X - (cx - D/2))/D
m = (tt >= 0) & (tt <= 1) & (np.abs(Y - cy) <= tt*(D/2))
elif shape == "airfoil": # наклонный эллипс (угол атаки 18°)
aoa = np.deg2rad(18.0); a = D*0.95; b = D*0.26
xr = (X - cx)*np.cos(aoa) + (Y - cy)*np.sin(aoa)
yr = -(X - cx)*np.sin(aoa) + (Y - cy)*np.cos(aoa)
m = (xr/a)**2 + (yr/b)**2 <= 1
else:
m = np.zeros((Ny, Nx), bool)
m[0, :] = True; m[-1, :] = True # стенки канала (верх/низ)
return m
def run_obstacle(shape, Nx=150, Ny=72, D=14, U=0.06, Re=150.0, steps=9600,
ramp=1000, frame_every=40):
cx, cy = Nx//4, Ny//2
solid_np = solid_mask(Nx, Ny, shape, cx, cy, D)
solid = cp.asarray(solid_np)
beta = cm.nu_to_beta(U*D/Re)
f = E.feq(cp.ones((Ny, Nx), cm.DTYPE), cp.zeros((2, Ny, Nx), cm.DTYPE))
frames, probe = [], []
px, py = cx + 3*D, cy
ones_col = cp.ones((Ny, 1), cm.DTYPE)
prog = cm.Viz.make_progress(shape)
for t in range(steps + 1):
rho, u = E.macros(f)
fcol = Coll.kbc_collide(f, E.feq(rho, u), beta)
for i in range(Q):
fcol[i, solid] = f[OPP[i], solid] # узловой bounce-back (тело + стенки)
f = Strm.stream(fcol)
Uin = U*cm.smoothstep(t/ramp) # плавный разгон входа
uin = cp.zeros((2, Ny, 1), cm.DTYPE); uin[0, :, 0] = Uin
f[:, 1:-1, 0] = E.feq(ones_col, uin)[:, 1:-1, 0] # вток (Дирихле)
f[:, 1:-1, -1] = f[:, 1:-1, -2] # отток (нуль-градиент)
if t % frame_every == 0:
_, uu = E.macros(f)
frames.append((t, cm.to_cpu(uu)))
probe.append(float(uu[1, py, px]))
prog(t, steps)
return dict(shape=shape, solid=solid_np, frames=frames, probe=np.array(probe),
Nx=Nx, Ny=Ny, U=U, Re=Re, D=D, cx=cx, cy=cy)
SHAPES = [("circle", "цилиндр"), ("square", "квадрат"),
("triangle", "треугольник"), ("airfoil", "профиль")]
obs = {key: run_obstacle(key) for key, _ in SHAPES}
out_lines = ["Nx=150 Ny=72 D=14 U=0.06 Re=150 steps=9600"]
for key, name in SHAPES:
p = obs[key]["probe"]; half = p[len(p)//2:]
out_lines.append(f"{key}: max|u_y(probe)|={np.abs(half).max():.4f}")
print(f"{name:12}: max|u_y(зонд)|={np.abs(half).max():.4f} (колебания → срыв вихрей)")
# --- гифка-монтаж всех четырёх тел -------------------------------------------------------------
_keys = [k for k, _ in SHAPES]
_titles = {k: n for k, n in SHAPES}
_nf = min(len(obs[k]["frames"]) for k in _keys)
_vmax = max(np.percentile(np.abs(vorticity(obs[k]["frames"][_nf//2][1])), 99.0) for k in _keys)
_ocmap = plt.get_cmap("RdBu_r").copy(); _ocmap.set_bad("#1b1b1b")
def _draw(fig, i):
for j, k in enumerate(_keys):
ax = fig.add_subplot(2, 2, j + 1)
t, u = obs[k]["frames"][i]
vort = np.where(obs[k]["solid"], np.nan, vorticity(u))
ax.imshow(vort, origin="lower", cmap=_ocmap, vmin=-_vmax, vmax=_vmax)
ax.set_title(f"{_titles[k]} · t={t}", fontsize=9)
ax.set_xticks([]); ax.set_yticks([])
fig.suptitle("Обтекание тел (KBC, Re≈150): завихренность", fontsize=11)
fig.tight_layout(rect=(0, 0, 1, 0.96))
cm.render_gif(_nf, _draw, "obstacle_shapes.gif", (11, 6.2), fps=24)
# --- статические панели ------------------------------------------------------------------------
fig, axs = plt.subplots(2, 2, figsize=(12, 5.6), constrained_layout=True)
for ax, k in zip(axs.ravel(), _keys):
t, u = obs[k]["frames"][-1]
vort = np.where(obs[k]["solid"], np.nan, vorticity(u))
ax.imshow(vort, origin="lower", cmap=_ocmap, vmin=-_vmax, vmax=_vmax)
ax.set_title(f"{_titles[k]} (t={t})", fontsize=10)
ax.set_xticks([]); ax.set_yticks([])
fig.suptitle("Обтекание тел: поле завихренности в конце прогона", fontweight="bold")
cm.finish(fig, "10_obstacle_shapes.png")
fig, ax = plt.subplots(figsize=(9.5, 3.4))
for k in _keys:
ax.plot(np.arange(len(obs[k]["probe"])), obs[k]["probe"], label=_titles[k])
ax.set_title("Поперечная скорость в следе (зонд за телом): колебания = срыв вихрей")
ax.set_xlabel("кадр"); ax.set_ylabel("$u_y$ в зонде [lu/ts]"); ax.legend(fontsize=8, ncol=4)
cm.finish(fig, "10b_obstacle_probe.png")
# фон для схемы блочного измельчения (figures_static.py): |ω| цилиндра в конце прогона
rc = obs["circle"]
np.savez(os.path.join(cm.OUTDIR, "obstacle_circle_field.npz"),
u=rc["frames"][-1][1], solid=rc["solid"],
Nx=rc["Nx"], Ny=rc["Ny"], cx=rc["cx"], cy=rc["cy"], D=rc["D"])
out_lines.append("PASS дорожки Кармана на всех четырёх телах")
cm.write_out("obstacle.txt", out_lines)
print("DONE (obstacle)")
+109
View File
@@ -0,0 +1,109 @@
# Демо-2: двойной сдвиговый слой (Minion–Brown) на недоразрешённой сетке — устойчивость
# BGK (poly-равновесие) против KBC (entropic + γ): BGK разрушается, KBC держит (implicit LES).
# Артефакты: figures/08_shear_stability.png, anim/shear_bgk_vs_kbc.gif, out/shear.txt.
# Запуск: python shear_gpu.py
import math
import numpy as np
import _common as cm
cp = cm.cp; xp = cm.xp; E = cm.E; Coll = cm.Coll; Strm = cm.Strm
plt = cm.plt; vorticity = cm.vorticity
def init_shear(N, U0=0.04, delta=0.05, kappa=80.0):
xs = cp.arange(N, dtype=cm.DTYPE)/N
X, Yc = cp.meshgrid(xs, xs)
ux = cp.where(Yc <= 0.5, U0*cp.tanh(kappa*(Yc - 0.25)), U0*cp.tanh(kappa*(0.75 - Yc)))
uy = delta*U0*cp.sin(2.0*math.pi*(X + 0.25))
return cm.B.pack((ux.astype(cm.DTYPE), uy.astype(cm.DTYPE)))
def run_shear(scheme, N=128, U0=0.04, nu=1.7e-4, steps=8000, rec=100, frame_every=33):
u0 = init_shear(N, U0=U0)
rho = cp.ones((N, N), cm.DTYPE)
beta = cm.nu_to_beta(nu)
f = E.feq(rho, u0) if scheme == "kbc" else cm.feq_poly(rho, u0)
ts, umax, frames = [], [], []
blew_up_at = None
last_u = cm.to_cpu(u0)
prog = cm.Viz.make_progress(scheme)
for t in range(steps + 1):
rho, u = E.macros(f)
um = float(xp.abs(u).max())
if not math.isfinite(um) or um > 0.5:
blew_up_at = t
print(f"\n{scheme}: разрушился на шаге {t}")
break
if t % frame_every == 0:
frames.append((t, cm.to_cpu(u)))
if scheme == "kbc":
f = Coll.kbc_collide(f, E.feq(rho, u), beta)
else:
f = cm.bgk_collide(f, cm.feq_poly(rho, u), beta)
f = Strm.stream(f)
last_u = cm.to_cpu(u)
if t % rec == 0:
ts.append(t); umax.append(um)
prog(t, steps)
gamma = None
if scheme == "kbc": # поле γ на финальном состоянии — для панели
r, u = E.macros(f)
gamma = cm.to_cpu(cm.kbc_gamma(f, E.feq(r, u), beta))
return dict(scheme=scheme, ts=np.array(ts), umax=np.array(umax), frames=frames,
u=last_u, gamma=gamma, blew_up_at=blew_up_at, N=N, U0=U0, nu=nu)
Re_shear = 0.04*128/1.7e-4
print(f"Сдвиговый слой: Re ~ {Re_shear:.0f}, β ~ {cm.nu_to_beta(1.7e-4):.4f}")
res_bgk = run_shear("bgk")
res_kbc = run_shear("kbc")
print("BGK:", (f"разрушился на t={res_bgk['blew_up_at']}" if res_bgk["blew_up_at"] else "устойчив"))
print("KBC:", (f"разрушился на t={res_kbc['blew_up_at']}" if res_kbc["blew_up_at"] else "устойчив"))
assert res_kbc["blew_up_at"] is None, "KBC должен оставаться устойчивым"
# --- панель: ω(KBC), поле γ−2, max|u|(t) -------------------------------------------------------
fig, axs = plt.subplots(1, 3, figsize=(13.5, 4.0))
om_k = vorticity(res_kbc["u"])
im0 = axs[0].imshow(om_k, origin="lower", cmap="RdBu_r")
axs[0].set_title("KBC: завихренность (устойчиво)")
axs[0].set_xlabel("$x$ [lu]"); axs[0].set_ylabel("$y$ [lu]")
plt.colorbar(im0, ax=axs[0], fraction=0.046)
im1 = axs[1].imshow(res_kbc["gamma"] - 2.0, origin="lower", cmap="magma")
axs[1].set_title("Поле стабилизатора $\\gamma-2$")
axs[1].set_xlabel("$x$ [lu]"); axs[1].set_ylabel("$y$ [lu]")
plt.colorbar(im1, ax=axs[1], fraction=0.046)
axs[2].plot(res_kbc["ts"], res_kbc["umax"], color="#2e7d32", label="KBC")
axs[2].plot(res_bgk["ts"], res_bgk["umax"], color="#d1495b", label="BGK")
if res_bgk["blew_up_at"]:
axs[2].axvline(res_bgk["blew_up_at"], color="#d1495b", ls="--", alpha=0.6)
axs[2].set_title("$\\max|\\mathbf{u}|$ во времени")
axs[2].set_xlabel("$t$ [ts]"); axs[2].set_ylabel("$\\max|\\mathbf{u}|$ [lu/ts]"); axs[2].legend()
fig.suptitle(f"Демо-2: сдвиговый слой, Re~{Re_shear:.0f} (BGK разрушается, KBC держит)",
y=1.04, fontweight="bold")
cm.finish(fig, "08_shear_stability.png")
# --- гифка: BGK (слева, замирает с пометкой) vs KBC (справа) -----------------------------------
_kf, _bf = res_kbc["frames"], res_bgk["frames"]
_om0s = np.percentile(np.abs(vorticity(_kf[len(_kf)//2][1])), 99.5)
def _draw(fig, i):
ax1 = fig.add_subplot(1, 2, 1)
ax2 = fig.add_subplot(1, 2, 2)
dead = i >= len(_bf)
tb, ub = _bf[min(i, len(_bf) - 1)]
ax1.imshow(vorticity(ub), origin="lower", cmap="RdBu_r", vmin=-_om0s, vmax=_om0s)
ax1.set_title("BGK — разрушился" if dead else f"BGK · t={tb}",
color="red" if dead else "black", fontsize=10)
tk, uk = _kf[i]
ax2.imshow(vorticity(uk), origin="lower", cmap="RdBu_r", vmin=-_om0s, vmax=_om0s)
ax2.set_title(f"KBC · t={tk}", fontsize=10)
for a in (ax1, ax2):
a.set_xticks([]); a.set_yticks([])
fig.tight_layout()
cm.render_gif(len(_kf), _draw, "shear_bgk_vs_kbc.gif", (8.4, 4.3), fps=24)
cm.write_out("shear.txt", [
f"N=128 U0=0.04 nu=1.7e-4 Re~{Re_shear:.0f} steps=8000",
f"BGK_blew_up_at={res_bgk['blew_up_at']}",
f"KBC_stable={res_kbc['blew_up_at'] is None}",
"PASS KBC устойчив там, где BGK разрушается",
])
print("DONE (shear)")
+108
View File
@@ -0,0 +1,108 @@
# Демо-1: вихрь Тейлора–Грина (валидация вязкости и H-теоремы) на компонентах solver_2x_sdf.
# Аналитика: E(t) = E0·exp(−4νk²t). Меряем ν по наклону ln E(t) — должен совпасть с заданным.
# Артефакты: figures/07_taylor_green.png, figures/07b_tgv_decay_slices.png, anim/tgv_decay.gif,
# out/tgv.txt. Запуск: python tgv_gpu.py
import math
import numpy as np
import _common as cm
cp = cm.cp; xp = cm.xp; E = cm.E; Coll = cm.Coll; Strm = cm.Strm
plt = cm.plt; vorticity = cm.vorticity
def run_tgv(N=96, U0=0.04, nu=0.0125, steps=10000, rec=50, n_snap=10, frame_every=42):
k = 2.0*math.pi/N
xs = cp.arange(N, dtype=cm.DTYPE)
X, Yc = cp.meshgrid(xs, xs) # X — ось 1, Yc — ось 0
ux = -U0*cp.cos(k*X)*cp.sin(k*Yc)
uy = U0*cp.sin(k*X)*cp.cos(k*Yc)
rho = cp.ones((N, N), cm.DTYPE)
f = E.feq(rho, cm.B.pack((ux.astype(cm.DTYPE), uy.astype(cm.DTYPE))))
beta = cm.nu_to_beta(nu)
snap_at = sorted(set(int(round(v)) for v in np.linspace(0, steps, n_snap)))
snaps, frames, ts, KE, Hs = [], [], [], [], []
prog = cm.Viz.make_progress("tgv")
for t in range(steps + 1):
rho, u = E.macros(f)
f = Coll.kbc_collide(f, E.feq(rho, u), beta)
f = Strm.stream(f) # периодический перенос — ровно случай TGV
if (t in snap_at) or (t % rec == 0) or (t % frame_every == 0):
_, uu = E.macros(f)
if t in snap_at:
snaps.append((t, cm.to_cpu(uu)))
if t % frame_every == 0:
frames.append((t, cm.to_cpu(uu)))
if t % rec == 0:
ts.append(t)
KE.append(float((0.5*(uu[0]**2 + uu[1]**2)).mean()))
Hs.append(float(cm.H_function(f).mean()))
prog(t, steps)
return dict(N=N, k=k, nu=nu, U0=U0, ts=np.array(ts), KE=np.array(KE),
H=np.array(Hs), u=cm.to_cpu(E.macros(f)[1]), snaps=snaps, frames=frames)
tgv = run_tgv()
mask = tgv["KE"] > 0
slope = np.polyfit(tgv["ts"][mask], np.log(tgv["KE"][mask]), 1)[0]
nu_meas = -slope/(4.0*tgv["k"]**2)
rel_err = abs(nu_meas - tgv["nu"])/tgv["nu"]
print(f"TGV: ν задано={tgv['nu']:.5f}, ν измерено={nu_meas:.5f}, отн. ошибка={rel_err*100:.2f}%")
assert rel_err < 0.06, "TGV: измеренная вязкость отклонилась слишком сильно"
dH = np.diff(tgv["H"])
print(f"TGV: H-функция монотонно невозрастает: max(ΔH)={dH.max():.2e}")
# --- статическая панель: ω, затухание энергии, H(t) -------------------------------------------
fig, axs = plt.subplots(1, 3, figsize=(13.5, 4.0))
om = vorticity(tgv["u"])
im = axs[0].imshow(om, origin="lower", cmap="RdBu_r")
axs[0].set_title("Завихренность $\\omega$ (TGV, срез)")
axs[0].set_xlabel("$x$ [lu]"); axs[0].set_ylabel("$y$ [lu]")
plt.colorbar(im, ax=axs[0], fraction=0.046)
axs[1].semilogy(tgv["ts"], tgv["KE"], "o", ms=3, label="KBC (измерено)")
axs[1].semilogy(tgv["ts"], tgv["KE"][0]*np.exp(-4*tgv["nu"]*tgv["k"]**2*tgv["ts"]),
"-", color="#d1495b", label="$E_0 e^{-4\\nu k^2 t}$ (аналитика)")
axs[1].set_title("Затухание кин. энергии"); axs[1].set_xlabel("$t$ [ts]")
axs[1].set_ylabel("$E(t)$"); axs[1].legend()
axs[2].plot(tgv["ts"], tgv["H"], color="#2e7d32")
axs[2].set_title("H-функция (энтропия) во времени")
axs[2].set_xlabel("$t$ [ts]"); axs[2].set_ylabel("$\\langle H\\rangle$")
fig.suptitle("Демо-1: вихрь Тейлора–Грина (KBC, D2Q9)", y=1.04, fontweight="bold")
cm.finish(fig, "07_taylor_green.png")
# --- 10 срезов распада с единой шкалой цвета ---------------------------------------------------
snaps = tgv["snaps"]
om0 = np.abs(vorticity(snaps[0][1])).max()
KE0 = 0.5*np.mean(snaps[0][1][0]**2 + snaps[0][1][1]**2)
fig, axs = plt.subplots(2, 5, figsize=(15, 6.3), constrained_layout=True)
im = None
for ax, (tt, uu) in zip(axs.ravel(), snaps):
im = ax.imshow(vorticity(uu), origin="lower", cmap="RdBu_r", vmin=-om0, vmax=om0)
KEf = 0.5*np.mean(uu[0]**2 + uu[1]**2)/KE0
ax.set_title(f"t={tt} · $E/E_0$={KEf:.2f}", fontsize=10)
ax.set_xticks([]); ax.set_yticks([])
fig.colorbar(im, ax=axs, fraction=0.025, pad=0.01, label="завихренность $\\omega$ [1/ts]")
fig.suptitle("Распад вихря Тейлора–Грина: завихренность на равных интервалах времени "
"(единая шкала цвета)", fontweight="bold")
cm.finish(fig, "07b_tgv_decay_slices.png")
# --- гифка затухания ---------------------------------------------------------------------------
_fr = tgv["frames"]
_om0 = np.abs(vorticity(_fr[0][1])).max()
_KE0 = 0.5*np.mean(_fr[0][1][0]**2 + _fr[0][1][1]**2)
def _draw(fig, i):
t, u = _fr[i]
ax = fig.add_subplot(111)
ax.imshow(vorticity(u), origin="lower", cmap="RdBu_r", vmin=-_om0, vmax=_om0)
ke = 0.5*np.mean(u[0]**2 + u[1]**2)/_KE0
ax.set_title(f"Тейлор–Грин · t={t} · $E/E_0$={ke:.2f}", fontsize=10)
ax.set_xticks([]); ax.set_yticks([])
fig.tight_layout()
cm.render_gif(len(_fr), _draw, "tgv_decay.gif", (4.6, 4.4), fps=24)
cm.write_out("tgv.txt", [
f"N={tgv['N']} U0={tgv['U0']} steps=10000",
f"nu_given={tgv['nu']:.5f} nu_measured={nu_meas:.5f} rel_err_pct={rel_err*100:.2f}",
f"H_monotone_max_dH={dH.max():.3e}",
"PASS TGV: вязкость по затуханию энергии и H-теорема",
])
print("DONE (tgv)")
+7
View File
@@ -0,0 +1,7 @@
when,name,steps,Nx,Ny,cx,in_D,out_D,outlet_uy,beta,St,St_corr,Cd,Clrms,Cm,Cd_st,Clrms_st,dCd_pct,dCl_pct,rho_last,cd_win_std,cl_win_std,wall_s,desc
2026-06-10 01:22:17,B0_baseline,100000,174,90,40,2.5,8.31,zero,0.1778,0.2207,0.1814,1.7987,0.7051,-2.9e-05,1.8165,0.732,0.99,3.8,0.99956,0.0244,0.0263,276.8,"база 174x90: вход 2.5D, выход 8.3D, uy=0 (контроль; кросс-чек уже с конвективным членом)"
2026-06-10 01:27:05,F1_outlet_far,100000,300,90,40,2.5,16.19,zero,0.1778,0.1977,0.1625,2.8379,6.7939,-0.000322,2.8839,7.4148,1.62,9.14,1.66349,0.2246,0.7482,287.7,выход 8.3D -> 16.2D (вход 2.5D как в базе) — изолирует близость выхода
2026-06-10 01:31:48,F2_inlet_far,100000,230,90,96,6.0,8.31,zero,0.1778,0.1676,0.1378,2.2287,6.1198,0.000547,2.0134,6.845,9.66,11.85,1.65477,0.3062,3.3794,282.9,вход 2.5D -> 6.0D (выход 8.3D как в базе) — изолирует близость входа
2026-06-10 01:36:32,F3_inout_far,100000,390,90,96,6.0,18.31,zero,0.1778,0.1944,0.1599,2.5772,5.3482,-0.000739,2.5247,5.6976,2.03,6.53,1.65972,0.2693,0.8575,284.0,вход 6.0D + выход 18.3D — оба продольных фактора вместе
2026-06-10 01:41:11,F4_outlet_uy,100000,174,90,40,2.5,8.31,extrapolate,0.1778,0.2223,0.1828,1.7539,0.5572,-3e-05,1.7642,0.5658,0.59,1.54,0.99985,0.0226,0.0429,279.3,мягкий выход: uy нуль-градиент вместо uy=0 (геометрия базовая) — изолирует отражение вихрей
2026-06-10 01:41:25,F5_combo,100000,390,90,96,6.0,18.31,extrapolate,0.1778,nan,nan,nan,nan,nan,nan,nan,nan,nan,nan,nan,nan,13.7,вход 6.0D + выход 18.3D + мягкий uy-выход — всё вместе
1 when name steps Nx Ny cx in_D out_D outlet_uy beta St St_corr Cd Clrms Cm Cd_st Clrms_st dCd_pct dCl_pct rho_last cd_win_std cl_win_std wall_s desc
2 2026-06-10 01:22:17 B0_baseline 100000 174 90 40 2.5 8.31 zero 0.1778 0.2207 0.1814 1.7987 0.7051 -2.9e-05 1.8165 0.732 0.99 3.8 0.99956 0.0244 0.0263 276.8 база 174x90: вход 2.5D, выход 8.3D, uy=0 (контроль; кросс-чек уже с конвективным членом)
3 2026-06-10 01:27:05 F1_outlet_far 100000 300 90 40 2.5 16.19 zero 0.1778 0.1977 0.1625 2.8379 6.7939 -0.000322 2.8839 7.4148 1.62 9.14 1.66349 0.2246 0.7482 287.7 выход 8.3D -> 16.2D (вход 2.5D как в базе) — изолирует близость выхода
4 2026-06-10 01:31:48 F2_inlet_far 100000 230 90 96 6.0 8.31 zero 0.1778 0.1676 0.1378 2.2287 6.1198 0.000547 2.0134 6.845 9.66 11.85 1.65477 0.3062 3.3794 282.9 вход 2.5D -> 6.0D (выход 8.3D как в базе) — изолирует близость входа
5 2026-06-10 01:36:32 F3_inout_far 100000 390 90 96 6.0 18.31 zero 0.1778 0.1944 0.1599 2.5772 5.3482 -0.000739 2.5247 5.6976 2.03 6.53 1.65972 0.2693 0.8575 284.0 вход 6.0D + выход 18.3D — оба продольных фактора вместе
6 2026-06-10 01:41:11 F4_outlet_uy 100000 174 90 40 2.5 8.31 extrapolate 0.1778 0.2223 0.1828 1.7539 0.5572 -3e-05 1.7642 0.5658 0.59 1.54 0.99985 0.0226 0.0429 279.3 мягкий выход: uy нуль-градиент вместо uy=0 (геометрия базовая) — изолирует отражение вихрей
7 2026-06-10 01:41:25 F5_combo 100000 390 90 96 6.0 18.31 extrapolate 0.1778 nan nan nan nan nan nan nan nan nan nan nan nan 13.7 вход 6.0D + выход 18.3D + мягкий uy-выход — всё вместе
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+886
View File
@@ -0,0 +1,886 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "ea656dfb",
"metadata": {},
"source": [
"# Метод KBC (энтропийный Lattice Boltzmann): от формул к валидированной GPU-модели\n",
"\n",
"**Karlin–Bösch–Chikatamarla (KBC)** — энтропийная multi-relaxation-time формулировка\n",
"решёточного метода Больцмана (LBM), дающая **безусловную нелинейную устойчивость** при больших\n",
"числах Рейнольдса без явной подсеточной модели турбулентности (работает как *implicit LES*).\n",
"\n",
"Этот документ — рабочий разбор метода и **история эволюции 2D-макета**: от формул и\n",
"самопроверок через классические бенчмарки (Тейлора–Грина, сдвиговый слой, каверна),\n",
"блочное измельчение сетки (AMR) и границу SDF+Bouzidi — к **финальной валидированной\n",
"GPU-модели** обтекания цилиндра `solver_2x_sdf` (D2Q9, KBC-N1, L0+L1, CUDA-графы) с\n",
"двумя независимыми способами считывания силы.\n",
"\n",
"> **Как устроен документ.** Ноутбук **не исполняет код** — только теория и готовые\n",
"> артефакты (картинки, гифки, числа). Весь код живёт рядом и запускается на GPU-сервере:\n",
">\n",
"> | Где | Что |\n",
"> |---|---|\n",
"> | `demos_gpu/` | GPU-прогоны всех демо и самопроверок (см. `demos_gpu/README.md`) |\n",
"> | `solver_2x_sdf/` | финальная модель (см. `solver_2x_sdf/README.md`) |\n",
"> | `figures/`, `anim/`, `*/out/` | артефакты, встроенные ниже |\n",
">\n",
"> Демо гоняются **теми же модулями** (решётка, равновесие, столкновение, перенос,\n",
"> AMR-связка), что и финальная модель, — каждый бенчмарк ниже одновременно валидирует\n",
"> компонент финальной модели."
]
},
{
"cell_type": "markdown",
"id": "23513c20",
"metadata": {},
"source": [
"## Сводная таблица обозначений\n",
"\n",
"Везде **решёточные единицы**: шаг сетки $\\Delta x = 1$ (lu), шаг по времени $\\Delta t = 1$ (ts).\n",
"\n",
"| Символ | Название | Смысл / формула | Диапазон |\n",
"|---|---|---|---|\n",
"| $f_i$ | популяция | плотность частиц со скоростью $\\mathbf c_i$ | $f_i>0$ |\n",
"| $\\mathbf c_i$ | дискретная скорость | направление переноса $i$ | компоненты $\\in\\{-1,0,1\\}$ |\n",
"| $w_i$ | вес квадратуры | $\\sum_i w_i=1$ | $\\{4/9,1/9,1/36\\}$ (D2Q9) |\n",
"| $c_s$ | скорость звука решётки | $c_s^2=1/3$ | $c_s=1/\\sqrt3$ |\n",
"| $\\rho$, $\\mathbf u$ | плотность, скорость | $\\rho=\\sum_i f_i$, $\\rho\\mathbf u=\\sum_i \\mathbf c_i f_i$ | $\\rho\\approx1$, $|\\mathbf u|\\ll c_s$ |\n",
"| $\\tau$, $\\beta$ | релаксация | $\\nu=c_s^2(\\tau-\\tfrac12)$, $\\beta=1/(2\\tau)$ | $\\tau>1/2$ |\n",
"| $\\gamma$ | **энтропийный стабилизатор** | релаксация высших мод | $\\approx2$ |\n",
"| $f_i^{\\mathrm{eq}}$ | равновесие | максимум энтропии при $\\rho,\\mathbf u$ | $>0$ |\n",
"| $k_i,s_i,h_i$ | разложение | кинематич./сдвиг/высшие моды | $k+s+h=f$ |\n",
"| $\\Delta s_i,\\Delta h_i$ | неравновесие | $P_s(f{-}f^{eq})$, остальное | малы |\n",
"| $H$ | H-функция | $H=\\sum_i f_i\\ln(f_i/w_i)$ | не растёт |\n",
"| $\\langle X|Y\\rangle$ | энтропийное скал. произв. | $\\sum_i X_iY_i/f_i^{eq}$ | — |\n",
"| $T,N,\\Pi_{xy}$ | натуральные 2-е моменты | $\\Pi_{xx}{+}\\Pi_{yy}$, $\\Pi_{xx}{-}\\Pi_{yy}$, сдвиг | — |\n",
"| $\\Pi^{neq}_{\\alpha\\beta}$ | неравновесный тензор | $\\sum_i c_{i\\alpha}c_{i\\beta}(f_i-f_i^{eq})$ | — |\n",
"| $\\sigma_{\\alpha\\beta}$ | тензор напряжений | $-p'\\delta_{\\alpha\\beta}-(1-\\tfrac{1}{2\\tau})\\Pi^{neq,dev}_{\\alpha\\beta}$ | — |\n",
"| $q$ | доля стенки (Bouzidi) | положение стенки на линке из SDF | $(0,1)$ |\n",
"| $C_d, C_l, C_m$ | коэффициенты | $\\tfrac{2F_x}{U^2 D}$, $\\tfrac{2F_y}{U^2 D}$, $\\tfrac{2T_z}{U^2 D^2}$ | — |\n",
"| $\\beta_{бл}$ | блокировка канала | $D/N_y$ (стеснение стенками) | $\\to0$ — безграничный |\n",
"| $\\mathrm{Re}$, $\\mathrm{Ma}$, $\\mathrm{St}$ | подобие | $UL/\\nu$, $|\\mathbf u|/c_s$, $fD/U$ | — |"
]
},
{
"cell_type": "markdown",
"id": "02c48319",
"metadata": {},
"source": [
"---\n",
"# Часть I. Теория\n",
"\n",
"## 1. Основы LBM и решётка D2Q9\n",
"\n",
"LBM отслеживает **популяции** $f_i(\\mathbf x, t)$ — плотность фиктивных частиц, движущихся в\n",
"узле $\\mathbf x$ со скоростью $\\mathbf c_i$. Динамика — чередование **переноса** (streaming) и\n",
"**столкновения** (collision):\n",
"$$ f_i(\\mathbf x + \\mathbf c_i\\,\\Delta t,\\; t+\\Delta t) = f_i(\\mathbf x,t) + \\Omega_i. $$\n",
"\n",
"**Решётка D2Q9**: покоящаяся частица, 4 осевые и 4 диагональные скорости; $c_s^2=1/3$;\n",
"веса $w_0=4/9$, осевые $1/9$, диагональные $1/36$ дают изотропию 4-го порядка.\n",
"Макромоменты: $\\rho=\\sum_i f_i$, $\\rho\\mathbf u=\\sum_i \\mathbf c_i f_i$.\n",
"\n",
"![Стенсиль D2Q9](figures/01_stencil_d2q9.png)\n",
"\n",
"**Самопроверка** (`demos_gpu/checks_gpu.py`, исполняется на компонентах финальной модели —\n",
"`solver_2x_sdf/lattice.py`): $\\sum w_i=1$, $\\sum w_i\\mathbf c_i=0$,\n",
"$\\sum w_i c_{i\\alpha}c_{i\\beta}=c_s^2\\delta_{\\alpha\\beta}$, $\\mathbf c_{\\bar i}=-\\mathbf c_i$ —\n",
"все тождества проходят (см. `demos_gpu/out/checks.txt`)."
]
},
{
"cell_type": "markdown",
"id": "8a74a375",
"metadata": {},
"source": [
"## 2. Равновесие: полиномиальное и энтропийное\n",
"\n",
"**Полиномиальное** (разложение максвеллиана до $O(u^2)$, стандарт BGK):\n",
"$$ f_i^{\\mathrm{eq,poly}} = w_i\\,\\rho\\left[1 + \\frac{\\mathbf c_i\\cdot\\mathbf u}{c_s^2}\n",
" + \\frac{(\\mathbf c_i\\cdot\\mathbf u)^2}{2c_s^4} - \\frac{\\mathbf u^2}{2c_s^2}\\right]. $$\n",
"\n",
"**Энтропийное** (точный максимум дискретной H-функции при связях $\\rho$, $\\rho\\mathbf u$) для\n",
"D2Q9/D3Q27 имеет замкнутую **product-form**:\n",
"$$ f_i^{\\mathrm{eq}} = \\rho\\,w_i\\prod_{\\alpha\\in\\{x,y\\}}\n",
" A(u_\\alpha)\\,B(u_\\alpha)^{\\,c_{i\\alpha}},\\qquad\n",
" A(u) = 2-\\sqrt{1+3u^2},\\qquad B(u) = \\frac{2u+\\sqrt{1+3u^2}}{1-u}. $$\n",
"\n",
"✓ **Сверено с первоисточником**: форма и коэффициенты в точности совпадают с уравнениями\n",
"(7)–(11) Bösch, Chikatamarla, Karlin, *Phys. Rev. E* **92**, 043309 (2015)\n",
"(восходит к Ansumali, Karlin, Öttinger, *Europhys. Lett.* **63**, 798, 2003).\n",
"Реализация — `solver_2x_sdf/equilibrium.py` (показатели $c_{i\\alpha}\\in\\{-1,0,1\\}$, поэтому\n",
"вместо `pow` берётся $B$ или $1/B$ — быстрее и совместимо с CUDA-графами).\n",
"\n",
"**Самопроверка** (`checks_gpu.py`): энтропийное равновесие воспроизводит $\\rho$ и\n",
"$\\rho\\mathbf u$ **точно** (до машинного нуля выбранной точности), а второй момент при малом\n",
"$\\mathrm{Ma}$ близок к $\\rho(c_s^2\\delta_{\\alpha\\beta}+u_\\alpha u_\\beta)$ — тензору давления\n",
"Навье–Стокса. В отличие от полинома, product-form гарантирует положительность и H-теорему."
]
},
{
"cell_type": "markdown",
"id": "c6abf6ca",
"metadata": {},
"source": [
"## 3. Разложение популяций $f = k + s + h$\n",
"\n",
"Сердце KBC — разбиение 9-вектора популяций по **моментным подпространствам**. Натуральные\n",
"вторые моменты D2Q9: след $T=\\Pi_{xx}{+}\\Pi_{yy}$, разность нормальных напряжений\n",
"$N=\\Pi_{xx}{-}\\Pi_{yy}$, сдвиг $\\Pi_{xy}$; плюс третьи ($Q_{xxy},Q_{xyy}$) и четвёртый\n",
"($\\Pi_{xxyy}$) моменты:\n",
"\n",
"- $k_i$ — **кинематическая** часть: только сохраняющиеся моменты ($\\rho, j_x, j_y$);\n",
"- $s_i$ — **сдвиговая** часть: девиаторный тензор напряжений ($N$ и $\\Pi_{xy}$);\n",
"- $h_i$ — **высшие** («ghost») моды: $T$, третьи и четвёртый моменты.\n",
"\n",
"Строится моментная матрица $M_{ai}=\\phi^{(a)}(\\mathbf c_i)$ из 9 независимых мономов; проектор\n",
"на группу моментов $G$: $P_G = M^{-1} D_G M$. По построению $P_k+P_s+P_h=I$, разложение точное.\n",
"\n",
"✓ **Сверено с первоисточником**: назначение $s$ задаёт *вариант* KBC; вариант с\n",
"$s=\\{N,\\Pi_{xy}\\}$ (только девиатор) — канонический «KBC-d» / **N1**\n",
"(Karlin, Bösch, Chikatamarla, *PRE* **90**, 031302(R), 2014; Bösch et al., *PRE* **92**,\n",
"043309, 2015 — там же семейство $s\\in\\{d,\\,d{+}t,\\,d{+}q,\\,d{+}t{+}q\\}$). Именно он\n",
"реализован в `solver_2x_sdf/lattice.py` (проектор `Ps` на строки $\\{c_x^2{-}c_y^2,\\,c_xc_y\\}$).\n",
"\n",
"**Самопроверка** (`checks_gpu.py`): `Ps` идемпотентен, $\\Delta s$ несёт **только** моменты\n",
"$\\{N,\\Pi_{xy}\\}$ (остальные строки $M\\Delta s$ — нули)."
]
},
{
"cell_type": "markdown",
"id": "3fe4992c",
"metadata": {},
"source": [
"## 4. Оператор столкновений KBC и стабилизатор $\\gamma$\n",
"\n",
"Так как $f$ и $f^{eq}$ имеют одинаковые сохраняющиеся моменты, $\\Delta k=0$ и\n",
"$f-f^{eq}=\\Delta s+\\Delta h$, где $\\Delta s = P_s(f-f^{eq})$.\n",
"\n",
"**Столкновение KBC** (через «зеркальное состояние» $f^{mirr}$):\n",
"$$ f_i' = (1-\\beta)\\,f_i + \\beta\\,f_i^{mirr}\n",
" \\;\\;\\Longleftrightarrow\\;\\;\n",
" \\boxed{\\,f_i' = f_i - \\beta\\,\\bigl(2\\,\\Delta s_i + \\gamma\\,\\Delta h_i\\bigr)\\,},\\qquad\n",
" \\nu = c_s^2\\Bigl(\\frac{1}{2\\beta}-\\frac12\\Bigr)\\;\\Rightarrow\\;\\beta=\\frac{1}{2\\tau}. $$\n",
"Множитель **2** перед $\\Delta s$ фиксирует сдвиговую вязкость; высшие моды релаксируются\n",
"отдельным $\\gamma$.\n",
"\n",
"**Стабилизатор** $\\gamma$ выбирается локально (в каждом узле на каждом шаге) из условия\n",
"неувеличения дискретной энтропии; аналитическое приближение корня:\n",
"$$ \\gamma^\\* = \\frac{1}{\\beta} - \\Bigl(2-\\frac{1}{\\beta}\\Bigr)\n",
" \\frac{\\langle \\Delta s\\,|\\,\\Delta h\\rangle}{\\langle \\Delta h\\,|\\,\\Delta h\\rangle},\n",
" \\qquad \\langle X|Y\\rangle = \\sum_i \\frac{X_i Y_i}{f_i^{eq}}. $$\n",
"\n",
"✓ **Сверено с первоисточником**: обе формулы (включая зеркальную форму и связь\n",
"$\\nu(\\beta)$) дословно совпадают с Karlin, Bösch, Chikatamarla, *PRE* **90**, 031302(R)\n",
"(2014) и Bösch et al., *PRE* **92**, 043309 (2015), ур. (24)–(25). Реализация —\n",
"`solver_2x_sdf/collision.py` (с регуляризацией $\\gamma\\to2$ при\n",
"$\\langle\\Delta h|\\Delta h\\rangle\\to0$).\n",
"\n",
"При $\\gamma=2$: $2\\Delta s+2\\Delta h=2(f-f^{eq})$ — KBC **сводится к BGK** с $\\omega=2\\beta$.\n",
"**Самопроверка** (`checks_gpu.py`): KBC$(\\gamma{=}2)\\equiv$ BGK до машинного нуля; столкновение\n",
"сохраняет $\\rho$ и $\\rho\\mathbf u$.\n",
"\n",
"**Популярно.** Разложение $k+s+h$ — три «слоя» движения: перенос массы/импульса, вязкий сдвиг\n",
"(трение слоёв — задаёт вязкость и Re) и мелкая «рябь» высших мод, где на грубой сетке копится\n",
"неустойчивость. KBC гасит сдвиг фиксированным темпом $2\\beta$ (физика), а рябь — адаптивным\n",
"$\\gamma\\beta$: в спокойных зонах $\\gamma\\approx2$ (как BGK), у резких градиентов $\\gamma$\n",
"отклоняется и добавляет ровно столько диссипации, сколько разрешает энтропия. Это «локальный\n",
"термостат потока» — встроенная, без подгоночных констант, модель мелких масштабов\n",
"(*implicit LES*)."
]
},
{
"cell_type": "markdown",
"id": "9eba837e",
"metadata": {},
"source": [
"## 5. Перенос и связь вязкости с $\\beta$; H-функция\n",
"\n",
"Перенос — сдвиг каждой популяции на её $\\mathbf c_i$ (pull-схема, `solver_2x_sdf/streaming.py`).\n",
"Вязкость: $\\nu = c_s^2(\\tfrac{1}{2\\beta}-\\tfrac12)$, т.е. $\\tau=\\nu/c_s^2+\\tfrac12$.\n",
"Число Рейнольдса $\\mathrm{Re}=UL/\\nu$; Маха $\\mathrm{Ma}=U/c_s\\lesssim0.1$ (ошибка\n",
"сжимаемости $\\propto \\mathrm{Ma}^2$).\n",
"\n",
"Дискретная энтропия $H=\\sum_i f_i\\ln(f_i/w_i)$ — «физический компас» устойчивости: из условия\n",
"«$H$ не растёт» выведен $\\gamma$; в демо Тейлора–Грина ниже $\\langle H\\rangle(t)$ монотонно\n",
"не возрастает.\n",
"\n",
"## 6. Алгоритм одного шага KBC\n",
"\n",
"```\n",
"для каждого узла:\n",
" 1) макро-моменты: rho = sum f_i, rho*u = sum c_i f_i\n",
" 2) равновесие: f_i^eq (энтропийное product-form)\n",
" 3) неравновесие: Ds = P_s (f - f^eq), Dh = (f - f^eq) - Ds\n",
" 4) стабилизатор: gamma = 1/beta - (2 - 1/beta) <Ds|Dh> / <Dh|Dh>\n",
" 5) столкновение: f <- f - beta (2 Ds + gamma Dh)\n",
" 6) перенос: f_i(x + c_i) <- f_i(x)\n",
" 7) граничные условия (bounce-back / Bouzidi / Zou-He / free-slip)\n",
"```\n",
"\n",
"![Блок-схема шага KBC](figures/06_algorithm_flow.png)"
]
},
{
"cell_type": "markdown",
"id": "a27e6548",
"metadata": {},
"source": [
"---\n",
"# Часть II. Валидационные демо\n",
"\n",
"Четыре классических теста; каждый гоняется скриптом из `demos_gpu/` **на тех же модулях, что\n",
"финальная модель** (решётка/равновесие/KBC/перенос из `solver_2x_sdf`). Числа ниже цитируются\n",
"из `demos_gpu/out/*.txt` — перегенерируются при каждом прогоне на GPU-сервере.\n",
"\n",
"## 7. Демо-1: вихрь Тейлора–Грина — валидация вязкости и H-теоремы\n",
"\n",
"Точное решение несжимаемого Навье–Стокса в периодической области:\n",
"$u_x=-U_0\\cos(kx)\\sin(ky)$, $u_y=U_0\\sin(kx)\\cos(ky)$, $k=2\\pi/N$; кинетическая энергия\n",
"затухает как $E(t)=E_0\\,e^{-4\\nu k^2 t}$. Прогоняем KBC ($N=96$, $\\nu=0.0125$, 10 000 шагов)\n",
"и **измеряем** $\\nu$ по наклону $\\ln E(t)$ — критерий приёмки: отклонение $<6\\%$\n",
"(фактически $\\lesssim1\\%$, см. `demos_gpu/out/tgv.txt`). $\\langle H\\rangle(t)$ монотонно\n",
"не возрастает — H-теорема выполняется.\n",
"\n",
"![TGV: завихренность, затухание энергии, H-функция](figures/07_taylor_green.png)\n",
"\n",
"![Распад вихря: 10 срезов на единой шкале цвета](figures/07b_tgv_decay_slices.png)\n",
"\n",
"![Анимация затухания](anim/tgv_decay.gif)\n",
"\n",
"*Скрипт: `demos_gpu/tgv_gpu.py`.*"
]
},
{
"cell_type": "markdown",
"id": "0f68444b",
"metadata": {},
"source": [
"## 8. Демо-2: устойчивость — двойной сдвиговый слой (BGK против KBC)\n",
"\n",
"Классический тест Minion–Brown: тонкие сдвиговые слои на **недоразрешённой** сетке\n",
"($N=128$, $\\nu=1.7\\cdot10^{-4}$, $\\mathrm{Re}\\sim3\\cdot10^4$). Стандартный BGK\n",
"(+полиномиальное равновесие) при малой вязкости разрушается (отрицательные популяции → NaN),\n",
"а KBC за счёт адаптивного $\\gamma$ остаётся устойчивым и гладко сворачивает вихри — это и есть\n",
"*implicit LES*. На средней панели — поле $\\gamma-2$: стабилизатор отклоняется от BGK-предела\n",
"именно там, где градиенты резкие.\n",
"\n",
"![Сдвиговый слой: KBC устойчив, BGK разрушается](figures/08_shear_stability.png)\n",
"\n",
"![BGK (слева, замирает в момент разрушения) против KBC (справа)](anim/shear_bgk_vs_kbc.gif)\n",
"\n",
"*Скрипт: `demos_gpu/shear_gpu.py`; момент разрушения BGK — в `demos_gpu/out/shear.txt`.*"
]
},
{
"cell_type": "markdown",
"id": "d8ebf6c3",
"metadata": {},
"source": [
"## 9. Демо-3: каверна с движущейся крышкой против эталона Ghia (Re=1000)\n",
"\n",
"Квадратная каверна, верхняя стенка движется со скоростью $U$. На неподвижных стенках —\n",
"**bounce-back**; на крышке — bounce-back с поправкой импульса подвижной стенки\n",
"$-2w_i\\rho\\,(\\mathbf c_i\\cdot\\mathbf u_w)/c_s^2$. Профили скорости по центральным линиям\n",
"сравниваются с эталоном **Ghia, Ghia & Shin (1982)**. Сетка намеренно грубая ($N=110$) и\n",
"bounce-back первого порядка, поэтому совпадение **качественное** (RMS-отклонение —\n",
"в `demos_gpu/out/cavity.txt`); оно улучшается измельчением и ГУ второго порядка.\n",
"\n",
"![Каверна: линии тока и сравнение с Ghia](figures/09_cavity_ghia.png)\n",
"\n",
"![Становление течения в каверне](anim/cavity_transient.gif)\n",
"\n",
"*Скрипт: `demos_gpu/cavity_gpu.py`; профили сохраняются в `demos_gpu/out/cavity.npz`.*"
]
},
{
"cell_type": "markdown",
"id": "761a536f",
"metadata": {},
"source": [
"## 10. Демо-4: обтекание тел разной формы — дорожки Кармана\n",
"\n",
"Канал с втоком слева (Дирихле + плавный разгон), нуль-градиентным оттоком и стенками.\n",
"Тела (цилиндр, квадрат, треугольник, профиль под углом атаки) — **узловой bounce-back** по\n",
"маске. При $\\mathrm{Re}\\approx150$ за телами формируется **вихревая дорожка Кармана**;\n",
"KBC держит грубую сетку устойчиво. Колебания $u_y$ в зонде за телом — мерило частоты срыва.\n",
"\n",
"Именно «лесенка» ступенчатой маски — мотивация перехода к **SDF + Bouzidi** в части IV.\n",
"\n",
"![Четыре тела: поле завихренности в конце прогона](figures/10_obstacle_shapes.png)\n",
"\n",
"![Зонд в следе: колебания = срыв вихрей](figures/10b_obstacle_probe.png)\n",
"\n",
"![Анимация всех четырёх тел](anim/obstacle_shapes.gif)\n",
"\n",
"*Скрипт: `demos_gpu/obstacle_gpu.py`.*"
]
},
{
"cell_type": "markdown",
"id": "2056c378",
"metadata": {},
"source": [
"---\n",
"# Часть III. Измельчение сетки (AMR)\n",
"\n",
"## 11. Блочное измельчение: зачем и как\n",
"\n",
"Чистый LBM живёт на **равномерной** решётке: перенос требует соседа ровно на $\\mathbf c_i$.\n",
"Неоднородное разрешение делают **вложенными равномерными блоками** разного шага: крупный\n",
"вдали, мелкий у тела и в следе. На стыке блоков ($\\Delta x\\to\\Delta x/2$,\n",
"$\\Delta t\\to\\Delta t/2$ — акустическое масштабирование, $c_s$ сохраняется):\n",
"\n",
"- **время релаксации**: $\\beta_f = \\dfrac{1}{1+r\\,(1/\\beta_c-1)}$, что при $r=2$ эквивалентно\n",
" $\\boxed{\\tau_f = 2\\tau_c - \\tfrac12}$ (вязкость $\\nu$ непрерывна через стык);\n",
"- **неравновесная часть** масштабируется: $f^{neq}_f = \\dfrac{\\beta_c}{r\\,\\beta_f}\\,f^{neq}_c\n",
" = \\dfrac{\\tau_f}{2\\tau_c}\\,f^{neq}_c$ (обратно — множитель $\\tfrac{2\\tau_c}{\\tau_f}$);\n",
"- **ghost-рамка** тонкого блока заполняется равновесием по интерполированным $\\rho,\\mathbf u$\n",
" плюс масштабированным $f^{neq}$; между подшагами тонкого уровня граница **линейно\n",
" интерполируется по времени** между состояниями родителя в $t$ и $t+\\Delta t$;\n",
"- **рестрикция**: внутренние узлы родителя обновляются с тонкого блока (каждый $r$-й узел)\n",
" с обратным масштабом $f^{neq}$.\n",
"\n",
"✓ **Сверено с первоисточниками**: формулы $\\beta_f$ и множитель $\\beta_c/(r\\beta_f)$ —\n",
"Dorschner, Frapolli, Chikatamarla, Karlin, *PRE* **94**, 053311 (2016), ур. (34), (40)\n",
"(KBC полностью совместим с измельчением — $\\gamma$ поузловой на каждом уровне); временна́я\n",
"интерполяция границы между подшагами — Lagrava, Malaspinas, Latt, Chopard, *J. Comput. Phys.*\n",
"**231**, 4808 (2012). Реализация — `solver_2x_sdf/amr.py` (`patch/ghost/fill/restrict`),\n",
"`R01 = τ_f/(2τ_c)` в `config.py`.\n",
"\n",
"![Схема блочного измельчения поверх реального поля |ω|](figures/11c_refinement_schematic.png)"
]
},
{
"cell_type": "markdown",
"id": "6645f68a",
"metadata": {},
"source": [
"## 12. Минимальный 2-уровневый AMR на цилиндре\n",
"\n",
"Рабочая связка «крупная сетка + мелкий блок 2× вокруг тела» (то же тело bounce-back на обоих\n",
"уровнях): на 1 крупный шаг — 2 мелких подшага, ghost из крупного уровня, рестрикция только по\n",
"жидким узлам. В этой *минимальной* версии граница блока держится постоянной в течение\n",
"подшагов (без временно́й интерполяции) — её эффект виден в бенчмарке ниже.\n",
"\n",
"![Минимальный AMR: крупное поле + мелкий блок 2× (жёлтая рамка)](anim/amr_cylinder.gif)\n",
"\n",
"*Скрипт: `demos_gpu/amr_demo_gpu.py` (часть 1) — на `amr.patch/ghost/fill/restrict`\n",
"финальной модели.*\n",
"\n",
"## 13. Полный вложенный 2×+4× и численное сравнение\n",
"\n",
"Три уровня (коарс → 2× → 4×), рекурсивный sub-cycling 1:2:4, временна́я интерполяция границ.\n",
"Контролируемый тест — локализованный резкий вихрь; эталон — равномерная мелкая 4× сетка.\n",
"Работа меряется в **cell-updates** (аппаратно-независимо).\n",
"\n",
"![AMR: точность и фронт «точность–работа»](figures/11d_amr_comparison.png)\n",
"\n",
"Выводы (точные числа — `demos_gpu/out/amr_bench.txt`):\n",
"- **вложенность эффективна**: полный 2×+4× точнее одиночного 4× при заметно меньшей работе;\n",
"- **временна́я интерполяция** снижает ошибку при той же работе;\n",
"- полный AMR даёт точность уровня мелкой сетки за **в разы меньшую работу**;\n",
"- крупная сетка дёшева, но её ошибка принципиально не лечится без измельчения.\n",
"\n",
"![Вложенный AMR: видны три размера ячеек](anim/amr_nested.gif)\n",
"\n",
"## 14. AMR на большой области (исторические прогоны)\n",
"\n",
"Та же связка на большой области (150×72, длинный прогон) — видны грубая сетка и учащённые\n",
"зоны: **2× (тело+след)** и **4× (у тела)**:\n",
"\n",
"![Одиночный 2× AMR на цилиндре](anim/amr2x_cyl.gif)\n",
"\n",
"![Вложенный 2×+4× AMR на цилиндре](anim/amr_nested_cyl.gif)"
]
},
{
"cell_type": "markdown",
"id": "ed665c26",
"metadata": {},
"source": [
"---\n",
"# Часть IV. Граница SDF + Bouzidi\n",
"\n",
"## 15. Каталог граничных условий\n",
"\n",
"KBC задаёт только оператор столкновения; полному решателю нужны ГУ:\n",
"\n",
"- **Bounce-back** — no-slip стенка «на полпути между узлами»: $f_{\\bar i}=f_i$. Первый\n",
" порядок; для движущейся стенки — поправка $-2w_i\\rho(\\mathbf c_i\\cdot\\mathbf u_w)/c_s^2$.\n",
"- **Интерполированный bounce-back (Bouzidi)** — для **кривых** стенок: доля стенки на линке\n",
" $q\\in(0,1)$ из SDF; две ветви:\n",
" $$ q<\\tfrac12:\\;\\; f_{\\bar i}(\\mathbf x_f) = 2q\\,f_i^{post}(\\mathbf x_f) + (1-2q)\\,f_i^{post}(\\mathbf x_f-\\mathbf c_i);\\qquad\n",
" q\\ge\\tfrac12:\\;\\; f_{\\bar i}(\\mathbf x_f) = \\tfrac{1}{2q}\\,f_i^{post}(\\mathbf x_f) + \\bigl(1-\\tfrac{1}{2q}\\bigr)f_{\\bar i}^{post}(\\mathbf x_f). $$\n",
" ✓ Bouzidi, Firdaouss, Lallemand, *Phys. Fluids* **13**, 3452 (2001); реализация —\n",
" `solver_2x_sdf/boundary.py` (`build_bc`/`apply_bc`).\n",
"- **Zou–He** — заданные скорость/давление на входе/выходе (восстановление недостающих\n",
" популяций из $\\rho,\\mathbf u$ + bounce-back неравновесной части; второй порядок).\n",
" ✓ Zou & He, *Phys. Fluids* **9**, 1591 (1997); реализация — `channel_bc`. На давление-выходе\n",
" поперечная скорость задаётся режимом `outlet_uy`: классическое жёсткое $u_y=0$ либо\n",
" нуль-градиент (мягкий выход — не отражает вихри дорожки; см. факторное исследование,\n",
" раздел 24).\n",
"- **Free-slip (specular)** — скользящая стенка: касательный импульс сохраняется, нормальный\n",
" отражается; массу не дрейфует. Реализация — `free_slip_walls`.\n",
"- **Sponge / absorbing-слой** — плавный рост вязкости перед выходом; гасит вихри и акустику до\n",
" отражающей границы. В финальной модели **добавлен** по итогам факторного исследования\n",
" (раздел 25): без него длинные домены с парой «вход-скорость / выход-давление» работают как\n",
" недодемпфированный акустический резонатор. Использовался и в прежней 3D-реализации.\n",
"- **Grad / регуляризованные** — реконструкция $f^{neq}$ через $\\Pi^{neq}$; в 2D-модели\n",
" не понадобились.\n",
"\n",
"## 16. Зачем SDF: лесенка против гладкой границы\n",
"\n",
"Ступенчатая маска (часть II, демо-4) приближает окружность «лесенкой»: положение стенки\n",
"скачет на $O(\\Delta x)$, что шумит в силе и завышает $C_d$. **SDF** (signed distance field,\n",
"$\\varphi<0$ в теле) даёт точную долю пересечения каждого линка $q=\\varphi_f/(\\varphi_f-\\varphi_s)$,\n",
"и Bouzidi восстанавливает отражение **в правильной точке** — граница эффективно гладкая\n",
"при том же разрешении.\n",
"\n",
"Сравнение реализаций без SDF (bounce-back) и с SDF+Bouzidi на одинаковой сетке:\n",
"\n",
"![Зонды: AMR с SDF](figures/11f_amr_sdf_probe_comparison.png)\n",
"\n",
"![Сводное сравнение SDF vs ступенчатая граница](figures/11g_sdf_vs_nosdf.png)\n",
"\n",
"![Цилиндр 2× AMR + SDF](anim/amr2x_cyl_sdf.gif)"
]
},
{
"cell_type": "markdown",
"id": "9d235a12",
"metadata": {},
"source": [
"---\n",
"# Часть V. Эволюция GPU-макета\n",
"\n",
"## 17. Поколения\n",
"\n",
"| Поколение | Файлы | Что нового | Известные проблемы (чинились в следующем) |\n",
"|---|---|---|---|\n",
"| 0. CPU-прототипы | `_amr_anims.py`, `_amr_anims_sdf.py` | первая полная связка: KBC + AMR (none/2×/2×+4×) на цилиндре; ветка с SDF+Bouzidi | numpy-CPU медленный; сила в BB-ветке считалась из post-collision |\n",
"| 1. Базовый GPU | `amr_gpu_core.py` + `amr_cylinder_gpu.py`, `amr_cylinder_sdf_gpu.py` | перенос на CuPy (~100× быстрее), оба типа границы в одном ядре | **сила `force_on`: оба члена из post-collision → Cd отрицательный** |\n",
"| 2. Оптимизированный GPU | `amr_opt/*` | **фикс силы** (второй член из post-streaming); `feq` без `pow`; `cp.fuse`; **CUDA-графы** с рантайм-самопроверкой; GPU-only | монолитные скрипты, параметры захардкожены |\n",
"| 3. Финальная модель | `solver_2x_sdf/*` | модульная архитектура; **Zou-He вход + давление-выход**; **free-slip стенки**; GMEM-сила с торком; **кросс-чек ∮σ·n ds**; серия по блокировке | — |\n",
"\n",
"## 18. Хронология багов и фиксов (чему научил макет)\n",
"\n",
"1. **Сила: второй член — из поля ПОСЛЕ стриминга.** В обмене импульсом\n",
" $F=\\sum c_i(f_i^{post}+f_{\\bar i})$ популяция $f_{\\bar i}$ — та, что *вернулась* в жидкий\n",
" узел после отражения. Если взять её из post-collision поля (как в поколении 1), ведущий\n",
" симметричный член $\\sim2\\rho w_i$ сокращается: $C_d$ выходил **отрицательным** и\n",
" схлопывался к нулю при измельчении. Урок: считывание силы валидируется так же строго,\n",
" как сам решатель.\n",
"2. **Дрейф массы из-за ГУ выхода.** Грубый zero-gradient выход не выпускал поток: масса и\n",
" противодавление копились ($\\langle\\rho\\rangle\\uparrow$), поток глох, $C_d$ и rms $C_l$\n",
" уползали к нулю за 100k шагов. Вскрыто **диагностикой по окнам времени** (тренды\n",
" $\\langle\\rho\\rangle$, $C_d/\\rho$). Фикс: **Zou-He скоростной вход + давление-выход** —\n",
" масса заякорена ($\\langle\\rho\\rangle\\approx1.000$).\n",
"3. **Free-slip стенки канала** (specular) вместо no-slip: нет паразитного пограничного слоя\n",
" на искусственных стенках, блокировка уменьшается; цилиндр остаётся no-slip (Bouzidi).\n",
"4. **Стартовое возмущение** — короткий поперечный sin-импульс входа после разгона: вихревая\n",
" дорожка запускается за единицы периодов вместо тысяч шагов симметричного «застоя».\n",
"5. **Зонд скорости — на тонком уровне L1** (меньше численного размытия вихрей) и **оценка St\n",
" с параболической интерполяцией пика FFT** (иначе St «застревает» на шаге сетки частот).\n",
"6. **CUDA-графы**: захват шага требовал убрать все host→device на горячем пути —\n",
" `cupy.stack` заменён преаллокацией (`backend.pack`), проектор KBC переписан без cuBLAS\n",
" (gemm тащит alpha/beta с хоста), границы clip запечены литералами в ядро. На шаге $t=1$ —\n",
" **рантайм-самопроверка** граф vs eager с автооткатом.\n",
"7. **Осреднение силы по подшагам L1 и момент $C_m$** — финальная ревизия считывания\n",
" (часть VI): сила меряется на каждом подшаге тонкого уровня, торк — с точкой приложения\n",
" на пересечении линка со стенкой."
]
},
{
"cell_type": "markdown",
"id": "e7b57ba6",
"metadata": {},
"source": [
"---\n",
"# Часть VI. Финальная модель `solver_2x_sdf`\n",
"\n",
"## 19. Архитектура\n",
"\n",
"Обтекание цилиндра: грубый уровень **L0** (174×90, $D=16$, $\\mathrm{Re}=150$, $U=0.07$) +\n",
"вложенный патч **L1** (×2 — тело и ближний след). Граница цилиндра — SDF+Bouzidi (no-slip);\n",
"вход/выход — Zou-He (скорость/давление); верх/низ — free-slip. Только GPU/CuPy, шаг целиком\n",
"захватывается в CUDA-граф.\n",
"\n",
"| Модуль | Ответственность |\n",
"|---|---|\n",
"| `config.py` | параметры + производные ($\\tau$, $\\beta$, патч из $N_y$); env-переопределения |\n",
"| `lattice.py` | D2Q9 + KBC-проектор $P_s$ |\n",
"| `equilibrium.py` | product-form $f^{eq}$, макромоменты |\n",
"| `collision.py` | KBC-N1 (единственный оператор) |\n",
"| `streaming.py` | pull-перенос |\n",
"| `geometry.py` | маски/SDF для L0 и L1 |\n",
"| `boundary.py` | Bouzidi + Zou-He + free-slip |\n",
"| `forces.py` | GMEM-сила и момент (считывание №1) |\n",
"| `forces_stress.py` | ∮σ·n ds по контуру (считывание №2, кросс-чек) |\n",
"| `amr.py` | связка L0↔L1 (часть III) |\n",
"| `solver.py` | оркестратор: persistent-буферы, CUDA-граф с самопроверкой |\n",
"| `diagnostics.py` | St (параболический пик), таблицы, кросс-чек, фиты серии по $\\beta_{бл}$ |\n",
"| `run.py` / `run_blockage.py` / `run_factors.py` | одиночный прогон / серия по блокировке / факторное исследование (реестр `out/factors.csv`) |\n",
"\n",
"## 20. Считывание силы №1: галилей-инвариантный обмен импульсом (GMEM)\n",
"\n",
"Для каждого линка жидкость→тело (Bouzidi-маска):\n",
"$$ \\Delta\\mathbf F = (\\mathbf c_i - \\mathbf u_w)\\,f_i^{post}(\\mathbf x_f)\n",
" - (\\mathbf c_{\\bar i} - \\mathbf u_w)\\,f_{\\bar i}^{stream}(\\mathbf x_f), $$\n",
"где $f_i^{post}$ — популяция, уходящая в стенку (после столкновения), $f_{\\bar i}^{stream}$ —\n",
"вернувшаяся (после стриминга/Bouzidi), $\\mathbf u_w$ — скорость стенки (для неподвижного\n",
"цилиндра $0$, но члены с $\\mathbf u_w$ делают оценку **галилей-инвариантной** и готовой к\n",
"подвижным телам — цель SimV4). ✓ Wen, Zhang, Tu, Wang, Fang, *J. Comput. Phys.* **266**,\n",
"161 (2014).\n",
"\n",
"**Момент (торк)**: $T_z=\\sum_{links} (\\mathbf r_w\\times\\Delta\\mathbf F)_z$ с точкой приложения\n",
"на **пересечении линка со стенкой** $\\mathbf r_w=\\mathbf r_f+q\\,\\mathbf c_i$ ($q$ уже лежит в\n",
"структуре Bouzidi). $C_m = 2T_z/(U^2D_{L1}^2)$; для кругового цилиндра $\\langle C_m\\rangle\\approx0$ —\n",
"встроенный **контроль симметрии** считывания.\n",
"\n",
"Сила меряется на **каждом** подшаге L1 и усредняется (анти-алиасинг быстрых мод).\n",
"\n",
"## 21. Считывание силы №2: баланс импульса контрольного объёма (кросс-чек)\n",
"\n",
"Независимая оценка по замкнутому контуру (окружность $R+\\delta$, $\\delta=2$ тонкие ячейки —\n",
"вне зоны Bouzidi-реконструкции). Полный баланс импульса контрольного объёма:\n",
"$$ \\mathbf F_{тела} = \\oint \\bigl[\\sigma\\cdot\\mathbf n\n",
" - \\rho\\,\\mathbf u\\,(\\mathbf u\\cdot\\mathbf n)\\bigr]\\, ds\n",
" \\;-\\;\\underbrace{\\frac{d}{dt}\\!\\int_{кольцо}\\rho\\mathbf u\\,dV}_{\\to\\,0\\ в\\ среднем},\\qquad\n",
" \\sigma_{\\alpha\\beta} = -p'\\,\\delta_{\\alpha\\beta}\n",
" - \\Bigl(1-\\frac{1}{2\\tau_1}\\Bigr)\\,\\Pi^{neq,dev}_{\\alpha\\beta}. $$\n",
"Тонкости:\n",
"- давление без константы ($\\oint p_0\\mathbf n\\,ds\\equiv0$);\n",
"- строго **девиаторная** проекция $\\Pi^{neq}=\\sum_i \\mathbf c_i\\mathbf c_i(f_i-f_i^{eq})$ —\n",
" в KBC след релаксирует со стабилизатором $\\gamma\\beta$, а не $1/\\tau$, тогда как\n",
" сдвиг-моменты — с точным $2\\beta=1/\\tau$;\n",
"- **конвективный член** $-\\rho\\mathbf u(\\mathbf u\\cdot\\mathbf n)$ зануляется только при\n",
" $\\delta\\to0$ (no-slip); в первой версии кросс-чека он был опущен, что давало\n",
" **систематический сдвиг $+4\\%$ по $\\langle C_d\\rangle$ на всех сетках** (см. таблицы\n",
" разделов 22–23) — диагностическая ценность: смещение одинаково при любом $N_y$, т.е. это\n",
" свойство эстиматора, а не течения. Добавлен в `forces_stress.py`;\n",
"- сравнение — по средним ($\\langle C_d\\rangle$, rms $C_l$): мгновенные ряды сдвинуты по фазе\n",
" инерцией кольца $R..R{+}\\delta$ (член $d/dt$).\n",
"\n",
"**Согласие двух независимых методов = считывание силы корректно** — расхождение с\n",
"литературой тогда лежит в физике постановки, что и проверяют эксперименты разделов 23–24."
]
},
{
"cell_type": "markdown",
"id": "ccac6a82",
"metadata": {},
"source": [
"## 22. Результаты основного прогона (174×90, 100k шагов, 2026-06-09)\n",
"\n",
"Прогон на GPU-сервере: CuPy float32, CUDA-графы активны (self-check $\\Delta=0$), 271 с на\n",
"100k шагов L0 (вместе с L1-подшагами и гифкой).\n",
"\n",
"![Финальная модель: |ω| с патчем L1](solver_2x_sdf/out/cyl_2x_sdf.gif)\n",
"\n",
"![Ряды Cd/Cl/Cm, кросс-чек, спектр, ⟨ρ⟩(t)](solver_2x_sdf/out/series_2x_sdf.png)\n",
"\n",
"**Валидация** ($\\beta_{бл}=D/N_y=0.178$, осреднение по второй половине ряда):\n",
"\n",
"| | raw | ×(1−β)$^k$ | лит. безгранич. (Re≈150) |\n",
"|---|---|---|---|\n",
"| St | 0.221 | **0.181** | **0.183** ✓ (1%) |\n",
"| $C_d$ | 1.799 | 1.216 | 1.330 |\n",
"| rms $C_l$ | 0.7051 | 0.4767 | ~0.3 |\n",
"| $\\langle C_m\\rangle$ | **−0.00003** | | 0 ✓ |\n",
"\n",
"**Кросс-чек считывания** (версия ещё без конвективного члена — см. раздел 21):\n",
"\n",
"| метод | $\\langle C_d\\rangle$ | rms $C_l$ | $\\langle C_m\\rangle$ |\n",
"|---|---|---|---|\n",
"| GMEM (обмен импульсом) | 1.799 | 0.7051 | −0.00003 |\n",
"| ∮σ·n ds (контур R+δ) | 1.872 | 0.7252 | −0.00003 |\n",
"| расхождение | **4.1%** | 2.8% | — |\n",
"\n",
"Диагностика сходимости: $\\langle\\rho\\rangle = 1.001$ стабильна (масса заякорена); оконные\n",
"средние $C_d$ в окнах 6–10: 1.797, 1.813, 1.795, 1.797, 1.791 — плато; rms $C_l$ насыщен.\n",
"\n",
"**Выводы по считыванию** (всё подтверждается):\n",
"1. Два независимых метода согласуются в пределах 4% (систематика +4% объяснена опущенным\n",
" конвективным членом и закрыта — раздел 21).\n",
"2. $\\langle C_m\\rangle\\approx-3\\cdot10^{-5}$ при масштабе $C_l\\sim0.7$ — симметрия в норме.\n",
"3. $C_d=1.799$ воспроизводит предыдущую итерацию бит-в-бит: осреднение силы по подшагам L1\n",
" не сместило среднее (регрессии нет, ряды стали глаже).\n",
"4. Следовательно $C_d\\approx1.8$ — **истинная сила в смоделированной постановке**; вопрос\n",
" к расхождению с литературой — вопрос к *постановке*, а не к считыванию.\n",
"\n",
"## 23. Эксперимент №0: серия по боковой блокировке — гипотеза ОПРОВЕРГНУТА\n",
"\n",
"**Гипотеза**: завышение $C_d$/rms $C_l$ объясняется боковым стеснением канала; тогда при\n",
"$D=16$ фикс. и $N_y\\uparrow$ величины должны монотонно стремиться к безграничной литературе,\n",
"а экстраполяция $\\beta\\to0$ — попасть в $C_d\\approx1.33$, rms $C_l\\approx0.3$.\n",
"\n",
"**Постановка**: $N_y\\in\\{90,128,180\\}$ → $\\beta\\in\\{0.178,0.125,0.089\\}$; центр и патч\n",
"выводятся из $N_y$; всё остальное фиксировано. 3×100k шагов (по ~282 с).\n",
"\n",
"**Результат** (`python run_blockage.py`, 2026-06-09):\n",
"\n",
"| $N_y$ | β | St | St(1−β) | $\\langle C_d\\rangle$ | rms $C_l$ | $\\langle C_m\\rangle$ | $C_d$ σ·n | dCd |\n",
"|---|---|---|---|---|---|---|---|---|\n",
"| 90 | 0.178 | 0.221 | 0.181 | 1.799 | 0.7051 | −0.00003 | 1.872 | 4.1% |\n",
"| 128 | 0.125 | 0.196 | 0.171 | 1.771 | 0.6734 | −0.00007 | 1.838 | 3.8% |\n",
"| 180 | 0.089 | 0.193 | 0.176 | **1.870** | **0.8354** | +0.00043 | 1.937 | 3.6% |\n",
"\n",
"Экстраполяция $\\beta\\to0$ (интерсепт линейного / квадратичного фита, спред = неопределённость):\n",
"\n",
"| | лин. | квадр. | спред | лит. |\n",
"|---|---|---|---|---|\n",
"| $C_d(0)$ | 1.905 | 1.855 | 0.050 | 1.33 |\n",
"| rms $C_l(0)$ | 0.910 | 0.818 | 0.091 | ~0.3 |\n",
"| St(0) | 0.161 | **0.181** | 0.020 | **0.183** |\n",
"\n",
"![Серия по блокировке: Cd(β), rms Cl(β), St(β) с экстраполяцией](solver_2x_sdf/out/blockage_extrapolation.png)\n",
"\n",
"**Анализ:**\n",
"1. $C_d(\\beta)$ **немонотонен** (1.80 → 1.77 → 1.87), rms $C_l$ при самом широком канале даже\n",
" вырос — экстраполяция «$C_d(0)\\approx1.9$» физически абсурдна и означает одно: **β — не\n",
" управляющий параметр**. Поправка $(1-\\beta)^2$ из предыдущей итерации была совпадением на\n",
" одной точке ($N_y=90$).\n",
"2. Это согласуется с классической теорией продувок: поправки Аллена–Винченти\n",
" (solid + wake blockage) при $\\beta=0.178$ дают завышение лишь ~15–18%, при $\\beta=0.089$ —\n",
" ~10%. Боковое стеснение объясняло максимум половину превышения даже на исходной сетке.\n",
"3. **St — единственная величина с правильным поведением**: raw 0.221→0.196→0.193 монотонно\n",
" стремится к 0.183 сверху; квадратичная экстраполяция даёт **0.181 ≈ лит. 0.183**.\n",
" Кинематика вихреобразования (частота, эффективное Re) верна; завышены именно силовые\n",
" (давленческие) амплитуды.\n",
"4. **Улика — поведение $N_y=180$**: низкочастотная модуляция с размахом оконных $C_d$\n",
" 1.68–1.98 и rms $C_l$ 0.58–0.96 (период ~20–30k шагов; на 50k установившегося режима —\n",
" всего 2–3 цикла, статистика среднего не набрана; $\\langle C_m\\rangle$ на порядок больше,\n",
" чем на узких каналах). Чем шире канал, тем свободнее меандрирует дорожка — и тем сильнее\n",
" она бьётся о **жёсткое условие $u_y=0$ на выходе**, порождая отражения и обратную связь.\n",
"5. **Диагноз**: доминируют **продольные ограничения**, которые серия держала константой:\n",
" вход 2.5D до тела (жёсткий равномерный профиль), выход 8.3D (Zou-He давление + $u_y=0$ —\n",
" отражает вихри), стык L1→L0 на 6.25D. Их изолирует эксперимент №1.\n",
"\n",
"## 24. Эксперимент №1: факторное исследование продольных границ\n",
"\n",
"**Метод**: каждый подозреваемый фактор варьируется **отдельно** при фиксированной боковой\n",
"блокировке ($N_y=90$, $\\beta=0.178$) и 100k шагах — изоляция вкладов входа, выхода и\n",
"выходного ГУ. Реализация: `config.py` выводит x-границы патча из `cx` (патч едет за телом),\n",
"`boundary.channel_bc` получил режим `outlet_uy`:\n",
"- `\"zero\"` — классический Zou-He: $u_y=0$ жёстко (как во всех прогонах выше);\n",
"- `\"extrapolate\"` — $u_y$ нуль-градиент (берётся из столбца $x{=}-2$), якорь $\\rho=1$\n",
" сохраняется; алгебраически: $f_6 \\mathrel{+}= \\tfrac12\\rho u_y$, $f_7 \\mathrel{-}= \\tfrac12\\rho u_y$\n",
" при тех же $f_3$ и $u_x$ (балансы массы и импульса выполняются точно).\n",
"\n",
"| Фактор | Nx | cx | вход | выход | $u_y$-выход | что изолирует |\n",
"|---|---|---|---|---|---|---|\n",
"| B0_baseline | 174 | 40 | 2.5D | 8.3D | zero | контроль (+ кросс-чек с конвективным членом) |\n",
"| F1_outlet_far | 300 | 40 | 2.5D | 16.2D | zero | близость выхода |\n",
"| F2_inlet_far | 230 | 96 | 6.0D | 8.3D | zero | близость входа |\n",
"| F3_inout_far | 390 | 96 | 6.0D | 18.3D | zero | оба продольных расстояния |\n",
"| F4_outlet_uy | 174 | 40 | 2.5D | 8.3D | extrapolate | кинематическое отражение вихрей ГУ |\n",
"| F5_combo | 390 | 96 | 6.0D | 18.3D | extrapolate | «чистая» постановка |\n",
"\n",
"Метрики каждого прогона: St, $\\langle C_d\\rangle$, rms $C_l$, $\\langle C_m\\rangle$, кросс-чек\n",
"dCd (уже с конвективным членом), **std оконных средних** (мера НЧ-модуляции), $\\langle\\rho\\rangle$.\n",
"Каждый прогон фиксируется строкой в накопительном реестре `solver_2x_sdf/out/factors.csv`\n",
"(полная конфигурация + результаты + время) — документация всех изменений постановки.\n",
"\n",
"**Результаты** (`python run_factors.py`, 2026-06-10, 6×100k шагов по ~280 с):\n",
"\n",
"| Фактор | вход | выход | $u_y$-выход | St(1−β) | $\\langle C_d\\rangle$ | rms $C_l$ | dCd кросс | $\\langle\\rho\\rangle_{фин}$ | модуляция | статус |\n",
"|---|---|---|---|---|---|---|---|---|---|---|\n",
"| B0_baseline | 2.5D | 8.3D | zero | 0.181 | 1.799 | 0.705 | **1.0%** | 1.000 | 0.024 | ok |\n",
"| F1_outlet_far | 2.5D | 16.2D | zero | 0.163 | 2.838 | 6.794 | 1.6% | **1.663** | 0.225 | режим смещён |\n",
"| F2_inlet_far | 6.0D | 8.3D | zero | 0.138 | 2.229 | 6.120 | 9.7% | **1.655** | 0.306 | режим смещён (срыв на ~75k) |\n",
"| F3_inout_far | 6.0D | 18.3D | zero | 0.160 | 2.577 | 5.348 | 2.0% | **1.660** | 0.269 | режим смещён (срыв на ~20k) |\n",
"| **F4_outlet_uy** | 2.5D | 8.3D | extrapolate | **0.183** | **1.754** | **0.557** | **0.6%** | **1.0000** | **0.023** | **ok ✓** |\n",
"| F5_combo | 6.0D | 18.3D | extrapolate | — | — | — | — | NaN | — | **взрыв на 4.5k** |\n",
"\n",
"![Факторное исследование: Cd, rms Cl и НЧ-модуляция по факторам](solver_2x_sdf/out/factors_summary.png)\n",
"\n",
"**Анализ:**\n",
"1. **Конвективный член подтверждён**: кросс-чек B0 сжался с 4.1% до **1.0%** (F4 — 0.6%) при\n",
" неизменной физике. Считывание силы теперь согласовано двумя методами на уровне ~1%.\n",
"2. **F4 (мягкий $u_y$-выход) — выигрыш по всем метрикам**: rms $C_l$ 0.705→0.557 (−21%),\n",
" $\\langle C_d\\rangle$ 1.799→1.754, $\\langle\\rho\\rangle=1.0000$ (точнее базы), нулевая\n",
" НЧ-модуляция (окна 6–10: $C_d$ 1.753–1.755, идеальное плато) и **St·(1−β)=0.1828 ≈ лит.\n",
" 0.183 точно**. Жёсткое $u_y=0$ действительно кинематически отражало вихри.\n",
"3. **Главное открытие — длинные домены без демпфера невалидны.** Все три (F1/F2/F3, $u_y=0$)\n",
" уходят на смещённую ветвь $\\langle\\rho\\rangle\\approx1.66$ (F1 и F3 — за ~20k шагов, F2 —\n",
" на ~75k), где $C_d$, $C_l$ бессмысленны для сравнения (у F2 разваливается и кросс-чек:\n",
" 9.7% — поле у контура нестационарно «звенит»). Механизм: канал «вход-скорость +\n",
" выход-давление» — **недодемпфированный акустический резонатор**; затухание продольной моды\n",
" $\\propto\\nu(\\pi/N_x)^2$ падает в ~5 раз при $N_x$ 174→390. Стартовый транзиент раскачивает\n",
" моду, вход с фиксированной скоростью качает массу $\\propto\\rho_{in}$ (положительная обратная\n",
" связь) — система садится на новую ветвь равновесия потоков массы. **F5** — чистая картина\n",
" роста: $\\langle\\rho\\rangle$ по окнам осциллирует 1.04→0.93→1.05 с периодом\n",
" $\\approx 2N_x/c_s\\approx1350$ шагов (точно акустический период туда-обратно) → NaN на 4.5k.\n",
"4. При этом rms $u_y$ зонда нормальный (0.033–0.046) во **всех** прогонах — гидродинамика\n",
" дорожки жива; ломаются акустика и массовый баланс. Это ретроспективно объясняет и «загадку»\n",
" эксперимента №0: $N_y=180$ был зачатком той же моды (НЧ-модуляция), но поперечное расширение\n",
" менее опасно продольного.\n",
"\n",
"**Урок**: продольное удлинение требует **поглощающего слоя** — стандартное лекарство из\n",
"каталога ГУ (раздел 15), упоминавшееся ещё в прежней 3D-реализации.\n",
"\n",
"## 25. Эксперимент №2: губка (absorbing layer) перед выходом\n",
"\n",
"Реализация: в последних `sponge_len` столбцах L0 вязкость плавно (smoothstep) растёт до\n",
"$\\nu\\cdot$`sponge_nu_mult` (по умолчанию ×30 — локальное Re падает до ~5, вихри и акустика\n",
"диссипируют до прихода на выход). Технически $\\beta$ на L0 становится **полем** $\\beta(x)$\n",
"(KBC это допускает: $\\gamma$ и так поузловой; CUDA-граф не страдает — поле предвычислено);\n",
"патч L1 губку не видит (гарантировано assert'ом). Ручки: `AMR_SPONGE_LEN`, `AMR_SPONGE_NU`.\n",
"\n",
"| Фактор | геометрия | $u_y$-выход | губка | что проверяет |\n",
"|---|---|---|---|---|\n",
"| F6_sponge | 174×90 (база) | extrapolate | 32 | валидация губки против F4 (не портит ли короткий домен) |\n",
"| F7_long_sponge | 390×90, вход 6D / выход 18.3D | extrapolate | 32 | **целевая «чистая» постановка** |\n",
"| F8_long_sp_uy0 | 390×90 | zero | 32 | изоляция: достаточно ли губки без мягкого $u_y$ |\n",
"\n",
"> ⏳ *Числа появятся после `python run_factors.py` с факторами F6–F8 (3×100k ≈ 15 мин).*\n",
"\n",
"**Критерии**: у всех $\\langle\\rho\\rangle\\approx1.000$ и нет НЧ-модуляции; F7 — ожидание\n",
"$\\langle C_d\\rangle\\approx1.5$–1.6 (остаётся боковая блокировка β=0.178: по Аллену–Винченти\n",
"+15–18% к безграничному 1.33) и rms $C_l<0.45$. Если критерии выполнены — **эксперимент №3**:\n",
"повторная серия по β ($N_y=90/128/180$) на конфигурации F7 → финальная экстраполяция к\n",
"безграничному пределу. Остаточное расхождение после неё — фактор разрешения (D-рефайнмент)."
]
},
{
"cell_type": "markdown",
"id": "0afa973f",
"metadata": {},
"source": [
"---\n",
"# Часть VII. Перенос на D3Q27 и связь с целевой реализацией\n",
"\n",
"Вся математика KBC переносится в 3D **без изменений по сути**: решётка D3Q27 (веса\n",
"$8/27, 2/27, 1/54, 1/216$ — изотропия 4-го порядка, предпочтительна для KBC/турбулентности),\n",
"моментный базис 27×27, сдвиговая часть $s$ — девиаторный тензор (5 независимых компонент).\n",
"Формула $\\gamma$ и оператор $f\\leftarrow f-\\beta(2\\Delta s+\\gamma\\Delta h)$ идентичны.\n",
"\n",
"![Решётка D3Q27](figures/11_d3q27.png)\n",
"\n",
"Чем настоящий KBC отличается от прежнего подхода в 3D-движке (poly eq + TRT + α-лимитер +\n",
"Smagorinsky):\n",
"\n",
"| Аспект | Настоящий KBC | Прежний подход |\n",
"|---|---|---|\n",
"| Равновесие | энтропийное product-form | полиномиальное $O(u^2)$ |\n",
"| Столкновение | энтропийный MRT, $k/s/h$ | TRT (симм./антисимм.) |\n",
"| Стабилизация | $\\gamma$ из энтропии | α-лимитер положительности |\n",
"| H-теорема | встроена в вывод $\\gamma$ | контролируется отдельно |\n",
"| Турбулентность | implicit LES (параметр-free) | явный Smagorinsky |\n",
"| Решётка | D3Q27 | D3Q27 (совпадает) |\n",
"\n",
"**Дорожная карта переноса** (всё отработано на 2D-макете): (1) product-form $f^{eq}$;\n",
"(2) проекторы $P_s$ для D3Q27 (предвычислить); (3) per-node $\\gamma$; (4) столкновение;\n",
"(5) SDF+Bouzidi и GMEM-сила с $\\mathbf u_w$ на линке (подвижные тела) + кросс-чек ∮σ·n dA по\n",
"SDF-изоповерхности; (6) AMR-связка $\\tau_f=2\\tau_c-\\tfrac12$ с временно́й интерполяцией."
]
},
{
"cell_type": "markdown",
"id": "d0691787",
"metadata": {},
"source": [
"---\n",
"# Часть VIII. Сверка формул с первоисточниками\n",
"\n",
"Все формулы, ранее помеченные «⚠ сверить с оригиналом», проверены по первоисточникам\n",
"(плюс численная самопроверка каждой в `demos_gpu/checks_gpu.py`):\n",
"\n",
"| Формула | Источник | Статус |\n",
"|---|---|---|\n",
"| Product-form $f^{eq}$: $A(u)=2-\\sqrt{1+3u^2}$, $B(u)=\\frac{2u+\\sqrt{1+3u^2}}{1-u}$ | Bösch/Chikatamarla/Karlin, PRE **92**, 043309 (2015), ур. (7)–(11); Ansumali/Karlin/Öttinger, EPL **63**, 798 (2003) | ✓ дословно |\n",
"| $f' = f-\\beta(2\\Delta s+\\gamma\\Delta h)$, $\\beta=1/(2\\tau)$, mirror-форма | Karlin/Bösch/Chikatamarla, PRE **90**, 031302(R) (2014) | ✓ дословно |\n",
"| $\\gamma^\\*=\\frac1\\beta-(2-\\frac1\\beta)\\frac{\\langle\\Delta s|\\Delta h\\rangle}{\\langle\\Delta h|\\Delta h\\rangle}$, $\\langle X|Y\\rangle=\\sum X_iY_i/f_i^{eq}$ | там же; PRE **92**, ур. (24)–(25) | ✓ дословно |\n",
"| Состав $s=\\{N,\\Pi_{xy}\\}$ (девиатор; «KBC-d»/N1) | PRE **92** (семейство $s\\in\\{d,d{+}t,d{+}q,d{+}t{+}q\\}$); Dorschner et al., JFM **801**, 623 (2016) | ✓ |\n",
"| AMR: $\\beta_f=\\frac{1}{1+r(1/\\beta_c-1)}$ ⇔ $\\tau_f=2\\tau_c-\\frac12$; $f^{neq}\\cdot\\frac{\\beta_c}{r\\beta_f}=\\frac{\\tau_f}{2\\tau_c}f^{neq}$ | Dorschner/Frapolli/Chikatamarla/Karlin, PRE **94**, 053311 (2016), ур. (34), (40) | ✓ дословно |\n",
"| Временна́я интерполяция ghost-границы | Lagrava/Malaspinas/Latt/Chopard, JCP **231**, 4808 (2012) | ✓ |\n",
"| Bouzidi: ветви $q<\\frac12$ / $q\\ge\\frac12$ | Bouzidi/Firdaouss/Lallemand, Phys. Fluids **13**, 3452 (2001) | ✓ |\n",
"| Zou-He: скоростной вход / давление-выход | Zou & He, Phys. Fluids **9**, 1591 (1997) | ✓ |\n",
"| GMEM: $(\\mathbf c_i-\\mathbf u_w)f_i^{post}-(\\mathbf c_{\\bar i}-\\mathbf u_w)f_{\\bar i}^{stream}$ | Wen/Zhang/Tu/Wang/Fang, JCP **266**, 161 (2014) | ✓ |\n",
"| $\\sigma^v=-(1-\\frac{1}{2\\tau})\\Pi^{neq}$ (Чепмен–Энског) | стандарт (Krüger et al., *The Lattice Boltzmann Method*, 2017) | ✓ |\n",
"\n",
"**Литература:**\n",
"- I. V. Karlin, F. Bösch, S. S. Chikatamarla, *Gibbs' principle for the lattice-kinetic theory\n",
" of fluid dynamics*, Phys. Rev. E **90**, 031302(R) (2014).\n",
"- F. Bösch, S. S. Chikatamarla, I. V. Karlin, *Entropic multirelaxation lattice Boltzmann\n",
" models for turbulent flows*, Phys. Rev. E **92**, 043309 (2015) (arXiv:1507.02518).\n",
"- S. Ansumali, I. V. Karlin, H. C. Öttinger, *Minimal entropic kinetic models for\n",
" hydrodynamics*, Europhys. Lett. **63**, 798 (2003).\n",
"- B. Dorschner, F. Bösch, S. S. Chikatamarla, K. Boulouchos, I. V. Karlin, *Entropic\n",
" multi-relaxation time lattice Boltzmann model for complex flows*, J. Fluid Mech. **801**,\n",
" 623–651 (2016).\n",
"- B. Dorschner, N. Frapolli, S. S. Chikatamarla, I. V. Karlin, *Grid refinement for entropic\n",
" lattice Boltzmann models*, Phys. Rev. E **94**, 053311 (2016) (arXiv:1608.06915).\n",
"- D. Lagrava, O. Malaspinas, J. Latt, B. Chopard, *Advances in multi-domain lattice Boltzmann\n",
" grid refinement*, J. Comput. Phys. **231**, 4808 (2012).\n",
"- M. Bouzidi, M. Firdaouss, P. Lallemand, *Momentum transfer of a Boltzmann-lattice fluid with\n",
" boundaries*, Phys. Fluids **13**, 3452 (2001).\n",
"- Q. Zou, X. He, *On pressure and velocity boundary conditions for the lattice Boltzmann BGK\n",
" model*, Phys. Fluids **9**, 1591 (1997).\n",
"- B. Wen, C. Zhang, Y. Tu, C. Wang, H. Fang, *Galilean invariant fluid–solid interfacial\n",
" dynamics in lattice Boltzmann simulations*, J. Comput. Phys. **266**, 161 (2014).\n",
"- Z. Guo, C. Zheng, B. Shi, *Discrete lattice effects on the forcing term in the lattice\n",
" Boltzmann method*, Phys. Rev. E **65**, 046308 (2002).\n",
"- U. Ghia, K. N. Ghia, C. T. Shin, *High-Re solutions for incompressible flow using the\n",
" Navier-Stokes equations and a multigrid method*, J. Comput. Phys. **48**, 387 (1982)."
]
},
{
"cell_type": "markdown",
"id": "a8cc8c0d",
"metadata": {},
"source": [
"---\n",
"# Часть IX. Итоги\n",
"\n",
"- **KBC** = энтропийный MRT-LBM: сохраняющиеся моды не трогаем, сдвиг релаксируем фиксированным\n",
" $2\\beta$ (вязкость), высшие моды — адаптивным $\\gamma$ из условия неувеличения энтропии.\n",
" При $\\gamma=2$ — в точности BGK. Все формулы сверены с первоисточниками и самопроверены\n",
" численно.\n",
"- **Бенчмарки** (на компонентах финальной модели): вязкость по TGV (<6%, факт ≲1%), H-теорема,\n",
" устойчивость на сдвиговом слое (BGK разрушается — KBC держит), каверна ≈ Ghia, дорожки\n",
" Кармана на четырёх телах.\n",
"- **Измельчение сетки**: связка $\\tau_f=2\\tau_c-\\tfrac12$ + масштабирование $f^{neq}$ +\n",
" временна́я интерполяция ghost; вложенный 2×+4× даёт точность мелкой сетки за в разы меньшую\n",
" работу.\n",
"- **Финальная модель** `solver_2x_sdf`: KBC-N1 + SDF/Bouzidi + Zou-He + free-slip + CUDA-графы.\n",
" Главные уроки эволюции: второй член силы — из post-streaming; дрейф массы лечится\n",
" давлением-выходом; зонд и сила — на тонком уровне; графы требуют чистоты горячего пути.\n",
"- **Считывание сил ДОКАЗАНО корректным** (разделы 22, 24): два независимых метода — GMEM с\n",
" торком и баланс импульса ∮[σ·n−ρu(u·n)]ds — после добавления конвективного члена согласуются\n",
" на уровне **0.6–1.0%** по $\\langle C_d\\rangle$; $\\langle C_m\\rangle\\approx0$; масса стабильна.\n",
"- **Исследование факторов постановки** (разделы 23–25):\n",
" - эксперимент №0 (серия по β) **опроверг** гипотезу бокового стеснения: $C_d(\\beta)$\n",
" немонотонен; при этом **St(β→0)=0.181 ≈ лит. 0.183** — кинематика верна;\n",
" - эксперимент №1 (6 факторов): **мягкий $u_y$-выход (F4) улучшил всё** (rms $C_l$ −21%,\n",
" St·(1−β)=0.183 точно, идеальное плато) — жёсткое $u_y=0$ отражало вихри. **Длинные домены\n",
" без демпфера невалидны**: пара «вход-скорость / выход-давление» — недодемпфированный\n",
" акустический резонатор (затухание ∝ν(π/Nx)²); масса накачивается до смещённой ветви\n",
" ⟨ρ⟩≈1.66 либо взрыв (F5, период осцилляций ⟨ρ⟩ ≈ 2Nx/c_s — прямая улика);\n",
" - эксперимент №2 (в работе): **губка перед выходом** (β(x)-поле на L0) — F6/F7/F8; затем\n",
" эксперимент №3 — серия по β на чистой постановке F7 → финальная экстраполяция.\n",
"- **Реестр всех прогонов** — `solver_2x_sdf/out/factors.csv`: полная конфигурация + метрики\n",
" каждого изменения постановки (готовый материал для отдельного исследования факторов).\n",
"- **Дальше**: эксперименты №2–3 → финальные числа; при остаточном превышении — фактор\n",
" разрешения (D-рефайнмент). Затем перенос отработанной схемы на D3Q27/GPU в основной движок\n",
" (часть VII)."
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
+160
View File
@@ -0,0 +1,160 @@
# solver_2x_sdf — модель цилиндра 2×+SDF, разложенная по компонентам
Самодостаточный LBM-решатель (D2Q9, KBC) для обтекания цилиндра: грубый уровень **L0**
плюс один вложенный патч **L1** (измельчение ×2). Граница цилиндра — **SDF + Bouzidi (no-slip)**;
вход/выход — **Zou-He** (скорость/давление); верх/низ канала — **free-slip (specular)**.
Только GPU/CuPy — **CPU-фолбэка нет**. Запуск — из этой папки: `python run.py`
(серия по блокировке: `python run_blockage.py`).
## Карта файлов (каждый компонент изолирован и легко меняется)
| Файл | Ответственность |
|---|---|
| `backend.py` | жёсткое требование CuPy/GPU, `DTYPE`, device-скаляры, `to_cpu` |
| `config.py` | все параметры модели + производные (τ, β, R, DL, cy/патч из Ny); `from_env()` |
| `lattice.py` | D2Q9 (c_i, w_i, OPP) + KBC-проектор `Ps` на сдвиг-моменты |
| `equilibrium.py` | `feq` (product-form, без `pow`), `macros` (ρ, u) |
| `collision.py` | **столкновение**: `kbc_collide` (энтропийный KBC-N1) — единственный оператор |
| `streaming.py` | **перенос**: `stream` (пул-схема через roll) |
| `geometry.py` | маски тела/стенок и SDF для L0 и L1 |
| `boundary.py` | `build_bc`/`apply_bc` (SDF+Bouzidi) + `channel_bc` (Zou-He) + `free_slip_walls` |
| `forces.py` | **силы**: галилей-инвариантный обмен импульсом `force_gmem` (Fx, Fy, **торк Tz**) + `coefficients` (Cd, Cl, **Cm**) |
| `forces_stress.py` | **кросс-чек силы**: ∮σ·n ds по контуру R+δ вокруг тела (независимый эстиматор) |
| `amr.py` | связка L0↔L1: `patch`/`ghost`/`fill`/`restrict` |
| `solver.py` | оркестратор: persistent-буферы, `step()`, `run()` (+ CUDA-граф) |
| `diagnostics.py` | `analyze`, St (параболическая интерполяция пика), кросс-чек, фиты серии по β |
| `visualization.py` | гифка |ω|, png-панель рядов (`series_2x_sdf.png`), прогресс-бар |
| `run.py` | точка входа (один прогон) |
| `run_blockage.py` | серия по блокировке Ny↑ → β↓, экстраполяция β→0, сводный npz+png |
| `run_factors.py` | факторное исследование постановки (вход/выход/uy-выход); реестр `out/factors.csv` |
## Где какая математика (для ревизии)
- **Столкновение** — `collision.py`. KBC-N1: `f ← f − β(2Δs + γΔh)`, где `Δs = Ps·(f−feq)` —
проекция на сдвиг-моменты, `γ` — энтропийный лимитер. Чтобы поэкспериментировать:
переключить `cfg.collision="bgk"`, либо добавить TRT новой функцией и зарегистрировать в `get`.
- **Перенос** — `streaming.py`. Чистая пул-схема; на физику влияет только корректность сдвигов.
- **Силы** — `forces.py`. Обмен импульсом по линкам тела:
`F = Σ_links c_i (f_i^{после столкн.} + f_ī^{после стриминга})`. Второй член — из поля ПОСЛЕ
стриминга (вернувшаяся Bouzidi-популяция); иначе ведущий симметричный член сокращается и Cd врёт.
Сила/момент меряются на **каждом** подшаге L1 и усредняются (анти-алиасинг; средний Cd не меняется).
**Торк** `Tz` — вокруг центра тела, точка приложения — пересечение линка со стенкой `r_f + q·c_i`
(Bouzidi-доля q уже лежит в BC); `Cm = 2Tz/(U²·DL²)`, для цилиндра ⟨Cm⟩≈0 — контроль симметрии.
- **Кросс-чек силы** — `forces_stress.py`. Независимый эстиматор — баланс импульса
контрольного объёма: `F = ∮[σ·n − ρu(u·n)] ds` по окружности R+δ (δ=2 тонкие ячейки);
`σ = −p′·I − (1−1/(2τ₁))·Π^neq_dev` (строго девиаторная проекция: след Π^neq в KBC
релаксирует с γβ, а не 1/τ). Конвективный член −ρu(u·n) обязателен при δ>0 — без него
систематика +4% по ⟨Cd⟩. Считается раз в `stress_every` шагов **вне CUDA-графа**; сравнение
по средним (⟨Cd⟩, rms Cl) — мгновенные ряды сдвинуты по фазе кольцом R..R+δ.
Согласие двух методов = считывание силы корректно.
- **Серия по блокировке** — `run_blockage.py`. D фиксирован, Ny ∈ {90,128,180} → β ∈ {0.178,
0.125, 0.089}; `cy` и y-границы патча выводятся из Ny автоматически (`config.__post_init__`).
Экстраполяция к β→0 двумя фитами (a+b·β и a+b·β²; спред интерсептов — неопределённость) —
прямое сравнение с безграничной литературой без ручной поправки (1−β)².
## ⚠ Сверка с первоисточниками (2026-08-14): найден дефект, все числа ниже требуют перепрогона
Сверка реализации со статьями в `docs/origins` (Karlin/Bösch/Chikatamarla, PRE **90**, 031302(R)
2014 — принцип; Bösch/Chikatamarla/Karlin, PRE **92**, 043309 2015 — алгоритм; arXiv:1507.02509 —
2D-реализация). **Ядро KBC-N1 подтверждено точным**: проектор `Ps` совпадает с аналитической
s-частью из ур.(10) 2D-статьи (ранг 2, идемпотентен, несёт ровно {N, Π_xy}); равновесие —
точный энтропийный максимизатор (сходится с численной максимизацией S при фикс. ρ, ρu);
γ по замкнутой формуле ур.(17)/(39) совпадает с корнем точного ур.(15); сдвиг-моменты
релаксируют ровно с 2β при любом γ; вязкость по затуханию сдвиговой волны воспроизводится
с ошибкой <0.1%.
**Дефект — порог `GEPS` в `kbc_collide`.** Он был АБСОЛЮТНЫМ (1e-6 в float32, режим по умолчанию),
тогда как `den = ⟨Δh|Δh⟩` квадратична по неравновесию и при разрешённой физике равна ~1e-7…1e-9.
Замерено на боевой конфигурации: откат `γ←2` срабатывал на **77–99% узлов**, то есть решатель
считал **чистый LBGK**, а не KBC. На эталоне 2D-статьи (двойной периодический сдвиговый слой,
Re=30000, N=128, разд. VII) это давало **+9.3%** по энстрофии на t=t_c (0.6599 против эталонных
0.6035); после исправления fp32 даёт 0.6038. Порог сделан относительным (`B.GREL`, доля от ‖Δ‖²);
на тех же полях он теперь не срабатывает нигде (min den/‖Δ‖² ≈ 1.5e-3 против порога 1e-8).
**Следствие: все количественные результаты ниже (Cd, rms Cl, St, серия по блокировке,
факторное исследование №0/№1) получены на дефектной ветке и подлежат перепрогону.**
Качественные выводы про ГУ (мода акустического резонатора, роль конвективного члена
в кросс-чеке) дефектом не затрагиваются.
Исправлено там же:
- **углы домена**: `stream()` на `roll` периодичен по обеим осям, а Zou-He стоял на срезе `1:-1` —
в 4 угловых узлах вход был напрямую связан с выходом (`f[1,0,0] == post[1,0,-1]`, величина ~0.12).
Порядок изменён на `free_slip_walls` → `channel_bc` по всему столбцу; заворот закрыт полностью.
- **диагностика ⟨ρ⟩** считалась по всему домену, включая фиктивные узлы внутри цилиндра
(смещение −5.4e-4 — ровно тот разряд, в котором ⟨ρ⟩ и цитируется). Теперь только по жидкости.
- **`refine` был зашит как 2** в `tau1`, `R01` и радиусе тела на L1/контуре σ·n. Обобщено
(при r=2 значения бит-в-бит прежние; при r=3 вязкость уровней теперь согласована).
**Отдельно — не дефект, а следствие выбора модели.** В KBC-N1 след тензора напряжений T отнесён
к h-части, поэтому объёмная вязкость плавает: ξ = c_s²(1/(γβ) − ½) (ур.36 статьи 2015, ур.57
2D-статьи). Замер на боевой конфигурации: γ ∈ [−4.1, 4.6], медиана ξ/ν ≈ 17, и на **2.1% узлов
ξ < 0** — акустическое АНТИдемпфирование. Это прямо релевантно диагнозу «недодемпфированный
акустический резонатор» ниже: KBC-N2 (T в s-части) даёт ξ = ν фиксированно и является
каноническим средством именно от этой проблемы. Модель не менялась — решение за автором.
## Статус: считывание силы валидировано; идёт факторное исследование постановки
Оператор столкновения зафиксирован — **KBC-N1 с энтропийным стабилизатором** (канонический
Karlin/Bösch/Chikatamarla 2014), без замен (никаких BGK/TRT).
**Полная документация исследования — в ноутбуке `../kbc_lbm.ipynb` (части VI, разделы 22–24).**
**Считывание силы доказано корректным** (прогон 100k, 174×90, 2026-06-09):
- кросс-чек GMEM vs ∮σ·n: dCd=4.1% (систематика объяснена конвективным членом −ρu(u·n),
добавлен в `forces_stress.py` — ожидаемое расхождение ≲1%);
- ⟨Cm⟩=−0.00003 (≈0 — симметрия), ⟨ρ⟩=1.001 стабильна, Cd=1.799 воспроизводит прежнюю
итерацию бит-в-бит (осреднение по подшагам не сместило среднее).
**Эксперимент №0 (серия по блокировке) — гипотеза бокового стеснения ОПРОВЕРГНУТА:**
Cd(β) немонотонен: 1.799 / 1.771 / **1.870** при Ny=90/128/180; rms Cl на широком канале
вырос (0.835), НЧ-модуляция (окна Cd 1.68–1.98). При этом **St(β→0)=0.181 ≈ лит. 0.183** —
кинематика верна, завышены давленческие амплитуды. Диагноз: продольные границы
(вход 2.5D с жёстким профилем; выход 8.3D Zou-He с жёстким uy=0 — отражает вихри).
**Эксперимент №1 (прогнан 2026-06-10) — факторное исследование** `run_factors.py`, 6 прогонов
при Ny=90 фикс. Итоги (полный разбор — ноутбук, раздел 24; реестр — `out/factors.csv`):
- конвективный член подтверждён: кросс-чек B0 4.1%→**1.0%**, F4 — **0.6%**;
- **F4 (мягкий uy-выход) — лучший по всем метрикам**: Cd 1.799→1.754, rms Cl 0.705→**0.557**,
St·(1−β)=**0.183** (точно лит.), ⟨ρ⟩=1.0000, нулевая НЧ-модуляция;
- **длинные домены без демпфера НЕвалидны** (F1/F2/F3 → смещённый режим ⟨ρ⟩≈1.66; F5 → NaN):
пара «вход-скорость / выход-давление» — недодемпфированный акустический резонатор
(затухание моды ∝ν(π/Nx)²; период осцилляций ⟨ρ⟩ у F5 ≈ 2Nx/c_s — прямая улика).
**Эксперимент №2 (в работе) — губка перед выходом**: плавный рост ν (×30, smoothstep) в
последних `sponge_len` столбцах L0; β(x) — поле (graph-safe), патч L1 губки не видит.
Факторы: F6_sponge (база+uy-extrap+губка 32 — валидация против F4), F7_long_sponge
(390×90, вход 6D/выход 18.3D — целевая постановка), F8_long_sp_uy0 (изоляция вклада губки).
Критерии: ⟨ρ⟩≈1.000 у всех; F7: Cd≈1.5–1.6, rms Cl<0.45. Затем эксперимент №3 — серия по β
на конфигурации F7.
| прогон | команда | артефакты в `out/` |
|---|---|---|
| смоук | `AMR_STEPS=2000 python run.py` | graph self-check OK, Cm≈0, кросс-чек ≲1% (с конв. членом) |
| основной | `python run.py` | `cyl_2x_sdf.gif`, `series_2x_sdf.png`, `probe_2x_sdf.npz` |
| серия по β | `python run_blockage.py` | `probe_blockage_Ny*.npz`, `blockage_summary.npz`, `blockage_extrapolation.png` |
| **факторы №2** | `AMR_FACTORS=F6_sponge,F7_long_sponge,F8_long_sp_uy0 python run_factors.py` | `factor_*.npz`, **`factors.csv`** (реестр; при смене схемы старый → `factors_vN.csv`), `factors_summary.png` |
Env-ручки: `AMR_NX`, `AMR_CX` (продольная геометрия; x-границы патча L1 выводятся из cx),
`AMR_OUTLET_UY=zero|extrapolate`, `AMR_SPONGE_LEN`/`AMR_SPONGE_NU` (губка),
`AMR_FACTORS=...` (подмножество факторов).
**Что привело сюда (хронология фиксов):**
1. Зонд скорости → L1; оценка St с параболической интерполяцией пика.
2. Сила → галилей-инвариантный `force_gmem` (Wen 2014), готов к подвижным телам.
3. Стартовое возмущение (поперечный sin-импульс входа) — запуск дорожки за единицы циклов.
4. Диагностика по окнам (`convergence_report`, трекинг ⟨ρ⟩) — вскрыла дрейф массы.
5. **Ключевой фикс:** грубое ГУ выхода (zero-gradient) копило массу → поток глох (Cd/rms→0 за 100k).
Заменено на **Zou-He скоростной вход + давление-выход** (`boundary.channel_bc`) — масса заякорена.
6. **Free-slip стенки** (`boundary.free_slip_walls`, specular) + умеренно расширенный домен (меньше
блокировка). Цилиндр остаётся no-slip.
7. Cm + осреднение по подшагам + кросс-чек ∮σ·n ds + серия по блокировке (текущая итерация).
**Прямой raw-матч на одной сетке непрактичен** (нужен `β<0.03 → Ny≈500+`); серия из трёх умеренных
Ny с экстраполяцией даёт безпоправочное сравнение за ~3 стоимости одного прогона (патч L1 —
основная работа — от Ny не зависит).
## CUDA Graphs
По умолчанию ВКЛ (`cfg.use_cuda_graph`, env `AMR_CUDAGRAPH=0` — выкл). **Захватываются успешно** (после
замены `cupy.stack`→`backend.pack`, отказа от cuBLAS в проекторе KBC и clip-литералов в ядре —
всё это убрало host→device при capture). На шаге t=1 — рантайм-самопроверка (граф vs eager); при
расхождении/сбое — откат на eager с печатью трейсбэка.
+54
View File
@@ -0,0 +1,54 @@
# AMR-связка L0 (грубый) ↔ L1 (тонкий, измельчение r).
#
# Идея: на тонком патче считаем r подшагов по времени на каждый шаг L0. Границы патча («ghost»)
# заполняем интерполяцией с грубого уровня (по пространству + по времени между «до» и «после» шага L0),
# а внутренность патча после подшагов проецируем обратно на грубый уровень (рестрикция).
# Неравновесная часть масштабируется множителями Rcf (грубый→тонкий) и Rfc (тонкий→грубый),
# т.к. при смене τ масштаб неравновесия меняется.
import backend as B
import lattice as L
from equilibrium import feq, macros
cp = B.cp; xp = B.xp; DTYPE = B.DTYPE
def patch(pNx, pNy, ax, bx, ay, by, r):
"""Описание патча: размеры (Nfy,Nfx), индексы/веса билинейной интерполяции с грубого уровня,
срезы для рестрикции каждого r-го узла."""
Wx, Wy = bx - ax, by - ay
Nfx, Nfy = r*Wx + 1, r*Wy + 1
FX, FY = cp.meshgrid(ax + cp.arange(Nfx)/r, ay + cp.arange(Nfy)/r) # координаты тонких узлов в L0
x0 = cp.floor(FX).astype(cp.int64); y0 = cp.floor(FY).astype(cp.int64)
x1 = cp.minimum(x0 + 1, pNx - 1); y1 = cp.minimum(y0 + 1, pNy - 1)
return dict(Nfx=int(Nfx), Nfy=int(Nfy), ax=ax, bx=bx, ay=ay, by=by, r=r,
x0=x0, y0=y0, x1=x1, y1=y1,
tx=(FX - x0).astype(DTYPE), ty=(FY - y0).astype(DTYPE),
slx=slice(r, r*Wx, r), sly=slice(r, r*Wy, r))
def pint(fld, P):
"""Билинейная интерполяция поля fld (грубого) в узлы тонкого патча."""
return (fld[..., P["y0"], P["x0"]]*(1 - P["tx"])*(1 - P["ty"])
+ fld[..., P["y0"], P["x1"]]*P["tx"]*(1 - P["ty"])
+ fld[..., P["y1"], P["x0"]]*(1 - P["tx"])*P["ty"]
+ fld[..., P["y1"], P["x1"]]*P["tx"]*P["ty"])
def ghost(pf, P, Rcf):
"""Граничные значения тонкого уровня из грубого поля pf: равновесие по интерполированным ρ,u
плюс масштабированная неравновесная часть Rcf·neq.
u в feq передаём кортежем (без cupy.stack) — H2D ломает CUDA-graph capture."""
r, u = macros(pf); neq = pf - feq(r, u)
return feq(pint(r, P), (pint(u[0], P), pint(u[1], P))) + Rcf*pint(neq, P)
def fill(cf, gh):
"""Записать ghost-значения gh в рамку (1 ряд) тонкого поля cf."""
cf[:, 0, :] = gh[:, 0, :]; cf[:, -1, :] = gh[:, -1, :]
cf[:, :, 0] = gh[:, :, 0]; cf[:, :, -1] = gh[:, :, -1]
return cf
def restrict(cf, pf, P, Rfc, fluid):
"""Спроецировать тонкое поле cf обратно на грубое pf (каждый r-й узел), масштабируя neq на Rfc.
Меняется только внутренняя жидкая часть перекрытия (маска fluid)."""
r, u = macros(cf); neq = cf - feq(r, u); slx, sly = P["slx"], P["sly"]
nv = feq(r[sly, slx], u[:, sly, slx]) + Rfc*neq[:, sly, slx]
cur = pf[:, P["ay"]+1:P["by"], P["ax"]+1:P["bx"]]
pf[:, P["ay"]+1:P["by"], P["ax"]+1:P["bx"]] = xp.where(fluid[None], nv, cur)
return pf
+40
View File
@@ -0,0 +1,40 @@
# Бэкенд: ТОЛЬКО GPU/CuPy. CPU-фолбэка нет (по требованию). Если CuPy/GPU недоступны — жёсткая ошибка.
# Здесь же — общие device-константы (DTYPE, скаляры 0/1/2) и помощник переноса на host для вывода.
import os
import cupy as cp
try:
_probe = (cp.zeros(2) + 1).sum(); cp.cuda.Device().synchronize() # реальная операция на device
except Exception as e:
raise RuntimeError("solver_2x_sdf требует рабочую GPU + CuPy (CPU-фолбэк удалён намеренно).") from e
xp = cp # единый алиас вычислительного бэкенда
DTYPE = cp.float64 if os.environ.get("AMR_FP64") else cp.float32
# Порог вырожденности знаменателя γ. ОТНОСИТЕЛЬНЫЙ (доля от ‖Δ‖² = ⟨Δ|Δ⟩), а НЕ абсолютный.
# Почему: den = ⟨Δh|Δh⟩ — квадратичная по неравновесию величина, у развитого следа она
# ~1e-7…1e-9 при вполне разрешённой физике. Прежний абсолютный порог 1e-6 (fp32) срабатывал
# на 77–99% узлов и молча подменял γ на 2, т.е. гнал ЧИСТЫЙ LBGK вместо KBC (замерено:
# энстрофия сдвигового слоя Re=3e4 уходила на +9%). den — сумма НЕотрицательных слагаемых,
# её относительная точность ~eps типа, поэтому дробный порог одинаков для fp32/fp64.
# 1e-8 на шесть порядков ниже наблюдаемого минимума den/‖Δ‖² (~5e-4) — срабатывает только на
# истинном вырождении (Δh ≡ 0), где γ всё равно ни на что не влияет, т.к. умножается на Δh.
GREL = 1e-8
BACKEND = f"CuPy/GPU dtype={'float64' if DTYPE == cp.float64 else 'float32'}"
# device-скаляры — не создаём host→device на горячем пути (важно для CUDA-graph capture)
ZERO = cp.zeros((), DTYPE)
ONE = cp.asarray(1.0, DTYPE)
TWO = cp.asarray(2.0, DTYPE)
def to_cpu(a):
"""Перенос на host (numpy) — только для диагностики/визуализации, не в солвере."""
return cp.asnumpy(a)
def pack(comps):
"""Собрать кортеж одинаковых по форме массивов в один (len,*shape) БЕЗ cupy.stack.
cupy.stack/concatenate грузит на device host-массив указателей (H2D) → запрещено при CUDA-graph
capture. Здесь: преаллокация + присваивание срезов — только device→device."""
out = xp.empty((len(comps),) + comps[0].shape, dtype=comps[0].dtype)
for i, c in enumerate(comps):
out[i] = c
return out
+104
View File
@@ -0,0 +1,104 @@
# Граничные условия: (1) тело/стенки — SDF + интерполированный отскок Bouzidi; (2) вход/выход канала.
#
# Bouzidi (линейный): для линка из жидкого узла x_f в твёрдый по направлению i вводится
# доля пересечения q = |x_f→стенка| / |x_f→x_solid| ∈ (0,1):
# q < 1/2 (есть «дальний» жидкий сосед x_f − c_i): f_ī = 2q·f_i^post + (1−2q)·f_i^post(дальний)
# q ≥ 1/2: f_ī = (1/2q)·f_i^post + (1−1/2q)·f_ī^post
# Здесь f_i^post — после столкновения, f_ī — то, что вернётся в x_f после переноса.
import backend as B
import lattice as L
cp = B.cp; xp = B.xp; DTYPE = B.DTYPE
Q = L.Q; Cx = L.Cx; Cy = L.Cy; OPP = L.OPP
def build_bc(solid, phi, body):
"""Сборка структуры ГУ (один раз). Для каждого направления i (1..8):
mask — жидкий узел, чей сосед по +c_i твёрдый;
q — доля пересечения по SDF (клип [0.02,0.98]);
xff — есть ли «дальний» жидкий сосед (для линейной формулы при q<1/2);
cl — подмаска линков именно ТЕЛА body (для расчёта силы)."""
fluid = ~solid; bc = []
for i in range(1, Q):
nb_solid = cp.roll(solid, (-Cy[i], -Cx[i]), (0, 1)); mask = fluid & nb_solid
cl = mask & cp.roll(body, (-Cy[i], -Cx[i]), (0, 1))
phinb = cp.roll(phi, (-Cy[i], -Cx[i]), (0, 1))
qq = phi / (phi - phinb) # деление; nan/inf маскируются ниже
q = xp.where(mask, cp.clip(qq, 0.02, 0.98), cp.asarray(0.5, DTYPE))
xff = mask & (~cp.roll(solid, (Cy[i], Cx[i]), (0, 1)))
bc.append((i, OPP[i], mask, q.astype(DTYPE), xff, cl))
return bc
def apply_bc(f, fpost, bc):
"""Применить Bouzidi ко всем линкам (полностью через where, GPU-friendly).
f — поле ПОСЛЕ стриминга (его и правим); fpost — поле ПОСЛЕ столкновения (до стриминга)."""
for (i, ib, mask, q, xff, cl) in bc:
fi = fpost[i]; fib = fpost[ib]
fiback = cp.roll(fpost[i], (Cy[i], Cx[i]), (0, 1)) # популяция «дальнего» соседа
near = mask & (q < 0.5) & xff
bad = mask & (q < 0.5) & (~xff) # нет дальнего соседа → простой отскок
far = mask & (q >= 0.5)
new = f[ib]
new = xp.where(near, 2*q*fi + (1 - 2*q)*fiback, new)
new = xp.where(far, (1/(2*q))*fi + (1 - 1/(2*q))*fib, new)
new = xp.where(bad, fi, new)
f[ib] = new
return f
def channel_bc(f, uin, rho_out=1.0, outlet_uy="zero"):
"""Корректные ГУ канала (Zou & He 1997), нумерация D2Q9: 1=E,2=N,3=W,4=S,5=NE,6=NW,7=SW,8=SE.
ВХОД (запад, x=0): скоростной Zou-He — задаём u=(ux,uy) из uin, плотность ρ ПЛАВАЕТ; достраиваем
приходящие извне популяции 1,5,8 из известных 0,2,3,4,6,7.
ВЫХОД (восток, x=-1): давление Zou-He — задаём ρ=rho_out, скорость ux плавает; достраиваем 3,6,7.
Поперечная скорость на выходе — по outlet_uy:
"zero" — классический Zou-He: uy=0 (жёстко). Вихри дорожки приходят на выход с
uy~±0.3U → принудительное зануление отражает их назад к телу (завышает
rms Cl и модулирует Cd — см. факторное исследование в ноутбуке);
"extrapolate" — uy берётся из соседнего столбца x=-2 (нуль-градиент поперечной скорости);
ρ-якорь (масса) сохраняется, кинематическое отражение снимается.
Зачем давление-выход вообще: грубый zero-gradient по f не выпускал поток → масса/противодавление
копились, поток глох (⟨ρ⟩↑, Cd/rmsCl→0). Давление-выход якорит массу, скоростной вход её не
пересоздаёт → дрейфа нет. Всё на срезах (graph-safe, без host→device).
ПОРЯДОК ВЫЗОВА: строго ПОСЛЕ free_slip_walls и покрывая ВЕСЬ столбец (включая ряды 0 и Ny−1).
Причина: stream() сделан через roll и потому периодичен по ОБЕИМ осям. Заворот по y снимают
стенки, заворот по x — этот ГУ. Если оставить углы (ряды 0 и Ny−1) без Zou-He, в них популяции
с cx=+1 на входе приходят прямо из столбца выхода (замерено: f[1,0,0] == post[1,0,-1], величина
~0.12) — вход и выход оказываются физически связаны в 4 узлах. Известные популяции, по которым
здесь считаются ρ (вход) и ux (выход), x-заворота не содержат ни в одном ряду, а те, что
заворот содержат, — это ровно неизвестные, которые ГУ и перезаписывает; поэтому такой порядок
закрывает углы полностью."""
# --- вход (запад): скоростной Zou-He ---
ux = uin[0, :, 0]; uy = uin[1, :, 0]
f0 = f[0, :, 0]; f2 = f[2, :, 0]; f3 = f[3, :, 0]
f4 = f[4, :, 0]; f6 = f[6, :, 0]; f7 = f[7, :, 0]
rho = (f0 + f2 + f4 + 2.0*(f3 + f6 + f7)) / (1.0 - ux)
f[1, :, 0] = f3 + (2.0/3.0)*rho*ux
f[5, :, 0] = f7 - 0.5*(f2 - f4) + (1.0/6.0)*rho*ux + 0.5*rho*uy
f[8, :, 0] = f6 + 0.5*(f2 - f4) + (1.0/6.0)*rho*ux - 0.5*rho*uy
# --- выход (восток): давление Zou-He ---
g0 = f[0, :, -1]; g1 = f[1, :, -1]; g2 = f[2, :, -1]
g4 = f[4, :, -1]; g5 = f[5, :, -1]; g8 = f[8, :, -1]
uxo = (g0 + g2 + g4 + 2.0*(g1 + g5 + g8)) / rho_out - 1.0
if outlet_uy == "extrapolate":
# нуль-градиент поперечной скорости: uy с предвыходного столбца (он полностью известен)
col = f[:, :, -2]
uyo = ((col[2] + col[5] + col[6]) - (col[4] + col[7] + col[8])) / col.sum(0)
else:
uyo = 0.0
f[3, :, -1] = g1 - (2.0/3.0)*rho_out*uxo
f[6, :, -1] = g8 - 0.5*(g2 - g4) - (1.0/6.0)*rho_out*uxo + 0.5*rho_out*uyo
f[7, :, -1] = g5 + 0.5*(g2 - g4) - (1.0/6.0)*rho_out*uxo - 0.5*rho_out*uyo
return f
def free_slip_walls(f):
"""FREE-SLIP (скользящие) стенки канала: верх/низ — зеркальное (specular) отражение.
Сохраняет касательный (x) импульс (нет трения, нет пограничного слоя) и нормальный заворачивает;
масса сохраняется (копирование популяций) → дрейфа не вносит. Применять ПОСЛЕ стриминга.
Нумерация: 2=(0,+1) 4=(0,-1) 5=(+1,+1) 6=(-1,+1) 7=(-1,-1) 8=(+1,-1).
Нижняя стенка (row 0): достраиваем вверх-идущие из вниз-идущих (зеркало по y, x сохранён).
Верхняя стенка (row -1): достраиваем вниз-идущие из вверх-идущих.
ВНИМАНИЕ: это только искусственные стенки канала; цилиндр остаётся no-slip (Bouzidi).
ПОРЯДОК: вызывать ПЕРЕД channel_bc (см. его docstring — так закрываются углы домена)."""
f[2, 0, :] = f[4, 0, :]; f[5, 0, :] = f[8, 0, :]; f[6, 0, :] = f[7, 0, :]
f[4, -1, :] = f[2, -1, :]; f[7, -1, :] = f[6, -1, :]; f[8, -1, :] = f[5, -1, :]
return f
+42
View File
@@ -0,0 +1,42 @@
# Оператор СТОЛКНОВЕНИЯ — KBC-N1 (энтропийный), единственный оператор модели.
#
# KBC-N1 (Karlin/Bösch/Chikatamarla 2014):
# Δ = f − feq (неравновесная часть)
# Δs = Ps·Δ (проекция на сдвиг-моменты {cx²−cy², cxcy})
# Δh = Δ − Δs (остальная часть)
# γ = 1/β − (2 − 1/β)·⟨Δs,Δh⟩ / ⟨Δh,Δh⟩, ⟨a,b⟩ = Σ_i a_i b_i / feq_i (H-теорема)
# f_post = f − β(2Δs + γΔh), β = 1/(2τ)
# γ — энтропийный стабилизатор: подстраивает диссипацию неhydro-мод под энтропийное ограничение
# (поэтому KBC устойчив при τ→1/2). Сдвиг-моменты (вязкость) релаксируют с правильным 2β = 1/τ,
# то есть физическая вязкость задаётся только τ — стабилизатор её не меняет.
#
# Порядок шагов дословно совпадает с алгоритмом статьи (Bösch/Chikatamarla/Karlin, PRE 92,
# 043309 (2015), разд. III, листинг после ур.39): ρ,u → feq → Δs → Δh = f − feq − Δs → γ → релаксация.
# Вырожденный случай ⟨Δh|Δh⟩→0 отсекается ОТНОСИТЕЛЬНЫМ порогом B.GREL (см. backend.py):
# абсолютный порог здесь недопустим — den квадратична по неравновесию и физически мала.
import backend as B
import lattice as L
cp = B.cp; xp = B.xp
Q = L.Q; Ps = L.Ps; GREL = B.GREL; ONE = B.ONE; TWO = B.TWO
def _project_shift(dfn):
"""Δs = Ps·Δ без cuBLAS (gemm при CUDA-graph capture тащит alpha/beta host→device).
Реализовано как Σ_k Ps[i,k]·Δ[k] через broadcast+редукцию — только обычные ядра."""
dfn2d = dfn.reshape(Q, -1) # (Q, N)
return (Ps[:, :, None] * dfn2d[None]).sum(1).reshape(dfn.shape)
def kbc_collide(f, fe, beta):
dfn = f - fe
ds = _project_shift(dfn)
dh = dfn - ds; inv = 1.0 / fe
num = (ds*dh*inv).sum(0); den = (dh*dh*inv).sum(0)
nrm = (dfn*dfn*inv).sum(0) # ‖Δ‖² — масштаб неравновесия узла
ok = den > GREL*nrm # относительный порог, не абсолютный
den_safe = xp.where(ok, den, ONE) # избегаем 0/0 (обе ветки where считаются)
binv = 1.0 / beta
g = xp.where(ok, binv - (2.0 - binv)*num/den_safe, TWO)
return f - beta*(2.0*ds + g[None]*dh)
# единственный оператор столкновения модели
collide = kbc_collide
+138
View File
@@ -0,0 +1,138 @@
# Конфигурация модели 2×+SDF. Всё в одном месте, чтобы математику/режимы было легко менять.
# Единицы — решёточные (lu — длина воксель, ts — шаг по времени).
import os
from dataclasses import dataclass
from typing import Optional
@dataclass
class Config:
# --- сетка / геометрия L0 (умеренно расширены: меньше блокировка + больше следа) ---
Nx: int = 174 # домен по x (цилиндр на 40 → ~8.4D вниз по потоку до выхода)
Ny: int = 90 # домен по y (стенки free-slip; блокировка β=D/Ny — варьируется серией)
D: int = 16 # диаметр цилиндра на грубом уровне L0
cx: int = 40
cy: Optional[int] = None # центр по вертикали; по умолчанию Ny//2 (см. __post_init__)
# --- физика ---
U: float = 0.07 # скорость набегающего потока
Re: float = 150.0 # число Рейнольдса по D и U
# --- время ---
steps: int = 100000
ramp: int = 1000 # smoothstep-разгон входной скорости (гасит импульсный старт)
frame_every: int = 34 # период сохранения кадров для гифки
# --- патч уровня L1 (в координатах L0); refine=2 → разрешение Δx/2 ---
ax1: Optional[int] = None # по умолчанию cx − 1.5D (запас перед телом; см. __post_init__)
bx1: Optional[int] = None # по умолчанию cx + 6.25D (расширен в след)
ay1: Optional[int] = None # по умолчанию cy − 2D (центрирован по cy)
by1: Optional[int] = None # по умолчанию cy + 2D
refine: int = 2 # коэффициент измельчения L1 (модель «2×» рассчитана на 2)
# --- зонд скорости в следе: px = cx + probe_dx*D, py = cy (берётся с тонкой сетки L1) ---
probe_dx: int = 3
# --- стартовое возмущение (триггер вихревой дорожки): поперечный sin-импульс входа ---
pert_amp: float = 0.1 # амплитуда u_y входа, доля от U (0 — выключить)
pert_dur: int = 300 # длительность импульса в шагах; окно [ramp, ramp+pert_dur]
# --- кросс-чек силы: интеграл тензора напряжений σ·n по окружности R1+δ на L1 (forces_stress.py) ---
stress_every: int = 50 # период выборки в шагах L0 (0 — выключить кросс-чек)
stress_ntheta: int = 256 # число точек контура по углу
stress_delta: float = 2.0 # отступ контура от поверхности (в тонких ячейках L1);
# δ=2 → билинейный стенсиль не трогает Bouzidi-зону (q_max≈0.98)
# --- ГУ выхода: режим поперечной скорости на Zou-He давление-выходе ---
# "zero" — классика: uy=0 на выходе (жёстко; отражает вихри дорожки)
# "extrapolate" — uy берётся из соседнего столбца (нуль-градиент); ρ-якорь сохраняется
outlet_uy: str = "zero"
# --- губка (absorbing layer) перед выходом: плавный рост вязкости в последних sponge_len
# столбцах L0 (smoothstep, ν → ν·sponge_nu_mult). Гасит вихри И акустику до прихода на
# давление-выход. Зачем: канал «вход-скорость + выход-давление» — недодемпфированный
# акустический резонатор (затухание моды ∝ ν(π/Nx)²); на длинных доменах мода раскачивается
# стартовым транзиентом → смещённый режим ⟨ρ⟩≈1.66 или взрыв (факторное исследование,
# эксперимент №1: F1/F2/F3/F5). 0 — выключена.
sponge_len: int = 0
sponge_nu_mult: float = 30.0
# --- исполнение ---
use_cuda_graph: bool = True
def __post_init__(self):
# производная геометрия по вертикали: центр и патч следуют за Ny (для серии по блокировке)
if self.cy is None:
self.cy = self.Ny // 2
if self.ay1 is None:
self.ay1 = self.cy - 2 * self.D
if self.by1 is None:
self.by1 = self.cy + 2 * self.D
# производная геометрия по горизонтали: патч следует за cx (для факторов вход/выход)
if self.ax1 is None:
self.ax1 = self.cx - (3 * self.D) // 2
if self.bx1 is None:
self.bx1 = self.cx + (25 * self.D) // 4
assert 1 <= self.ay1 < self.by1 <= self.Ny - 2, (
f"патч L1 [{self.ay1},{self.by1}] должен лежать строго внутри (0,{self.Ny-1}) — "
f"specular-ряды стенок не накрывать; минимально допустимое Ny ≈ {4*self.D + 6}")
assert 2 <= self.ax1 < self.cx < self.bx1 <= self.Nx - 2, (
f"патч L1 [{self.ax1},{self.bx1}] должен лежать строго внутри (1,{self.Nx-1}) "
f"и накрывать тело (cx={self.cx})")
assert self.outlet_uy in ("zero", "extrapolate"), self.outlet_uy
if self.sponge_len:
assert self.Nx - 1 - self.sponge_len > self.bx1, (
f"губка (последние {self.sponge_len} столбцов) не должна накрывать патч L1 "
f"(bx1={self.bx1}, Nx={self.Nx})")
# ---------- производные величины ----------
@property
def nu(self): return self.U * self.D / self.Re # кинематическая вязкость (lu²/ts)
@property
def tau0(self): return self.nu / (1.0/3.0) + 0.5 # время релаксации на L0 (cs²=1/3)
@property
def tau1(self): # связка τ при измельчении в r раз: ν одна и та же ⇒ τ_f = r(τ_c − ½) + ½
return self.refine * (self.tau0 - 0.5) + 0.5
@property
def beta0(self): return 1.0 / (2.0 * self.tau0) # β = 1/(2τ) для KBC/BGK на L0
@property
def beta1(self): return 1.0 / (2.0 * self.tau1) # β на L1
@property
def R01(self): # масштаб неравновесной части грубый→тонкий: f^neq ∝ τ·δt ⇒ (τ_f/τ_c)/r
return self.tau1 / (self.refine * self.tau0)
@property
def Rf01(self): return 1.0 / self.R01 # тонкий→грубый
@property
def DL(self): return self.refine * self.D # эффективный диаметр у тела (на L1)
@property
def px(self): return self.cx + self.probe_dx * self.D
@property
def py(self): return self.cy
def from_env(**overrides):
"""Создать Config с учётом переменных окружения и явных overrides.
Геометрия (AMR_NY/AMR_NX/AMR_CX) попадает в overrides ДО конструктора —
производные cy/ay1/by1/ax1/bx1 строятся в __post_init__."""
if "Ny" not in overrides and os.environ.get("AMR_NY"):
overrides["Ny"] = int(os.environ["AMR_NY"])
if "Nx" not in overrides and os.environ.get("AMR_NX"):
overrides["Nx"] = int(os.environ["AMR_NX"])
if "cx" not in overrides and os.environ.get("AMR_CX"):
overrides["cx"] = int(os.environ["AMR_CX"])
if "outlet_uy" not in overrides and os.environ.get("AMR_OUTLET_UY"):
overrides["outlet_uy"] = os.environ["AMR_OUTLET_UY"]
if "sponge_len" not in overrides and os.environ.get("AMR_SPONGE_LEN"):
overrides["sponge_len"] = int(os.environ["AMR_SPONGE_LEN"])
if "sponge_nu_mult" not in overrides and os.environ.get("AMR_SPONGE_NU"):
overrides["sponge_nu_mult"] = float(os.environ["AMR_SPONGE_NU"])
cfg = Config(**overrides)
if os.environ.get("AMR_STEPS"):
cfg.steps = int(os.environ["AMR_STEPS"])
if "AMR_CUDAGRAPH" in os.environ:
cfg.use_cuda_graph = (os.environ["AMR_CUDAGRAPH"] != "0")
if os.environ.get("AMR_STRESS_EVERY"):
cfg.stress_every = int(os.environ["AMR_STRESS_EVERY"])
if os.environ.get("AMR_STRESS_NTHETA"):
cfg.stress_ntheta = int(os.environ["AMR_STRESS_NTHETA"])
if os.environ.get("AMR_STRESS_DELTA"):
cfg.stress_delta = float(os.environ["AMR_STRESS_DELTA"])
return cfg
+140
View File
@@ -0,0 +1,140 @@
# Диагностика по собранным рядам сил/скорости. numpy здесь — только host-постобработка малых рядов
# (FFT/статистика), это НЕ вычислительный бэкенд солвера.
import numpy as np
import backend as B
def strouhal(uy_host, D, U, pad=8192):
"""Число Струхаля из ряда поперечной скорости в следе.
УЛУЧШЕНО: параболическая (3-точечная) интерполяция пика спектра → суб-биновая точность.
(Раньше брался ближайший FFT-бин: шаг по St ≈ D/(U·pad) ≈ 0.028, из-за чего St 'застревал'.)"""
sig = uy_host[len(uy_host)//2:] # установившийся режим — вторая половина
sig = sig - sig.mean()
amp = np.abs(np.fft.rfft(sig, n=pad))
if len(amp) <= 3:
return 0.0, np.fft.rfftfreq(pad, 1.0), amp
k = 1 + int(np.argmax(amp[1:])) # индекс пика (без DC)
if 1 <= k < len(amp) - 1: # параболическая интерполяция вершины
a0, a1, a2 = amp[k-1], amp[k], amp[k+1]
denom = (a0 - 2*a1 + a2)
delta = 0.5*(a0 - a2)/denom if denom != 0 else 0.0
else:
delta = 0.0
f_peak = (k + delta) / pad
return f_peak * D / U, np.fft.rfftfreq(pad, 1.0), amp
def analyze(res, cfg):
Fx = B.to_cpu(res["Fx"]); Fy = B.to_cpu(res["Fy"]); uy = B.to_cpu(res["uy"])
DL, U, D = res["DL"], cfg.U, cfg.D
n = len(uy); h = slice(n//2, n)
Cd = 2.0*Fx/(U**2*DL); Cl = 2.0*Fy/(U**2*DL)
St, freqs, amp = strouhal(uy, D, U)
a = dict(Cd=float(Cd[h].mean()), Clrms=float(Cl[h].std()), St=float(St),
uyrms=float(uy[h].std()), Cd_s=Cd, Cl_s=Cl, uy=uy, freqs=freqs, amp=amp, h0=n//2)
if "Tz" in res: # момент: Cm = 2Tz/(U²·DL²); для цилиндра ⟨Cm⟩≈0
Cm = 2.0*B.to_cpu(res["Tz"])/(U**2*DL*DL)
a["Cm"] = float(Cm[h].mean()); a["Cmrms"] = float(Cm[h].std()); a["Cm_s"] = Cm
K = int(res.get("stress_every", 0) or 0) # кросс-чек σ·n (редкая сетка по времени)
if K and len(res.get("Fx_st", ())) > 3:
Fxs = B.to_cpu(res["Fx_st"]); Fys = B.to_cpu(res["Fy_st"]); Tzs = B.to_cpu(res["Tz_st"])
m = len(Fxs); hs = slice(m//2, m)
Cds = 2.0*Fxs/(U**2*DL); Cls = 2.0*Fys/(U**2*DL); Cms = 2.0*Tzs/(U**2*DL*DL)
a.update(Cd_st=float(Cds[hs].mean()), Clrms_st=float(Cls[hs].std()),
Cm_st=float(Cms[hs].mean()), Cd_st_s=Cds, Cl_st_s=Cls, stress_every=K)
return a
def print_table(a, cfg):
# поправка на блокировку канала (приведение к скорости в зазоре U/(1−β)):
# St — кинематическая → ×(1−β); Cd, rms Cl ~ скорость² → ×(1−β)²
beta = cfg.D / cfg.Ny
St_c = a['St'] * (1 - beta)
Cd_c = a['Cd'] * (1 - beta)**2
Cl_c = a['Clrms'] * (1 - beta)**2
cm = a.get('Cm')
cms = f"{cm:>10.5f}" if cm is not None else f"{'—':>10}"
print("\n=== Модель 2×+SDF — установившийся режим ===")
print(f"{'версия':18}{'D у тела':>9}{'St':>8}{'<Cd>':>9}{'rms Cl':>9}{'<Cm>':>10}{'rms u_y':>9}")
print(f"{'2×+SDF (raw)':18}{cfg.DL:>9}{a['St']:>8.3f}{a['Cd']:>9.3f}{a['Clrms']:>9.4f}{cms}{a['uyrms']:>9.4f}")
print(f"{'2×+SDF (×блок.)':18}{cfg.DL:>9}{St_c:>8.3f}{Cd_c:>9.3f}{Cl_c:>9.4f}{'—':>10}{'—':>9}")
print(f"{'лит. Re≈150':18}{'—':>9}{0.183:>8.3f}{1.330:>9.3f}{'~0.3':>9}{0.0:>10.1f}{'':>9}")
print(f"(блокировка β=D/Ny={beta:.3f}; поправка: St×(1−β), Cd/rmsCl×(1−β)². "
"Лит. — безграничный цилиндр; ⟨Cm⟩≈0 — контроль симметрии; см. README)")
def print_crosscheck(a):
"""Сравнение двух НЕЗАВИСИМЫХ считываний силы: GMEM (обмен импульсом по линкам) vs ∮σ·n ds
(интеграл тензора напряжений по контуру R+δ). Сходство — прямое свидетельство корректности
считывания; сравниваем средние (мгновенные ряды σ·n сдвинуты по фазе кольцом R..R+δ)."""
if "Cd_st" not in a:
print("\n[кросс-чек σ·n: выключен (stress_every=0) или слишком мало точек]")
return
dcd = abs(a["Cd_st"] - a["Cd"]) / max(abs(a["Cd"]), 1e-12)
dcl = abs(a["Clrms_st"] - a["Clrms"]) / max(abs(a["Clrms"]), 1e-12)
cm = a.get("Cm", float("nan"))
print("\n=== Кросс-чек считывания силы: GMEM vs ∮σ·n ds (установившийся режим) ===")
print(f"{'метод':26}{'<Cd>':>9}{'rms Cl':>9}{'<Cm>':>10}")
print(f"{'GMEM (обмен импульсом)':26}{a['Cd']:>9.3f}{a['Clrms']:>9.4f}{cm:>10.5f}")
print(f"{'∮σ·n ds (контур R+δ)':26}{a['Cd_st']:>9.3f}{a['Clrms_st']:>9.4f}{a['Cm_st']:>10.5f}")
verdict = "СОГЛАСОВАНО" if (dcd < 0.05 and dcl < 0.10) else "ПРОВЕРИТЬ"
print(f"расхождение: dCd={dcd*100:.1f}% (порог 5%), d(rmsCl)={dcl*100:.1f}% (порог 10%) → {verdict}")
def fit_blockage(betas, vals):
"""Экстраполяция величины серии к β→0 двумя моделями: val ≈ a + b·β и val ≈ a + b·β².
(Теория solid-blockage даёт ведущий член O(β²), практика поправки (1−β)² содержит и линейный.)
Спред интерсептов |a_lin − a_quad| — оценка модельной неопределённости экстраполяции."""
b = np.asarray(betas, float); v = np.asarray(vals, float)
sl, al = np.polyfit(b, v, 1)
sq, aq = np.polyfit(b**2, v, 1)
return dict(lin=(float(al), float(sl)), quad=(float(aq), float(sq)),
spread=float(abs(al - aq)))
def print_blockage_table(rows, fits):
"""Сводка серии по блокировке. rows — список dict(Ny, beta, St, Cd, Clrms, Cm, Cd_st);
fits — dict('Cd'/'Clrms'/'St' → результат fit_blockage)."""
print("\n=== Серия по блокировке: D фикс., Ny варьируется ===")
print(f"{'Ny':>5}{'β=D/Ny':>9}{'St':>8}{'St(1−β)':>9}{'<Cd>':>9}{'rms Cl':>9}{'<Cm>':>10}{'Cd σ·n':>9}")
for r in rows:
cdst = f"{r['Cd_st']:>9.3f}" if r.get("Cd_st") is not None else f"{'—':>9}"
print(f"{r['Ny']:>5}{r['beta']:>9.3f}{r['St']:>8.3f}{r['St']*(1-r['beta']):>9.3f}"
f"{r['Cd']:>9.3f}{r['Clrms']:>9.4f}{r['Cm']:>10.5f}{cdst}")
print("экстраполяция β→0 (интерсепт лин. фита / квадр. фита; спред — неопределённость):")
print(f" Cd(0) = {fits['Cd']['lin'][0]:.3f} / {fits['Cd']['quad'][0]:.3f} "
f"(спред {fits['Cd']['spread']:.3f}; лит. безгранич. 1.33)")
print(f" rms Cl(0) = {fits['Clrms']['lin'][0]:.3f} / {fits['Clrms']['quad'][0]:.3f} "
f"(спред {fits['Clrms']['spread']:.3f}; лит. ~0.3)")
print(f" St(0) = {fits['St']['lin'][0]:.3f} / {fits['St']['quad'][0]:.3f} "
f"(спред {fits['St']['spread']:.3f}; лит. 0.183)")
def convergence_report(res, cfg, nwin=10):
"""Эволюция по окнам времени: видно, НАСЫЩЕНО ли решение или ДРЕЙФУЕТ.
Ключевое: ⟨ρ⟩ (средняя плотность) и Cd, нормированный на реальную ⟨ρ⟩ (дрейф-устойчивый Cd).
Если ⟨ρ⟩ уплывает от 1 — это дрейф массы из-за ГУ входа/выхода, а Cd_raw искажён нормировкой на ρ=1."""
Fx = B.to_cpu(res["Fx"]); Fy = B.to_cpu(res["Fy"]); uy = B.to_cpu(res["uy"]); rho = B.to_cpu(res["rho"])
n = len(Fx); U = cfg.U; DL = res["DL"]
if n < nwin * 2:
return
Cd = 2.0*Fx/(U**2*DL); Cl = 2.0*Fy/(U**2*DL)
w = n // nwin
print("\n=== Сходимость по времени / дрейф (по окнам) ===")
print(f"{'окно':>4}{'шаги':>16}{'<ρ>':>8}{'<Cd>raw':>9}{'<Cd>/ρ':>9}{'rms Cl':>9}{'rms u_y':>9}")
rows = []
for k in range(nwin):
s = slice(k*w, (k+1)*w if k < nwin-1 else n)
rm = float(rho[s].mean()); cdr = float(Cd[s].mean())
cdn = cdr / rm if rm != 0 else float("nan")
clr = float(Cl[s].std()); uyr = float(uy[s].std())
rows.append((rm, cdr, cdn, clr, uyr))
print(f"{k+1:>4}{f'{k*w}-{(k+1)*w}':>16}{rm:>8.3f}{cdr:>9.3f}{cdn:>9.3f}{clr:>9.4f}{uyr:>9.4f}")
# вердикт по последним двум окнам
(rm1, cdr1, cdn1, clr1, _), (rm0, _, _, clr0, _) = rows[-1], rows[-2]
drho = rm1 - rm0; dcl = clr1 - clr0
verdict = []
if abs(rm1 - 1.0) > 0.02:
# «стабильна, но не там»: система села на смещённую ветвь (акустическая накачка массы;
# ⟨Cd⟩/Cl на ней НЕвалидны для сравнения с литературой) — см. факторное исследование
verdict.append(f"плотность: ⟨ρ⟩={rm1:.3f} → РЕЖИМ СМЕЩЁН (⟨ρ⟩ далеко от 1; прогон невалиден)")
else:
verdict.append(f"плотность: ⟨ρ⟩={rm1:.3f}, Δ за окно={drho:+.4f} → "
+ ("ДРЕЙФ массы (правь ГУ выхода: давление/Zou-He)" if abs(drho) > 1e-3 else "стабильна"))
verdict.append(f"rms Cl: Δ за окно={dcl:+.4f} → "
+ ("ещё растёт (не насыщено)" if dcl > 1e-3 else "насыщено/стабильно"))
verdict.append(f"дрейф-устойчивый Cd (⟨Cd⟩/ρ) в последнем окне = {cdn1:.3f}")
print("вердикт: " + "; ".join(verdict))
+45
View File
@@ -0,0 +1,45 @@
# Равновесие и макропеременные.
#
# Используется ЭНТРОПИЙНОЕ равновесие в product-form (как в KBC), а не усечённый полином:
# feq_i = w_i · ρ · Π_d (2 − sqrt(1+3 u_d²)) · ((2 u_d + sqrt(1+3 u_d²))/(1 − u_d))^{c_{i,d}}
# Показатели c_{i,d} ∈ {−1,0,+1}, поэтому степень НЕ нужна — берём q или 1/q (быстрее и совместимо
# с CUDA-graph: нет pow и нет host→device для границ clip).
import backend as B
import lattice as L
cp = B.cp; xp = B.xp
CXa = L.CXa; CYa = L.CYa
def _feq9_body(rho, ux, uy):
ux = cp.minimum(cp.maximum(ux, -0.95), 0.95) # clip скоростей литералами (запекается в кернел)
uy = cp.minimum(cp.maximum(uy, -0.95), 0.95)
sx = cp.sqrt(1.0 + 3.0*ux*ux); sy = cp.sqrt(1.0 + 3.0*uy*uy)
base = rho * (2.0 - sx) * (2.0 - sy)
qx = (2.0*ux + sx) / (1.0 - ux); qy = (2.0*uy + sy) / (1.0 - uy)
ix = 1.0/qx; iy = 1.0/qy
return (base*(4.0/9.0), # ( 0, 0)
base*(1.0/9.0)*qx, base*(1.0/9.0)*qy, # (+1,0) (0,+1)
base*(1.0/9.0)*ix, base*(1.0/9.0)*iy, # (-1,0) (0,-1)
base*(1.0/36.0)*qx*qy, base*(1.0/36.0)*ix*qy, # (+1,+1) (-1,+1)
base*(1.0/36.0)*ix*iy, base*(1.0/36.0)*qx*iy) # (-1,-1) (+1,-1)
_feq9 = cp.fuse()(_feq9_body) # одно слитное ядро (если cp.fuse доступен)
_FUSE_OK = [True]
def feq(rho, u):
"""Равновесные популяции (Q,Ny,Nx) по плотности rho и скорости u (индексируется [0],[1]).
Сборка через B.pack (без cupy.stack) — иначе H2D ломает CUDA-graph capture."""
if _FUSE_OK[0]:
try:
return B.pack(_feq9(rho, u[0], u[1]))
except Exception as e: # откат на не-fused (тоже без pow), один раз
print(f"[equilibrium] cp.fuse отключён ({type(e).__name__}: {e}); обычный путь")
_FUSE_OK[0] = False
return B.pack(_feq9_body(rho, u[0], u[1]))
def macros(f):
"""Макропеременные: ρ = Σ_i f_i, u = (Σ_i c_i f_i)/ρ. Возвращает (rho, u=(2,Ny,Nx))."""
r = f.sum(0)
ux = (CXa[:, None, None] * f).sum(0) / r
uy = (CYa[:, None, None] * f).sum(0) / r
return r, B.pack((ux, uy))
+52
View File
@@ -0,0 +1,52 @@
# СИЛА и МОМЕНТ на теле — галилей-инвариантный обмен импульсом (GMEM, Wen et al. 2014).
#
# Для каждого линка жидкость→тело по направлению i (маска cl):
# ΔF = (c_i − u_w)·f_i^{после столкновения}(x_f) − (c_ī − u_w)·f_ī^{после стриминга}(x_f)
# где u_w — скорость стенки на линке (для неподвижного цилиндра = 0). Т.к. c_ī = −c_i, по компонентам:
# F_x += (Cx_i − u_wx)·f_i + (Cx_i + u_wx)·f_ī
# F_y += (Cy_i − u_wy)·f_i + (Cy_i + u_wy)·f_ī
#
# МОМЕНТ (торк) вокруг центра тела: T_z = Σ_links (r_w × ΔF)_z, где точка приложения —
# ПЕРЕСЕЧЕНИЕ линка со стенкой r_w = r_f + q·c_i (q — Bouzidi-доля, уже лежит в bc).
# Узел жидкости дал бы ошибку плеча до 1 ячейки; q бесплатен. Для кругового цилиндра
# ⟨Cm⟩ ≈ 0 — это контроль симметрии считывания; для подвижных/вращающихся тел (цель SimV4) —
# рабочая величина.
#
# Свойства:
# * f_i — популяция, уходящая в стенку (после столкновения); f_ī — вернувшаяся (после стриминга/Bouzidi).
# f_ī берётся из поля ПОСЛЕ стриминга — иначе ведущий симметричный член ~2ρw_i сокращается и Cd врёт.
# * При u_w=0 сводится к классическому Σ c_i (f_i + f_ī); члены с u_w делают оценку галилей-инвариантной
# и ГОТОВОЙ к подвижным/вращающимся телам (цель проекта SimV4) — туда передаётся u_w на линке.
#
# Независимый кросс-чек этой оценки — интегрирование тензора напряжений по контуру вокруг тела:
# см. forces_stress.py (σ = −p′·I + σ^v из неравновесных моментов; вызывается редко, вне CUDA-графа).
import backend as B
import lattice as L
xp = B.xp; ZERO = B.ZERO
Cx = L.Cx; Cy = L.Cy
def force_gmem(fpost, fnew, bc, rxy=None, u_wall=(0.0, 0.0)):
"""GMEM по линкам тела (маска cl). Возвращает (Fx, Fy, Tz) — device-скаляры.
rxy=(rx,ry) — координаты узлов решётки относительно центра тела (для торка); None → Tz=0.
u_wall — скорость стенки. Graph-safe: только where/арифметика с литералами."""
uwx, uwy = u_wall
Fx = ZERO; Fy = ZERO; Tz = ZERO
if rxy is not None:
rx, ry = rxy
for (i, ib, mask, q, xff, cl) in bc:
fi = fpost[i]; fib = fnew[ib]
dFx = xp.where(cl, (Cx[i] - uwx)*fi + (Cx[i] + uwx)*fib, ZERO)
dFy = xp.where(cl, (Cy[i] - uwy)*fi + (Cy[i] + uwy)*fib, ZERO)
Fx = Fx + dFx.sum(); Fy = Fy + dFy.sum()
if rxy is not None: # плечо до точки пересечения линка со стенкой
Tz = Tz + ((rx + q*Cx[i])*dFy - (ry + q*Cy[i])*dFx).sum()
return Fx, Fy, Tz
def coefficients(Fx, Fy, U, DL, Tz=None):
"""Безразмерные коэффициенты: Cd = 2Fx/(U²·DL), Cl = 2Fy/(U²·DL), Cm = 2Tz/(U²·DL²).
DL — диаметр у тела (на L1); сила/плечо тоже в единицах L1, так что нормировка согласована."""
Cd = 2.0*Fx/(U**2*DL); Cl = 2.0*Fy/(U**2*DL)
if Tz is None:
return Cd, Cl
return Cd, Cl, 2.0*Tz/(U**2*DL*DL)
@@ -0,0 +1,80 @@
# НЕЗАВИСИМЫЙ КРОСС-ЧЕК силы/момента: баланс импульса контрольного объёма, ограниченного
# замкнутым контуром (окружностью) вокруг тела на тонком уровне L1.
#
# F_тела = ∮ [σ·n − ρ·u·(u·n)] ds − d/dt ∫_кольцо ρu dV, σ = −p′·I + σ^v
#
# * давление: p = ρ·c_s²; интегрируем p′ = (ρ − ⟨ρ⟩_контур)·c_s² — константа давления по замкнутому
# контуру даёт ∮ p0·n ds ≡ 0, а вычитание убирает катастрофическое сокращение в float32;
# * вязкая часть из неравновесных моментов (Чепмен–Энског): σ^v_αβ = −(1 − 1/(2τ))·Π^neq_αβ,
# Π^neq_αβ = Σ_i c_iα c_iβ (f_i − f_i^eq).
# ВАЖНО (KBC): след Π^neq релаксирует не с 1/τ, а с γβ (поле-зависимый стабилизатор), поэтому
# берём строго ДЕВИАТОРНУЮ проекцию (след исключён) — сдвиг-моменты в KBC релаксируют с точным
# 2β = 1/τ, для них префактор корректен. Bulk-часть в силу трения и не входит.
# * КОНВЕКТИВНЫЙ член −ρu(u·n): поток импульса через контур. Зануляется только при δ→0 (no-slip
# на поверхности); при δ=2 скорость на контуре уже заметна, и без этого члена кросс-чек давал
# систематический сдвиг +4% по ⟨Cd⟩ на всех сетках (факторное исследование, эксперимент №0).
# * член d/dt ∫ρu dV по кольцу R..R1+δ НЕ считается: в среднем по времени он зануляется,
# на мгновенных рядах даёт малый сдвиг фазы → кросс-чек ведётся по средним (⟨Cd⟩, rms Cl).
# * контур — окружность радиуса R1+δ (δ = cfg.stress_delta тонких ячеек, по умолчанию 2):
# билинейный стенсиль выборки не задевает первое жидкое кольцо у стенки, где популяции
# реконструированы Bouzidi (q_max ≈ 0.98 < 1).
#
# Вызывается РЕДКО (каждые cfg.stress_every шагов) в Solver.run() ВНЕ step()/CUDA-графа.
import math
import backend as B
import lattice as L
import equilibrium as E
cp = B.cp; xp = B.xp; DTYPE = B.DTYPE
CS2 = L.CS2
def build_probe(cfg):
"""Прекомпьют контура (один раз, вне захвата): N=stress_ntheta точек на окружности R1+δ
вокруг центра тела на L1; индексы/веса билинейной интерполяции, нормали, плечи, элемент дуги."""
r = cfg.refine
R1 = r * float(cfg.D) / 2.0 # радиус цилиндра на L1 (тонкие ячейки)
Rp = R1 + float(cfg.stress_delta)
ccx = (cfg.cx - cfg.ax1) * r; ccy = (cfg.cy - cfg.ay1) * r
N = int(cfg.stress_ntheta)
th = (2.0 * math.pi / N) * cp.arange(N)
nx = cp.cos(th).astype(DTYPE); ny = cp.sin(th).astype(DTYPE)
px = ccx + Rp * nx; py = ccy + Rp * ny
x0 = cp.floor(px).astype(cp.int64); y0 = cp.floor(py).astype(cp.int64)
# контур обязан лежать внутри патча L1 (он центрирован вокруг тела — выполняется с запасом)
Nfx = (cfg.bx1 - cfg.ax1) * r + 1; Nfy = (cfg.by1 - cfg.ay1) * r + 1
assert int(x0.min()) >= 0 and int(x0.max()) + 1 <= Nfx - 1, "контур σ·n вышел за патч L1 по x"
assert int(y0.min()) >= 0 and int(y0.max()) + 1 <= Nfy - 1, "контур σ·n вышел за патч L1 по y"
return dict(x0=x0, y0=y0, x1=x0 + 1, y1=y0 + 1,
tx=(px - x0).astype(DTYPE), ty=(py - y0).astype(DTYPE),
nx=nx, ny=ny, rx=(Rp * nx), ry=(Rp * ny), ds=2.0 * math.pi * Rp / N)
def _sample(fld, pr):
"""Билинейная выборка 2D-поля в точках контура (аналог amr.pint, но по 1D-набору точек)."""
return (fld[pr["y0"], pr["x0"]] * (1 - pr["tx"]) * (1 - pr["ty"])
+ fld[pr["y0"], pr["x1"]] * pr["tx"] * (1 - pr["ty"])
+ fld[pr["y1"], pr["x0"]] * (1 - pr["tx"]) * pr["ty"]
+ fld[pr["y1"], pr["x1"]] * pr["tx"] * pr["ty"])
def force_stress(F1, pr, tau1):
"""Сила/момент на тело из баланса импульса ∮[σ·n − ρu(u·n)]ds по контуру pr на состоянии F1
(L1, после стриминга). Возвращает (Fx, Fy, Tz) — device-скаляры. tau1 — релаксация на L1."""
rho, u = E.macros(F1)
fneq = F1 - E.feq(rho, u)
CX = L.CXa[:, None, None]; CY = L.CYa[:, None, None]
Pxx = (CX * CX * fneq).sum(0)
Pyy = (CY * CY * fneq).sum(0)
Pxy = (CX * CY * fneq).sum(0)
rho_s = _sample(rho, pr)
ux_s = _sample(u[0], pr); uy_s = _sample(u[1], pr)
Pxx_s = _sample(Pxx, pr); Pyy_s = _sample(Pyy, pr); Pxy_s = _sample(Pxy, pr)
p = (rho_s - rho_s.mean()) * CS2 # p′: давление без константы (см. шапку)
pref = 1.0 - 1.0 / (2.0 * tau1)
tr = 0.5 * (Pxx_s + Pyy_s) # девиаторная проекция (след исключён)
sxx = -pref * (Pxx_s - tr); syy = -pref * (Pyy_s - tr); sxy = -pref * Pxy_s
un = ux_s * pr["nx"] + uy_s * pr["ny"] # нормальная скорость на контуре
flux = rho_s * un # ρ(u·n): конвективный вынос импульса
tx = (-p + sxx) * pr["nx"] + sxy * pr["ny"] - flux * ux_s # t = σ·n − ρu(u·n)
ty = sxy * pr["nx"] + (-p + syy) * pr["ny"] - flux * uy_s
Fx = tx.sum() * pr["ds"]; Fy = ty.sum() * pr["ds"]
Tz = (pr["rx"] * ty - pr["ry"] * tx).sum() * pr["ds"] # давление в торк не входит (n ∥ r)
return Fx, Fy, Tz
+47
View File
@@ -0,0 +1,47 @@
# Геометрия: маски тела/стенок и знаковое поле расстояний (SDF) для L0 и патча L1.
# Чистый CuPy. Соглашение по SDF: phi>0 в жидкости, phi<0 в теле; |phi| ≈ расстоянию до границы.
import backend as B
import lattice as L
import amr
cp = B.cp; xp = B.xp; DTYPE = B.DTYPE
def _cmask(NX, NY, ccx, ccy, R):
"""Булева маска круга радиуса R (центр ccx,ccy) на сетке (NY,NX)."""
Y, X = cp.meshgrid(cp.arange(NY), cp.arange(NX), indexing="ij")
return (X - ccx)**2 + (Y - ccy)**2 <= R*R
def _csdf(NX, NY, ccx, ccy, R):
"""SDF круга: sqrt((x−ccx)²+(y−ccy)²) − R (отрицательно внутри)."""
Y, X = cp.meshgrid(cp.arange(NY), cp.arange(NX), indexing="ij")
return cp.sqrt((X - ccx).astype(DTYPE)**2 + (Y - ccy).astype(DTYPE)**2) - R
class Geometry:
"""Контейнер масок/SDF/патча. Поля *_np — host-копии для визуализации."""
pass
def build(cfg):
Nx, Ny, D, cx, cy = cfg.Nx, cfg.Ny, cfg.D, cfg.cx, cfg.cy
r = cfg.refine
ax1, bx1, ay1, by1 = cfg.ax1, cfg.bx1, cfg.ay1, cfg.by1
g = Geometry()
# --- L0: только цилиндр как твёрдое тело (стенки канала верх/низ — free-slip, не solid) ---
g.cyl0 = _cmask(Nx, Ny, cx, cy, D/2.0)
g.solid0 = g.cyl0 # стенки обрабатываются specular-отражением, см. boundary.free_slip_walls
g.phi0 = _csdf(Nx, Ny, cx, cy, D/2.0) # SDF только цилиндра (для Bouzidi тела)
# --- патч L1 (измельчение r) над областью [ax1,bx1]×[ay1,by1] ---
g.P1 = amr.patch(Nx, Ny, ax1, bx1, ay1, by1, r)
Nfx, Nfy = g.P1["Nfx"], g.P1["Nfy"]
# цилиндр на L1: центр и радиус масштабируются на r (диаметр r·D → радиус r·D/2)
R1 = r * D / 2.0
g.solid1 = _cmask(Nfx, Nfy, (cx - ax1)*r, (cy - ay1)*r, R1)
g.phi1 = _csdf(Nfx, Nfy, (cx - ax1)*r, (cy - ay1)*r, R1)
# маска жидких узлов для рестрикции L1→L0 (внутренняя часть патча на L0)
g.fl1 = ~g.solid0[ay1+1:by1, ax1+1:bx1]
# host-копии для визуализации
g.solid0_np = B.to_cpu(g.solid0); g.solid1_np = B.to_cpu(g.solid1)
return g
+34
View File
@@ -0,0 +1,34 @@
# Решётка D2Q9 и KBC-проектор. Чистый CuPy; всё считается один раз при импорте.
#
# Скорости e_i и веса w_i (стандартная нумерация D2Q9):
# 0:( 0, 0) 1:(+1,0) 2:(0,+1) 3:(-1,0) 4:(0,-1)
# 5:(+1,+1) 6:(-1,+1) 7:(-1,-1) 8:(+1,-1)
# OPP[i] — индекс противоположного направления.
import backend as B
cp = B.cp; xp = B.xp; DTYPE = B.DTYPE
Q = 9
Cx = [0, 1, 0, -1, 0, 1, -1, -1, 1] # python-инты (для roll/индексации)
Cy = [0, 0, 1, 0, -1, 1, 1, -1, -1]
OPP = [0, 3, 4, 1, 2, 7, 8, 5, 6]
_Wlist = [4/9, 1/9, 1/9, 1/9, 1/9, 1/36, 1/36, 1/36, 1/36]
CS2 = 1.0 / 3.0 # квадрат скорости звука решётки
# device-векторы
Wa = cp.asarray(_Wlist, DTYPE)
CXa = cp.asarray(Cx, DTYPE)
CYa = cp.asarray(Cy, DTYPE)
def _build_projector():
"""Проектор Ps на сдвиг-моменты {cx²−cy², cx·cy} (ядро KBC-N1).
Ps = M⁻¹ · diag(оставить только эти 2 момента) · M, где M — моментный базис."""
cxv = cp.asarray(Cx, cp.float64); cyv = cp.asarray(Cy, cp.float64)
M = cp.zeros((Q, Q), cp.float64)
M[0] = 1.0; M[1] = cxv; M[2] = cyv; M[3] = 3.0*(cxv**2 + cyv**2) - 2.0
M[4] = cxv**2 - cyv**2; M[5] = cxv*cyv
M[6] = cxv**2*cyv; M[7] = cxv*cyv**2; M[8] = cxv**2*cyv**2
Dm = cp.zeros((Q, Q), cp.float64); Dm[4, 4] = Dm[5, 5] = 1.0
return (cp.linalg.inv(M) @ Dm @ M)
Ps = _build_projector().astype(DTYPE) # (Q,Q) проектор на сдвиг

Some files were not shown because too many files have changed in this diff Show More