Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
251293cd05 | ||
|
|
d5e37fcb8d | ||
|
|
6344c7d205 | ||
|
|
81985b169e | ||
|
|
e75b404f8a | ||
|
|
8d17bc8014 | ||
|
|
26a8475396 | ||
|
|
9120affab5 | ||
|
|
b97686c8db | ||
|
|
039e5f4806 | ||
|
|
e3c2d417f8 | ||
|
|
f174f1c9a0 | ||
|
|
5d762ad9d6 | ||
|
|
11ff7b79b4 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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 "")
|
||||
@@ -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 } }
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,14 @@
|
||||
# Каталог сборки cargo — сотни мегабайт, внутри образа он собирается заново.
|
||||
target/
|
||||
# Результаты прогонов: гифки и ряды монтируются с хоста, в образ не кладутся.
|
||||
bench/out/
|
||||
*.gif
|
||||
*.csv
|
||||
*.json
|
||||
!bench/scenarios.json
|
||||
# Мусор редакторов и ОС
|
||||
.vs/
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,35 @@
|
||||
[package]
|
||||
name = "kbc2d"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.75"
|
||||
description = "Двумерный решатель LBM D2Q9 с энтропийным оператором столкновения KBC (модель D / N1)"
|
||||
|
||||
[[bin]]
|
||||
name = "kbc2d"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
rayon = "1"
|
||||
gif = "0.13"
|
||||
bytemuck = { version = "1", features = ["derive"] }
|
||||
wgpu = { version = "22", optional = true }
|
||||
pollster = { version = "0.3", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["gpu"]
|
||||
# GPU-бэкенд на wgpu (Vulkan/DX12/Metal). Отключается: --no-default-features
|
||||
gpu = ["dep:wgpu", "dep:pollster"]
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
|
||||
# Отдельный профиль для прогонов с проверками переполнения индексов
|
||||
[profile.release-dbg]
|
||||
inherits = "release"
|
||||
debug = true
|
||||
panic = "unwind"
|
||||
@@ -0,0 +1,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"]
|
||||
@@ -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`),
|
||||
геометрия при этом собирается из любого числа тел, но силы сверх четвёртого сливаются вместе.
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 при этом должен быть создан заранее и принадлежать вам).
|
||||
@@ -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"
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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)")
|
||||
@@ -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)")
|
||||
@@ -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)")
|
||||
@@ -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))
|
||||
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 6.2 MiB |
|
After Width: | Height: | Size: 19 MiB |
|
After Width: | Height: | Size: 16 MiB |
|
After Width: | Height: | Size: 8.5 MiB |
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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)")
|
||||
@@ -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)")
|
||||
@@ -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)
|
||||
@@ -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)")
|
||||
@@ -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)")
|
||||
@@ -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)")
|
||||
@@ -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)")
|
||||
@@ -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-выход — всё вместе
|
||||
|
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 42 KiB |
@@ -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",
|
||||
"\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",
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"\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",
|
||||
"\n",
|
||||
"\n",
|
||||
"\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",
|
||||
"\n",
|
||||
"\n",
|
||||
"\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",
|
||||
"\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"\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",
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "6645f68a",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 12. Минимальный 2-уровневый AMR на цилиндре\n",
|
||||
"\n",
|
||||
"Рабочая связка «крупная сетка + мелкий блок 2× вокруг тела» (то же тело bounce-back на обоих\n",
|
||||
"уровнях): на 1 крупный шаг — 2 мелких подшага, ghost из крупного уровня, рестрикция только по\n",
|
||||
"жидким узлам. В этой *минимальной* версии граница блока держится постоянной в течение\n",
|
||||
"подшагов (без временно́й интерполяции) — её эффект виден в бенчмарке ниже.\n",
|
||||
"\n",
|
||||
"\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",
|
||||
"\n",
|
||||
"\n",
|
||||
"Выводы (точные числа — `demos_gpu/out/amr_bench.txt`):\n",
|
||||
"- **вложенность эффективна**: полный 2×+4× точнее одиночного 4× при заметно меньшей работе;\n",
|
||||
"- **временна́я интерполяция** снижает ошибку при той же работе;\n",
|
||||
"- полный AMR даёт точность уровня мелкой сетки за **в разы меньшую работу**;\n",
|
||||
"- крупная сетка дёшева, но её ошибка принципиально не лечится без измельчения.\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"## 14. AMR на большой области (исторические прогоны)\n",
|
||||
"\n",
|
||||
"Та же связка на большой области (150×72, длинный прогон) — видны грубая сетка и учащённые\n",
|
||||
"зоны: **2× (тело+след)** и **4× (у тела)**:\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"\n",
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"\n",
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"\n",
|
||||
"\n",
|
||||
"\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",
|
||||
"\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",
|
||||
"\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",
|
||||
"\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
|
||||
}
|
||||
@@ -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 с печатью трейсбэка.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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) проектор на сдвиг
|
||||