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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B9Gcr11JJJyf8NnWsXjDzZ
2026-09-06 16:25:03 +03:00
NotBigGhostandClaude Opus 5 11ff7b79b4 Начальный коммит: Vulkan-редактор SimVulcan + исследование KBC-LBM
Состояние на момент заведения репозитория.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 16:37:38 +03:00
139 changed files with 88548 additions and 729 deletions
+24
View File
@@ -0,0 +1,24 @@
# Детерминированные переводы строк. У пользователя core.autocrlf=true (дефолт Git for Windows),
# из-за чего git ругался на LF→CRLF почти на каждом файле. Проект собирается ещё под Linux и
# macOS (см. CMakePresets.json), поэтому в репозитории храним LF, а в рабочей копии Windows
# git сам развернёт CRLF там, где это нужно.
* text=auto eol=lf
# .bat обязан быть CRLF — иначе cmd.exe спотыкается на многострочных конструкциях
*.bat text eol=crlf
# Бинарные: не диффать, не трогать переводы строк
*.png binary
*.gif binary
*.pdf binary
*.npz binary
*.spv binary
*.bin binary
*.exe binary
# Wavefront OBJ — текст (ASCII), но диффы бессмысленны
*.obj text diff=none
# Ноутбуки: слияние по строкам всегда даёт мусор из-за outputs/метаданных —
# конфликт решается вручную, а не авто-мержем
*.ipynb -merge
+58
View File
@@ -0,0 +1,58 @@
# ---------------------------------------------------------------------------
# Сборка
# ---------------------------------------------------------------------------
# Каталог пресетов 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.
# ---------------------------------------------------------------------------
# Редакторы и ОС
# ---------------------------------------------------------------------------
.vs/
.vscode/
.idea/
*.user
*.suo
*.swp
*~
.DS_Store
Thumbs.db
desktop.ini
# Общие заметки Claude Code: один файл на все ветки, вне версионирования
/CLAUDE.md
-729
View File
@@ -1,729 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Read this first: the file is shared across branches
`CLAUDE.md` is **deliberately untracked on every working branch** — `/CLAUDE.md`
sits in `.gitignore` on `main`, `dev`, `research` and `rust`. One physical file
therefore survives every `git checkout`, so these notes stay put while the tree
around them changes, and editing them never shows up in `git status`.
The master copy lives on the **orphan branch `notes`**, which holds this one file
and nothing else, one commit per revision — that branch is both the backup and the
change history. It shares no ancestor with any code branch, so it never merges into
anything and never appears in a diff.
```sh
git show notes:CLAUDE.md > CLAUDE.md # restore it in a fresh clone
git log --oneline notes # history of these notes
git diff notes~1 notes # what changed in the last revision
```
After editing this file, publish the new revision — this touches neither the
working tree nor the checked-out branch:
```sh
blob=$(git hash-object -w CLAUDE.md)
tree=$(printf '100644 blob %s\tCLAUDE.md\n' "$blob" | git mktree)
git branch -f notes "$(git commit-tree "$tree" -p notes -m 'CLAUDE.md: what changed')"
git push CFDManager notes
```
`git clean -xfd` deletes the working copy, since it is untracked — restore it with
the first command above.
Drift is guarded automatically: a `PostToolUse` hook
(`.claude/hooks/notes_guard.py`, wired in `.claude/settings.local.json`) compares
this file against `notes` after every edit and demands a described commit when the
two diverge — necessary because an untracked file never shows up in `git status`.
Both live outside git, excluded through `.git/info/exclude` rather than
`.gitignore`, so no branch has to carry them.
The consequence to keep in mind: **this file describes the whole project, but no
single branch contains all of it.** Before assuming a directory exists, check
`git branch --show-current` against the map below — `rust/` and
`docs/theory/2d_solver/` each live on one branch only.
## Branch map
| branch | what it adds | trees present |
|---|---|---|
| `main`, `dev` | baseline; both at the same commit, one past the initial import — and that commit only untracks `CLAUDE.md` | `src/`, `shaders/`, `tests/`, `docs/theory/` (Python) |
| `research` | the CFD work — **`kbc2d`**, a Rust LBM solver, its validation campaign and Docker images | `+ docs/theory/2d_solver/`, `+ docs/origins/Grad's_aproximation.pdf` |
| `rust` | a **Rust port of the C++ editor** plus a measured C++/Rust comparison | `+ rust/`, `docs/rust_vs_cpp.md` |
All five branches (including `notes`) are fully pushed to the `CFDManager` remote;
verify with `git ls-remote CFDManager` rather than trusting a stale tracking ref.
Nothing is shared between the two Rust efforts: `docs/theory/2d_solver/` (a CFD
solver, branch `research`) and `rust/` (a port of the 3D editor, branch `rust`) are
unrelated projects that merely both happen to be Rust. Neither is part of the CMake
build — `CMakeLists.txt` never mentions either tree.
**A checkout away from `research` does not remove `docs/theory/2d_solver/`** — the
tracked sources vanish but the untracked artefacts stay, so on `rust` that directory
contains only `out/`, `target/`, `wake.gif` and `demo_wake.gif` and looks like a
mutilated copy of the solver. To read the solver from another branch, go through
git rather than the filesystem:
```sh
git show research:docs/theory/2d_solver/src/math.rs
git archive research docs/theory/2d_solver | tar -x -C /tmp/kbc2d # to grep it
```
Untracked leftovers you will see regardless of branch: `build/`, `rust/target/`,
`docs/theory/2d_solver/target/`, the `__pycache__` dirs under `docs/theory/`, and
`docs/theory/2d_solver/out/` plus the two ~9 MB GIFs beside it (campaign results —
**the user's data, do not clean**). `pipeline_cache.bin` and `imgui.ini` are written
next to whichever executable ran, i.e. in `build/<preset>/src/app/` and
`rust/pipeline_cache.bin` — not at the repo root.
**`build/` in this working tree is foreign and stale.** Its
`CMakeCache.txt` records `CMAKE_HOME_DIRECTORY=C:/Users/ivan/UAS/SimV4(Not_KBC)/SimVulcan`,
and `_deps/` carries a 262 MB `nlohmann_json` clone that this project never declares.
Do not measure anything from it without re-configuring first.
---
# SimVulcan — C++/Vulkan editor (all branches)
A minimal **Vulkan 1.3 / C++20** 3D model editor: three reference grid planes (XY,
XZ, YZ) through the origin plus coloured X/Y/Z axes, an orbit camera, and `.obj`
loading with selectable display modes (solid, wireframe, solid + wireframe). It
runs **no simulation** — no compute pipeline or `.comp` shader exists anywhere, and
`Renderer.cpp` disables async compute explicitly. It is a rendering skeleton with
two ImGui windows, titled **`Viewport`** and **`Mesh`** (the class behind the second
is `MeshLoadPanel`, which is why the docs keep calling it the "mesh load panel").
Unchanged since the initial commit on every branch — `git log --all -- src` returns
exactly one commit.
## Build
Prerequisites: **Vulkan SDK** (for `glslangValidator`), **CMake 3.26+**, **Ninja**,
a C++20 compiler. Only CMake 3.26 and C++20 are actually enforced
(`CMakeLists.txt:1`, `:9-11`). `cmake/VulkanSetup.cmake:3` is
`find_package(Vulkan REQUIRED COMPONENTS glslangValidator)` with **no version
argument**, so the "SDK 1.3.290+" in `README.md` is a comment, not a check; the
de-facto floor comes from the pinned volk `1.3.295` and vk-bootstrap `v1.3.295`.
The compiler minimums (MSVC 19.36+, gcc 11+, clang 14+) are likewise README prose,
enforced nowhere. On macOS the only hard constraint is
`CMAKE_OSX_ARCHITECTURES: arm64` in the preset; MoltenVK is mentioned in a
`message(STATUS)`.
```sh
cmake --preset windows-msvc-release
cmake --build --preset windows-msvc-release
```
Presets: `windows-msvc-debug`, `windows-msvc-release`, `linux-gcc-release`,
`linux-clang-release`, `macos-arm64-release` (all Ninja, one dir per preset under
`build/<presetName>/`). None of them pins a compiler — they inherit whatever the
environment provides, so `README.md`'s "MSVC / clang-cl" is aspirational.
The first configure fetches nine repositories via FetchContent (GLFW, GLM, volk,
vk-bootstrap, VulkanMemoryAllocator, Dear ImGui, spdlog, tinyobjloader, Catch2) and
needs network access. There is no `GIT_SHALLOW`, so each is a clone with full
history, and **no shared cache** is configured (the only `FETCHCONTENT_*` variable
set is `FETCHCONTENT_QUIET OFF`) — every new build directory downloads all of it
again. Budget **~400 MB of sources / ~510 MB of `_deps` once built**, not the
775 MB quoted in `docs/rust_vs_cpp.md`: that figure was measured on the foreign
`build/` tree described above and includes 262 MB of `nlohmann_json` that nothing
here declares.
`VK_NO_PROTOTYPES` is **not** project-wide — there is no `add_compile_definitions`
at project scope. It is set `PUBLIC` on exactly two targets, `imgui`
(`CMakeLists.txt:114`) and `simv_vk` (`src/vk/CMakeLists.txt:29`). Because
`simv_core` links `simv_vk` **PRIVATE**, the definition never reaches `simv_mesh`,
`simv_editor` or `simv_tests`. Vulkan entry points load through **volk**.
Warnings come from `simv_set_warnings` (`cmake/CompilerWarnings.cmake`), called by
all six targets: `/W4 /permissive- /Zc:preprocessor /Zc:__cplusplus /wd4100` plus
the defines `_CRT_SECURE_NO_WARNINGS`, `NOMINMAX`, `WIN32_LEAN_AND_MEAN` on MSVC;
`-Wall -Wextra -Wpedantic -Wno-unused-parameter -Wshadow -Wnon-virtual-dtor
-Wold-style-cast -Wcast-align -Wunused -Woverloaded-virtual` elsewhere. They are
**not** errors — no `/WX` or `-Werror` anywhere. `cmake/Sanitizers.cmake` defines
`simv_enable_sanitizers` (Debug-only; ASan **only** on MSVC, ASan+UBSan elsewhere)
but no target calls it — wire it in manually when chasing memory bugs.
## Running
The executable resolves SPIR-V relative to the working directory (`FindSpvPath`,
`src/vk/Shader.cpp:24-33`, probes `spirv/<rel>` then `current_path()/spirv/<rel>` —
the second probe is a no-op, since the first is already CWD-relative). Two
`SimVulcan` POST_BUILD steps copy `assets/` and the compiled `spirv/` tree next to
the executable, so **run from the executable's own directory**
(`build/<preset>/src/app/`). `main.cpp:40-50` additionally probes four `../`
ancestors for `assets/meshes`. Meshes load at runtime through the `Mesh` panel.
Running writes two files into the CWD: `pipeline_cache.bin` (serialised
`VkPipelineCache`, reloaded on the next start) and ImGui's `imgui.ini` (no
`IniFilename` is ever set, so ImGui's default applies). Both are disposable —
delete them if pipeline creation or the panel layout misbehaves.
`ContextOptions::enableValidation` / `enableDebugUtils` default to **true**
(`src/vk/Context.h:15-16`) and nothing overrides them, so the Khronos validation
layer is requested even in Release. The severity mask is error + warning only
(`Context.cpp:56-58`), which makes the `INFO` branch of the callback dead code.
Those messages go to **raw `spdlog::`**, i.e. spdlog's default logger — not the
`"simv"` logger that `core::Logger` creates.
`run.bat` at the repo root is **stale**: it launches
`build/vs2022/src/app/Release/SimVulcan.exe`, a path the Ninja presets never
produce — while its own error message tells the user to run those presets. Do not
point users at it without fixing the path first.
## Tests
Catch2 unit tests, mesh bounds + welding. No GPU is touched at runtime, but the
binary is **not** free of Vulkan: static-lib propagation through
`simv_mesh → simv_core → (PRIVATE) simv_vk` makes `simv_tests.exe` link
`simv_vk`, `vk-bootstrap`, `imgui`, `volk` and `glfw3`.
```sh
cmake --build --preset windows-msvc-debug --target simv_tests
ctest --preset windows-msvc-debug
```
`windows-msvc-debug` is the only preset with a `testPreset` — for the other
configs, invoke `ctest` in `build/<preset>/` directly.
There are exactly three `TEST_CASE`s, all in `tests/unit/test_mesh.cpp`:
`Mesh::RecalculateBounds finds AABB` `[mesh]`, `WeldVertices collapses
near-duplicate vertices` and `WeldVertices drops degenerate triangles`
(both `[mesh][decimator]`). Each is registered individually by
`catch_discover_tests`, so either filter works:
```sh
ctest --preset windows-msvc-debug -R "WeldVertices" -V # matches the last two
./build/windows-msvc-debug/tests/simv_tests.exe "[decimator]"
./build/windows-msvc-debug/tests/simv_tests.exe --list-tests
```
## Architecture
### Library / target map
Arrows below show the **project** edges; each target also links third-party
libraries, and the visibility keywords matter more than they look.
```
SimVulcan (exe) → simv_core, simv_vk, simv_mesh, simv_editor (all PRIVATE)
+ glm, spdlog; add_dependencies(… simv_shaders)
simv_core → PUBLIC spdlog · PRIVATE simv_vk, glfw (App owns Window + Renderer)
simv_editor → PUBLIC simv_core, simv_mesh, imgui, glm (no Vulkan)
simv_mesh → PUBLIC simv_core, glm, tinyobjloader (no Vulkan)
simv_vk → PUBLIC Vulkan::Headers, volk, vk-bootstrap, VMA, glfw, glm, spdlog
PRIVATE imgui
simv_shaders → glslangValidator (GLSL → SPIR-V), an ALL custom target
```
The `simv_core → simv_vk` edge being **PRIVATE** is what keeps `VK_NO_PROTOTYPES`
(and volk) from reaching the rest of the tree — see the Build section.
Namespaces follow directories: `simv::core`, `simv::vk`, `simv::mesh`,
`simv::editor`. Each library exports `src/` as its include root, so includes are
written module-qualified (`#include "mesh/Mesh.h"`, `#include "vk/Renderer.h"`).
### Frame loop
`main.cpp` creates `core::App`, which owns the `core::Window` and a
`vk::Renderer`, then runs the loop. Each frame `Renderer::DrawFrame` acquires a
swapchain image, calls the UI callback (between ImGui NewFrame/Render), *then*
begins the command buffer and records the scene: grid + mesh into one
dynamic-rendering pass with a depth attachment, followed by ImGui, then presents
(2 frames in flight, `vkQueueSubmit2` / `VkImageMemoryBarrier2`).
The acquire-before-callback ordering is the root of defect 2 below — keep it in
mind before moving work into the callback.
`main.cpp` wires the editor via the UI callback: it draws the panels, applies
mouse input to the `editor::Camera`, and pushes the resulting state into the
renderer (`SetRenderMode`, `SetGridVisible`, `SetViewProj`). Mesh loads go through
`MeshLoadPanel`'s callback → `Renderer::SetMeshCpu`.
### Mesh load path
`MeshLoadPanel` lists `*.obj` in the mesh directory (extension match is
**case-insensitive**, so `.OBJ` shows up too) and kicks off `mesh::LoadObjAsync`
(`std::async(std::launch::async, …)`, `std::future`). The future is **drained on
the main thread** at the top of `MeshLoadPanel::Draw`, before the first
`ImGui::Begin`, so the `OnLoaded` callback — and therefore the GPU upload — always
runs on the render thread. Loader exceptions surface as the panel's status string.
`main.cpp`'s `OnLoaded` welds the mesh (`WeldVertices`, tolerance `1e-4`), logs
the counts, uploads via `SetMeshCpu`, and reframes the camera to the bbox radius.
Despite the file name, `mesh/MeshDecimator.h` declares **one** function and
implements **only** spatial-hash vertex welding (round-to-nearest cell hash,
collapse near-duplicates, drop degenerate triangles, recompute bounds) — there is
no LOD/decimation.
### Non-obvious invariants (read before editing)
- **All Vulkan lives in `src/vk/`.** `core/`, `mesh/`, `editor/` and `app/` make no
Vulkan API calls — a grep for `volk|vulkan|Vk[A-Z]` across them returns zero hits.
`vk::Renderer`'s public header is deliberately Vulkan-free (pImpl + glm/`RenderMode`
only) so `App` can own it without pulling in volk. Keep it that way — do not leak
`Vk*` types into the public interfaces of those modules. Note this is **convention
only**: every library exports `src/` publicly, so nothing stops a
`#include <volk.h>` in `mesh/`; the compiler will not catch it.
- **Single render pass with depth.** Scene (grid + mesh) and ImGui draw into one
`vkCmdBeginRendering` pass that has both a colour and a `D32_SFLOAT` depth
attachment (depth `storeOp` is `DONT_CARE`). The ImGui backend is initialised with
`depthAttachmentFormat` set so its pipeline matches the pass.
- **Wireframe needs `fillModeNonSolid`.** `MeshRenderer` builds a fill pipeline and
a `VK_POLYGON_MODE_LINE` pipeline; the line pipeline uses a small *static* depth
bias (`constantFactor` and `slopeFactor` both `-1.0`) so the overlay sits on top
of the fill. The device feature is requested in `Context`. Culling is off
(`VK_CULL_MODE_NONE`) — loaded models may have mixed winding.
- **Camera is fixed on the origin.** `editor::Camera` orbits (yaw/pitch/distance)
the world origin; loaded models are recentred there via a translate-only model
matrix. Projection uses Vulkan clip space (`GLM_FORCE_DEPTH_ZERO_TO_ONE` + Y flip,
isolated to `Camera.cpp` — the macro appears nowhere else in the tree).
- **Buffers.** `vk::GpuMesh` owns the model's vertex/index buffers (staged upload on
the dedicated transfer queue). `GridRenderer` builds a static host-visible mapped
line buffer once in its constructor. Both scene renderers create pipeline layouts
with one push-constant range and **no** `pSetLayouts` — no descriptor sets at all.
- **Push-constant layout is a cross-file contract.** `MeshRenderer.cpp`'s anonymous
`MeshPC { mat4 mvp; vec4 color; }` (range `VERTEX|FRAGMENT`) must stay
byte-identical to the `push_constant` block in `mesh.vert`/`mesh.frag`; `color.a`
is a *flag*, not alpha — `mesh.frag` does `mix(base, base*shade, pc.color.a)`, so
1 = flat-shaded and 0 = constant colour for wireframe. `GridRenderer` pushes a
bare `mat4` (vertex stage only). Change either side and you must change both.
- **Swapchain recreation rebuilds depth *and* sync objects.** `RecreateSwapDependent`
runs `WaitIdle` → `swap->Recreate()` → **`CreateDepth`** → per-frame
`imageAvailable` semaphores (a failed acquire can leave one signalled) → per-
swapchain-image `renderFinished` semaphores (the image count may change) → re-ensure
both pipelines. Keep that ordering if you touch resize handling.
- **Mesh upload stalls the device.** `SetMeshCpu`/`ClearMesh` call `WaitIdle`
before touching `GpuMesh` — acceptable because loads are rare; do not copy that
pattern into per-frame paths. `SetMeshCpu` early-returns on an empty mesh *before*
the wait.
### Known defects (found by the Rust port, all still present here)
Porting the editor surfaced four real bugs that nobody was looking for. They are
unfixed in the C++ tree on every branch; full write-up in `docs/rust_vs_cpp.md`
(branch `rust`).
1. **A source-level dependency cycle that the CMake graph hides.** `core::App` owns
`vk::Renderer`, and `vk::Renderer` takes a `core::Window&` — so the *sources*
are mutually dependent. The **CMake graph is acyclic**, which is exactly why
nothing complains: `simv_vk` links no project target at all, seeing
`core/Window.h` through `target_include_directories(simv_vk PUBLIC ..)` and
getting `Window`'s symbols at executable link time. Cargo rejects the same shape
outright, which is what surfaced it.
2. **Mesh upload runs inside the frame.** `MeshLoadPanel::Draw` calls `OnLoaded`
from the UI callback, which `Renderer::DrawFrame` invokes *after*
`vkAcquireNextImageKHR`; the handler calls `SetMeshCpu` → `vkDeviceWaitIdle`.
Legal, and command recording has not started yet (`vkBeginCommandBuffer` comes
later) — but it waits for device idle while holding an acquired swapchain image.
3. **Two sources of truth for "is there a mesh".** `GpuMesh::IsValid()` and
`Renderer`'s separate `hasMesh` flag must be kept consistent by hand;
`MeshRenderer::Draw` then re-checks `IsValid()` a third time.
4. **`Mesh ready` is never printed.** `ObjLoader::LoadObj` logs to category
`MeshIO`, and `main.cpp`'s handler logs `Mesh ready: …` to the same category
immediately after; the category throttles at 0.5 s and both are `Info`, so the
second message is always dropped (only Warn/Error/Critical bypass the throttle).
Post-weld counts have therefore never appeared in any log.
Also dead weight: **`vk::Buffer` and `vk::Image` are used by nobody** — 362 lines
across four files, still compiled into `simv_vk`. `GridRenderer`, `GpuMesh` and the
depth attachment all call VMA directly. `Image::Desc` even defaults to
`VK_IMAGE_TYPE_3D` and an `R32G32B32A32_SFLOAT` storage image, a leftover from some
compute/CFD design. Note `README.md:53-54` still lists both as part of the working
Vulkan stack — that line is wrong.
### Shaders
GLSL under `shaders/editor/` (`mesh.{vert,frag}`, `grid.{vert,frag}`). `simv_shaders`
compiles each to `build/<preset>/spirv/editor/<name>.<stage>.spv` — the stage suffix
is **kept**, so the real artefacts are `mesh.vert.spv`, `mesh.frag.spv` and so on —
targeting `vulkan1.3`, with `shaders/` as the `-I` root (so `#include
"common/foo.glsl"` would resolve). The glob is `CONFIGURE_DEPENDS`, so a new shader
is picked up automatically, but it matches **only `editor/*.vert` and
`editor/*.frag`**; a `.comp` or `.geom` dropped there is silently ignored.
`mesh.frag` reconstructs a flat normal from screen-space derivatives, so the mesh
vertex stream carries only positions (tight `float3`, one binding, one attribute).
The *grid* pipeline is different — two attributes, position + colour.
**The Rust port on branch `rust` compiles this same directory** (`build.rs` reaches
`../../../shaders`) with the same `glslangValidator` flags — a shader change affects
both versions.
### Logging
`simv::core::Logger` (spdlog-backed, singleton) with category-based throttling.
Log via `LogFmt(LogCategory, LogLevel, fmt, args...)`. Categories: `Core`,
`Vulkan`, `MeshIO`, `UI`, `Test` (printed as `core`, `vk`, `mesh`, `ui`, `test`).
Two things the API surface does not tell you:
- **Only `MeshIO` is ever used.** The entire tree contains three `LogFmt` call
sites — two in `ObjLoader.cpp`, one in `main.cpp`. `Core`, `Vulkan`, `UI` and
`Test` have zero.
- **The runtime knobs are never turned.** `SetMinLevel` / `SetThrottle` /
`SetEnabled` exist but have no callers, and no flag, env var or UI control is
wired to them. They are settable programmatically, not configurable.
And the whole of `vk/` logs through **raw `spdlog::`**, never through `LogFmt` —
as do `core/App.cpp` and `main.cpp`'s fatal handler. That bypasses not just the
category throttle but the `"simv"` logger entirely (different pattern, unaffected
by `Logger::Shutdown()`), which is the only reason the GPU name survives startup;
see defect 4 above.
---
# `rust/` — Rust port of the editor (branch `rust` only)
A port of SimVulcan from C++20 to Rust, existing **for comparison**: both versions
sit side by side and build independently. Same Vulkan 1.3, dynamic rendering,
synchronization2, and the same `shaders/` directory.
**The declared MSRV is wrong.** `rust/Cargo.toml:13` says `rust-version = "1.82"`
(and `rust/README.md` repeats it), but the locked `egui`/`egui-winit`/`epaint`
0.36.1 each declare `rust-version = "1.95"`, so Cargo refuses to resolve on 1.82.
**The real minimum is 1.95**; the tree is tested on 1.97.1. The Vulkan SDK is needed
for `glslangValidator` only.
```sh
cd rust
cargo build --release
./target/release/SimVulcan # SimVulcan.exe on Windows; package is simv-app
cargo test # 13 tests + 1 #[ignore]d diagnostic
```
**Work on the code with plain `cargo build`, not `--release`.** The release profile
sets `lto = "thin"` with `codegen-units = 1`, which makes a one-line edit
re-optimise and relink the whole binary: 52 s versus ~5 s in debug.
The tests cover mesh bounds, welding (three cases), `Cube.obj` loading and its error
path, camera clip space and zoom/pitch clamping, and logger category mapping. They
need no GPU but are **not** pure math: the loader tests read real files from
`assets/meshes` via `$CARGO_MANIFEST_DIR`, so they require the repo checkout. The
ignored `weld_counts_for_bundled_assets` is the diagnostic that produced the loader
parity table below.
Five crates mirror the CMake target map, and the "all Vulkan in one place"
invariant is **compiler-enforced** here — `simv-mesh` and `simv-editor` have
neither `ash` nor `simv-vk` among their dependencies. Third-party deps in full
(each crate also depends on the workspace crates shown):
```
crates/simv-core/ logger, window → log, chrono, winit
crates/simv-mesh/ .obj, welding, bounds → glam, tobj, log, thiserror
(+ simv-core)
crates/simv-vk/ all Vulkan + build.rs → ash, ash-window, raw-window-handle,
(GLSL→SPIR-V) gpu-allocator, egui, egui-winit,
egui-ash-renderer, winit, glam,
bytemuck, log, thiserror
(+ simv-core, simv-mesh)
crates/simv-editor/ camera, input, panels → egui, glam, log
(+ simv-core, simv-mesh)
crates/simv-app/ App and entry point → the four crates above
+ egui, winit, glam, log, anyhow
```
Structural differences from C++, each with a reason (full list in
`docs/rust_vs_cpp.md`): `App` lives in the executable crate (Cargo rejects the
target-graph cycle); the UI closure **returns** a `FrameState` instead of calling
setters (it is invoked from a renderer method, so it cannot borrow the renderer) —
`Renderer`'s public API has no `Set*` methods at all; the loaded mesh is uploaded
*after* `draw_frame` returns, not from inside it; `Buffer` and `Image` are actually
used; no pImpl (private module fields give the same isolation); winit owns the
event loop, so `ShouldClose`/`PollEvents` are gone.
SPIR-V is embedded via `include_bytes!` from `build.rs`, so there is no runtime
shader lookup. Two runtime path dependencies remain, though, so "runs from any
directory" is not quite true: `assets/meshes` is searched from the executable's
ancestors **and then the CWD's**, and `pipeline_cache.bin` is written next to the
exe. Unlike the C++ build, nothing copies `assets/` — the port finds the repo-root
copy by walking up from `rust/target/<profile>/`.
The UI toolkit differs **by design**: egui instead of Dear ImGui, because the
`imgui` crate is bindings and would compile ~40k lines of C++, defeating the point
of comparing ecosystems.
## Comparison result (`docs/rust_vs_cpp.md`)
The document deliberately picks no winner. Machine: i5-1135G7 / Iris Xe /
Windows 11, both builds release.
| | C++ | Rust |
|---|---|---|
| lines of code | 2434 | 2976 (+22 %) |
| release from scratch | **114 s** | 194 s (thin LTO) · 182 s (no LTO) |
| release, one-file edit | **4.4 s** | 52.3 s (LTO) · **5.5 s** (no LTO) |
| debug from scratch / one-file edit | 112 s / 8.0 s | **83 s** / **5.2 s** |
| release exe | **1.18 MB** | 5.80 MB |
| dependency sources | ~400 MB **per build dir** | **80 MB** shared registry |
| startup to renderer ready (best of 3) | 1068 ms | **754 ms** |
| CPU at 60 Hz | **11.2 %** | 12.6 % |
Frame rate measures nothing — both are FIFO on a 60 Hz screen. The 1.4 pp CPU gap
is egui rebuilding its layout every frame, not the language. The one sharp build
cell (52 s) is the price of thin LTO, not of Rust — with LTO off it is 5.5 s
against 4.4 s. Note the document's summary claim that *no* measurement differs by
an order of magnitude is false on its own numbers: 52.3 / 4.4 = **11.9×**. Every
other cell stays under 10×.
The +22 % line count is **mostly, not almost entirely**, the missing vk-bootstrap
replacement: `Context` + `Swapchain` go from 301 to 606 lines, which is 305 of the
542-line overhang (56 %); the whole Vulkan layer accounts for 346 (64 %). The rest
is real — mesh +117, editor +104, app +90, core −64, and 176 in-module test lines
against C++'s 51.
Loader parity: `cow.obj` matches exactly (2451 verts / 4898 tris); `plane.obj`
differs by 0.13 %, entirely due to the reading libraries (tobj drops the 35
vertices no face references; fan triangulation of n-gons versus tinyobjloader's
earcut).
**Known errors in `docs/rust_vs_cpp.md`** (numbers below are the measured truth):
line-count total is 2976 / 494 comments, not 2962 / 492 — the `mesh` row is stale by
the 17 lines of the ignored diagnostic test; "775 MB per build dir" is the foreign
`build/` tree (see the Build section) and so is the matching "10 packages in the
C++ graph" — there are nine; the Rust package graph is 82, not 76; `plane.obj` has
119 faces with five or more vertices, not 121; "three Catch2 tests ported verbatim"
should be *all three ported, two more added*; and `Renderer::Impl`'s destructor is
22 lines, not thirty.
---
# `docs/theory/2d_solver/` — kbc2d, Rust LBM solver (branch `research` only)
The active research work. A **D2Q9 Lattice Boltzmann solver with the entropic KBC
collision operator**, written from scratch in Rust (not a port): flow past bodies in
a channel, two interchangeable backends, a ×2 nested AMR patch, sub-grid wall
models, and GIF output locked to *physical* flow time. `Cargo.toml` declares
**Rust 1.75+**; the Dockerfile pins `rust:1.97-bookworm`. Physics is anchored to the
method authors' papers in `docs/origins/`; formula references in the code follow the
2D paper (arXiv:1507.02509), the only arXiv id in the tree, with the wall models
citing Dorschner et al. *JFM* 801 (2016) for `grad` and Malaspinas 2015 /
Coreixas et al. *PRE* 96 for `hrr`.
`README.md` there is the authoritative status/validation log (609 lines: what is
verified, against which equation, with what measured numbers) and `bench/README.md`
(248 lines) covers the validation campaign. **Read them before changing physics or
defaults** — most defaults are the outcome of a documented measurement, not a guess.
## Build / test / run
```sh
cd docs/theory/2d_solver
cargo build --release # with the GPU backend (default feature `gpu`)
cargo build --release --no-default-features # CPU only, no wgpu
cargo test --release # 29 fast tests
cargo test --release -- --include-ignored # + 4 long CPU runs (~18 s)
cargo test --release -- --ignored taylor_green_kbc_vs_bgk --nocapture # one diagnostic
```
33 `#[test]` functions exist (18 in `math.rs`, 9 in `cpu.rs`, 6 in `gif.rs`); the
4 `#[ignore]`d ones are all in `cpu.rs` and are two reference benchmarks plus two
diagnostics, not four benchmarks.
**Never build `--no-default-features` last.** Both builds write the same
`target/release/kbc2d`, so a CPU-only build silently overwrites the wgpu one and
`--backend gpu` then refuses to run. Order: no-default-features first, normal
build second.
```sh
./target/release/kbc2d --shape cylinder --size 24 --re 150 \
--nx 480 --ny 240 --steps 40000 --sponge-len 32 \
--gif wake.gif --gif-field vorticity --verbose full
```
`--help` groups every key by role, under **Russian** headings —
`Физика, Сетка, Тело, Время, Схема, Анимация, Вывод`. Note `--time <seconds>` as an
alternative to `--steps` (the two conflict): a step is not a fixed slice of time
(`dt = u_lat * dx / u_phys`, literally `math.rs`'s `Units::new`), so refining the
cell silently shortens a fixed step count.
Defaults worth knowing beyond the three discussed below: `--backend` is **`cpu`**,
`--collision kbc`, `--outlet extrapolate`, `--case channel`, `--refine 2`.
## File roles — one concern each
`src/` holds exactly these five files — no `lib.rs`, no `benches/`, no integration
tests.
| file | owns |
|---|---|
| `src/math.rs` | **all solver mathematics**, nodewise and pure: D2Q9 lattice, product-form entropic equilibrium, shear projector, γ stabiliser, collision, Zou–He, body SDFs, wall-model closures, unit conversion, spectral diagnostics (`strouhal`). Tests against the papers' formulas live here. `pub type R = f64`. |
| `src/cpu.rs` | CPU backend: AoS layout, rayon, **and the shared topology** — `Geom::build` (masks), Bouzidi link assembly, `Patch` (AMR level coupling), `initial_field`. Also holds the four ignored reference runs. |
| `src/gpu.rs` | GPU backend: wgpu + WGSL (Vulkan/DX12/Metal), SoA layout, f32. Re-implements the physics line-for-line in WGSL but **imports topology from `cpu`** rather than duplicating it. |
| `src/main.rs` | CLI, problem assembly, step loop, live output, report, CSV. Owns the `Spec` / `StepRec` / `FieldKind` contract shared by both backends. |
| `src/gif.rs` | encoding, palettes, normalisation, the HUD, delay dithering, and the physical-time frame timing. |
## Invariants (read before editing)
- **Physics is written twice, topology once.** `gpu.rs` mirrors `math.rs` by hand
in WGSL; masks, Bouzidi links and the patch frame come from `cpu::Geom` /
`cpu::Patch` / `cpu::initial_field`. Keep it that way — a past regression had the
GPU silently running Bouzidi for `--wall staircase`, caught only because two
models produced bit-identical output where they had to differ. Backend parity is
a hard requirement and `bench/parity.py` checks it: CPU f64 vs GPU f32 agree to
4–5 significant digits, and for **each** wall model the two backends agree to
0.007 % on Cd. (That number is *backend* parity, not agreement between wall
models — those differ from one another by ~1 %.)
- **The backends have different step contracts.** `Backend` in `main.rs` is an
enum, not a trait: CPU `step()` returns one `StepRec`, GPU `advance(out)` /
`flush(out)` push a *batch* — results accumulate in a 128-slot ring (`HIST`,
declared identically in Rust and WGSL) and sync once per batch. Adding a per-step
GPU readback outside `StepRec` reintroduces a `map_async` + `poll(Wait)` per step,
which cost ~8× throughput before batching (measured 863 → 6715 steps/s).
- **`GREL = 1e-8` is a relative threshold.** The test is `den > GREL * nrm`, where
`den = ⟨Δh|Δh⟩` and `nrm = ⟨Δ|Δ⟩` — so GREL is a fraction of the **full**
non-equilibrium norm, not of ⟨Δh|Δh⟩ itself. ⟨Δh|Δh⟩ is quadratic in
non-equilibrium and physically tiny (~1e-7…1e-9), so an absolute threshold fires
almost everywhere and silently substitutes γ = 2, i.e. plain LBGK instead of KBC.
The report prints the degenerate-node fraction; on a healthy threshold it must be
~0 (except the very first step).
- **GPU is f32 and cannot be otherwise** — WGSL has no `f64` type at all, so no
hardware helps. Convergence studies therefore run on CPU, everything else on GPU;
below a true error of ~1e-3 f32 diverges from f64 by an order of magnitude.
The **statistics reductions** (ρ, ⟨γ⟩, node and degenerate counts) use compensated
Kahan–Neumaier summation via `kadd`. The **force/torque kernel does not** — it
accumulates in plain f32 and reduces with a naive tree. Both `README.md:366` and
earlier revisions of this file claim otherwise; the code is the authority.
- **GPU dispatch is 2-D with a linear index rebuilt in the shader** (`lin()` /
`wlin()`), lifting the 65535-workgroup limit that capped grids at ~2048². The
workgroup → node mapping stays exactly linear, which is what lets the reductions
keep working; verified to 4096×4096.
- **Population storage has two binding layouts.** Normally two bindings (`f` and
`post`). When the adapter's max binding size is small (dzn/WSL2 caps a binding at
128 MiB while allowing a 2047 MiB buffer), each array is split across nine
bindings — one per direction, **18 in total** — raising the ceiling from 3.73 M to
33.5 M nodes. The choice comes from adapter limits. The split layout is the one
built out of `switch` statements in `fget`/`fset`/`pget`/`pset`; the combined
layout has none, which is the whole reason it is kept. Both give identical numbers;
the split costs 5.7 % bandwidth. Force it with `KBC2D_SPLIT_POPULATIONS` set to
anything other than `0`.
- **`MAX_BODY_BUCKETS = 4`.** Geometry is assembled from any number of bodies, but
per-body force is bucketed — bodies beyond the fourth have their forces merged
(`min(body, MAXB-1)` in three places), and the CLI warns when that happens.
- **Defaults encode measurements, not taste**: `--init uniform` (a rest start pumps
a quarter-wave channel resonance to 75 % of U and wrecks St/Cd/Cl — and the sponge
cannot remove it, since a standing mode has its pressure node exactly where the
sponge sits); `--kbc-model n1` (equal accuracy to `n2`, but ~4× higher bulk
viscosity, which damps longitudinal acoustics); `--wall hrr` (**provisional** —
chosen on scheme structure, pending campaign group C; `bouzidi` resolves geometry
~4× better). Accepted values: `--init uniform|rest`, `--kbc-model n1|n2`,
`--wall hrr|bouzidi|grad|staircase`.
## Validation campaign (`bench/`)
115 runs in nine groups (A 12, B 16, C 20, D 14, E 12, F 9, G 14, H 9, I 9), ≈90
machine-hours — ≈87 GPU-hours over 108 runs plus ≈3 CPU-hours over the 7 CPU runs
of group A. Each run writes `cmd.txt`, `log.txt`, `report.txt`, `series.csv` and
`summary.json` into its own folder under `out/` (untracked — **those are results,
never clean them**). Only **59 of the 115** pass `--gif`; the rest produce no
animation at all.
```sh
cd bench
python preflight.py # start every scenario for two steps — catches typos
./run_campaign.sh --calibrate # measure this machine's MLUPS (hour estimates need it)
./run_campaign.sh --dry-run # cost estimate
./run_campaign.sh --resume # run, skipping what is already done
```
`run_campaign.sh` also takes `--smoke`, `--group`, `--only` and `--budget-hours`;
`run_campaign.ps1` is the Windows twin. Run `preflight.py` for real — it caught
group I failing on a GPU limit and group F's nine runs passing `--body-x` twice,
both of which would otherwise have surfaced twenty hours into a server campaign.
Note the campaign has barely started: `out/` currently holds four finished runs
(`A01`, `A02`, `A05`, `C09`) plus `campaign.log` and `summary.csv`.
## Deployment
Published image **`notbigghost/kbc2d:1.2.0`**; the server needs only
`docker-compose.server.yml` (the only compose file with no `build:` section), not
the sources. `ENTRYPOINT` is the campaign driver and `CMD` defaults to `--dry-run`,
so a stray `docker run` prints an estimate instead of starting a 100-hour job. Check
the card first (`--profile check run --rm vulkan`) — a missing GPU is better
discovered in a minute than in an hour. The `check` profile also carries
`preflight`, `calibrate` and `plan` services (plus `parity` in the WSL file).
Two version caveats: the `1.2.0` tag lives only in the compose files and prose —
`Cargo.toml` still says `version = "0.1.0"` and the Dockerfile defaults
`ARG VERSION=dev`. And `linux/amd64` is asserted in `README.md` only; no compose
file sets `platform:` and the Dockerfile sets no `--platform`.
Two environment traps, both already handled in the image but overridable from
outside: NVIDIA Container Toolkit only injects the Vulkan ICD when
`NVIDIA_DRIVER_CAPABILITIES` contains `graphics` (with `compute` alone wgpu sees no
adapter), and **WSL2 has no NVIDIA Vulkan driver for Linux at all** — the card
arrives over `/dev/dxg`. That path instead uses **dzn** (Mesa's Vulkan→D3D12
translation, why the base image is `archlinux:base` — Debian/Ubuntu do not build
dzn), needs no NVIDIA runtime, and requires
`WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER=1` because dzn reports
`conformanceVersion = 0.0.0.0` and wgpu hides such adapters by default (the variable
is read only because `gpu.rs` builds the instance with `.with_env()`). Use
`docker-compose.wsl.yml` there.
---
# `docs/theory/` — Python prototype (all branches)
The earlier **Python/CuPy** implementation of the same LBM physics — cylinder flow
with a nested AMR patch and SDF+Bouzidi boundaries. `kbc2d` is a deliberate rewrite
of this, not a port. Do not fold any of it into the CMake build, and do not treat it
as dead code — but do not treat it as one program either.
**This directory holds four generations of the same solver.** CLAUDE.md used to name
only the newest, which made the rest look like clutter. Oldest to newest:
| generation | files | status |
|---|---|---|
| the original write-up | `_legacy/kbc_lbm.ipynb` (64 cells, executed, outputs stored) + `_legacy/kbc_lbm.py` | superseded |
| CPU reference prototypes | `_amr_anims.py`, `_amr_anims_sdf.py` (pure NumPy) | superseded, but these are the "verified CPU prototypes" the GPU core is checked against |
| GPU core + runners | `amr_gpu_core.py` (**has a NumPy CPU fallback**) driven by `amr_cylinder_gpu.py` (no SDF) and `amr_cylinder_sdf_gpu.py` (SDF+Bouzidi); both self-titled "ОСНОВНОЙ", three modes: none / 2× / nested 2×+4× | superseded as a model, **still the producer of committed figures 11e–11g and four `*_cyl*.gif` animations** — deleting it orphans notebook images |
| optimised rewrite | `amr_opt/` (`*_opt.py`: no NumPy fallback, `cp.fuse`, precomputed link masks, CUDA Graphs) | dead intermediate — produced nothing that is committed |
| **the current model** | `solver_2x_sdf/` (17 modules + README), demos in `demos_gpu/` | live |
So "**GPU/CuPy only, no CPU fallback**" is true of `solver_2x_sdf/` (`backend.py`
raises outright), `demos_gpu/` and `amr_opt/` — but **not** of `amr_gpu_core.py`,
which silently falls back to NumPy, nor of `_amr_anims*.py` and `_legacy/`, which are
CPU-only.
Also committed and easy to mistake for junk: `figures/` (14 PNG) and `anim/`
(10 GIF, 55 MB) are tracked **deliberately** so the notebook renders on a fresh
clone; `_amr_probe_data*.npz` are probe scalars one runner writes and the other
reads; `factors.csv` is a hand-preserved snapshot of the Experiment-№1 registry
(nothing writes that path — `run_factors.py` writes `solver_2x_sdf/out/factors.csv`,
which is gitignored).
- `solver_2x_sdf/README.md` — maps every file to the physics it owns (17 rows, 17
modules, exact); entry points `run.py`, `run_blockage.py`, `run_factors.py`.
**Two defects in it:** it suggests switching `cfg.collision="bgk"` or registering
a TRT operator in a `get` registry — neither the config field nor the registry
exists, and the same README says two paragraphs later that the operator is fixed;
and it declares **all of its own quantitative results stale** pending a re-run
after the `GREL` fix, which also invalidates notebook §22–24.
- `demos_gpu/` — 8 scripts + README producing 17 of the notebook's artefacts. They
import the model's operators from `solver_2x_sdf` and add only what the model
deliberately lacks: `_common.py` reimplements a polynomial equilibrium, BGK, the
H-function and a γ-field for visualisation. `figures_static.py` is pure matplotlib
and imports no physics at all.
- `kbc_lbm.ipynb` — the write-up. It does not merely "execute nothing": it has **20
cells, all markdown, zero code cells**. Artefacts are relative-path links, not
embedded data, so it is only readable in place — and **4 of its 26 image links are
dead**, all pointing into the gitignored `solver_2x_sdf/out/`.
- `docs/origins/` — the six source PDFs behind both implementations (seven on
`research`, which adds Grad's approximation).
## How kbc2d differs from this prototype
Documented in one paragraph at `docs/theory/2d_solver/README.md:390` — **on branch
`research` only**; nothing under `docs/theory/` mentions the divergence. There are
**three** differences, not two:
1. kbc2d does not collide inside the body (those populations are fictitious; γ
statistics are then gathered strictly over fluid). The Python solver *does*
collide there — `kbc_collide` takes no fluid mask.
2. kbc2d's restriction additionally skips coarse nodes whose *fine source* node lies
inside the body. The Python restriction already gates on the **coarse
destination** mask.
3. kbc2d's CPU backend is f64 and its GPU backend f32; the Python side is fp32 by
default, f64 only via `AMR_FP64`.
+139
View File
@@ -0,0 +1,139 @@
cmake_minimum_required(VERSION 3.26)
project(SimVulcan
VERSION 0.1.0
DESCRIPTION "Vulkan 3D editor: orbit camera, reference grid, OBJ model viewer"
LANGUAGES CXX C
)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")
include(CompilerWarnings)
include(Sanitizers)
include(VulkanSetup)
include(ShaderCompile)
include(FetchContent)
# ----------------------------------------------------------------------------
# External dependencies (FetchContent — pinned commits)
# ----------------------------------------------------------------------------
set(FETCHCONTENT_QUIET OFF)
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
# GLFW
set(GLFW_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(GLFW_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(GLFW_BUILD_DOCS OFF CACHE BOOL "" FORCE)
FetchContent_Declare(glfw
GIT_REPOSITORY https://github.com/glfw/glfw.git
GIT_TAG 3.4
)
# GLM
FetchContent_Declare(glm
GIT_REPOSITORY https://github.com/g-truc/glm.git
GIT_TAG 1.0.1
)
# volk (lazy Vulkan loader)
FetchContent_Declare(volk
GIT_REPOSITORY https://github.com/zeux/volk.git
GIT_TAG 1.3.295
)
# vk-bootstrap
FetchContent_Declare(vk-bootstrap
GIT_REPOSITORY https://github.com/charles-lunarg/vk-bootstrap.git
GIT_TAG v1.3.295
)
# Vulkan Memory Allocator
FetchContent_Declare(VulkanMemoryAllocator
GIT_REPOSITORY https://github.com/GPUOpen-LibrariesAndSDKs/VulkanMemoryAllocator.git
GIT_TAG v3.1.0
)
# Dear ImGui (we wrap it in a small CMake target ourselves below).
FetchContent_Declare(imgui
GIT_REPOSITORY https://github.com/ocornut/imgui.git
GIT_TAG v1.91.5-docking
)
# spdlog
set(SPDLOG_BUILD_SHARED OFF CACHE BOOL "" FORCE)
set(SPDLOG_FMT_EXTERNAL OFF CACHE BOOL "" FORCE)
FetchContent_Declare(spdlog
GIT_REPOSITORY https://github.com/gabime/spdlog.git
GIT_TAG v1.14.1
)
# tinyobjloader (header-only)
FetchContent_Declare(tinyobjloader
GIT_REPOSITORY https://github.com/tinyobjloader/tinyobjloader.git
GIT_TAG v2.0.0rc13
)
# Catch2
FetchContent_Declare(Catch2
GIT_REPOSITORY https://github.com/catchorg/Catch2.git
GIT_TAG v3.7.1
)
FetchContent_MakeAvailable(
glfw glm volk vk-bootstrap VulkanMemoryAllocator
imgui spdlog tinyobjloader Catch2
)
# ----------------------------------------------------------------------------
# ImGui static library (needs explicit target — official repo has none)
# ----------------------------------------------------------------------------
add_library(imgui STATIC
${imgui_SOURCE_DIR}/imgui.cpp
${imgui_SOURCE_DIR}/imgui_demo.cpp
${imgui_SOURCE_DIR}/imgui_draw.cpp
${imgui_SOURCE_DIR}/imgui_tables.cpp
${imgui_SOURCE_DIR}/imgui_widgets.cpp
${imgui_SOURCE_DIR}/backends/imgui_impl_glfw.cpp
${imgui_SOURCE_DIR}/backends/imgui_impl_vulkan.cpp
)
target_include_directories(imgui PUBLIC
${imgui_SOURCE_DIR}
${imgui_SOURCE_DIR}/backends
)
target_link_libraries(imgui PUBLIC glfw Vulkan::Headers volk)
# Have ImGui's Vulkan backend pick up function pointers from volk directly,
# instead of asking us to load them via ImGui_ImplVulkan_LoadFunctions.
target_compile_definitions(imgui PUBLIC
IMGUI_IMPL_VULKAN_NO_PROTOTYPES
IMGUI_IMPL_VULKAN_USE_VOLK
VK_NO_PROTOTYPES
)
# Catch2 helpers (catch_discover_tests).
include(CTest)
include(${Catch2_SOURCE_DIR}/extras/Catch.cmake)
# ----------------------------------------------------------------------------
# Project targets
# ----------------------------------------------------------------------------
add_subdirectory(shaders) # produces simv_shaders custom target
add_subdirectory(src/core)
add_subdirectory(src/vk)
add_subdirectory(src/mesh)
add_subdirectory(src/editor)
add_subdirectory(src/app)
enable_testing()
add_subdirectory(tests)
message(STATUS "")
message(STATUS "SimVulcan ${PROJECT_VERSION} configured.")
message(STATUS " Build type: ${CMAKE_BUILD_TYPE}")
message(STATUS " C++ std: ${CMAKE_CXX_STANDARD}")
message(STATUS " Vulkan: ${Vulkan_VERSION}")
message(STATUS " Generator: ${CMAKE_GENERATOR}")
message(STATUS "")
+67
View File
@@ -0,0 +1,67 @@
{
"version": 6,
"cmakeMinimumRequired": { "major": 3, "minor": 26, "patch": 0 },
"configurePresets": [
{
"name": "base",
"hidden": true,
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/${presetName}",
"cacheVariables": {
"CMAKE_EXPORT_COMPILE_COMMANDS": "ON"
}
},
{
"name": "windows-msvc-debug",
"inherits": "base",
"cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" },
"condition": { "type": "equals", "lhs": "${hostSystemName}", "rhs": "Windows" }
},
{
"name": "windows-msvc-release",
"inherits": "base",
"cacheVariables": { "CMAKE_BUILD_TYPE": "Release" },
"condition": { "type": "equals", "lhs": "${hostSystemName}", "rhs": "Windows" }
},
{
"name": "linux-gcc-release",
"inherits": "base",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"CMAKE_C_COMPILER": "gcc",
"CMAKE_CXX_COMPILER": "g++"
},
"condition": { "type": "equals", "lhs": "${hostSystemName}", "rhs": "Linux" }
},
{
"name": "linux-clang-release",
"inherits": "base",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"CMAKE_C_COMPILER": "clang",
"CMAKE_CXX_COMPILER": "clang++"
},
"condition": { "type": "equals", "lhs": "${hostSystemName}", "rhs": "Linux" }
},
{
"name": "macos-arm64-release",
"inherits": "base",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"CMAKE_OSX_ARCHITECTURES": "arm64"
},
"condition": { "type": "equals", "lhs": "${hostSystemName}", "rhs": "Darwin" }
}
],
"buildPresets": [
{ "name": "windows-msvc-debug", "configurePreset": "windows-msvc-debug" },
{ "name": "windows-msvc-release", "configurePreset": "windows-msvc-release" },
{ "name": "linux-gcc-release", "configurePreset": "linux-gcc-release" },
{ "name": "linux-clang-release", "configurePreset": "linux-clang-release" },
{ "name": "macos-arm64-release", "configurePreset": "macos-arm64-release" }
],
"testPresets": [
{ "name": "windows-msvc-debug", "configurePreset": "windows-msvc-debug",
"output": { "outputOnFailure": true } }
]
}
+61
View File
@@ -0,0 +1,61 @@
# SimVulcan
A minimal Vulkan 1.3 / C++20 **3D model editor**: an orbit camera over three
reference grid planes through the origin, with `.obj` model loading and display.
## Features
* Orbit camera — drag to rotate around the centre, wheel to zoom, and Front / Top
/ Side buttons to align the view to the principal planes.
* A 3D editor space: three grid planes (XY, XZ, YZ) and coloured X/Y/Z axes meeting
at the origin.
* `.obj` loading with three display modes — solid (flat-shaded), wireframe, and
solid + wireframe overlay.
* ImGui interface (Viewport controls + Mesh load panel).
All Vulkan code is isolated in `src/vk/`; the rest of the app is Vulkan-free.
## Targets
* Windows x64 (MSVC / clang-cl)
* Linux x64 (gcc / clang)
* macOS arm64 (Apple Silicon, via MoltenVK)
## Prerequisites
* Vulkan SDK 1.3.290+ (`https://vulkan.lunarg.com/`)
* CMake 3.26+
* Ninja
* C++20 compiler (MSVC 19.36+, gcc 11+, clang 14+)
## Build
```sh
cmake --preset windows-msvc-release
cmake --build --preset windows-msvc-release
```
Run from the executable's own directory (`build/<preset>/src/app/`) so it finds
the `spirv/` and `assets/` trees copied next to it.
## Tests
```sh
cmake --build --preset windows-msvc-debug --target simv_tests
ctest --preset windows-msvc-debug
```
## Layout
```
src/
core/ Logger, Window, App (orchestration, Vulkan-free)
vk/ All Vulkan: Context, Swapchain, Buffer, Image, Shader, GpuMesh,
Renderer (frame loop + depth + ImGui), MeshRenderer, GridRenderer
mesh/ CPU mesh: tinyobjloader + welding (no Vulkan)
editor/ Camera, mouse input, EditorUI + MeshLoadPanel (ImGui, no Vulkan)
app/ main.cpp
shaders/
editor/ mesh + grid vertex/fragment shaders (GLSL → SPIR-V at build time)
tests/ Catch2 unit tests (mesh math)
```
+70
View File
@@ -0,0 +1,70 @@
mtllib C:\Users\ivan\Downloads\Cube.mtl
o Solid_1
v -0.50000000 0.50000000 1
v -0.50000000 -0.50000000 1
v 0.50000000 -0.50000000 1
v 0.50000000 0.50000000 1
v -0.50000000 -0.50000000 0
v 0.50000000 -0.50000000 0
v -0.50000000 0.50000000 0
v 0.50000000 0.50000000 0
vt 0 1
vt 0 0
vt 1 0
vt 1 1
vt 1 0
vt 1 1
vt 0 1
vt 0 0
vt 0 1
vt 0 0
vt 1 0
vt 1 1
vt 0 1
vt 0 0
vt 1 0
vt 1 1
vt 1 1
vt 0 1
vt 0 0
vt 1 0
vt 0 0
vt 1 0
vt 1 1
vt 0 1
vn 0 0 1
vn 0 0 1
vn 0 0 1
vn 0 0 1
vn 0 -1 0
vn 0 -1 0
vn 0 -1 0
vn 0 -1 0
vn -1 0 0
vn -1 0 0
vn -1 0 0
vn -1 0 0
vn 0 1 0
vn 0 1 0
vn 0 1 0
vn 0 1 0
vn 0 0 -1
vn 0 0 -1
vn 0 0 -1
vn 0 0 -1
vn 1 0 0
vn 1 0 0
vn 1 0 0
vn 1 0 0
f 1/1/1 2/2/2 3/3/3
f 3/3/3 4/4/4 1/1/1
f 5/5/5 6/6/6 3/7/7
f 3/7/7 2/8/8 5/5/5
f 7/9/9 5/10/10 2/11/11
f 2/11/11 1/12/12 7/9/9
f 8/13/13 7/14/14 1/15/15
f 1/15/15 4/16/16 8/13/13
f 7/17/17 8/18/18 6/19/19
f 6/19/19 5/20/20 7/17/17
f 3/21/21 6/22/22 8/23/23
f 8/23/23 4/24/24 3/21/21
+70
View File
@@ -0,0 +1,70 @@
mtllib C:\Users\ivan\Downloads\Cube.mtl
o Solid_1
v -0.50000000 0.50000000 1
v -0.50000000 -0.50000000 1
v 0.50000000 -0.50000000 1
v 0.50000000 0.50000000 1
v -0.50000000 -0.50000000 0
v 0.50000000 -0.50000000 0
v -0.50000000 0.50000000 0
v 0.50000000 0.50000000 0
vt 0 1
vt 0 0
vt 1 0
vt 1 1
vt 1 0
vt 1 1
vt 0 1
vt 0 0
vt 0 1
vt 0 0
vt 1 0
vt 1 1
vt 0 1
vt 0 0
vt 1 0
vt 1 1
vt 1 1
vt 0 1
vt 0 0
vt 1 0
vt 0 0
vt 1 0
vt 1 1
vt 0 1
vn 0 0 1
vn 0 0 1
vn 0 0 1
vn 0 0 1
vn 0 -1 0
vn 0 -1 0
vn 0 -1 0
vn 0 -1 0
vn -1 0 0
vn -1 0 0
vn -1 0 0
vn -1 0 0
vn 0 1 0
vn 0 1 0
vn 0 1 0
vn 0 1 0
vn 0 0 -1
vn 0 0 -1
vn 0 0 -1
vn 0 0 -1
vn 1 0 0
vn 1 0 0
vn 1 0 0
vn 1 0 0
f 1/1/1 2/2/2 3/3/3
f 3/3/3 4/4/4 1/1/1
f 5/5/5 6/6/6 3/7/7
f 3/7/7 2/8/8 5/5/5
f 7/9/9 5/10/10 2/11/11
f 2/11/11 1/12/12 7/9/9
f 8/13/13 7/14/14 1/15/15
f 1/15/15 4/16/16 8/13/13
f 7/17/17 8/18/18 6/19/19
f 6/19/19 5/20/20 7/17/17
f 3/21/21 6/22/22 8/23/23
f 8/23/23 4/24/24 3/21/21
+19993
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
function(simv_set_warnings TARGET)
if(MSVC)
target_compile_options(${TARGET} PRIVATE
/W4 /permissive- /Zc:preprocessor /Zc:__cplusplus
/wd4100 # unused parameter (interfaces have many)
)
target_compile_definitions(${TARGET} PRIVATE
_CRT_SECURE_NO_WARNINGS
NOMINMAX
WIN32_LEAN_AND_MEAN
)
else()
target_compile_options(${TARGET} PRIVATE
-Wall -Wextra -Wpedantic
-Wno-unused-parameter
-Wshadow -Wnon-virtual-dtor -Wold-style-cast
-Wcast-align -Wunused -Woverloaded-virtual
)
endif()
endfunction()
+11
View File
@@ -0,0 +1,11 @@
function(simv_enable_sanitizers TARGET)
if(NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
return()
endif()
if(MSVC)
target_compile_options(${TARGET} PRIVATE /fsanitize=address)
else()
target_compile_options(${TARGET} PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer)
target_link_options (${TARGET} PRIVATE -fsanitize=address,undefined)
endif()
endfunction()
+50
View File
@@ -0,0 +1,50 @@
# Compile GLSL (.comp/.vert/.frag) into SPIR-V at build time.
#
# Usage:
# simv_compile_shaders(simv_shaders
# shaders/editor/mesh.vert
# shaders/editor/mesh.frag
# INCLUDES shaders
# )
#
# Produces ${CMAKE_BINARY_DIR}/spirv/<relative_dir>/<name>.spv as a custom target.
function(simv_compile_shaders TARGET)
cmake_parse_arguments(SCS "" "" "INCLUDES" ${ARGN})
set(SOURCES ${SCS_UNPARSED_ARGUMENTS})
set(SPIRV_OUT_DIR "${CMAKE_BINARY_DIR}/spirv")
file(MAKE_DIRECTORY "${SPIRV_OUT_DIR}")
set(SPV_OUTPUTS "")
foreach(SRC IN LISTS SOURCES)
get_filename_component(SHADER_NAME "${SRC}" NAME)
file(RELATIVE_PATH REL_PATH "${CMAKE_CURRENT_SOURCE_DIR}" "${SRC}")
get_filename_component(REL_DIR "${REL_PATH}" DIRECTORY)
set(OUT_PATH "${SPIRV_OUT_DIR}/${REL_DIR}/${SHADER_NAME}.spv")
get_filename_component(OUT_DIR "${OUT_PATH}" DIRECTORY)
file(MAKE_DIRECTORY "${OUT_DIR}")
set(INCLUDE_FLAGS "")
foreach(INC IN LISTS SCS_INCLUDES)
# glslangValidator requires -I<dir> with NO whitespace.
list(APPEND INCLUDE_FLAGS "-I${INC}")
endforeach()
add_custom_command(
OUTPUT "${OUT_PATH}"
COMMAND "${Vulkan_GLSLANG_VALIDATOR_EXECUTABLE}" -V --target-env vulkan1.3
${INCLUDE_FLAGS} -o "${OUT_PATH}" "${SRC}"
DEPENDS "${SRC}"
COMMENT "GLSL → SPIR-V: ${REL_PATH}"
VERBATIM
)
list(APPEND SPV_OUTPUTS "${OUT_PATH}")
endforeach()
add_custom_target(${TARGET} ALL DEPENDS ${SPV_OUTPUTS})
set_target_properties(${TARGET} PROPERTIES
SIMV_SPIRV_DIR "${SPIRV_OUT_DIR}"
)
endfunction()
+17
View File
@@ -0,0 +1,17 @@
# Locate Vulkan SDK + glslangValidator (needed for offline shader compilation).
# On macOS this expects MoltenVK to be present (provided by Vulkan SDK 1.3.290+).
find_package(Vulkan REQUIRED COMPONENTS glslangValidator)
if(NOT Vulkan_glslangValidator_FOUND)
message(FATAL_ERROR "glslangValidator not found. Install Vulkan SDK from https://vulkan.lunarg.com/")
endif()
message(STATUS "Vulkan version: ${Vulkan_VERSION}")
message(STATUS "Vulkan include dir: ${Vulkan_INCLUDE_DIRS}")
message(STATUS "glslangValidator: ${Vulkan_GLSLANG_VALIDATOR_EXECUTABLE}")
if(APPLE)
# MoltenVK ships with the SDK; it's loaded as ICD at runtime, no link change needed,
# but we set MVK_CONFIG_USE_METAL_PRIVATE_API=0 in env when running.
message(STATUS "macOS detected — Vulkan via MoltenVK (Apple Silicon arm64 only)")
endif()
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+241
View File
@@ -0,0 +1,241 @@
# AMR на цилиндре: полный домен 150x72, три версии (без AMR / 2x / вложенный 2x+4x).
# Встроенные зонды: сила на теле (обмен импульсом) -> Cd/Cl; u_y в следе -> Струхаль.
# Визуализация: inferno по |omega|, ТОЛЬКО рамки зон (без линий сетки), подписи осей.
# После прогона — анализ и сравнение трёх версий на реально собранных данных.
import sys, os, io as _io, numpy as np, matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt, matplotlib.patches as mpatches
from PIL import Image
try: sys.stdout.reconfigure(encoding="utf-8")
except Exception: pass
HERE = os.path.dirname(os.path.abspath(__file__))
ANIM = os.path.join(HERE, "anim"); os.makedirs(ANIM, exist_ok=True)
FIGS = os.path.join(HERE, "figures"); os.makedirs(FIGS, exist_ok=True)
C=np.array([[0,0],[1,0],[0,1],[-1,0],[0,-1],[1,1],[-1,1],[-1,-1],[1,-1]],float)
W=np.array([4/9,1/9,1/9,1/9,1/9,1/36,1/36,1/36,1/36]); CS2=1/3; Q=9
CX,CY=C[:,0],C[:,1]; OPP=np.array([0,3,4,1,2,7,8,5,6])
M=np.zeros((Q,Q)); M[0]=1;M[1]=CX;M[2]=CY;M[3]=3*(CX**2+CY**2)-2
M[4]=CX**2-CY**2;M[5]=CX*CY;M[6]=CX**2*CY;M[7]=CX*CY**2;M[8]=CX**2*CY**2
Dm=np.zeros((Q,Q)); Dm[4,4]=Dm[5,5]=1.0; Ps=np.linalg.inv(M)@Dm@M
def feq(rho,u):
ux=np.clip(u[0],-0.95,0.95); uy=np.clip(u[1],-0.95,0.95)
sx=np.sqrt(1+3*ux**2); sy=np.sqrt(1+3*uy**2); base=rho*(2-sx)*(2-sy)
Bx=((2*ux+sx)/(1-ux))[None]**CX[:,None,None]; By=((2*uy+sy)/(1-uy))[None]**CY[:,None,None]
return W[:,None,None]*base[None]*Bx*By
def macros(f):
r=f.sum(0); return r,np.stack([(CX[:,None,None]*f).sum(0)/r,(CY[:,None,None]*f).sum(0)/r])
def collide(f,fe,beta):
dfn=f-fe; ds=np.einsum("ij,jab->iab",Ps,dfn); dh=dfn-ds; inv=1.0/fe
num=(ds*dh*inv).sum(0); den=(dh*dh*inv).sum(0)
with np.errstate(divide="ignore",invalid="ignore"):
g=np.where(den>1e-14,1/beta-(2-1/beta)*num/den,2.0)
return f-beta*(2*ds+g[None]*dh)
def stream(f):
fs=np.empty_like(f)
for i in range(Q): fs[i]=np.roll(f[i],(int(CY[i]),int(CX[i])),(0,1))
return fs
def smoothstep(x): x=min(max(x,0.0),1.0); return x*x*(3-2*x)
def tauc(tp,r): return r*tp-(r-1)/2
def vort(u):
return (np.roll(u[1],-1,1)-np.roll(u[1],1,1))*0.5-(np.roll(u[0],-1,0)-np.roll(u[0],1,0))*0.5
def patch(pNx,pNy,ax,bx,ay,by,r):
Wx,Wy=bx-ax,by-ay; Nfx,Nfy=r*Wx+1,r*Wy+1
FX,FY=np.meshgrid(ax+np.arange(Nfx)/r, ay+np.arange(Nfy)/r)
x0=np.floor(FX).astype(int); y0=np.floor(FY).astype(int)
x1=np.minimum(x0+1,pNx-1); y1=np.minimum(y0+1,pNy-1)
return dict(Nfx=Nfx,Nfy=Nfy,ax=ax,bx=bx,ay=ay,by=by,r=r,x0=x0,y0=y0,x1=x1,y1=y1,
tx=FX-x0,ty=FY-y0,slx=slice(r,r*Wx,r),sly=slice(r,r*Wy,r))
def pint(fld,P):
return (fld[...,P["y0"],P["x0"]]*(1-P["tx"])*(1-P["ty"])+fld[...,P["y0"],P["x1"]]*P["tx"]*(1-P["ty"])
+fld[...,P["y1"],P["x0"]]*(1-P["tx"])*P["ty"]+fld[...,P["y1"],P["x1"]]*P["tx"]*P["ty"])
def ghost(pf,P,Rcf):
r,u=macros(pf); neq=pf-feq(r,u)
return feq(pint(r,P),np.stack([pint(u[0],P),pint(u[1],P)]))+Rcf*pint(neq,P)
def fill(cf,gh):
cf[:,0,:]=gh[:,0,:];cf[:,-1,:]=gh[:,-1,:];cf[:,:,0]=gh[:,:,0];cf[:,:,-1]=gh[:,:,-1]; return cf
def restrict(cf,pf,P,Rfc,fluid):
r,u=macros(cf); neq=cf-feq(r,u); slx,sly=P["slx"],P["sly"]
nv=feq(r[sly,slx],u[:,sly,slx])+Rfc*neq[:,sly,slx]
cur=pf[:,P["ay"]+1:P["by"],P["ax"]+1:P["bx"]]
pf[:,P["ay"]+1:P["by"],P["ax"]+1:P["bx"]]=np.where(fluid[None],nv,cur); return pf
def bb(pre,f,solid):
for i in range(Q): f[i,solid]=pre[OPP[i],solid]
return f
def cyl(NX,NY,ccx,ccy,rad,walls=True):
Y,X=np.mgrid[0:NY,0:NX]; m=(X-ccx)**2+(Y-ccy)**2<=rad*rad
if walls: m[0,:]=True; m[-1,:]=True
return m
def force_on(fpost,solid_body):
# обмен импульсом (Mei et al. 2002): по линкам жидкий-узел -> твёрдый сосед,
# F = sum c_i (f_i + f_ī)^post — корректная передача импульса на стенку.
fluid=~solid_body; Fx=0.0; Fy=0.0
for i in range(1,Q):
nb=np.roll(solid_body,(-int(CY[i]),-int(CX[i])),(0,1)) # nb[x]=solid[x+c_i]
link=fluid & nb; s=(fpost[i]+fpost[OPP[i]])[link].sum()
Fx+=CX[i]*s; Fy+=CY[i]*s
return Fx,Fy
# --- полный домен 150x72 (вся сетка в кадре, измельчение внутри) ---
Nx,Ny=150,72; D=16; cx,cy=40,36; U=0.07; Re=150.0; STEPS=8000; ramp=1000; FE=34
D0=D; nu=U*D/Re; tau0=nu/CS2+0.5; b0=1/(2*tau0)
px,py=cx+3*D,cy # зонд в следе
solid0=cyl(Nx,Ny,cx,cy,D/2) # коарс: цилиндр + стенки
solid0_cyl=cyl(Nx,Ny,cx,cy,D/2,walls=False)
# уровень1 (2×, зелёный): тело+след
ax1,bx1,ay1,by1=16,118,8,64; P1=patch(Nx,Ny,ax1,bx1,ay1,by1,2)
t1=tauc(tau0,2); b1=1/(2*t1); R01=t1/(2*tau0); Rf01=1/R01
solid1=cyl(P1["Nfx"],P1["Nfy"],(cx-ax1)*2,(cy-ay1)*2,D,walls=False) # D_L=32
fl1=~solid0[ay1+1:by1,ax1+1:bx1]
# уровень2 (4×, оранжевый): у тела (в коарс-координатах [26:64]x[18:54])
cx2a,cx2b,cy2a,cy2b=26,64,18,54
P2=patch(P1["Nfx"],P1["Nfy"],(cx2a-ax1)*2,(cx2b-ax1)*2,(cy2a-ay1)*2,(cy2b-ay1)*2,2)
t2=tauc(t1,2); b2=1/(2*t2); R12=t2/(2*t1); Rf12=1/R12
solid2=cyl(P2["Nfx"],P2["Nfy"],(cx-cx2a)*4,(cy-cy2a)*4,2*D,walls=False) # D_L=64
fl2=np.ones((P2["by"]-P2["ay"]-1,P2["bx"]-P2["ax"]-1),bool)
onecol=np.ones((Ny,1))
print(f"домен {Nx}x{Ny}; L1(2×) {P1['Nfx']}x{P1['Nfy']}; L2(4×) {P2['Nfx']}x{P2['Nfy']}; "
f"Re={Re} D0={D0} steps={STEPS}")
def run(mode): # "none" | "amr2x" | "nested"
use1=mode in ("amr2x","nested"); use2=(mode=="nested")
DL={"none":D0,"amr2x":2*D0,"nested":4*D0}[mode]
f0=feq(np.ones((Ny,Nx)),np.zeros((2,Ny,Nx)))
f1=feq(np.ones((P1["Nfy"],P1["Nfx"])),np.zeros((2,P1["Nfy"],P1["Nfx"]))) if use1 else None
f2=feq(np.ones((P2["Nfy"],P2["Nfx"])),np.zeros((2,P2["Nfy"],P2["Nfx"]))) if use2 else None
Fx=np.zeros(STEPS+1); Fy=np.zeros(STEPS+1); uy=np.zeros(STEPS+1); frames=[]
for t in range(STEPS+1):
rho,u0=macros(f0)
if not np.all(np.isfinite(u0)): print("BLEW UP",mode,t); Fx=Fx[:t];Fy=Fy[:t];uy=uy[:t]; break
pre=f0.copy(); post0=collide(f0,feq(rho,u0),b0)
if mode=="none": Fx[t],Fy[t]=force_on(post0,solid0_cyl)
f0=stream(bb(pre,post0,solid0))
uin=np.zeros((2,Ny,1)); uin[0,:,0]=U*smoothstep(t/ramp)
f0[:,1:-1,0]=feq(onecol,uin)[:,1:-1,0]; f0[:,1:-1,-1]=f0[:,1:-1,-2]
if use1:
g1o=ghost(pre,P1,R01); g1n=ghost(f0,P1,R01)
for s1 in range(2):
pre1=f1.copy(); r1,u1=macros(f1); post1=collide(f1,feq(r1,u1),b1)
if mode=="amr2x" and s1==1: Fx[t],Fy[t]=force_on(post1,solid1)
f1=stream(bb(pre1,post1,solid1)); f1=fill(f1,(1-(s1+1)/2)*g1o+((s1+1)/2)*g1n)
if use2:
g2o=ghost(pre1,P2,R12); g2n=ghost(f1,P2,R12)
for s2 in range(2):
pre2=f2.copy(); r2,u2=macros(f2); post2=collide(f2,feq(r2,u2),b2)
if s1==1 and s2==1: Fx[t],Fy[t]=force_on(post2,solid2)
f2=stream(bb(pre2,post2,solid2)); f2=fill(f2,(1-(s2+1)/2)*g2o+((s2+1)/2)*g2n)
f1=restrict(f2,f1,P2,Rf12,fl2)
f0=restrict(f1,f0,P1,Rf01,fl1)
uc=macros(f0)[1]; uy[t]=uc[1,py,px]
if use1 and t%FE==0:
frames.append((t,uc.copy(),macros(f1)[1].copy(),(macros(f2)[1].copy() if use2 else None)))
return dict(mode=mode,DL=DL,Fx=Fx,Fy=Fy,uy=uy,frames=frames)
def save_gif(res,name,fps=24):
nested=(res["mode"]=="nested"); frames=res["frames"]
mid=frames[len(frames)//2][1]
vm=np.nanpercentile(np.abs(np.where(solid0,np.nan,vort(mid))),99.0)
cm=plt.get_cmap("inferno").copy(); cm.set_bad("white")
pil=[]
for (t,u0,u1,u2) in frames:
fig=plt.figure(figsize=(11.2,5.7),dpi=96); ax=fig.add_subplot(111)
ax.imshow(np.abs(np.where(solid0,np.nan,vort(u0))),origin="lower",cmap=cm,vmin=0,vmax=vm,
extent=(0,Nx,0,Ny),interpolation="nearest")
o1=np.abs(np.where(solid1,np.nan,vort(u1)))[1:-1,1:-1]
ax.imshow(o1,origin="lower",cmap=cm,vmin=0,vmax=vm,
extent=(ax1+0.5,bx1-0.5,ay1+0.5,by1-0.5),interpolation="nearest")
if nested and u2 is not None:
o2=np.abs(np.where(solid2,np.nan,vort(u2)))[1:-1,1:-1]
ax.imshow(o2,origin="lower",cmap=cm,vmin=0,vmax=vm,
extent=(cx2a+0.25,cx2b-0.25,cy2a+0.25,cy2b-0.25),interpolation="nearest")
# ТОЛЬКО рамки зон (без линий сетки)
ax.add_patch(mpatches.Rectangle((0.2,0.2),Nx-0.4,Ny-0.4,fill=False,edgecolor="#4fc3f7",lw=1.6))
ax.text(1.5,Ny-4,"уровень 0: Δx (крупный)",color="#4fc3f7",fontsize=9,weight="bold")
ax.add_patch(mpatches.Rectangle((ax1,ay1),bx1-ax1,by1-ay1,fill=False,edgecolor="#aed581",lw=2.2))
ax.text(ax1+1,by1-3.5,"уровень 1: Δx/2 (тело+след)",color="#aed581",fontsize=9,weight="bold")
if nested:
ax.add_patch(mpatches.Rectangle((cx2a,cy2a),cx2b-cx2a,cy2b-cy2a,fill=False,edgecolor="#ff8a65",lw=2.2))
ax.text(cx2a+1,cy2b-3.5,"уровень 2: Δx/4 (у тела)",color="#ff8a65",fontsize=9,weight="bold")
ttl="Вложенный AMR на цилиндре: 2× (след) + 4× (у тела)" if nested else "AMR на цилиндре: одиночный блок 2×"
ax.set_title(f"{ttl} · поверх $|\\omega|$ · t={t}",fontsize=11)
ax.set_xlabel("x [lu]"); ax.set_ylabel("y [lu]")
ax.set_xlim(0,Nx); ax.set_ylim(0,Ny)
buf=_io.BytesIO(); fig.savefig(buf,format="png",bbox_inches="tight"); buf.seek(0); plt.close(fig)
pil.append(Image.open(buf).convert("RGB").convert("P",palette=Image.ADAPTIVE))
path=os.path.join(ANIM,name)
pil[0].save(path,save_all=True,append_images=pil[1:],duration=int(1000/fps),loop=0,optimize=True)
print("saved",name,len(frames),"кадров")
def analyze(res):
Fx,Fy,uy,DL=res["Fx"],res["Fy"],res["uy"],res["DL"]
n=len(uy); h=slice(n//2,n) # статистически установившаяся половина
Cd=2*Fx/(U**2*DL); Cl=2*Fy/(U**2*DL)
sig=uy[h]-uy[h].mean(); npad=8192
freqs=np.fft.rfftfreq(npad,1.0); amp=np.abs(np.fft.rfft(sig,n=npad))
fpk=freqs[1+int(np.argmax(amp[1:]))] if len(amp)>2 else 0.0
St=fpk*D0/U
return dict(Cd=float(Cd[h].mean()),Clrms=float(Cl[h].std()),St=float(St),
uyrms=float(uy[h].std()),Cd_s=Cd,Cl_s=Cl,uy=uy,freqs=freqs,amp=amp,h0=n//2)
# ===== прогон трёх версий =====
print("=== none ==="); R0=run("none")
print("=== amr2x ==="); R1=run("amr2x")
print("=== nested ==="); R2=run("nested")
save_gif(R1,"amr2x_cyl.gif"); save_gif(R2,"amr_nested_cyl.gif")
A0,A1,A2=analyze(R0),analyze(R1),analyze(R2)
LABELS=["без AMR (D=16)","AMR 2× (D=32)","AMR 2×+4× (D=64)"]
AS=[A0,A1,A2]
print("\n=== СРАВНЕНИЕ НА СОБРАННЫХ ДАННЫХ (установившийся режим) ===")
print(f"{'версия':22}{'D у тела':>9}{'St':>8}{'<Cd>':>9}{'rms Cl':>9}{'rms u_y':>9}")
for lab,DL,a in zip(LABELS,[16,32,64],AS):
print(f"{lab:22}{DL:>9}{a['St']:>8.3f}{a['Cd']:>9.3f}{a['Clrms']:>9.4f}{a['uyrms']:>9.4f}")
print(f"{'литература Re≈150':22}{'—':>9}{0.183:>8.3f}{1.33:>9.3f}{'~0.3':>9}")
print("(лит.: St≈0.18, Cd≈1.3 для цилиндра при Re≈150; Williamson 1996, Henderson 1995)")
# ===== фигура сравнения =====
np.savez(os.path.join(HERE,"_amr_probe_data.npz"),
St=np.array([a['St'] for a in AS]), Cd=np.array([a['Cd'] for a in AS]),
Clrms=np.array([a['Clrms'] for a in AS]), uyrms=np.array([a['uyrms'] for a in AS]),
Cd0=A0['Cd_s'],Cd1=A1['Cd_s'],Cd2=A2['Cd_s'],
Cl0=A0['Cl_s'],Cl1=A1['Cl_s'],Cl2=A2['Cl_s'],
uy0=R0['uy'],uy1=R1['uy'],uy2=R2['uy'])
cols=["#9e9e9e","#1f6feb","#d1495b"]
fig=plt.figure(figsize=(13.5,8.2))
gs=fig.add_gridspec(2,2,hspace=0.32,wspace=0.22)
# поле |omega| вложенного AMR
axA=fig.add_subplot(gs[0,0])
mid=R2["frames"][len(R2["frames"])//2]; vmf=np.nanpercentile(np.abs(np.where(solid0,np.nan,vort(mid[1]))),99)
cmf=plt.get_cmap("inferno").copy(); cmf.set_bad("white")
axA.imshow(np.abs(np.where(solid0,np.nan,vort(mid[1]))),origin="lower",cmap=cmf,vmin=0,vmax=vmf,
extent=(0,Nx,0,Ny),interpolation="nearest")
axA.add_patch(mpatches.Rectangle((ax1,ay1),bx1-ax1,by1-ay1,fill=False,edgecolor="#aed581",lw=1.8))
axA.add_patch(mpatches.Rectangle((cx2a,cy2a),cx2b-cx2a,cy2b-cy2a,fill=False,edgecolor="#ff8a65",lw=1.8))
axA.set_title("Вложенный AMR: $|\\omega|$ + рамки зон 2×/4×"); axA.set_xlabel("x [lu]"); axA.set_ylabel("y [lu]")
# Cl(t)
axB=fig.add_subplot(gs[0,1])
for a,lab,c in zip(AS,LABELS,cols):
axB.plot(a['Cl_s'][a['h0']:],lw=0.8,color=c,label=lab)
axB.set_title("Подъёмная сила $C_l(t)$ (зонд силы, обмен импульсом)")
axB.set_xlabel("шаг (установившийся режим)"); axB.set_ylabel("$C_l$"); axB.legend(fontsize=8); axB.grid(alpha=0.3)
# спектры u_y
axC=fig.add_subplot(gs[1,0])
for a,lab,c in zip(AS,LABELS,cols):
sp=a['amp']/a['amp'][1:].max(); axC.plot(a['freqs']*D0/U,sp,lw=1.0,color=c,label=lab)
axC.axvline(0.183,color="k",ls="--",alpha=0.6,label="лит. St≈0.18")
axC.set_xlim(0,0.6); axC.set_title("Спектр $u_y$ в следе → число Струхаля")
axC.set_xlabel("St = f·D/U"); axC.set_ylabel("нормир. амплитуда"); axC.legend(fontsize=8); axC.grid(alpha=0.3)
# столбики St и Cd
axD=fig.add_subplot(gs[1,1]); x=np.arange(3); w=0.35
axD.bar(x-w/2,[a['St'] for a in AS],w,color="#1f6feb",label="St")
axD.bar(x+w/2,[a['Cd'] for a in AS],w,color="#fb8c00",label="<Cd>")
axD.axhline(0.183,color="#1f6feb",ls="--",alpha=0.6); axD.axhline(1.33,color="#fb8c00",ls="--",alpha=0.6)
axD.set_xticks(x); axD.set_xticklabels(["без AMR","2×","2×+4×"],fontsize=9)
axD.set_title("St и <Cd> vs разрешение у тела (пунктир — лит.)"); axD.legend(fontsize=8); axD.grid(alpha=0.3,axis="y")
for i,a in enumerate(AS):
axD.text(i-w/2,a['St']+0.005,f"{a['St']:.3f}",ha="center",fontsize=7)
axD.text(i+w/2,a['Cd']+0.02,f"{a['Cd']:.2f}",ha="center",fontsize=7)
fig.suptitle("AMR на цилиндре: сравнение трёх версий на реально собранных данных зондов",fontweight="bold")
fig.savefig(os.path.join(FIGS,"11e_amr_probe_comparison.png"),dpi=110,bbox_inches="tight"); plt.close(fig)
print("saved figures/11e_amr_probe_comparison.png")
print("DONE")
+244
View File
@@ -0,0 +1,244 @@
# СЛЕДУЮЩАЯ ИТЕРАЦИЯ решателя: тот же AMR (без / 2× / вложенный 2×+4×), но граница тела
# задана ПОЛНОЦЕННЫМ SDF (знаковое поле расстояний) + интерполированное отражение Bouzidi
# (Bouzidi-Firdaouss-Lallemand 2001) — сглаживает ступенчатость криволинейной границы.
# Геометрия/Re/шаги ИДЕНТИЧНЫ _amr_anims.py — отличие ТОЛЬКО в граничном условии, поэтому
# результаты прямо сравнимы. В конце — сводное сравнение с версиями без SDF.
import sys, os, io as _io, numpy as np, matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt, matplotlib.patches as mpatches
from PIL import Image
try: sys.stdout.reconfigure(encoding="utf-8")
except Exception: pass
HERE = os.path.dirname(os.path.abspath(__file__))
ANIM = os.path.join(HERE, "anim"); os.makedirs(ANIM, exist_ok=True)
FIGS = os.path.join(HERE, "figures"); os.makedirs(FIGS, exist_ok=True)
# ===== проверенное ядро D2Q9 KBC (идентично _amr_anims.py) =====
C=np.array([[0,0],[1,0],[0,1],[-1,0],[0,-1],[1,1],[-1,1],[-1,-1],[1,-1]],float)
W=np.array([4/9,1/9,1/9,1/9,1/9,1/36,1/36,1/36,1/36]); CS2=1/3; Q=9
CX,CY=C[:,0],C[:,1]; OPP=np.array([0,3,4,1,2,7,8,5,6])
M=np.zeros((Q,Q)); M[0]=1;M[1]=CX;M[2]=CY;M[3]=3*(CX**2+CY**2)-2
M[4]=CX**2-CY**2;M[5]=CX*CY;M[6]=CX**2*CY;M[7]=CX*CY**2;M[8]=CX**2*CY**2
Dm=np.zeros((Q,Q)); Dm[4,4]=Dm[5,5]=1.0; Ps=np.linalg.inv(M)@Dm@M
def feq(rho,u):
ux=np.clip(u[0],-0.95,0.95); uy=np.clip(u[1],-0.95,0.95)
sx=np.sqrt(1+3*ux**2); sy=np.sqrt(1+3*uy**2); base=rho*(2-sx)*(2-sy)
Bx=((2*ux+sx)/(1-ux))[None]**CX[:,None,None]; By=((2*uy+sy)/(1-uy))[None]**CY[:,None,None]
return W[:,None,None]*base[None]*Bx*By
def macros(f):
r=f.sum(0); return r,np.stack([(CX[:,None,None]*f).sum(0)/r,(CY[:,None,None]*f).sum(0)/r])
def collide(f,fe,beta):
dfn=f-fe; ds=np.einsum("ij,jab->iab",Ps,dfn); dh=dfn-ds; inv=1.0/fe
num=(ds*dh*inv).sum(0); den=(dh*dh*inv).sum(0)
with np.errstate(divide="ignore",invalid="ignore"):
g=np.where(den>1e-14,1/beta-(2-1/beta)*num/den,2.0)
return f-beta*(2*ds+g[None]*dh)
def stream(f):
fs=np.empty_like(f)
for i in range(Q): fs[i]=np.roll(f[i],(int(CY[i]),int(CX[i])),(0,1))
return fs
def smoothstep(x): x=min(max(x,0.0),1.0); return x*x*(3-2*x)
def tauc(tp,r): return r*tp-(r-1)/2
def vort(u):
return (np.roll(u[1],-1,1)-np.roll(u[1],1,1))*0.5-(np.roll(u[0],-1,0)-np.roll(u[0],1,0))*0.5
def patch(pNx,pNy,ax,bx,ay,by,r):
Wx,Wy=bx-ax,by-ay; Nfx,Nfy=r*Wx+1,r*Wy+1
FX,FY=np.meshgrid(ax+np.arange(Nfx)/r, ay+np.arange(Nfy)/r)
x0=np.floor(FX).astype(int); y0=np.floor(FY).astype(int)
x1=np.minimum(x0+1,pNx-1); y1=np.minimum(y0+1,pNy-1)
return dict(Nfx=Nfx,Nfy=Nfy,ax=ax,bx=bx,ay=ay,by=by,r=r,x0=x0,y0=y0,x1=x1,y1=y1,
tx=FX-x0,ty=FY-y0,slx=slice(r,r*Wx,r),sly=slice(r,r*Wy,r))
def pint(fld,P):
return (fld[...,P["y0"],P["x0"]]*(1-P["tx"])*(1-P["ty"])+fld[...,P["y0"],P["x1"]]*P["tx"]*(1-P["ty"])
+fld[...,P["y1"],P["x0"]]*(1-P["tx"])*P["ty"]+fld[...,P["y1"],P["x1"]]*P["tx"]*P["ty"])
def ghost(pf,P,Rcf):
r,u=macros(pf); neq=pf-feq(r,u)
return feq(pint(r,P),np.stack([pint(u[0],P),pint(u[1],P)]))+Rcf*pint(neq,P)
def fill(cf,gh):
cf[:,0,:]=gh[:,0,:];cf[:,-1,:]=gh[:,-1,:];cf[:,:,0]=gh[:,:,0];cf[:,:,-1]=gh[:,:,-1]; return cf
def restrict(cf,pf,P,Rfc,fluid):
r,u=macros(cf); neq=cf-feq(r,u); slx,sly=P["slx"],P["sly"]
nv=feq(r[sly,slx],u[:,sly,slx])+Rfc*neq[:,sly,slx]
cur=pf[:,P["ay"]+1:P["by"],P["ax"]+1:P["bx"]]
pf[:,P["ay"]+1:P["by"],P["ax"]+1:P["bx"]]=np.where(fluid[None],nv,cur); return pf
# ===== ПОЛНОЦЕННЫЙ SDF + интерполированное отражение Bouzidi =====
def sdf_circle(NX,NY,ccx,ccy,R):
# знаковое расстояние до окружности: >0 жидкость, <0 внутри тела
Y,X=np.mgrid[0:NY,0:NX]; return np.sqrt((X-ccx)**2+(Y-ccy)**2)-R
def sdf_from_mask(mask):
# общий SDF из произвольной бинарной маски (для не-круглых тел), через EDT
from scipy import ndimage
din=ndimage.distance_transform_edt(mask); dout=ndimage.distance_transform_edt(~mask)
return dout-din-0.5 # >0 жидкость, <0 тело; -0.5 — поверхность между узлами
def add_walls(phi):
NY=phi.shape[0]; Y=np.arange(NY)[:,None]*np.ones((1,phi.shape[1]))
return np.minimum(phi, np.minimum(Y-0.5, (NY-1.5)-Y)) # стенки: поверхность на y=0.5 и Ny-1.5
def build_bc(solid, phi, cyl_mask):
# для каждого направления: линки жидкий->твёрдый, доля стенки q (по SDF),
# есть ли второй жидкий узел (для интерполяции), и принадлежит ли сосед телу (для силы)
fluid=~solid; bc=[]
for i in range(1,Q):
cy_,cx_=int(CY[i]),int(CX[i])
nb_solid=np.roll(solid,(-cy_,-cx_),(0,1)) # solid[x+c_i]
mask=fluid & nb_solid
cl=mask & np.roll(cyl_mask,(-cy_,-cx_),(0,1)) # сосед — тело (не стенка)
phinb=np.roll(phi,(-cy_,-cx_),(0,1))
with np.errstate(divide="ignore",invalid="ignore"):
qq=phi/(phi-phinb) # доля линка до стенки (phi=0)
q=np.where(mask, np.clip(qq,0.02,0.98), 0.5)
xff=mask & (~np.roll(solid,(cy_,cx_),(0,1))) # x_f - c_i — жидкий?
bc.append((i,int(OPP[i]),mask,q,xff,cl))
return bc
def apply_bc(f, fpost, bc):
# Bouzidi (линейный): пристеночный жидкий узел получает корректно отражённую популяцию
for (i,ib,mask,q,xff,cl) in bc:
cy_,cx_=int(CY[i]),int(CX[i])
fi=fpost[i]; fib=fpost[ib]; fiback=np.roll(fpost[i],(cy_,cx_),(0,1)) # f_i в x_f-c_i
near=mask&(q<0.5)&xff; bad=mask&(q<0.5)&(~xff); far=mask&(q>=0.5)
qn=q[near]; f[ib][near]=2*qn*fi[near]+(1-2*qn)*fiback[near]
f[ib][bad]=fi[bad] # запас: нет второго узла -> halfway
qf=q[far]; f[ib][far]=(1/(2*qf))*fi[far]+(1-1/(2*qf))*fib[far]
return f
def force_bc(fpost, f, bc):
# обмен импульсом (Mei et al. 2002) для интерполированной границы: F=sum c_i (f_i^post + f_ī^bc)
Fx=0.0; Fy=0.0
for (i,ib,mask,q,xff,cl) in bc:
s=(fpost[i]+f[ib])[cl].sum(); Fx+=CX[i]*s; Fy+=CY[i]*s
return Fx,Fy
# ===== геометрия (идентична версии без SDF) =====
Nx,Ny=150,72; D=16; cx,cy=40,36; U=0.07; Re=150.0; STEPS=8000; ramp=1000; FE=34
D0=D; nu=U*D/Re; tau0=nu/CS2+0.5; b0=1/(2*tau0); px,py=cx+3*D,cy
# коарс: цилиндр (SDF) + стенки
phi0=add_walls(sdf_circle(Nx,Ny,cx,cy,D/2)); solid0=phi0<0
cyl0=sdf_circle(Nx,Ny,cx,cy,D/2)<0
bc0=build_bc(solid0,phi0,cyl0)
# уровень1 (2×): тело+след
ax1,bx1,ay1,by1=16,118,8,64; P1=patch(Nx,Ny,ax1,bx1,ay1,by1,2)
t1=tauc(tau0,2); b1=1/(2*t1); R01=t1/(2*tau0); Rf01=1/R01
phi1=sdf_circle(P1["Nfx"],P1["Nfy"],(cx-ax1)*2,(cy-ay1)*2,D); solid1=phi1<0
bc1=build_bc(solid1,phi1,solid1); fl1=~solid0[ay1+1:by1,ax1+1:bx1]
# уровень2 (4×): у тела
cx2a,cx2b,cy2a,cy2b=26,64,18,54
P2=patch(P1["Nfx"],P1["Nfy"],(cx2a-ax1)*2,(cx2b-ax1)*2,(cy2a-ay1)*2,(cy2b-ay1)*2,2)
t2=tauc(t1,2); b2=1/(2*t2); R12=t2/(2*t1); Rf12=1/R12
phi2=sdf_circle(P2["Nfx"],P2["Nfy"],(cx-cx2a)*4,(cy-cy2a)*4,2*D); solid2=phi2<0
bc2=build_bc(solid2,phi2,solid2); fl2=np.ones((P2["by"]-P2["ay"]-1,P2["bx"]-P2["ax"]-1),bool)
onecol=np.ones((Ny,1))
print(f"[SDF] домен {Nx}x{Ny}; L1(2×) {P1['Nfx']}x{P1['Nfy']}; L2(4×) {P2['Nfx']}x{P2['Nfy']}; "
f"Re={Re} D0={D0} steps={STEPS}")
def run(mode): # "none" | "amr2x" | "nested"
use1=mode in ("amr2x","nested"); use2=(mode=="nested")
f0=feq(np.ones((Ny,Nx)),np.zeros((2,Ny,Nx)))
f1=feq(np.ones((P1["Nfy"],P1["Nfx"])),np.zeros((2,P1["Nfy"],P1["Nfx"]))) if use1 else None
f2=feq(np.ones((P2["Nfy"],P2["Nfx"])),np.zeros((2,P2["Nfy"],P2["Nfx"]))) if use2 else None
Fx=np.zeros(STEPS+1); Fy=np.zeros(STEPS+1); uy=np.zeros(STEPS+1); frames=[]
for t in range(STEPS+1):
rho,u0=macros(f0)
if not np.all(np.isfinite(u0)): print("BLEW UP",mode,t); Fx=Fx[:t];Fy=Fy[:t];uy=uy[:t]; break
pre=f0.copy(); post0=collide(f0,feq(rho,u0),b0); f0=stream(post0); f0=apply_bc(f0,post0,bc0)
if mode=="none": Fx[t],Fy[t]=force_bc(post0,f0,bc0)
uin=np.zeros((2,Ny,1)); uin[0,:,0]=U*smoothstep(t/ramp)
f0[:,1:-1,0]=feq(onecol,uin)[:,1:-1,0]; f0[:,1:-1,-1]=f0[:,1:-1,-2]
if use1:
g1o=ghost(pre,P1,R01); g1n=ghost(f0,P1,R01)
for s1 in range(2):
pre1=f1.copy(); r1,u1=macros(f1); post1=collide(f1,feq(r1,u1),b1)
f1=stream(post1); f1=apply_bc(f1,post1,bc1)
if mode=="amr2x" and s1==1: Fx[t],Fy[t]=force_bc(post1,f1,bc1)
f1=fill(f1,(1-(s1+1)/2)*g1o+((s1+1)/2)*g1n)
if use2:
g2o=ghost(pre1,P2,R12); g2n=ghost(f1,P2,R12)
for s2 in range(2):
pre2=f2.copy(); r2,u2=macros(f2); post2=collide(f2,feq(r2,u2),b2)
f2=stream(post2); f2=apply_bc(f2,post2,bc2)
if s1==1 and s2==1: Fx[t],Fy[t]=force_bc(post2,f2,bc2)
f2=fill(f2,(1-(s2+1)/2)*g2o+((s2+1)/2)*g2n)
f1=restrict(f2,f1,P2,Rf12,fl2)
f0=restrict(f1,f0,P1,Rf01,fl1)
uc=macros(f0)[1]; uy[t]=uc[1,py,px]
if use1 and t%FE==0:
frames.append((t,uc.copy(),macros(f1)[1].copy(),(macros(f2)[1].copy() if use2 else None)))
return dict(mode=mode,Fx=Fx,Fy=Fy,uy=uy,frames=frames,DL={"none":D0,"amr2x":2*D0,"nested":4*D0}[mode])
def save_gif(res,name,fps=24):
nested=(res["mode"]=="nested"); frames=res["frames"]
vm=np.nanpercentile(np.abs(np.where(solid0,np.nan,vort(frames[len(frames)//2][1]))),99.0)
cm=plt.get_cmap("inferno").copy(); cm.set_bad("white")
pil=[]
for (t,u0,u1,u2) in frames:
fig=plt.figure(figsize=(11.2,5.7),dpi=96); ax=fig.add_subplot(111)
ax.imshow(np.abs(np.where(solid0,np.nan,vort(u0))),origin="lower",cmap=cm,vmin=0,vmax=vm,
extent=(0,Nx,0,Ny),interpolation="nearest")
ax.imshow(np.abs(np.where(solid1,np.nan,vort(u1)))[1:-1,1:-1],origin="lower",cmap=cm,vmin=0,vmax=vm,
extent=(ax1+0.5,bx1-0.5,ay1+0.5,by1-0.5),interpolation="nearest")
if nested and u2 is not None:
ax.imshow(np.abs(np.where(solid2,np.nan,vort(u2)))[1:-1,1:-1],origin="lower",cmap=cm,vmin=0,vmax=vm,
extent=(cx2a+0.25,cx2b-0.25,cy2a+0.25,cy2b-0.25),interpolation="nearest")
ax.add_patch(mpatches.Rectangle((0.2,0.2),Nx-0.4,Ny-0.4,fill=False,edgecolor="#4fc3f7",lw=1.6))
ax.text(1.5,Ny-4,"уровень 0: Δx (крупный)",color="#4fc3f7",fontsize=9,weight="bold")
ax.add_patch(mpatches.Rectangle((ax1,ay1),bx1-ax1,by1-ay1,fill=False,edgecolor="#aed581",lw=2.2))
ax.text(ax1+1,by1-3.5,"уровень 1: Δx/2 (тело+след)",color="#aed581",fontsize=9,weight="bold")
if nested:
ax.add_patch(mpatches.Rectangle((cx2a,cy2a),cx2b-cx2a,cy2b-cy2a,fill=False,edgecolor="#ff8a65",lw=2.2))
ax.text(cx2a+1,cy2b-3.5,"уровень 2: Δx/4 (у тела)",color="#ff8a65",fontsize=9,weight="bold")
ttl="Вложенный AMR + SDF: 2×(след)+4×(тело)" if nested else "AMR + SDF: одиночный блок 2×"
ax.set_title(f"{ttl} · граница тела по SDF (Bouzidi) · поверх $|\\omega|$ · t={t}",fontsize=11)
ax.set_xlabel("x [lu]"); ax.set_ylabel("y [lu]"); ax.set_xlim(0,Nx); ax.set_ylim(0,Ny)
buf=_io.BytesIO(); fig.savefig(buf,format="png",bbox_inches="tight"); buf.seek(0); plt.close(fig)
pil.append(Image.open(buf).convert("RGB").convert("P",palette=Image.ADAPTIVE))
pil[0].save(os.path.join(ANIM,name),save_all=True,append_images=pil[1:],duration=int(1000/fps),loop=0,optimize=True)
print("saved",name,len(frames),"кадров")
def analyze(res):
Fx,Fy,uy,DL=res["Fx"],res["Fy"],res["uy"],res["DL"]
n=len(uy); h=slice(n//2,n); Cd=2*Fx/(U**2*DL); Cl=2*Fy/(U**2*DL)
sig=uy[h]-uy[h].mean(); npad=8192
freqs=np.fft.rfftfreq(npad,1.0); amp=np.abs(np.fft.rfft(sig,n=npad))
St=(freqs[1+int(np.argmax(amp[1:]))] if len(amp)>2 else 0.0)*D0/U
return dict(Cd=float(Cd[h].mean()),Clrms=float(Cl[h].std()),St=float(St),uyrms=float(uy[h].std()),
Cl_s=Cl,uy=uy,freqs=freqs,amp=amp,h0=n//2)
print("=== SDF none ==="); R0=run("none")
print("=== SDF amr2x ==="); R1=run("amr2x")
print("=== SDF nested ==="); R2=run("nested")
save_gif(R1,"amr2x_cyl_sdf.gif"); save_gif(R2,"amr_nested_cyl_sdf.gif")
AS=[analyze(R0),analyze(R1),analyze(R2)]; LAB=["без AMR (D=16)","AMR 2× (D=32)","AMR 2×+4× (D=64)"]
print("\n=== SDF: СРАВНЕНИЕ НА СОБРАННЫХ ДАННЫХ ===")
print(f"{'версия':22}{'D у тела':>9}{'St':>8}{'<Cd>':>9}{'rms Cl':>9}{'rms u_y':>9}")
for lab,DL,a in zip(LAB,[16,32,64],AS):
print(f"{lab:22}{DL:>9}{a['St']:>8.3f}{a['Cd']:>9.3f}{a['Clrms']:>9.4f}{a['uyrms']:>9.4f}")
np.savez(os.path.join(HERE,"_amr_probe_data_sdf.npz"),
St=np.array([a['St'] for a in AS]),Cd=np.array([a['Cd'] for a in AS]),
Clrms=np.array([a['Clrms'] for a in AS]),uyrms=np.array([a['uyrms'] for a in AS]))
# ===== сводное сравнение SDF vs без SDF (если есть данные без SDF) =====
nosdf_path=os.path.join(HERE,"_amr_probe_data.npz")
if os.path.exists(nosdf_path) and ("St" in np.load(nosdf_path).files):
N=np.load(nosdf_path); St_n,Cd_n,Cl_n=N["St"],N["Cd"],N["Clrms"]
St_s=np.array([a['St'] for a in AS]); Cd_s=np.array([a['Cd'] for a in AS]); Cl_s=np.array([a['Clrms'] for a in AS])
print("\n=== СВОДНО: SDF vs без SDF ===")
print(f"{'версия':14}{'St(noSDF)':>11}{'St(SDF)':>9}{'Cd(noSDF)':>11}{'Cd(SDF)':>9}")
for k,lab in enumerate(["без AMR","2×","2×+4×"]):
print(f"{lab:14}{St_n[k]:>11.3f}{St_s[k]:>9.3f}{Cd_n[k]:>11.3f}{Cd_s[k]:>9.3f}")
print(f"{'лит. Re≈150':14}{0.183:>11.3f}{0.183:>9.3f}{1.330:>11.3f}{1.330:>9.3f}")
x=np.arange(3); w=0.38
fig,axs=plt.subplots(1,2,figsize=(13.5,4.6))
axs[0].bar(x-w/2,Cd_n,w,color="#9e9e9e",label="без SDF (ступенч.)")
axs[0].bar(x+w/2,Cd_s,w,color="#1f6feb",label="с SDF (Bouzidi)")
axs[0].axhline(1.33,color="k",ls="--",alpha=0.6,label="лит. Cd≈1.33")
axs[0].set_xticks(x); axs[0].set_xticklabels(["без AMR","2×","2×+4×"]); axs[0].set_ylabel("<Cd>")
axs[0].set_title("Сопротивление: SDF убирает завышение от ступенчатой границы"); axs[0].legend(fontsize=8); axs[0].grid(alpha=0.3,axis="y")
axs[1].bar(x-w/2,St_n,w,color="#9e9e9e",label="без SDF")
axs[1].bar(x+w/2,St_s,w,color="#1f6feb",label="с SDF")
axs[1].axhline(0.183,color="k",ls="--",alpha=0.6,label="лит. St≈0.18")
axs[1].set_xticks(x); axs[1].set_xticklabels(["без AMR","2×","2×+4×"]); axs[1].set_ylabel("St")
axs[1].set_title("Число Струхаля"); axs[1].legend(fontsize=8); axs[1].grid(alpha=0.3,axis="y")
fig.suptitle("Цилиндр: SDF (Bouzidi) против ступенчатого bounce-back, на реально собранных данных",fontweight="bold")
fig.savefig(os.path.join(FIGS,"11f_sdf_vs_nosdf.png"),dpi=110,bbox_inches="tight"); plt.close(fig)
print("saved figures/11f_sdf_vs_nosdf.png")
else:
print("\n(нет свежего _amr_probe_data.npz со скалярами — сначала запусти _amr_anims.py)")
print("DONE")
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
# ОСНОВНОЙ GPU-решатель (без SDF): цилиндр, три версии (без AMR / 2× / вложенный 2×+4×).
# Математика идентична CPU-прототипу _amr_anims.py; backend — CuPy (GPU) с откатом на numpy.
# Запуск: python amr_cylinder_gpu.py (fp32 по умолчанию)
# AMR_FP64=1 python amr_cylinder_gpu.py (двойная точность)
# AMR_STEPS=2000 python amr_cylinder_gpu.py (короткий прогон)
import os, time, numpy as np
import amr_gpu_core as core
HERE = os.path.dirname(os.path.abspath(__file__))
ANIM = os.path.join(HERE, "anim"); os.makedirs(ANIM, exist_ok=True)
FIGS = os.path.join(HERE, "figures"); os.makedirs(FIGS, exist_ok=True)
print(f"[ОСНОВНОЙ · без SDF] backend={core.BACKEND}; домен {core.Nx}x{core.Ny}; steps={core.STEPS}; "
f"L1(2×) {core.P1['Nfx']}x{core.P1['Nfy']}; L2(4×) {core.P2['Nfx']}x{core.P2['Nfy']}")
t0 = time.perf_counter()
R0 = core.run("none", False); print(" none :", round(time.perf_counter()-t0, 1), "s")
R1 = core.run("amr2x", False); print(" amr2x :", round(time.perf_counter()-t0, 1), "s")
R2 = core.run("nested", False); print(" nested :", round(time.perf_counter()-t0, 1), "s")
core.save_gif(R1, "amr2x_cyl.gif", ANIM, " (без SDF)")
core.save_gif(R2, "amr_nested_cyl.gif", ANIM, " (без SDF)")
AS = [core.analyze(R0), core.analyze(R1), core.analyze(R2)]
core.print_table(AS, "БЕЗ SDF — сравнение на реально собранных данных (установившийся режим)")
np.savez(os.path.join(HERE, "_amr_probe_data.npz"),
St=np.array([a["St"] for a in AS]), Cd=np.array([a["Cd"] for a in AS]),
Clrms=np.array([a["Clrms"] for a in AS]), uyrms=np.array([a["uyrms"] for a in AS]),
Cl0=AS[0]["Cl_s"], Cl1=AS[1]["Cl_s"], Cl2=AS[2]["Cl_s"],
uy0=AS[0]["uy"], uy1=AS[1]["uy"], uy2=AS[2]["uy"])
core.probe_figure(AS, R2, os.path.join(FIGS, "11e_amr_probe_comparison.png"),
"AMR на цилиндре (без SDF): сравнение трёх версий на данных зондов")
print("DONE (без SDF)")
+75
View File
@@ -0,0 +1,75 @@
# ОСНОВНОЙ GPU-решатель (с SDF + Bouzidi): цилиндр, три версии (без AMR / 2× / 2×+4×).
# Граница тела — по знаковому полю расстояний + интерполированное отражение (Bouzidi);
# стенки канала — тот же механизм с q=0.5 (обычный halfway). Математика идентична _amr_anims_sdf.py.
# backend — CuPy (GPU) с откатом на numpy. В конце — сводное сравнение с версией без SDF.
# Запуск: python amr_cylinder_sdf_gpu.py (после amr_cylinder_gpu.py — для сводного сравнения)
import os, time, numpy as np
import amr_gpu_core as core
HERE = os.path.dirname(os.path.abspath(__file__))
ANIM = os.path.join(HERE, "anim"); os.makedirs(ANIM, exist_ok=True)
FIGS = os.path.join(HERE, "figures"); os.makedirs(FIGS, exist_ok=True)
print(f"[ОСНОВНОЙ · SDF+Bouzidi] backend={core.BACKEND}; домен {core.Nx}x{core.Ny}; steps={core.STEPS}; "
f"L1(2×) {core.P1['Nfx']}x{core.P1['Nfy']}; L2(4×) {core.P2['Nfx']}x{core.P2['Nfy']}")
def make_progress(label, width=32, every=0.1):
"""Колбэк-индикатор: рисует ASCII-шкалу по выполненным шагам (t из STEPS), обновляется на месте."""
st = {"t0": time.perf_counter(), "last": 0.0}
def cb(t, total):
now = time.perf_counter(); done = (t >= total)
if not done and (now - st["last"] < every): # троттлинг, чтобы не спамить консоль
return
st["last"] = now; frac = (t + 1) / (total + 1)
filled = int(round(width * frac)); bar = "#" * filled + "-" * (width - filled)
el = now - st["t0"]; eta = (el / frac - el) if frac > 0 else 0.0
print(f"\r {label:7}[{bar}] {frac*100:5.1f}% ({t}/{total}) {el:5.1f}s ETA {eta:5.1f}s",
end=("\n" if done else ""), flush=True)
return cb
t0 = time.perf_counter()
R0 = core.run("none", True, make_progress("none")); print(" none :", round(time.perf_counter()-t0, 1), "s")
R1 = core.run("amr2x", True, make_progress("amr2x")); print(" amr2x :", round(time.perf_counter()-t0, 1), "s")
R2 = core.run("nested", True, make_progress("nested")); print(" nested :", round(time.perf_counter()-t0, 1), "s")
core.save_gif(R1, "amr2x_cyl_sdf.gif", ANIM, " + SDF (Bouzidi)")
core.save_gif(R2, "amr_nested_cyl_sdf.gif", ANIM, " + SDF (Bouzidi)")
AS = [core.analyze(R0), core.analyze(R1), core.analyze(R2)]
core.print_table(AS, "С SDF — сравнение на реально собранных данных (установившийся режим)")
np.savez(os.path.join(HERE, "_amr_probe_data_sdf.npz"),
St=np.array([a["St"] for a in AS]), Cd=np.array([a["Cd"] for a in AS]),
Clrms=np.array([a["Clrms"] for a in AS]), uyrms=np.array([a["uyrms"] for a in AS]),
Cl0=AS[0]["Cl_s"], Cl1=AS[1]["Cl_s"], Cl2=AS[2]["Cl_s"],
uy0=AS[0]["uy"], uy1=AS[1]["uy"], uy2=AS[2]["uy"])
core.probe_figure(AS, R2, os.path.join(FIGS, "11f_amr_sdf_probe_comparison.png"),
"AMR на цилиндре (SDF+Bouzidi): сравнение трёх версий на данных зондов")
# ---- сводное сравнение: SDF против без-SDF (если есть свежие данные без SDF) ----
nosdf = os.path.join(HERE, "_amr_probe_data.npz")
if os.path.exists(nosdf) and ("St" in np.load(nosdf).files):
import matplotlib; matplotlib.use("Agg"); import matplotlib.pyplot as plt
N = np.load(nosdf); Stn, Cdn = N["St"], N["Cd"]
Sts = np.array([a["St"] for a in AS]); Cds = np.array([a["Cd"] for a in AS])
print("\n=== СВОДНО: SDF vs без SDF (Cd, St) ===")
print(f"{'версия':14}{'St(noSDF)':>11}{'St(SDF)':>9}{'Cd(noSDF)':>11}{'Cd(SDF)':>9}")
for k, lab in enumerate(["без AMR", "2×", "2×+4×"]):
print(f"{lab:14}{Stn[k]:>11.3f}{Sts[k]:>9.3f}{Cdn[k]:>11.3f}{Cds[k]:>9.3f}")
print(f"{'лит. Re≈150':14}{0.183:>11.3f}{0.183:>9.3f}{1.330:>11.3f}{1.330:>9.3f}")
x = np.arange(3); w = 0.38
fig, axs = plt.subplots(1, 2, figsize=(13.5, 4.6))
axs[0].bar(x-w/2, Cdn, w, color="#9e9e9e", label="без SDF (ступенч.)")
axs[0].bar(x+w/2, Cds, w, color="#1f6feb", label="с SDF (Bouzidi)")
axs[0].axhline(1.33, color="k", ls="--", alpha=0.6, label="лит. Cd≈1.33")
axs[0].set_xticks(x); axs[0].set_xticklabels(["без AMR", "2×", "2×+4×"]); axs[0].set_ylabel("<Cd>")
axs[0].set_title("Сопротивление: SDF убирает завышение от ступенчатой границы")
axs[0].legend(fontsize=8); axs[0].grid(alpha=0.3, axis="y")
axs[1].bar(x-w/2, Stn, w, color="#9e9e9e", label="без SDF"); axs[1].bar(x+w/2, Sts, w, color="#1f6feb", label="с SDF")
axs[1].axhline(0.183, color="k", ls="--", alpha=0.6, label="лит. St≈0.18")
axs[1].set_xticks(x); axs[1].set_xticklabels(["без AMR", "2×", "2×+4×"]); axs[1].set_ylabel("St")
axs[1].set_title("Число Струхаля"); axs[1].legend(fontsize=8); axs[1].grid(alpha=0.3, axis="y")
fig.suptitle("Цилиндр: SDF (Bouzidi) против ступенчатого bounce-back — на реальных данных", fontweight="bold")
fig.savefig(os.path.join(FIGS, "11g_sdf_vs_nosdf.png"), dpi=110, bbox_inches="tight"); plt.close(fig)
print("saved 11g_sdf_vs_nosdf.png")
else:
print("\n(нет свежего _amr_probe_data.npz — сначала запусти amr_cylinder_gpu.py)")
print("DONE (SDF)")
+309
View File
@@ -0,0 +1,309 @@
# Общее GPU-ядро для основных AMR-решателей (D2Q9 KBC) на цилиндре.
# Математика ДОСЛОВНО та же, что в проверенных CPU-прототипах (_amr_anims*.py):
# - энтропийное равновесие (product-form), KBC-N1 столкновение f<-f-β(2Δs+γΔh),
# - AMR-связка τ_f=2τ_c-½, R_cf=τ_f/(2τ_c), m подшагов, временна́я интерполяция,
# - сила по Mei (обмен импульсом), вход/выход/стенки, опционально SDF+Bouzidi.
# Оптимизация под GPU (CuPy): всё векторно на device; проектор через matmul; ГУ и силы
# через where (без gather/scatter); БЕЗ пер-шаговых синхронизаций host↔device.
import os, io as _io, numpy as np
# ---- backend: CuPy (GPU) с откатом на numpy (CPU) ----
try:
import cupy as cp
_ = (cp.zeros(2) + 1).sum() # реальная операция на device — ловит "cupy есть, GPU нет"
xp = cp; GPU = True
except Exception:
import numpy as cp # noqa
xp = np; GPU = False
def to_cpu(a):
return cp.asnumpy(a) if GPU else np.asarray(a)
DTYPE = xp.float64 if os.environ.get("AMR_FP64") else xp.float32
GREL = 1e-8 # ОТНОСИТЕЛЬНЫЙ порог вырожденности знаменателя γ (доля от ‖Δ‖²); см. solver_2x_sdf/backend.py.
# Прежний абсолютный порог (1e-6 в fp32) срабатывал на большинстве узлов и подменял KBC на LBGK.
BACKEND = f"{'CuPy/GPU' if GPU else 'numpy/CPU'} dtype={'float64' if DTYPE == xp.float64 else 'float32'}"
# ---- решётка D2Q9 ----
Cx = [0, 1, 0, -1, 0, 1, -1, -1, 1] # python-инты для roll/индексации
Cy = [0, 0, 1, 0, -1, 1, 1, -1, -1]
OPP = [0, 3, 4, 1, 2, 7, 8, 5, 6]
Q = 9
_W = np.array([4/9, 1/9, 1/9, 1/9, 1/9, 1/36, 1/36, 1/36, 1/36])
_CXn = np.array(Cx, float); _CYn = np.array(Cy, float)
# моментный базис и проектор на сдвиг {cx²−cy², cxcy} (KBC-N1) — считаем в numpy, грузим на device
_M = np.zeros((Q, Q)); _M[0] = 1; _M[1] = _CXn; _M[2] = _CYn; _M[3] = 3*(_CXn**2+_CYn**2)-2
_M[4] = _CXn**2-_CYn**2; _M[5] = _CXn*_CYn; _M[6] = _CXn**2*_CYn; _M[7] = _CXn*_CYn**2; _M[8] = _CXn**2*_CYn**2
_Dm = np.zeros((Q, Q)); _Dm[4, 4] = _Dm[5, 5] = 1.0
_Psn = np.linalg.inv(_M) @ _Dm @ _M
# device-константы
Wa = xp.asarray(_W, dtype=DTYPE)
CXa = xp.asarray(_CXn, dtype=DTYPE); CYa = xp.asarray(_CYn, dtype=DTYPE)
Ps = xp.asarray(_Psn, dtype=DTYPE)
CS2 = 1.0/3.0
# ---- ядро LBM/KBC (идентичная математика) ----
def feq(rho, u):
ux = xp.clip(u[0], -0.95, 0.95); uy = xp.clip(u[1], -0.95, 0.95)
sx = xp.sqrt(1 + 3*ux*ux); sy = xp.sqrt(1 + 3*uy*uy); base = rho*(2-sx)*(2-sy)
Bx = ((2*ux+sx)/(1-ux))[None]**CXa[:, None, None]
By = ((2*uy+sy)/(1-uy))[None]**CYa[:, None, None]
return Wa[:, None, None]*base[None]*Bx*By
def macros(f):
r = f.sum(0)
return r, xp.stack([(CXa[:, None, None]*f).sum(0)/r, (CYa[:, None, None]*f).sum(0)/r])
def collide(f, fe, beta):
dfn = f - fe
ds = (Ps @ dfn.reshape(Q, -1)).reshape(dfn.shape) # проектор на сдвиг (matmul, GPU-friendly)
dh = dfn - ds; inv = 1.0/fe
num = (ds*dh*inv).sum(0); den = (dh*dh*inv).sum(0)
nrm = (dfn*dfn*inv).sum(0) # ‖Δ‖² — масштаб неравновесия узла
ok = den > GREL*nrm
den_safe = xp.where(ok, den, xp.asarray(1.0, DTYPE)) # избегаем 0/0 (обе ветки where считаются)
g = xp.where(ok, 1/beta - (2 - 1/beta)*num/den_safe, xp.asarray(2.0, DTYPE))
return f - beta*(2*ds + g[None]*dh)
def stream(f):
fs = xp.empty_like(f)
for i in range(Q):
fs[i] = xp.roll(f[i], (Cy[i], Cx[i]), (0, 1))
return fs
# ---- AMR-связка ----
def patch(pNx, pNy, ax, bx, ay, by, r):
Wx, Wy = bx-ax, by-ay; Nfx, Nfy = r*Wx+1, r*Wy+1
FX, FY = np.meshgrid(ax+np.arange(Nfx)/r, ay+np.arange(Nfy)/r)
x0 = np.floor(FX).astype(np.int64); y0 = np.floor(FY).astype(np.int64)
x1 = np.minimum(x0+1, pNx-1); y1 = np.minimum(y0+1, pNy-1)
return dict(Nfx=Nfx, Nfy=Nfy, ax=ax, bx=bx, ay=ay, by=by, r=r,
x0=xp.asarray(x0), y0=xp.asarray(y0), x1=xp.asarray(x1), y1=xp.asarray(y1),
tx=xp.asarray(FX-x0, DTYPE), ty=xp.asarray(FY-y0, DTYPE),
slx=slice(r, r*Wx, r), sly=slice(r, r*Wy, r))
def pint(fld, P):
return (fld[..., P["y0"], P["x0"]]*(1-P["tx"])*(1-P["ty"]) + fld[..., P["y0"], P["x1"]]*P["tx"]*(1-P["ty"])
+ fld[..., P["y1"], P["x0"]]*(1-P["tx"])*P["ty"] + fld[..., P["y1"], P["x1"]]*P["tx"]*P["ty"])
def ghost(pf, P, Rcf):
r, u = macros(pf); neq = pf - feq(r, u)
return feq(pint(r, P), xp.stack([pint(u[0], P), pint(u[1], P)])) + Rcf*pint(neq, P)
def fill(cf, gh):
cf[:, 0, :] = gh[:, 0, :]; cf[:, -1, :] = gh[:, -1, :]; cf[:, :, 0] = gh[:, :, 0]; cf[:, :, -1] = gh[:, :, -1]
return cf
def restrict(cf, pf, P, Rfc, fluid):
r, u = macros(cf); neq = cf - feq(r, u); slx, sly = P["slx"], P["sly"]
nv = feq(r[sly, slx], u[:, sly, slx]) + Rfc*neq[:, sly, slx]
cur = pf[:, P["ay"]+1:P["by"], P["ax"]+1:P["bx"]]
pf[:, P["ay"]+1:P["by"], P["ax"]+1:P["bx"]] = xp.where(fluid[None], nv, cur)
return pf
# ---- граница: (1) узловой bounce-back (без SDF); (2) SDF+Bouzidi ----
def bb_set(post, pre, solid): # узлы тела <- развёрнутые до-столкновения (как в CPU-версии)
for i in range(Q):
post[i] = xp.where(solid, pre[OPP[i]], post[i])
return post
def force_on(fpost, cyl, fluid): # Mei для узловой границы: F=Σ c_i(f_i+f_ī) по линкам жидк.->тело
Fx = 0.0; Fy = 0.0
for i in range(1, Q):
nb = xp.roll(cyl, (-Cy[i], -Cx[i]), (0, 1)); link = fluid & nb
s = xp.where(link, fpost[i] + fpost[OPP[i]], xp.asarray(0.0, DTYPE)).sum()
Fx = Fx + Cx[i]*s; Fy = Fy + Cy[i]*s
return Fx, Fy
def build_bc(solid_np, phi_np, cyl_np): # SDF-границу строим на host один раз, переносим на device
fluid = ~solid_np; bc = []
for i in range(1, Q):
nb_solid = np.roll(solid_np, (-Cy[i], -Cx[i]), (0, 1)); mask = fluid & nb_solid
cl = mask & np.roll(cyl_np, (-Cy[i], -Cx[i]), (0, 1))
phinb = np.roll(phi_np, (-Cy[i], -Cx[i]), (0, 1))
with np.errstate(divide="ignore", invalid="ignore"):
qq = phi_np/(phi_np - phinb)
q = np.where(mask, np.clip(qq, 0.02, 0.98), 0.5)
xff = mask & (~np.roll(solid_np, (Cy[i], Cx[i]), (0, 1)))
bc.append((i, OPP[i], xp.asarray(mask), xp.asarray(q, DTYPE), xp.asarray(xff), xp.asarray(cl)))
return bc
def apply_bc(f, fpost, bc): # Bouzidi (линейный), полностью через where (GPU)
for (i, ib, mask, q, xff, cl) in bc:
fi = fpost[i]; fib = fpost[ib]; fiback = xp.roll(fpost[i], (Cy[i], Cx[i]), (0, 1))
near = mask & (q < 0.5) & xff; bad = mask & (q < 0.5) & (~xff); far = mask & (q >= 0.5)
new = f[ib]
new = xp.where(near, 2*q*fi + (1-2*q)*fiback, new)
new = xp.where(far, (1/(2*q))*fi + (1-1/(2*q))*fib, new)
new = xp.where(bad, fi, new)
f[ib] = new
return f
def force_bc(fpost, f, bc): # Mei для интерполированной границы
Fx = 0.0; Fy = 0.0
for (i, ib, mask, q, xff, cl) in bc:
s = xp.where(cl, fpost[i] + f[ib], xp.asarray(0.0, DTYPE)).sum()
Fx = Fx + Cx[i]*s; Fy = Fy + Cy[i]*s
return Fx, Fy
# ---- геометрия (ИДЕНТИЧНА CPU-версиям) ----
Nx, Ny, D, cx, cy = 150, 72, 16, 40, 36
U, Re, STEPS, ramp, FE = 0.07, 150.0, int(os.environ.get("AMR_STEPS", 8000)), 1000, 34
D0 = D; nu = U*D/Re; tau0 = nu/CS2 + 0.5
ax1, bx1, ay1, by1 = 16, 118, 8, 64
cx2a, cx2b, cy2a, cy2b = 26, 64, 18, 54
px, py = cx + 3*D, cy
def _cmask(NX, NY, ccx, ccy, R):
Y, X = np.meshgrid(np.arange(NY), np.arange(NX), indexing="ij"); return (X-ccx)**2 + (Y-ccy)**2 <= R*R
def _csdf(NX, NY, ccx, ccy, R):
Y, X = np.meshgrid(np.arange(NY), np.arange(NX), indexing="ij"); return np.sqrt((X-ccx)**2.0 + (Y-ccy)**2.0) - R
# host-маски/SDF
_walls = np.zeros((Ny, Nx), bool); _walls[0, :] = True; _walls[-1, :] = True
cyl0_np = _cmask(Nx, Ny, cx, cy, D/2); solid0_np = cyl0_np | _walls
_Yg = np.arange(Ny)[:, None]*np.ones((1, Nx))
phi0_np = np.minimum(_csdf(Nx, Ny, cx, cy, D/2), np.minimum(_Yg-0.5, (Ny-1.5)-_Yg))
P1 = patch(Nx, Ny, ax1, bx1, ay1, by1, 2); P2 = patch(P1["Nfx"], P1["Nfy"], (cx2a-ax1)*2, (cx2b-ax1)*2, (cy2a-ay1)*2, (cy2b-ay1)*2, 2)
solid1_np = _cmask(P1["Nfx"], P1["Nfy"], (cx-ax1)*2, (cy-ay1)*2, D); phi1_np = _csdf(P1["Nfx"], P1["Nfy"], (cx-ax1)*2, (cy-ay1)*2, D)
solid2_np = _cmask(P2["Nfx"], P2["Nfy"], (cx-cx2a)*4, (cy-cy2a)*4, 2*D); phi2_np = _csdf(P2["Nfx"], P2["Nfy"], (cx-cx2a)*4, (cy-cy2a)*4, 2*D)
# device-маски (без SDF)
g_solid0 = xp.asarray(solid0_np); g_cyl0 = xp.asarray(cyl0_np); g_fl0 = ~g_solid0
g_solid1 = xp.asarray(solid1_np); g_fl1 = ~g_solid1
g_solid2 = xp.asarray(solid2_np); g_fl2 = ~g_solid2
# SDF-ГУ
BC0 = build_bc(solid0_np, phi0_np, cyl0_np); BC1 = build_bc(solid1_np, phi1_np, solid1_np); BC2 = build_bc(solid2_np, phi2_np, solid2_np)
# рестрикция: жидкие узлы
fl1 = xp.asarray(~solid0_np[ay1+1:by1, ax1+1:bx1])
fl2 = xp.ones((P2["by"]-P2["ay"]-1, P2["bx"]-P2["ax"]-1), bool); fl2 = xp.asarray(fl2)
# τ / масштабы
t1 = 2*tau0 - 0.5; t2 = 2*t1 - 0.5
b0 = 1/(2*tau0); b1 = 1/(2*t1); b2 = 1/(2*t2)
R01 = t1/(2*tau0); Rf01 = 1/R01; R12 = t2/(2*t1); Rf12 = 1/R12
def smoothstep(x):
x = min(max(x, 0.0), 1.0); return x*x*(3-2*x)
def run(mode, use_sdf, progress=None):
"""mode: none|amr2x|nested. Возвращает {Fx,Fy,uy (на host), frames (host), DL, mode}.
progress(t, STEPS) — необязательный колбэк прогресса (вызывается каждый шаг)."""
use1 = mode in ("amr2x", "nested"); use2 = (mode == "nested")
DL = {"none": D0, "amr2x": 2*D0, "nested": 4*D0}[mode]
f0 = feq(xp.ones((Ny, Nx), DTYPE), xp.zeros((2, Ny, Nx), DTYPE))
f1 = feq(xp.ones((P1["Nfy"], P1["Nfx"]), DTYPE), xp.zeros((2, P1["Nfy"], P1["Nfx"]), DTYPE)) if use1 else None
f2 = feq(xp.ones((P2["Nfy"], P2["Nfx"]), DTYPE), xp.zeros((2, P2["Nfy"], P2["Nfx"]), DTYPE)) if use2 else None
Fx = xp.zeros(STEPS+1, DTYPE); Fy = xp.zeros(STEPS+1, DTYPE); uy = xp.zeros(STEPS+1, DTYPE)
onecol = xp.ones((Ny, 1), DTYPE); frames = []
for t in range(STEPS+1):
rho, u0 = macros(f0)
if t % 500 == 0 and not bool(xp.isfinite(rho).all()):
print("BLEW UP", mode, t); Fx = Fx[:t]; Fy = Fy[:t]; uy = uy[:t]; break
pre = f0.copy(); post0 = collide(f0, feq(rho, u0), b0)
if not use_sdf:
post0b = bb_set(post0.copy(), pre, g_solid0); f0 = stream(post0b)
else:
f0 = stream(post0); f0 = apply_bc(f0, post0, BC0)
if mode == "none":
Fx[t], Fy[t] = (force_bc(post0, f0, BC0) if use_sdf else force_on(post0, g_cyl0, g_fl0))
uin = xp.zeros((2, Ny, 1), DTYPE); uin[0, :, 0] = U*smoothstep(t/ramp)
f0[:, 1:-1, 0] = feq(onecol, uin)[:, 1:-1, 0]; f0[:, 1:-1, -1] = f0[:, 1:-1, -2]
if use1:
g1o = ghost(pre, P1, R01); g1n = ghost(f0, P1, R01)
for s1 in range(2):
pre1 = f1.copy(); r1, u1 = macros(f1); post1 = collide(f1, feq(r1, u1), b1)
if not use_sdf:
f1 = stream(bb_set(post1.copy(), pre1, g_solid1))
else:
f1 = stream(post1); f1 = apply_bc(f1, post1, BC1)
if mode == "amr2x" and s1 == 1:
Fx[t], Fy[t] = (force_bc(post1, f1, BC1) if use_sdf else force_on(post1, g_solid1, g_fl1))
f1 = fill(f1, (1-(s1+1)/2)*g1o + ((s1+1)/2)*g1n)
if use2:
g2o = ghost(pre1, P2, R12); g2n = ghost(f1, P2, R12)
for s2 in range(2):
pre2 = f2.copy(); r2, u2 = macros(f2); post2 = collide(f2, feq(r2, u2), b2)
if not use_sdf:
f2 = stream(bb_set(post2.copy(), pre2, g_solid2))
else:
f2 = stream(post2); f2 = apply_bc(f2, post2, BC2)
if s1 == 1 and s2 == 1:
Fx[t], Fy[t] = (force_bc(post2, f2, BC2) if use_sdf else force_on(post2, g_solid2, g_fl2))
f2 = fill(f2, (1-(s2+1)/2)*g2o + ((s2+1)/2)*g2n)
f1 = restrict(f2, f1, P2, Rf12, fl2)
f0 = restrict(f1, f0, P1, Rf01, fl1)
col = f0[:, py, px]; uy[t] = (CYa*col).sum()/col.sum() # зонд следа (без full-macros)
if progress is not None: progress(t, STEPS)
if use1 and t % FE == 0:
frames.append((t, to_cpu(macros(f0)[1]), to_cpu(macros(f1)[1]),
(to_cpu(macros(f2)[1]) if use2 else None)))
return dict(mode=mode, DL=DL, Fx=to_cpu(Fx), Fy=to_cpu(Fy), uy=to_cpu(uy), frames=frames)
# ---- анализ (на host: FFT малых рядов) ----
def analyze(res):
Fx, Fy, uy, DL = res["Fx"], res["Fy"], res["uy"], res["DL"]
n = len(uy); h = slice(n//2, n); Cd = 2*Fx/(U**2*DL); Cl = 2*Fy/(U**2*DL)
sig = uy[h] - uy[h].mean(); npad = 8192
freqs = np.fft.rfftfreq(npad, 1.0); amp = np.abs(np.fft.rfft(sig, n=npad))
St = (freqs[1+int(np.argmax(amp[1:]))] if len(amp) > 2 else 0.0)*D0/U
return dict(Cd=float(Cd[h].mean()), Clrms=float(Cl[h].std()), St=float(St), uyrms=float(uy[h].std()),
Cl_s=Cl, uy=uy, freqs=freqs, amp=amp, h0=n//2)
# ---- визуализация (matplotlib на CPU; кадры уже host) ----
def _vort_np(u):
return (np.roll(u[1], -1, 1)-np.roll(u[1], 1, 1))*0.5 - (np.roll(u[0], -1, 0)-np.roll(u[0], 1, 0))*0.5
def save_gif(res, name, anim_dir, sdf_tag, fps=24):
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt, matplotlib.patches as mpatches
from PIL import Image
nested = (res["mode"] == "nested"); frames = res["frames"]
vm = np.nanpercentile(np.abs(np.where(solid0_np, np.nan, _vort_np(frames[len(frames)//2][1]))), 99.0)
cm = plt.get_cmap("inferno").copy(); cm.set_bad("white"); pil = []
for (t, u0, u1, u2) in frames:
fig = plt.figure(figsize=(11.2, 5.7), dpi=96); ax = fig.add_subplot(111)
ax.imshow(np.abs(np.where(solid0_np, np.nan, _vort_np(u0))), origin="lower", cmap=cm, vmin=0, vmax=vm,
extent=(0, Nx, 0, Ny), interpolation="nearest")
ax.imshow(np.abs(np.where(solid1_np, np.nan, _vort_np(u1)))[1:-1, 1:-1], origin="lower", cmap=cm, vmin=0, vmax=vm,
extent=(ax1+0.5, bx1-0.5, ay1+0.5, by1-0.5), interpolation="nearest")
if nested and u2 is not None:
ax.imshow(np.abs(np.where(solid2_np, np.nan, _vort_np(u2)))[1:-1, 1:-1], origin="lower", cmap=cm, vmin=0, vmax=vm,
extent=(cx2a+0.25, cx2b-0.25, cy2a+0.25, cy2b-0.25), interpolation="nearest")
ax.add_patch(mpatches.Rectangle((0.2, 0.2), Nx-0.4, Ny-0.4, fill=False, edgecolor="#4fc3f7", lw=1.6))
ax.text(1.5, Ny-4, "уровень 0: Δx (крупный)", color="#4fc3f7", fontsize=9, weight="bold")
ax.add_patch(mpatches.Rectangle((ax1, ay1), bx1-ax1, by1-ay1, fill=False, edgecolor="#aed581", lw=2.2))
ax.text(ax1+1, by1-3.5, "уровень 1: Δx/2 (тело+след)", color="#aed581", fontsize=9, weight="bold")
if nested:
ax.add_patch(mpatches.Rectangle((cx2a, cy2a), cx2b-cx2a, cy2b-cy2a, fill=False, edgecolor="#ff8a65", lw=2.2))
ax.text(cx2a+1, cy2b-3.5, "уровень 2: Δx/4 (у тела)", color="#ff8a65", fontsize=9, weight="bold")
base = "Вложенный AMR" if nested else "AMR (одиночный блок 2×)"
ax.set_title(f"{base}{sdf_tag} · поверх $|\\omega|$ · t={t}", fontsize=11)
ax.set_xlabel("x [lu]"); ax.set_ylabel("y [lu]"); ax.set_xlim(0, Nx); ax.set_ylim(0, Ny)
buf = _io.BytesIO(); fig.savefig(buf, format="png", bbox_inches="tight"); buf.seek(0); plt.close(fig)
pil.append(Image.open(buf).convert("RGB").convert("P", palette=Image.ADAPTIVE))
path = os.path.join(anim_dir, name)
pil[0].save(path, save_all=True, append_images=pil[1:], duration=int(1000/fps), loop=0, optimize=True)
print("saved", name, len(frames), "кадров")
LAB = ["без AMR (D=16)", "AMR 2× (D=32)", "AMR 2×+4× (D=64)"]
def print_table(AS, header):
print(f"\n=== {header} ===")
print(f"{'версия':22}{'D у тела':>9}{'St':>8}{'<Cd>':>9}{'rms Cl':>9}{'rms u_y':>9}")
for lab, DL, a in zip(LAB, [16, 32, 64], AS):
print(f"{lab:22}{DL:>9}{a['St']:>8.3f}{a['Cd']:>9.3f}{a['Clrms']:>9.4f}{a['uyrms']:>9.4f}")
print(f"{'литература Re≈150':22}{'—':>9}{0.183:>8.3f}{1.330:>9.3f}{'~0.3':>9}{'':>9}")
print("(лит.: St≈0.18, Cd≈1.3 для цилиндра при Re≈150; Williamson 1996, Henderson 1995)")
def probe_figure(AS, R2, path, suptitle):
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt, matplotlib.patches as mpatches
cols = ["#9e9e9e", "#1f6feb", "#d1495b"]
fig = plt.figure(figsize=(13.5, 8.2)); gs = fig.add_gridspec(2, 2, hspace=0.32, wspace=0.22)
axA = fig.add_subplot(gs[0, 0]); mid = R2["frames"][len(R2["frames"])//2]
vmf = np.nanpercentile(np.abs(np.where(solid0_np, np.nan, _vort_np(mid[1]))), 99)
cmf = plt.get_cmap("inferno").copy(); cmf.set_bad("white")
axA.imshow(np.abs(np.where(solid0_np, np.nan, _vort_np(mid[1]))), origin="lower", cmap=cmf, vmin=0, vmax=vmf,
extent=(0, Nx, 0, Ny), interpolation="nearest")
axA.add_patch(mpatches.Rectangle((ax1, ay1), bx1-ax1, by1-ay1, fill=False, edgecolor="#aed581", lw=1.8))
axA.add_patch(mpatches.Rectangle((cx2a, cy2a), cx2b-cx2a, cy2b-cy2a, fill=False, edgecolor="#ff8a65", lw=1.8))
axA.set_title("$|\\omega|$ + рамки зон 2×/4×"); axA.set_xlabel("x [lu]"); axA.set_ylabel("y [lu]")
axB = fig.add_subplot(gs[0, 1])
for a, lab, c in zip(AS, LAB, cols): axB.plot(a["Cl_s"][a["h0"]:], lw=0.8, color=c, label=lab)
axB.set_title("Подъёмная сила $C_l(t)$ (зонд силы)"); axB.set_xlabel("шаг"); axB.set_ylabel("$C_l$")
axB.legend(fontsize=8); axB.grid(alpha=0.3)
axC = fig.add_subplot(gs[1, 0])
for a, lab, c in zip(AS, LAB, cols):
sp = a["amp"]/a["amp"][1:].max(); axC.plot(a["freqs"]*D0/U, sp, lw=1.0, color=c, label=lab)
axC.axvline(0.183, color="k", ls="--", alpha=0.6, label="лит. St≈0.18"); axC.set_xlim(0, 0.6)
axC.set_title("Спектр $u_y$ в следе → St"); axC.set_xlabel("St = f·D/U"); axC.set_ylabel("норм. ампл.")
axC.legend(fontsize=8); axC.grid(alpha=0.3)
axD = fig.add_subplot(gs[1, 1]); x = np.arange(3); w = 0.35
axD.bar(x-w/2, [a["St"] for a in AS], w, color="#1f6feb", label="St")
axD.bar(x+w/2, [a["Cd"] for a in AS], w, color="#fb8c00", label="<Cd>")
axD.axhline(0.183, color="#1f6feb", ls="--", alpha=0.6); axD.axhline(1.33, color="#fb8c00", ls="--", alpha=0.6)
axD.set_xticks(x); axD.set_xticklabels(["без AMR", "2×", "2×+4×"]); axD.set_title("St и <Cd> (пунктир — лит.)")
axD.legend(fontsize=8); axD.grid(alpha=0.3, axis="y")
fig.suptitle(suptitle, fontweight="bold"); fig.savefig(path, dpi=110, bbox_inches="tight"); plt.close(fig)
print("saved", os.path.basename(path))
@@ -0,0 +1,36 @@
# ОПТИМИЗИРОВАННЫЙ GPU-решатель (без SDF): цилиндр, три версии (без AMR / 2× / вложенный 2×+4×).
# То же, что amr_cylinder_gpu.py, но: GPU-only ядро _opt (фикс силового зонда + ускорение),
# прогресс-бар на каждый прогон, гифки для ВСЕХ трёх режимов (none/amr2x/nested).
# Запуск: python amr_cylinder_gpu_opt.py
# AMR_FP64=1 ... (двойная точность)
# AMR_STEPS=2000 ... (короткий прогон)
# AMR_CUDAGRAPH=0 ... (выключить CUDA Graphs; по умолчанию ВКЛ, с рантайм-самопроверкой)
import os, time, numpy as np
import amr_gpu_core_opt as core
HERE = os.path.dirname(os.path.abspath(__file__))
ANIM = os.path.join(HERE, "anim"); os.makedirs(ANIM, exist_ok=True)
FIGS = os.path.join(HERE, "figures"); os.makedirs(FIGS, exist_ok=True)
print(f"[ОПТ · без SDF] backend={core.BACKEND}; домен {core.Nx}x{core.Ny}; steps={core.STEPS}; "
f"graphs={'on' if os.environ.get('AMR_CUDAGRAPH','1') != '0' else 'off'}; "
f"L1(2×) {core.P1['Nfx']}x{core.P1['Nfy']}; L2(4×) {core.P2['Nfx']}x{core.P2['Nfy']}")
t0 = time.perf_counter()
R0 = core.run("none", False, core.make_progress("none")); print(" none :", round(time.perf_counter()-t0, 1), "s")
R1 = core.run("amr2x", False, core.make_progress("amr2x")); print(" amr2x :", round(time.perf_counter()-t0, 1), "s")
R2 = core.run("nested", False, core.make_progress("nested")); print(" nested :", round(time.perf_counter()-t0, 1), "s")
core.save_gif(R0, "none_cyl.gif", ANIM, " (без SDF)")
core.save_gif(R1, "amr2x_cyl.gif", ANIM, " (без SDF)")
core.save_gif(R2, "amr_nested_cyl.gif", ANIM, " (без SDF)")
AS = [core.analyze(R0), core.analyze(R1), core.analyze(R2)]
core.print_table(AS, "БЕЗ SDF — сравнение на реально собранных данных (установившийся режим)")
np.savez(os.path.join(HERE, "_amr_probe_data.npz"),
St=np.array([a["St"] for a in AS]), Cd=np.array([a["Cd"] for a in AS]),
Clrms=np.array([a["Clrms"] for a in AS]), uyrms=np.array([a["uyrms"] for a in AS]),
Cl0=AS[0]["Cl_s"], Cl1=AS[1]["Cl_s"], Cl2=AS[2]["Cl_s"],
uy0=AS[0]["uy"], uy1=AS[1]["uy"], uy2=AS[2]["uy"])
core.probe_figure(AS, R2, os.path.join(FIGS, "11e_amr_probe_comparison.png"),
"AMR на цилиндре (без SDF): сравнение трёх версий на данных зондов")
print("DONE (без SDF)")
@@ -0,0 +1,63 @@
# ОПТИМИЗИРОВАННЫЙ GPU-решатель (с SDF + Bouzidi): цилиндр, три версии (без AMR / 2× / 2×+4×).
# То же, что amr_cylinder_sdf_gpu.py, но: GPU-only ядро _opt (ускорение), прогресс-бар на
# каждый прогон, гифки для ВСЕХ трёх режимов (none/amr2x/nested). В конце — сводное сравнение с без-SDF.
# Запуск: python amr_cylinder_sdf_gpu_opt.py (после amr_cylinder_gpu_opt.py — для сводного сравнения)
# AMR_CUDAGRAPH=0 ... (выключить CUDA Graphs; по умолчанию ВКЛ, с рантайм-самопроверкой)
import os, time, numpy as np
import amr_gpu_core_opt as core
HERE = os.path.dirname(os.path.abspath(__file__))
ANIM = os.path.join(HERE, "anim"); os.makedirs(ANIM, exist_ok=True)
FIGS = os.path.join(HERE, "figures"); os.makedirs(FIGS, exist_ok=True)
print(f"[ОПТ · SDF+Bouzidi] backend={core.BACKEND}; домен {core.Nx}x{core.Ny}; steps={core.STEPS}; "
f"graphs={'on' if os.environ.get('AMR_CUDAGRAPH','1') != '0' else 'off'}; "
f"L1(2×) {core.P1['Nfx']}x{core.P1['Nfy']}; L2(4×) {core.P2['Nfx']}x{core.P2['Nfy']}")
t0 = time.perf_counter()
R0 = core.run("none", True, core.make_progress("none")); print(" none :", round(time.perf_counter()-t0, 1), "s")
R1 = core.run("amr2x", True, core.make_progress("amr2x")); print(" amr2x :", round(time.perf_counter()-t0, 1), "s")
R2 = core.run("nested", True, core.make_progress("nested")); print(" nested :", round(time.perf_counter()-t0, 1), "s")
core.save_gif(R0, "none_cyl_sdf.gif", ANIM, " + SDF (Bouzidi)")
core.save_gif(R1, "amr2x_cyl_sdf.gif", ANIM, " + SDF (Bouzidi)")
core.save_gif(R2, "amr_nested_cyl_sdf.gif", ANIM, " + SDF (Bouzidi)")
AS = [core.analyze(R0), core.analyze(R1), core.analyze(R2)]
core.print_table(AS, "С SDF — сравнение на реально собранных данных (установившийся режим)")
np.savez(os.path.join(HERE, "_amr_probe_data_sdf.npz"),
St=np.array([a["St"] for a in AS]), Cd=np.array([a["Cd"] for a in AS]),
Clrms=np.array([a["Clrms"] for a in AS]), uyrms=np.array([a["uyrms"] for a in AS]),
Cl0=AS[0]["Cl_s"], Cl1=AS[1]["Cl_s"], Cl2=AS[2]["Cl_s"],
uy0=AS[0]["uy"], uy1=AS[1]["uy"], uy2=AS[2]["uy"])
core.probe_figure(AS, R2, os.path.join(FIGS, "11f_amr_sdf_probe_comparison.png"),
"AMR на цилиндре (SDF+Bouzidi): сравнение трёх версий на данных зондов")
# ---- сводное сравнение: SDF против без-SDF (если есть свежие данные без SDF) ----
nosdf = os.path.join(HERE, "_amr_probe_data.npz")
if os.path.exists(nosdf) and ("St" in np.load(nosdf).files):
import matplotlib; matplotlib.use("Agg"); import matplotlib.pyplot as plt
N = np.load(nosdf); Stn, Cdn = N["St"], N["Cd"]
Sts = np.array([a["St"] for a in AS]); Cds = np.array([a["Cd"] for a in AS])
print("\n=== СВОДНО: SDF vs без SDF (Cd, St) ===")
print(f"{'версия':14}{'St(noSDF)':>11}{'St(SDF)':>9}{'Cd(noSDF)':>11}{'Cd(SDF)':>9}")
for k, lab in enumerate(["без AMR", "2×", "2×+4×"]):
print(f"{lab:14}{Stn[k]:>11.3f}{Sts[k]:>9.3f}{Cdn[k]:>11.3f}{Cds[k]:>9.3f}")
print(f"{'лит. Re≈150':14}{0.183:>11.3f}{0.183:>9.3f}{1.330:>11.3f}{1.330:>9.3f}")
x = np.arange(3); w = 0.38
fig, axs = plt.subplots(1, 2, figsize=(13.5, 4.6))
axs[0].bar(x-w/2, Cdn, w, color="#9e9e9e", label="без SDF (ступенч.)")
axs[0].bar(x+w/2, Cds, w, color="#1f6feb", label="с SDF (Bouzidi)")
axs[0].axhline(1.33, color="k", ls="--", alpha=0.6, label="лит. Cd≈1.33")
axs[0].set_xticks(x); axs[0].set_xticklabels(["без AMR", "2×", "2×+4×"]); axs[0].set_ylabel("<Cd>")
axs[0].set_title("Сопротивление: SDF убирает завышение от ступенчатой границы")
axs[0].legend(fontsize=8); axs[0].grid(alpha=0.3, axis="y")
axs[1].bar(x-w/2, Stn, w, color="#9e9e9e", label="без SDF"); axs[1].bar(x+w/2, Sts, w, color="#1f6feb", label="с SDF")
axs[1].axhline(0.183, color="k", ls="--", alpha=0.6, label="лит. St≈0.18")
axs[1].set_xticks(x); axs[1].set_xticklabels(["без AMR", "2×", "2×+4×"]); axs[1].set_ylabel("St")
axs[1].set_title("Число Струхаля"); axs[1].legend(fontsize=8); axs[1].grid(alpha=0.3, axis="y")
fig.suptitle("Цилиндр: SDF (Bouzidi) против ступенчатого bounce-back — на реальных данных", fontweight="bold")
fig.savefig(os.path.join(FIGS, "11g_sdf_vs_nosdf.png"), dpi=110, bbox_inches="tight"); plt.close(fig)
print("saved 11g_sdf_vs_nosdf.png")
else:
print("\n(нет свежего _amr_probe_data.npz — сначала запусти amr_cylinder_gpu_opt.py)")
print("DONE (SDF)")
+448
View File
@@ -0,0 +1,448 @@
# Оптимизированное GPU-ЯДРО (D2Q9 KBC) для AMR-решателей на цилиндре. GPU-only (CuPy).
# Отличия от amr_gpu_core.py:
# * УБРАН numpy-фолбэк: backend всегда CuPy/GPU (иначе жёсткая ошибка). numpy остаётся
# только для host-задач (маски/SDF/базис один раз, FFT в analyze, matplotlib/PIL).
# * ФИКС силового зонда force_on: второй член обмена импульсом берётся из поля ПОСЛЕ
# стриминга (как в корректном force_bc), а не из post-collision. Раньше это давало
# нефизичный отрицательный Cd и крошечный rms Cl.
# * feq без pow (показатели c∈{-1,0,1}) + cp.fuse; фьюзинг хвоста collide.
# * Предвычисленные маски линков для силы (без per-step roll статических масок).
# * run(): один шаг = замкнутая _step() над persistent-буферами; опционально CUDA Graphs
# (AMR_CUDAGRAPH=1, по умолчанию вкл) с автоматическим откатом на eager.
# * Кадры для всех трёх режимов (none/amr2x/nested); save_gif рисует и режим none.
# * make_progress — общий прогресс-бар в ядре.
# Запуск через раннеры: amr_cylinder_gpu_opt.py (без SDF) и amr_cylinder_sdf_gpu_opt.py (SDF).
import os, io as _io, numpy as np
# ---- backend: ТОЛЬКО CuPy/GPU (фолбэк удалён) ----
import cupy as cp
try:
_ = (cp.zeros(2) + 1).sum(); cp.cuda.Device().synchronize() # реальная операция на device
except Exception as e:
raise RuntimeError("Этому решателю нужна рабочая GPU + CuPy: numpy-фолбэк удалён.") from e
xp = cp; GPU = True
def to_cpu(a):
return cp.asnumpy(a)
DTYPE = cp.float64 if os.environ.get("AMR_FP64") else cp.float32
GREL = 1e-8 # ОТНОСИТЕЛЬНЫЙ порог вырожденности знаменателя γ (доля от ‖Δ‖²); см. solver_2x_sdf/backend.py.
# Прежний абсолютный порог (1e-6 в fp32) срабатывал на большинстве узлов и подменял KBC на LBGK.
BACKEND = f"CuPy/GPU dtype={'float64' if DTYPE == cp.float64 else 'float32'}"
# ---- решётка D2Q9 ----
Cx = [0, 1, 0, -1, 0, 1, -1, -1, 1] # python-инты для roll/индексации
Cy = [0, 0, 1, 0, -1, 1, 1, -1, -1]
OPP = [0, 3, 4, 1, 2, 7, 8, 5, 6]
Q = 9
_W = np.array([4/9, 1/9, 1/9, 1/9, 1/9, 1/36, 1/36, 1/36, 1/36])
_CXn = np.array(Cx, float); _CYn = np.array(Cy, float)
# моментный базис и проектор на сдвиг {cx²−cy², cxcy} (KBC-N1) — считаем в numpy, грузим на device
_M = np.zeros((Q, Q)); _M[0] = 1; _M[1] = _CXn; _M[2] = _CYn; _M[3] = 3*(_CXn**2+_CYn**2)-2
_M[4] = _CXn**2-_CYn**2; _M[5] = _CXn*_CYn; _M[6] = _CXn**2*_CYn; _M[7] = _CXn*_CYn**2; _M[8] = _CXn**2*_CYn**2
_Dm = np.zeros((Q, Q)); _Dm[4, 4] = _Dm[5, 5] = 1.0
_Psn = np.linalg.inv(_M) @ _Dm @ _M
# device-константы
Wa = xp.asarray(_W, dtype=DTYPE)
CXa = xp.asarray(_CXn, dtype=DTYPE); CYa = xp.asarray(_CYn, dtype=DTYPE)
Ps = xp.asarray(_Psn, dtype=DTYPE)
CS2 = 1.0/3.0
# device-скаляры (чтобы не делать host→device asarray на горячем пути и не ломать graph-capture)
_Z0 = xp.zeros((), DTYPE); _ONE = xp.asarray(1.0, DTYPE); _TWO = xp.asarray(2.0, DTYPE)
# ---- ядро LBM/KBC (математика идентична; feq без pow) ----
def _feq9_body(rho, ux, uy):
# равновесие product-form для D2Q9: показатели c∈{-1,0,1} → используем qx/iqx вместо pow.
# clip входит в ТЕЛО ядра через min/max c литералами (запекаются в кернел → нет H2D при capture).
ux = cp.minimum(cp.maximum(ux, -0.95), 0.95); uy = cp.minimum(cp.maximum(uy, -0.95), 0.95)
sx = cp.sqrt(1.0 + 3.0*ux*ux); sy = cp.sqrt(1.0 + 3.0*uy*uy)
base = rho*(2.0 - sx)*(2.0 - sy)
qx = (2.0*ux + sx)/(1.0 - ux); qy = (2.0*uy + sy)/(1.0 - uy)
ix = 1.0/qx; iy = 1.0/qy
return (base*(4.0/9.0), # ( 0, 0)
base*(1.0/9.0)*qx, base*(1.0/9.0)*qy, # (+1,0) (0,+1)
base*(1.0/9.0)*ix, base*(1.0/9.0)*iy, # (-1,0) (0,-1)
base*(1.0/36.0)*qx*qy, base*(1.0/36.0)*ix*qy, # (+1,+1) (-1,+1)
base*(1.0/36.0)*ix*iy, base*(1.0/36.0)*qx*iy) # (-1,-1) (+1,-1)
_feq9 = cp.fuse()(_feq9_body) # одно слитное ядро (если cp.fuse доступен)
_FUSE_OK = True
def feq(rho, u):
global _FUSE_OK
if _FUSE_OK:
try:
return xp.stack(_feq9(rho, u[0], u[1]), axis=0)
except Exception as e: # откат на не-fused (тоже без pow)
print(f"[feq] cp.fuse отключён ({type(e).__name__}: {e}); обычный путь без pow")
_FUSE_OK = False
return xp.stack(_feq9_body(rho, u[0], u[1]), axis=0)
def macros(f):
r = f.sum(0)
return r, xp.stack([(CXa[:, None, None]*f).sum(0)/r, (CYa[:, None, None]*f).sum(0)/r])
def collide(f, fe, beta):
dfn = f - fe
# проектор на сдвиг БЕЗ cuBLAS (gemm при capture тащит alpha/beta host→device → H2D):
dfn2d = dfn.reshape(Q, -1) # (Q, N)
ds = (Ps[:, :, None] * dfn2d[None]).sum(1).reshape(dfn.shape) # Σ_k Ps[i,k]·dfn[k] — чистые ядра
dh = dfn - ds; inv = 1.0/fe
num = (ds*dh*inv).sum(0); den = (dh*dh*inv).sum(0)
nrm = (dfn*dfn*inv).sum(0) # ‖Δ‖² — масштаб неравновесия узла
ok = den > GREL*nrm
den_safe = xp.where(ok, den, _ONE) # избегаем 0/0
binv = 1.0/beta
g = xp.where(ok, binv - (2.0 - binv)*num/den_safe, _TWO)
return f - beta*(2.0*ds + g[None]*dh)
def stream(f):
fs = xp.empty_like(f)
for i in range(Q):
fs[i] = xp.roll(f[i], (Cy[i], Cx[i]), (0, 1))
return fs
# ---- AMR-связка ----
def patch(pNx, pNy, ax, bx, ay, by, r):
Wx, Wy = bx-ax, by-ay; Nfx, Nfy = r*Wx+1, r*Wy+1
FX, FY = np.meshgrid(ax+np.arange(Nfx)/r, ay+np.arange(Nfy)/r)
x0 = np.floor(FX).astype(np.int64); y0 = np.floor(FY).astype(np.int64)
x1 = np.minimum(x0+1, pNx-1); y1 = np.minimum(y0+1, pNy-1)
return dict(Nfx=Nfx, Nfy=Nfy, ax=ax, bx=bx, ay=ay, by=by, r=r,
x0=xp.asarray(x0), y0=xp.asarray(y0), x1=xp.asarray(x1), y1=xp.asarray(y1),
tx=xp.asarray(FX-x0, DTYPE), ty=xp.asarray(FY-y0, DTYPE),
slx=slice(r, r*Wx, r), sly=slice(r, r*Wy, r))
def pint(fld, P):
return (fld[..., P["y0"], P["x0"]]*(1-P["tx"])*(1-P["ty"]) + fld[..., P["y0"], P["x1"]]*P["tx"]*(1-P["ty"])
+ fld[..., P["y1"], P["x0"]]*(1-P["tx"])*P["ty"] + fld[..., P["y1"], P["x1"]]*P["tx"]*P["ty"])
def ghost(pf, P, Rcf):
r, u = macros(pf); neq = pf - feq(r, u)
return feq(pint(r, P), xp.stack([pint(u[0], P), pint(u[1], P)])) + Rcf*pint(neq, P)
def fill(cf, gh):
cf[:, 0, :] = gh[:, 0, :]; cf[:, -1, :] = gh[:, -1, :]; cf[:, :, 0] = gh[:, :, 0]; cf[:, :, -1] = gh[:, :, -1]
return cf
def restrict(cf, pf, P, Rfc, fluid):
r, u = macros(cf); neq = cf - feq(r, u); slx, sly = P["slx"], P["sly"]
nv = feq(r[sly, slx], u[:, sly, slx]) + Rfc*neq[:, sly, slx]
cur = pf[:, P["ay"]+1:P["by"], P["ax"]+1:P["bx"]]
pf[:, P["ay"]+1:P["by"], P["ax"]+1:P["bx"]] = xp.where(fluid[None], nv, cur)
return pf
# ---- граница: (1) узловой bounce-back (без SDF); (2) SDF+Bouzidi ----
def bb_set(post, pre, solid): # узлы тела <- развёрнутые до-столкновения
for i in range(Q):
post[i] = xp.where(solid, pre[OPP[i]], post[i])
return post
def build_links(solid_np, body_np): # предвычисление масок линков жидкость->тело (статичны)
fluid = ~solid_np; links = []
for i in range(1, Q):
nb = np.roll(body_np, (-Cy[i], -Cx[i]), (0, 1)); mask = fluid & nb
links.append((i, OPP[i], Cx[i], Cy[i], xp.asarray(mask, DTYPE)))
return links
def force_on(fpost, fnew, links): # Mei (узловая граница): F=Σ c_i (f_i^после-столкн. + f_ī^после-стриминга)
Fx = _Z0; Fy = _Z0
for (i, ib, cx_i, cy_i, maskf) in links:
s = (maskf*(fpost[i] + fnew[ib])).sum()
Fx = Fx + cx_i*s; Fy = Fy + cy_i*s
return Fx, Fy
def build_bc(solid_np, phi_np, cyl_np): # SDF-границу строим на host один раз, переносим на device
fluid = ~solid_np; bc = []
for i in range(1, Q):
nb_solid = np.roll(solid_np, (-Cy[i], -Cx[i]), (0, 1)); mask = fluid & nb_solid
cl = mask & np.roll(cyl_np, (-Cy[i], -Cx[i]), (0, 1))
phinb = np.roll(phi_np, (-Cy[i], -Cx[i]), (0, 1))
with np.errstate(divide="ignore", invalid="ignore"):
qq = phi_np/(phi_np - phinb)
q = np.where(mask, np.clip(qq, 0.02, 0.98), 0.5)
xff = mask & (~np.roll(solid_np, (Cy[i], Cx[i]), (0, 1)))
bc.append((i, OPP[i], xp.asarray(mask), xp.asarray(q, DTYPE), xp.asarray(xff), xp.asarray(cl)))
return bc
def apply_bc(f, fpost, bc): # Bouzidi (линейный), полностью через where (GPU)
for (i, ib, mask, q, xff, cl) in bc:
fi = fpost[i]; fib = fpost[ib]; fiback = xp.roll(fpost[i], (Cy[i], Cx[i]), (0, 1))
near = mask & (q < 0.5) & xff; bad = mask & (q < 0.5) & (~xff); far = mask & (q >= 0.5)
new = f[ib]
new = xp.where(near, 2*q*fi + (1-2*q)*fiback, new)
new = xp.where(far, (1/(2*q))*fi + (1-1/(2*q))*fib, new)
new = xp.where(bad, fi, new)
f[ib] = new
return f
def force_bc(fpost, f, bc): # Mei для интерполированной границы (уже корректно: f[ib] — после стриминга)
Fx = _Z0; Fy = _Z0
for (i, ib, mask, q, xff, cl) in bc:
s = xp.where(cl, fpost[i] + f[ib], _Z0).sum()
Fx = Fx + Cx[i]*s; Fy = Fy + Cy[i]*s
return Fx, Fy
# ---- геометрия (ИДЕНТИЧНА базовым версиям) ----
Nx, Ny, D, cx, cy = 150, 72, 16, 40, 36
U, Re, STEPS, ramp, FE = 0.07, 150.0, int(os.environ.get("AMR_STEPS", 8000)), 1000, 34
D0 = D; nu = U*D/Re; tau0 = nu/CS2 + 0.5
ax1, bx1, ay1, by1 = 16, 118, 8, 64
cx2a, cx2b, cy2a, cy2b = 26, 64, 18, 54
px, py = cx + 3*D, cy
def _cmask(NX, NY, ccx, ccy, R):
Y, X = np.meshgrid(np.arange(NY), np.arange(NX), indexing="ij"); return (X-ccx)**2 + (Y-ccy)**2 <= R*R
def _csdf(NX, NY, ccx, ccy, R):
Y, X = np.meshgrid(np.arange(NY), np.arange(NX), indexing="ij"); return np.sqrt((X-ccx)**2.0 + (Y-ccy)**2.0) - R
# host-маски/SDF
_walls = np.zeros((Ny, Nx), bool); _walls[0, :] = True; _walls[-1, :] = True
cyl0_np = _cmask(Nx, Ny, cx, cy, D/2); solid0_np = cyl0_np | _walls
_Yg = np.arange(Ny)[:, None]*np.ones((1, Nx))
phi0_np = np.minimum(_csdf(Nx, Ny, cx, cy, D/2), np.minimum(_Yg-0.5, (Ny-1.5)-_Yg))
P1 = patch(Nx, Ny, ax1, bx1, ay1, by1, 2); P2 = patch(P1["Nfx"], P1["Nfy"], (cx2a-ax1)*2, (cx2b-ax1)*2, (cy2a-ay1)*2, (cy2b-ay1)*2, 2)
solid1_np = _cmask(P1["Nfx"], P1["Nfy"], (cx-ax1)*2, (cy-ay1)*2, D); phi1_np = _csdf(P1["Nfx"], P1["Nfy"], (cx-ax1)*2, (cy-ay1)*2, D)
solid2_np = _cmask(P2["Nfx"], P2["Nfy"], (cx-cx2a)*4, (cy-cy2a)*4, 2*D); phi2_np = _csdf(P2["Nfx"], P2["Nfy"], (cx-cx2a)*4, (cy-cy2a)*4, 2*D)
# device-маски (без SDF)
g_solid0 = xp.asarray(solid0_np); g_cyl0 = xp.asarray(cyl0_np); g_fl0 = ~g_solid0
g_solid1 = xp.asarray(solid1_np); g_fl1 = ~g_solid1
g_solid2 = xp.asarray(solid2_np); g_fl2 = ~g_solid2
# предвычисленные маски линков для силы (узловая граница)
L0_links = build_links(solid0_np, cyl0_np)
L1_links = build_links(solid1_np, solid1_np)
L2_links = build_links(solid2_np, solid2_np)
# SDF-ГУ
BC0 = build_bc(solid0_np, phi0_np, cyl0_np); BC1 = build_bc(solid1_np, phi1_np, solid1_np); BC2 = build_bc(solid2_np, phi2_np, solid2_np)
# рестрикция: жидкие узлы
fl1 = xp.asarray(~solid0_np[ay1+1:by1, ax1+1:bx1])
fl2 = xp.ones((P2["by"]-P2["ay"]-1, P2["bx"]-P2["ax"]-1), bool); fl2 = xp.asarray(fl2)
# τ / масштабы
t1 = 2*tau0 - 0.5; t2 = 2*t1 - 0.5
b0 = 1/(2*tau0); b1 = 1/(2*t1); b2 = 1/(2*t2)
R01 = t1/(2*tau0); Rf01 = 1/R01; R12 = t2/(2*t1); Rf12 = 1/R12
# ---- прогресс-бар (ASCII-шкала по выполненным шагам из заданных) ----
import time as _time
def make_progress(label, width=32, every=0.1):
st = {"t0": _time.perf_counter(), "last": 0.0}
def cb(t, total):
now = _time.perf_counter(); done = (t >= total)
if not done and (now - st["last"] < every): # троттлинг, чтобы не спамить консоль
return
st["last"] = now; frac = (t + 1) / (total + 1)
filled = int(round(width * frac)); bar = "#" * filled + "-" * (width - filled)
el = now - st["t0"]; eta = (el / frac - el) if frac > 0 else 0.0
print(f"\r {label:7}[{bar}] {frac*100:5.1f}% ({t}/{total}) {el:5.1f}s ETA {eta:5.1f}s",
end=("\n" if done else ""), flush=True)
return cb
# ---- захват CUDA-графа одного шага ----
def _capture(step_fn):
cp.cuda.Device().synchronize()
s = cp.cuda.Stream(non_blocking=True)
with s:
s.begin_capture()
try:
step_fn()
except Exception:
try: s.end_capture()
except Exception: pass
raise
g = s.end_capture()
return g
def run(mode, use_sdf, progress=None):
"""mode: none|amr2x|nested. Возвращает {Fx,Fy,uy (host), frames (host), DL, mode}.
progress(t, STEPS) — необязательный колбэк. CUDA Graphs: env AMR_CUDAGRAPH (по умолч. вкл)."""
use1 = mode in ("amr2x", "nested"); use2 = (mode == "nested")
DL = {"none": D0, "amr2x": 2*D0, "nested": 4*D0}[mode]
# ---- persistent-состояние (фиксированные буферы для replay графа) ----
F0 = feq(xp.ones((Ny, Nx), DTYPE), xp.zeros((2, Ny, Nx), DTYPE)).copy()
F1 = feq(xp.ones((P1["Nfy"], P1["Nfx"]), DTYPE), xp.zeros((2, P1["Nfy"], P1["Nfx"]), DTYPE)).copy() if use1 else None
F2 = feq(xp.ones((P2["Nfy"], P2["Nfx"]), DTYPE), xp.zeros((2, P2["Nfy"], P2["Nfx"]), DTYPE)).copy() if use2 else None
onecol = xp.ones((Ny, 1), DTYPE)
uin_dev = xp.zeros((2, Ny, 1), DTYPE) # вход; обновляется СНАРУЖИ захвата
Fx_s = xp.zeros((), DTYPE); Fy_s = xp.zeros((), DTYPE); uy_s = xp.zeros((), DTYPE)
# разгон входа на device: U*smoothstep(t/ramp)
_ts = np.minimum(np.arange(STEPS+1)/ramp, 1.0)
ramp_u = xp.asarray(U*(_ts*_ts*(3-2*_ts)), DTYPE)
Fx = xp.zeros(STEPS+1, DTYPE); Fy = xp.zeros(STEPS+1, DTYPE); uy = xp.zeros(STEPS+1, DTYPE)
frames = []
def _step():
# ----- уровень 0 -----
rho, u0 = macros(F0)
pre = F0.copy()
post0 = collide(F0, feq(rho, u0), b0)
if not use_sdf:
f0 = stream(bb_set(post0.copy(), pre, g_solid0))
else:
f0 = stream(post0); f0 = apply_bc(f0, post0, BC0)
if mode == "none":
fx, fy = (force_bc(post0, f0, BC0) if use_sdf else force_on(post0, f0, L0_links))
Fx_s[...] = fx; Fy_s[...] = fy
# вход/выход (разгон читаем из uin_dev)
f0[:, 1:-1, 0] = feq(onecol, uin_dev)[:, 1:-1, 0]; f0[:, 1:-1, -1] = f0[:, 1:-1, -2]
# ----- уровень 1 -----
if use1:
g1o = ghost(pre, P1, R01); g1n = ghost(f0, P1, R01)
f1 = F1
for s1 in range(2):
pre1 = f1.copy(); r1, u1 = macros(f1); post1 = collide(f1, feq(r1, u1), b1)
if not use_sdf:
f1 = stream(bb_set(post1.copy(), pre1, g_solid1))
else:
f1 = stream(post1); f1 = apply_bc(f1, post1, BC1)
if mode == "amr2x" and s1 == 1:
fx, fy = (force_bc(post1, f1, BC1) if use_sdf else force_on(post1, f1, L1_links))
Fx_s[...] = fx; Fy_s[...] = fy
f1 = fill(f1, (1-(s1+1)/2)*g1o + ((s1+1)/2)*g1n)
# ----- уровень 2 -----
if use2:
g2o = ghost(pre1, P2, R12); g2n = ghost(f1, P2, R12)
f2 = F2
for s2 in range(2):
pre2 = f2.copy(); r2, u2 = macros(f2); post2 = collide(f2, feq(r2, u2), b2)
if not use_sdf:
f2 = stream(bb_set(post2.copy(), pre2, g_solid2))
else:
f2 = stream(post2); f2 = apply_bc(f2, post2, BC2)
if s1 == 1 and s2 == 1:
fx, fy = (force_bc(post2, f2, BC2) if use_sdf else force_on(post2, f2, L2_links))
Fx_s[...] = fx; Fy_s[...] = fy
f2 = fill(f2, (1-(s2+1)/2)*g2o + ((s2+1)/2)*g2n)
f1 = restrict(f2, f1, P2, Rf12, fl2)
F2[...] = f2
f0 = restrict(f1, f0, P1, Rf01, fl1)
F1[...] = f1
# зонд скорости в следе + запись состояния
col = f0[:, py, px]; uy_s[...] = (CYa*col).sum()/col.sum()
F0[...] = f0
use_graph = (os.environ.get("AMR_CUDAGRAPH", "1") != "0") # CUDA Graphs по умолчанию ВКЛ (AMR_CUDAGRAPH=0 — выкл)
graph = None; graph_failed = False
def _snap():
return (F0.copy(), F1.copy() if use1 else None, F2.copy() if use2 else None)
def _restore(s):
F0[...] = s[0]
if use1: F1[...] = s[1]
if use2: F2[...] = s[2]
def _maxdiff(s):
d = float(xp.abs(s[0]-F0).max())
if use1: d = max(d, float(xp.abs(s[1]-F1).max()))
if use2: d = max(d, float(xp.abs(s[2]-F2).max()))
return d
for t in range(STEPS+1):
uin_dev[0, :, 0] = ramp_u[t]
did = False
if t == 1 and use_graph and not graph_failed:
# захват графа + РАНТАЙМ-САМОПРОВЕРКА: один шаг графом vs eager; при расхождении — откат
try:
graph = _capture(_step) # запись (без исполнения)
before = _snap() # состояние после t=0
graph.launch() # граф -> результат t=1
gres = _snap()
_restore(before); _step() # eager -> доверенный результат t=1
d = _maxdiff(gres)
if d > 1e-3:
print(f"\n[graph] self-check разошёлся (Δ={d:.2e}) — отключаю графы, работаю eager")
graph = None; graph_failed = True
else:
print(f"\n[graph] self-check OK (Δ={d:.2e}) — CUDA graphs активны")
did = True # шаг t=1 уже выполнен (eager-результат — доверенный)
except Exception as e:
print(f"\n[graph] недоступны ({type(e).__name__}: {e}) — работаю eager")
import traceback as _tb; _tb.print_exc() # точная строка H2D/несовместимости
graph = None; graph_failed = True
try: cp.cuda.Device().synchronize()
except Exception: pass
if not did:
if graph is not None and t >= 1:
graph.launch()
else:
_step()
Fx[t] = Fx_s; Fy[t] = Fy_s; uy[t] = uy_s
if progress is not None:
progress(t, STEPS)
if t % 500 == 0 and not bool(xp.isfinite(F0).all()):
print("BLEW UP", mode, t); Fx = Fx[:t]; Fy = Fy[:t]; uy = uy[:t]; break
if t % FE == 0:
frames.append((t, to_cpu(macros(F0)[1]),
to_cpu(macros(F1)[1]) if use1 else None,
to_cpu(macros(F2)[1]) if use2 else None))
return dict(mode=mode, DL=DL, Fx=to_cpu(Fx), Fy=to_cpu(Fy), uy=to_cpu(uy), frames=frames)
# ---- анализ (на host: FFT малых рядов) ----
def analyze(res):
Fx, Fy, uy, DL = res["Fx"], res["Fy"], res["uy"], res["DL"]
n = len(uy); h = slice(n//2, n); Cd = 2*Fx/(U**2*DL); Cl = 2*Fy/(U**2*DL)
sig = uy[h] - uy[h].mean(); npad = 8192
freqs = np.fft.rfftfreq(npad, 1.0); amp = np.abs(np.fft.rfft(sig, n=npad))
St = (freqs[1+int(np.argmax(amp[1:]))] if len(amp) > 2 else 0.0)*D0/U
return dict(Cd=float(Cd[h].mean()), Clrms=float(Cl[h].std()), St=float(St), uyrms=float(uy[h].std()),
Cl_s=Cl, uy=uy, freqs=freqs, amp=amp, h0=n//2)
# ---- визуализация (matplotlib на CPU; кадры уже host) ----
def _vort_np(u):
return (np.roll(u[1], -1, 1)-np.roll(u[1], 1, 1))*0.5 - (np.roll(u[0], -1, 0)-np.roll(u[0], 1, 0))*0.5
def save_gif(res, name, anim_dir, sdf_tag, fps=24):
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt, matplotlib.patches as mpatches
from PIL import Image
nested = (res["mode"] == "nested"); has1 = res["mode"] in ("amr2x", "nested"); frames = res["frames"]
vm = np.nanpercentile(np.abs(np.where(solid0_np, np.nan, _vort_np(frames[len(frames)//2][1]))), 99.0)
cm = plt.get_cmap("inferno").copy(); cm.set_bad("white"); pil = []
for (t, u0, u1, u2) in frames:
fig = plt.figure(figsize=(11.2, 5.7), dpi=96); ax = fig.add_subplot(111)
ax.imshow(np.abs(np.where(solid0_np, np.nan, _vort_np(u0))), origin="lower", cmap=cm, vmin=0, vmax=vm,
extent=(0, Nx, 0, Ny), interpolation="nearest")
if has1 and u1 is not None:
ax.imshow(np.abs(np.where(solid1_np, np.nan, _vort_np(u1)))[1:-1, 1:-1], origin="lower", cmap=cm, vmin=0, vmax=vm,
extent=(ax1+0.5, bx1-0.5, ay1+0.5, by1-0.5), interpolation="nearest")
if nested and u2 is not None:
ax.imshow(np.abs(np.where(solid2_np, np.nan, _vort_np(u2)))[1:-1, 1:-1], origin="lower", cmap=cm, vmin=0, vmax=vm,
extent=(cx2a+0.25, cx2b-0.25, cy2a+0.25, cy2b-0.25), interpolation="nearest")
ax.add_patch(mpatches.Rectangle((0.2, 0.2), Nx-0.4, Ny-0.4, fill=False, edgecolor="#4fc3f7", lw=1.6))
ax.text(1.5, Ny-4, "уровень 0: Δx (крупный)", color="#4fc3f7", fontsize=9, weight="bold")
if has1:
ax.add_patch(mpatches.Rectangle((ax1, ay1), bx1-ax1, by1-ay1, fill=False, edgecolor="#aed581", lw=2.2))
ax.text(ax1+1, by1-3.5, "уровень 1: Δx/2 (тело+след)", color="#aed581", fontsize=9, weight="bold")
if nested:
ax.add_patch(mpatches.Rectangle((cx2a, cy2a), cx2b-cx2a, cy2b-cy2a, fill=False, edgecolor="#ff8a65", lw=2.2))
ax.text(cx2a+1, cy2b-3.5, "уровень 2: Δx/4 (у тела)", color="#ff8a65", fontsize=9, weight="bold")
base = ("Вложенный AMR" if nested else "AMR (одиночный блок 2×)") if has1 else "Без AMR (D=16)"
ax.set_title(f"{base}{sdf_tag} · поверх $|\\omega|$ · t={t}", fontsize=11)
ax.set_xlabel("x [lu]"); ax.set_ylabel("y [lu]"); ax.set_xlim(0, Nx); ax.set_ylim(0, Ny)
buf = _io.BytesIO(); fig.savefig(buf, format="png", bbox_inches="tight"); buf.seek(0); plt.close(fig)
pil.append(Image.open(buf).convert("RGB").convert("P", palette=Image.ADAPTIVE))
path = os.path.join(anim_dir, name)
pil[0].save(path, save_all=True, append_images=pil[1:], duration=int(1000/fps), loop=0, optimize=True)
print("saved", name, len(frames), "кадров")
LAB = ["без AMR (D=16)", "AMR 2× (D=32)", "AMR 2×+4× (D=64)"]
def print_table(AS, header):
print(f"\n=== {header} ===")
print(f"{'версия':22}{'D у тела':>9}{'St':>8}{'<Cd>':>9}{'rms Cl':>9}{'rms u_y':>9}")
for lab, DL, a in zip(LAB, [16, 32, 64], AS):
print(f"{lab:22}{DL:>9}{a['St']:>8.3f}{a['Cd']:>9.3f}{a['Clrms']:>9.4f}{a['uyrms']:>9.4f}")
print(f"{'литература Re≈150':22}{'—':>9}{0.183:>8.3f}{1.330:>9.3f}{'~0.3':>9}{'':>9}")
print("(лит.: St≈0.18, Cd≈1.3 для цилиндра при Re≈150; Williamson 1996, Henderson 1995)")
def probe_figure(AS, R2, path, suptitle):
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt, matplotlib.patches as mpatches
cols = ["#9e9e9e", "#1f6feb", "#d1495b"]
fig = plt.figure(figsize=(13.5, 8.2)); gs = fig.add_gridspec(2, 2, hspace=0.32, wspace=0.22)
axA = fig.add_subplot(gs[0, 0]); mid = R2["frames"][len(R2["frames"])//2]
vmf = np.nanpercentile(np.abs(np.where(solid0_np, np.nan, _vort_np(mid[1]))), 99)
cmf = plt.get_cmap("inferno").copy(); cmf.set_bad("white")
axA.imshow(np.abs(np.where(solid0_np, np.nan, _vort_np(mid[1]))), origin="lower", cmap=cmf, vmin=0, vmax=vmf,
extent=(0, Nx, 0, Ny), interpolation="nearest")
axA.add_patch(mpatches.Rectangle((ax1, ay1), bx1-ax1, by1-ay1, fill=False, edgecolor="#aed581", lw=1.8))
axA.add_patch(mpatches.Rectangle((cx2a, cy2a), cx2b-cx2a, cy2b-cy2a, fill=False, edgecolor="#ff8a65", lw=1.8))
axA.set_title("$|\\omega|$ + рамки зон 2×/4×"); axA.set_xlabel("x [lu]"); axA.set_ylabel("y [lu]")
axB = fig.add_subplot(gs[0, 1])
for a, lab, c in zip(AS, LAB, cols): axB.plot(a["Cl_s"][a["h0"]:], lw=0.8, color=c, label=lab)
axB.set_title("Подъёмная сила $C_l(t)$ (зонд силы)"); axB.set_xlabel("шаг"); axB.set_ylabel("$C_l$")
axB.legend(fontsize=8); axB.grid(alpha=0.3)
axC = fig.add_subplot(gs[1, 0])
for a, lab, c in zip(AS, LAB, cols):
sp = a["amp"]/a["amp"][1:].max(); axC.plot(a["freqs"]*D0/U, sp, lw=1.0, color=c, label=lab)
axC.axvline(0.183, color="k", ls="--", alpha=0.6, label="лит. St≈0.18"); axC.set_xlim(0, 0.6)
axC.set_title("Спектр $u_y$ в следе → St"); axC.set_xlabel("St = f·D/U"); axC.set_ylabel("норм. ампл.")
axC.legend(fontsize=8); axC.grid(alpha=0.3)
axD = fig.add_subplot(gs[1, 1]); x = np.arange(3); w = 0.35
axD.bar(x-w/2, [a["St"] for a in AS], w, color="#1f6feb", label="St")
axD.bar(x+w/2, [a["Cd"] for a in AS], w, color="#fb8c00", label="<Cd>")
axD.axhline(0.183, color="#1f6feb", ls="--", alpha=0.6); axD.axhline(1.33, color="#fb8c00", ls="--", alpha=0.6)
axD.set_xticks(x); axD.set_xticklabels(["без AMR", "2×", "2×+4×"]); axD.set_title("St и <Cd> (пунктир — лит.)")
axD.legend(fontsize=8); axD.grid(alpha=0.3, axis="y")
fig.suptitle(suptitle, fontweight="bold"); fig.savefig(path, dpi=110, bbox_inches="tight"); plt.close(fig)
print("saved", os.path.basename(path))
Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 MiB

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

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+886
View File
@@ -0,0 +1,886 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "ea656dfb",
"metadata": {},
"source": [
"# Метод KBC (энтропийный Lattice Boltzmann): от формул к валидированной GPU-модели\n",
"\n",
"**Karlin–Bösch–Chikatamarla (KBC)** — энтропийная multi-relaxation-time формулировка\n",
"решёточного метода Больцмана (LBM), дающая **безусловную нелинейную устойчивость** при больших\n",
"числах Рейнольдса без явной подсеточной модели турбулентности (работает как *implicit LES*).\n",
"\n",
"Этот документ — рабочий разбор метода и **история эволюции 2D-макета**: от формул и\n",
"самопроверок через классические бенчмарки (Тейлора–Грина, сдвиговый слой, каверна),\n",
"блочное измельчение сетки (AMR) и границу SDF+Bouzidi — к **финальной валидированной\n",
"GPU-модели** обтекания цилиндра `solver_2x_sdf` (D2Q9, KBC-N1, L0+L1, CUDA-графы) с\n",
"двумя независимыми способами считывания силы.\n",
"\n",
"> **Как устроен документ.** Ноутбук **не исполняет код** — только теория и готовые\n",
"> артефакты (картинки, гифки, числа). Весь код живёт рядом и запускается на GPU-сервере:\n",
">\n",
"> | Где | Что |\n",
"> |---|---|\n",
"> | `demos_gpu/` | GPU-прогоны всех демо и самопроверок (см. `demos_gpu/README.md`) |\n",
"> | `solver_2x_sdf/` | финальная модель (см. `solver_2x_sdf/README.md`) |\n",
"> | `figures/`, `anim/`, `*/out/` | артефакты, встроенные ниже |\n",
">\n",
"> Демо гоняются **теми же модулями** (решётка, равновесие, столкновение, перенос,\n",
"> AMR-связка), что и финальная модель, — каждый бенчмарк ниже одновременно валидирует\n",
"> компонент финальной модели."
]
},
{
"cell_type": "markdown",
"id": "23513c20",
"metadata": {},
"source": [
"## Сводная таблица обозначений\n",
"\n",
"Везде **решёточные единицы**: шаг сетки $\\Delta x = 1$ (lu), шаг по времени $\\Delta t = 1$ (ts).\n",
"\n",
"| Символ | Название | Смысл / формула | Диапазон |\n",
"|---|---|---|---|\n",
"| $f_i$ | популяция | плотность частиц со скоростью $\\mathbf c_i$ | $f_i>0$ |\n",
"| $\\mathbf c_i$ | дискретная скорость | направление переноса $i$ | компоненты $\\in\\{-1,0,1\\}$ |\n",
"| $w_i$ | вес квадратуры | $\\sum_i w_i=1$ | $\\{4/9,1/9,1/36\\}$ (D2Q9) |\n",
"| $c_s$ | скорость звука решётки | $c_s^2=1/3$ | $c_s=1/\\sqrt3$ |\n",
"| $\\rho$, $\\mathbf u$ | плотность, скорость | $\\rho=\\sum_i f_i$, $\\rho\\mathbf u=\\sum_i \\mathbf c_i f_i$ | $\\rho\\approx1$, $|\\mathbf u|\\ll c_s$ |\n",
"| $\\tau$, $\\beta$ | релаксация | $\\nu=c_s^2(\\tau-\\tfrac12)$, $\\beta=1/(2\\tau)$ | $\\tau>1/2$ |\n",
"| $\\gamma$ | **энтропийный стабилизатор** | релаксация высших мод | $\\approx2$ |\n",
"| $f_i^{\\mathrm{eq}}$ | равновесие | максимум энтропии при $\\rho,\\mathbf u$ | $>0$ |\n",
"| $k_i,s_i,h_i$ | разложение | кинематич./сдвиг/высшие моды | $k+s+h=f$ |\n",
"| $\\Delta s_i,\\Delta h_i$ | неравновесие | $P_s(f{-}f^{eq})$, остальное | малы |\n",
"| $H$ | H-функция | $H=\\sum_i f_i\\ln(f_i/w_i)$ | не растёт |\n",
"| $\\langle X|Y\\rangle$ | энтропийное скал. произв. | $\\sum_i X_iY_i/f_i^{eq}$ | — |\n",
"| $T,N,\\Pi_{xy}$ | натуральные 2-е моменты | $\\Pi_{xx}{+}\\Pi_{yy}$, $\\Pi_{xx}{-}\\Pi_{yy}$, сдвиг | — |\n",
"| $\\Pi^{neq}_{\\alpha\\beta}$ | неравновесный тензор | $\\sum_i c_{i\\alpha}c_{i\\beta}(f_i-f_i^{eq})$ | — |\n",
"| $\\sigma_{\\alpha\\beta}$ | тензор напряжений | $-p'\\delta_{\\alpha\\beta}-(1-\\tfrac{1}{2\\tau})\\Pi^{neq,dev}_{\\alpha\\beta}$ | — |\n",
"| $q$ | доля стенки (Bouzidi) | положение стенки на линке из SDF | $(0,1)$ |\n",
"| $C_d, C_l, C_m$ | коэффициенты | $\\tfrac{2F_x}{U^2 D}$, $\\tfrac{2F_y}{U^2 D}$, $\\tfrac{2T_z}{U^2 D^2}$ | — |\n",
"| $\\beta_{бл}$ | блокировка канала | $D/N_y$ (стеснение стенками) | $\\to0$ — безграничный |\n",
"| $\\mathrm{Re}$, $\\mathrm{Ma}$, $\\mathrm{St}$ | подобие | $UL/\\nu$, $|\\mathbf u|/c_s$, $fD/U$ | — |"
]
},
{
"cell_type": "markdown",
"id": "02c48319",
"metadata": {},
"source": [
"---\n",
"# Часть I. Теория\n",
"\n",
"## 1. Основы LBM и решётка D2Q9\n",
"\n",
"LBM отслеживает **популяции** $f_i(\\mathbf x, t)$ — плотность фиктивных частиц, движущихся в\n",
"узле $\\mathbf x$ со скоростью $\\mathbf c_i$. Динамика — чередование **переноса** (streaming) и\n",
"**столкновения** (collision):\n",
"$$ f_i(\\mathbf x + \\mathbf c_i\\,\\Delta t,\\; t+\\Delta t) = f_i(\\mathbf x,t) + \\Omega_i. $$\n",
"\n",
"**Решётка D2Q9**: покоящаяся частица, 4 осевые и 4 диагональные скорости; $c_s^2=1/3$;\n",
"веса $w_0=4/9$, осевые $1/9$, диагональные $1/36$ дают изотропию 4-го порядка.\n",
"Макромоменты: $\\rho=\\sum_i f_i$, $\\rho\\mathbf u=\\sum_i \\mathbf c_i f_i$.\n",
"\n",
"![Стенсиль D2Q9](figures/01_stencil_d2q9.png)\n",
"\n",
"**Самопроверка** (`demos_gpu/checks_gpu.py`, исполняется на компонентах финальной модели —\n",
"`solver_2x_sdf/lattice.py`): $\\sum w_i=1$, $\\sum w_i\\mathbf c_i=0$,\n",
"$\\sum w_i c_{i\\alpha}c_{i\\beta}=c_s^2\\delta_{\\alpha\\beta}$, $\\mathbf c_{\\bar i}=-\\mathbf c_i$ —\n",
"все тождества проходят (см. `demos_gpu/out/checks.txt`)."
]
},
{
"cell_type": "markdown",
"id": "8a74a375",
"metadata": {},
"source": [
"## 2. Равновесие: полиномиальное и энтропийное\n",
"\n",
"**Полиномиальное** (разложение максвеллиана до $O(u^2)$, стандарт BGK):\n",
"$$ f_i^{\\mathrm{eq,poly}} = w_i\\,\\rho\\left[1 + \\frac{\\mathbf c_i\\cdot\\mathbf u}{c_s^2}\n",
" + \\frac{(\\mathbf c_i\\cdot\\mathbf u)^2}{2c_s^4} - \\frac{\\mathbf u^2}{2c_s^2}\\right]. $$\n",
"\n",
"**Энтропийное** (точный максимум дискретной H-функции при связях $\\rho$, $\\rho\\mathbf u$) для\n",
"D2Q9/D3Q27 имеет замкнутую **product-form**:\n",
"$$ f_i^{\\mathrm{eq}} = \\rho\\,w_i\\prod_{\\alpha\\in\\{x,y\\}}\n",
" A(u_\\alpha)\\,B(u_\\alpha)^{\\,c_{i\\alpha}},\\qquad\n",
" A(u) = 2-\\sqrt{1+3u^2},\\qquad B(u) = \\frac{2u+\\sqrt{1+3u^2}}{1-u}. $$\n",
"\n",
"✓ **Сверено с первоисточником**: форма и коэффициенты в точности совпадают с уравнениями\n",
"(7)–(11) Bösch, Chikatamarla, Karlin, *Phys. Rev. E* **92**, 043309 (2015)\n",
"(восходит к Ansumali, Karlin, Öttinger, *Europhys. Lett.* **63**, 798, 2003).\n",
"Реализация — `solver_2x_sdf/equilibrium.py` (показатели $c_{i\\alpha}\\in\\{-1,0,1\\}$, поэтому\n",
"вместо `pow` берётся $B$ или $1/B$ — быстрее и совместимо с CUDA-графами).\n",
"\n",
"**Самопроверка** (`checks_gpu.py`): энтропийное равновесие воспроизводит $\\rho$ и\n",
"$\\rho\\mathbf u$ **точно** (до машинного нуля выбранной точности), а второй момент при малом\n",
"$\\mathrm{Ma}$ близок к $\\rho(c_s^2\\delta_{\\alpha\\beta}+u_\\alpha u_\\beta)$ — тензору давления\n",
"Навье–Стокса. В отличие от полинома, product-form гарантирует положительность и H-теорему."
]
},
{
"cell_type": "markdown",
"id": "c6abf6ca",
"metadata": {},
"source": [
"## 3. Разложение популяций $f = k + s + h$\n",
"\n",
"Сердце KBC — разбиение 9-вектора популяций по **моментным подпространствам**. Натуральные\n",
"вторые моменты D2Q9: след $T=\\Pi_{xx}{+}\\Pi_{yy}$, разность нормальных напряжений\n",
"$N=\\Pi_{xx}{-}\\Pi_{yy}$, сдвиг $\\Pi_{xy}$; плюс третьи ($Q_{xxy},Q_{xyy}$) и четвёртый\n",
"($\\Pi_{xxyy}$) моменты:\n",
"\n",
"- $k_i$ — **кинематическая** часть: только сохраняющиеся моменты ($\\rho, j_x, j_y$);\n",
"- $s_i$ — **сдвиговая** часть: девиаторный тензор напряжений ($N$ и $\\Pi_{xy}$);\n",
"- $h_i$ — **высшие** («ghost») моды: $T$, третьи и четвёртый моменты.\n",
"\n",
"Строится моментная матрица $M_{ai}=\\phi^{(a)}(\\mathbf c_i)$ из 9 независимых мономов; проектор\n",
"на группу моментов $G$: $P_G = M^{-1} D_G M$. По построению $P_k+P_s+P_h=I$, разложение точное.\n",
"\n",
"✓ **Сверено с первоисточником**: назначение $s$ задаёт *вариант* KBC; вариант с\n",
"$s=\\{N,\\Pi_{xy}\\}$ (только девиатор) — канонический «KBC-d» / **N1**\n",
"(Karlin, Bösch, Chikatamarla, *PRE* **90**, 031302(R), 2014; Bösch et al., *PRE* **92**,\n",
"043309, 2015 — там же семейство $s\\in\\{d,\\,d{+}t,\\,d{+}q,\\,d{+}t{+}q\\}$). Именно он\n",
"реализован в `solver_2x_sdf/lattice.py` (проектор `Ps` на строки $\\{c_x^2{-}c_y^2,\\,c_xc_y\\}$).\n",
"\n",
"**Самопроверка** (`checks_gpu.py`): `Ps` идемпотентен, $\\Delta s$ несёт **только** моменты\n",
"$\\{N,\\Pi_{xy}\\}$ (остальные строки $M\\Delta s$ — нули)."
]
},
{
"cell_type": "markdown",
"id": "3fe4992c",
"metadata": {},
"source": [
"## 4. Оператор столкновений KBC и стабилизатор $\\gamma$\n",
"\n",
"Так как $f$ и $f^{eq}$ имеют одинаковые сохраняющиеся моменты, $\\Delta k=0$ и\n",
"$f-f^{eq}=\\Delta s+\\Delta h$, где $\\Delta s = P_s(f-f^{eq})$.\n",
"\n",
"**Столкновение KBC** (через «зеркальное состояние» $f^{mirr}$):\n",
"$$ f_i' = (1-\\beta)\\,f_i + \\beta\\,f_i^{mirr}\n",
" \\;\\;\\Longleftrightarrow\\;\\;\n",
" \\boxed{\\,f_i' = f_i - \\beta\\,\\bigl(2\\,\\Delta s_i + \\gamma\\,\\Delta h_i\\bigr)\\,},\\qquad\n",
" \\nu = c_s^2\\Bigl(\\frac{1}{2\\beta}-\\frac12\\Bigr)\\;\\Rightarrow\\;\\beta=\\frac{1}{2\\tau}. $$\n",
"Множитель **2** перед $\\Delta s$ фиксирует сдвиговую вязкость; высшие моды релаксируются\n",
"отдельным $\\gamma$.\n",
"\n",
"**Стабилизатор** $\\gamma$ выбирается локально (в каждом узле на каждом шаге) из условия\n",
"неувеличения дискретной энтропии; аналитическое приближение корня:\n",
"$$ \\gamma^\\* = \\frac{1}{\\beta} - \\Bigl(2-\\frac{1}{\\beta}\\Bigr)\n",
" \\frac{\\langle \\Delta s\\,|\\,\\Delta h\\rangle}{\\langle \\Delta h\\,|\\,\\Delta h\\rangle},\n",
" \\qquad \\langle X|Y\\rangle = \\sum_i \\frac{X_i Y_i}{f_i^{eq}}. $$\n",
"\n",
"✓ **Сверено с первоисточником**: обе формулы (включая зеркальную форму и связь\n",
"$\\nu(\\beta)$) дословно совпадают с Karlin, Bösch, Chikatamarla, *PRE* **90**, 031302(R)\n",
"(2014) и Bösch et al., *PRE* **92**, 043309 (2015), ур. (24)–(25). Реализация —\n",
"`solver_2x_sdf/collision.py` (с регуляризацией $\\gamma\\to2$ при\n",
"$\\langle\\Delta h|\\Delta h\\rangle\\to0$).\n",
"\n",
"При $\\gamma=2$: $2\\Delta s+2\\Delta h=2(f-f^{eq})$ — KBC **сводится к BGK** с $\\omega=2\\beta$.\n",
"**Самопроверка** (`checks_gpu.py`): KBC$(\\gamma{=}2)\\equiv$ BGK до машинного нуля; столкновение\n",
"сохраняет $\\rho$ и $\\rho\\mathbf u$.\n",
"\n",
"**Популярно.** Разложение $k+s+h$ — три «слоя» движения: перенос массы/импульса, вязкий сдвиг\n",
"(трение слоёв — задаёт вязкость и Re) и мелкая «рябь» высших мод, где на грубой сетке копится\n",
"неустойчивость. KBC гасит сдвиг фиксированным темпом $2\\beta$ (физика), а рябь — адаптивным\n",
"$\\gamma\\beta$: в спокойных зонах $\\gamma\\approx2$ (как BGK), у резких градиентов $\\gamma$\n",
"отклоняется и добавляет ровно столько диссипации, сколько разрешает энтропия. Это «локальный\n",
"термостат потока» — встроенная, без подгоночных констант, модель мелких масштабов\n",
"(*implicit LES*)."
]
},
{
"cell_type": "markdown",
"id": "9eba837e",
"metadata": {},
"source": [
"## 5. Перенос и связь вязкости с $\\beta$; H-функция\n",
"\n",
"Перенос — сдвиг каждой популяции на её $\\mathbf c_i$ (pull-схема, `solver_2x_sdf/streaming.py`).\n",
"Вязкость: $\\nu = c_s^2(\\tfrac{1}{2\\beta}-\\tfrac12)$, т.е. $\\tau=\\nu/c_s^2+\\tfrac12$.\n",
"Число Рейнольдса $\\mathrm{Re}=UL/\\nu$; Маха $\\mathrm{Ma}=U/c_s\\lesssim0.1$ (ошибка\n",
"сжимаемости $\\propto \\mathrm{Ma}^2$).\n",
"\n",
"Дискретная энтропия $H=\\sum_i f_i\\ln(f_i/w_i)$ — «физический компас» устойчивости: из условия\n",
"«$H$ не растёт» выведен $\\gamma$; в демо Тейлора–Грина ниже $\\langle H\\rangle(t)$ монотонно\n",
"не возрастает.\n",
"\n",
"## 6. Алгоритм одного шага KBC\n",
"\n",
"```\n",
"для каждого узла:\n",
" 1) макро-моменты: rho = sum f_i, rho*u = sum c_i f_i\n",
" 2) равновесие: f_i^eq (энтропийное product-form)\n",
" 3) неравновесие: Ds = P_s (f - f^eq), Dh = (f - f^eq) - Ds\n",
" 4) стабилизатор: gamma = 1/beta - (2 - 1/beta) <Ds|Dh> / <Dh|Dh>\n",
" 5) столкновение: f <- f - beta (2 Ds + gamma Dh)\n",
" 6) перенос: f_i(x + c_i) <- f_i(x)\n",
" 7) граничные условия (bounce-back / Bouzidi / Zou-He / free-slip)\n",
"```\n",
"\n",
"![Блок-схема шага KBC](figures/06_algorithm_flow.png)"
]
},
{
"cell_type": "markdown",
"id": "a27e6548",
"metadata": {},
"source": [
"---\n",
"# Часть II. Валидационные демо\n",
"\n",
"Четыре классических теста; каждый гоняется скриптом из `demos_gpu/` **на тех же модулях, что\n",
"финальная модель** (решётка/равновесие/KBC/перенос из `solver_2x_sdf`). Числа ниже цитируются\n",
"из `demos_gpu/out/*.txt` — перегенерируются при каждом прогоне на GPU-сервере.\n",
"\n",
"## 7. Демо-1: вихрь Тейлора–Грина — валидация вязкости и H-теоремы\n",
"\n",
"Точное решение несжимаемого Навье–Стокса в периодической области:\n",
"$u_x=-U_0\\cos(kx)\\sin(ky)$, $u_y=U_0\\sin(kx)\\cos(ky)$, $k=2\\pi/N$; кинетическая энергия\n",
"затухает как $E(t)=E_0\\,e^{-4\\nu k^2 t}$. Прогоняем KBC ($N=96$, $\\nu=0.0125$, 10 000 шагов)\n",
"и **измеряем** $\\nu$ по наклону $\\ln E(t)$ — критерий приёмки: отклонение $<6\\%$\n",
"(фактически $\\lesssim1\\%$, см. `demos_gpu/out/tgv.txt`). $\\langle H\\rangle(t)$ монотонно\n",
"не возрастает — H-теорема выполняется.\n",
"\n",
"![TGV: завихренность, затухание энергии, H-функция](figures/07_taylor_green.png)\n",
"\n",
"![Распад вихря: 10 срезов на единой шкале цвета](figures/07b_tgv_decay_slices.png)\n",
"\n",
"![Анимация затухания](anim/tgv_decay.gif)\n",
"\n",
"*Скрипт: `demos_gpu/tgv_gpu.py`.*"
]
},
{
"cell_type": "markdown",
"id": "0f68444b",
"metadata": {},
"source": [
"## 8. Демо-2: устойчивость — двойной сдвиговый слой (BGK против KBC)\n",
"\n",
"Классический тест Minion–Brown: тонкие сдвиговые слои на **недоразрешённой** сетке\n",
"($N=128$, $\\nu=1.7\\cdot10^{-4}$, $\\mathrm{Re}\\sim3\\cdot10^4$). Стандартный BGK\n",
"(+полиномиальное равновесие) при малой вязкости разрушается (отрицательные популяции → NaN),\n",
"а KBC за счёт адаптивного $\\gamma$ остаётся устойчивым и гладко сворачивает вихри — это и есть\n",
"*implicit LES*. На средней панели — поле $\\gamma-2$: стабилизатор отклоняется от BGK-предела\n",
"именно там, где градиенты резкие.\n",
"\n",
"![Сдвиговый слой: KBC устойчив, BGK разрушается](figures/08_shear_stability.png)\n",
"\n",
"![BGK (слева, замирает в момент разрушения) против KBC (справа)](anim/shear_bgk_vs_kbc.gif)\n",
"\n",
"*Скрипт: `demos_gpu/shear_gpu.py`; момент разрушения BGK — в `demos_gpu/out/shear.txt`.*"
]
},
{
"cell_type": "markdown",
"id": "d8ebf6c3",
"metadata": {},
"source": [
"## 9. Демо-3: каверна с движущейся крышкой против эталона Ghia (Re=1000)\n",
"\n",
"Квадратная каверна, верхняя стенка движется со скоростью $U$. На неподвижных стенках —\n",
"**bounce-back**; на крышке — bounce-back с поправкой импульса подвижной стенки\n",
"$-2w_i\\rho\\,(\\mathbf c_i\\cdot\\mathbf u_w)/c_s^2$. Профили скорости по центральным линиям\n",
"сравниваются с эталоном **Ghia, Ghia & Shin (1982)**. Сетка намеренно грубая ($N=110$) и\n",
"bounce-back первого порядка, поэтому совпадение **качественное** (RMS-отклонение —\n",
"в `demos_gpu/out/cavity.txt`); оно улучшается измельчением и ГУ второго порядка.\n",
"\n",
"![Каверна: линии тока и сравнение с Ghia](figures/09_cavity_ghia.png)\n",
"\n",
"![Становление течения в каверне](anim/cavity_transient.gif)\n",
"\n",
"*Скрипт: `demos_gpu/cavity_gpu.py`; профили сохраняются в `demos_gpu/out/cavity.npz`.*"
]
},
{
"cell_type": "markdown",
"id": "761a536f",
"metadata": {},
"source": [
"## 10. Демо-4: обтекание тел разной формы — дорожки Кармана\n",
"\n",
"Канал с втоком слева (Дирихле + плавный разгон), нуль-градиентным оттоком и стенками.\n",
"Тела (цилиндр, квадрат, треугольник, профиль под углом атаки) — **узловой bounce-back** по\n",
"маске. При $\\mathrm{Re}\\approx150$ за телами формируется **вихревая дорожка Кармана**;\n",
"KBC держит грубую сетку устойчиво. Колебания $u_y$ в зонде за телом — мерило частоты срыва.\n",
"\n",
"Именно «лесенка» ступенчатой маски — мотивация перехода к **SDF + Bouzidi** в части IV.\n",
"\n",
"![Четыре тела: поле завихренности в конце прогона](figures/10_obstacle_shapes.png)\n",
"\n",
"![Зонд в следе: колебания = срыв вихрей](figures/10b_obstacle_probe.png)\n",
"\n",
"![Анимация всех четырёх тел](anim/obstacle_shapes.gif)\n",
"\n",
"*Скрипт: `demos_gpu/obstacle_gpu.py`.*"
]
},
{
"cell_type": "markdown",
"id": "2056c378",
"metadata": {},
"source": [
"---\n",
"# Часть III. Измельчение сетки (AMR)\n",
"\n",
"## 11. Блочное измельчение: зачем и как\n",
"\n",
"Чистый LBM живёт на **равномерной** решётке: перенос требует соседа ровно на $\\mathbf c_i$.\n",
"Неоднородное разрешение делают **вложенными равномерными блоками** разного шага: крупный\n",
"вдали, мелкий у тела и в следе. На стыке блоков ($\\Delta x\\to\\Delta x/2$,\n",
"$\\Delta t\\to\\Delta t/2$ — акустическое масштабирование, $c_s$ сохраняется):\n",
"\n",
"- **время релаксации**: $\\beta_f = \\dfrac{1}{1+r\\,(1/\\beta_c-1)}$, что при $r=2$ эквивалентно\n",
" $\\boxed{\\tau_f = 2\\tau_c - \\tfrac12}$ (вязкость $\\nu$ непрерывна через стык);\n",
"- **неравновесная часть** масштабируется: $f^{neq}_f = \\dfrac{\\beta_c}{r\\,\\beta_f}\\,f^{neq}_c\n",
" = \\dfrac{\\tau_f}{2\\tau_c}\\,f^{neq}_c$ (обратно — множитель $\\tfrac{2\\tau_c}{\\tau_f}$);\n",
"- **ghost-рамка** тонкого блока заполняется равновесием по интерполированным $\\rho,\\mathbf u$\n",
" плюс масштабированным $f^{neq}$; между подшагами тонкого уровня граница **линейно\n",
" интерполируется по времени** между состояниями родителя в $t$ и $t+\\Delta t$;\n",
"- **рестрикция**: внутренние узлы родителя обновляются с тонкого блока (каждый $r$-й узел)\n",
" с обратным масштабом $f^{neq}$.\n",
"\n",
"✓ **Сверено с первоисточниками**: формулы $\\beta_f$ и множитель $\\beta_c/(r\\beta_f)$ —\n",
"Dorschner, Frapolli, Chikatamarla, Karlin, *PRE* **94**, 053311 (2016), ур. (34), (40)\n",
"(KBC полностью совместим с измельчением — $\\gamma$ поузловой на каждом уровне); временна́я\n",
"интерполяция границы между подшагами — Lagrava, Malaspinas, Latt, Chopard, *J. Comput. Phys.*\n",
"**231**, 4808 (2012). Реализация — `solver_2x_sdf/amr.py` (`patch/ghost/fill/restrict`),\n",
"`R01 = τ_f/(2τ_c)` в `config.py`.\n",
"\n",
"![Схема блочного измельчения поверх реального поля |ω|](figures/11c_refinement_schematic.png)"
]
},
{
"cell_type": "markdown",
"id": "6645f68a",
"metadata": {},
"source": [
"## 12. Минимальный 2-уровневый AMR на цилиндре\n",
"\n",
"Рабочая связка «крупная сетка + мелкий блок 2× вокруг тела» (то же тело bounce-back на обоих\n",
"уровнях): на 1 крупный шаг — 2 мелких подшага, ghost из крупного уровня, рестрикция только по\n",
"жидким узлам. В этой *минимальной* версии граница блока держится постоянной в течение\n",
"подшагов (без временно́й интерполяции) — её эффект виден в бенчмарке ниже.\n",
"\n",
"![Минимальный AMR: крупное поле + мелкий блок 2× (жёлтая рамка)](anim/amr_cylinder.gif)\n",
"\n",
"*Скрипт: `demos_gpu/amr_demo_gpu.py` (часть 1) — на `amr.patch/ghost/fill/restrict`\n",
"финальной модели.*\n",
"\n",
"## 13. Полный вложенный 2×+4× и численное сравнение\n",
"\n",
"Три уровня (коарс → 2× → 4×), рекурсивный sub-cycling 1:2:4, временна́я интерполяция границ.\n",
"Контролируемый тест — локализованный резкий вихрь; эталон — равномерная мелкая 4× сетка.\n",
"Работа меряется в **cell-updates** (аппаратно-независимо).\n",
"\n",
"![AMR: точность и фронт «точность–работа»](figures/11d_amr_comparison.png)\n",
"\n",
"Выводы (точные числа — `demos_gpu/out/amr_bench.txt`):\n",
"- **вложенность эффективна**: полный 2×+4× точнее одиночного 4× при заметно меньшей работе;\n",
"- **временна́я интерполяция** снижает ошибку при той же работе;\n",
"- полный AMR даёт точность уровня мелкой сетки за **в разы меньшую работу**;\n",
"- крупная сетка дёшева, но её ошибка принципиально не лечится без измельчения.\n",
"\n",
"![Вложенный AMR: видны три размера ячеек](anim/amr_nested.gif)\n",
"\n",
"## 14. AMR на большой области (исторические прогоны)\n",
"\n",
"Та же связка на большой области (150×72, длинный прогон) — видны грубая сетка и учащённые\n",
"зоны: **2× (тело+след)** и **4× (у тела)**:\n",
"\n",
"![Одиночный 2× AMR на цилиндре](anim/amr2x_cyl.gif)\n",
"\n",
"![Вложенный 2×+4× AMR на цилиндре](anim/amr_nested_cyl.gif)"
]
},
{
"cell_type": "markdown",
"id": "ed665c26",
"metadata": {},
"source": [
"---\n",
"# Часть IV. Граница SDF + Bouzidi\n",
"\n",
"## 15. Каталог граничных условий\n",
"\n",
"KBC задаёт только оператор столкновения; полному решателю нужны ГУ:\n",
"\n",
"- **Bounce-back** — no-slip стенка «на полпути между узлами»: $f_{\\bar i}=f_i$. Первый\n",
" порядок; для движущейся стенки — поправка $-2w_i\\rho(\\mathbf c_i\\cdot\\mathbf u_w)/c_s^2$.\n",
"- **Интерполированный bounce-back (Bouzidi)** — для **кривых** стенок: доля стенки на линке\n",
" $q\\in(0,1)$ из SDF; две ветви:\n",
" $$ q<\\tfrac12:\\;\\; f_{\\bar i}(\\mathbf x_f) = 2q\\,f_i^{post}(\\mathbf x_f) + (1-2q)\\,f_i^{post}(\\mathbf x_f-\\mathbf c_i);\\qquad\n",
" q\\ge\\tfrac12:\\;\\; f_{\\bar i}(\\mathbf x_f) = \\tfrac{1}{2q}\\,f_i^{post}(\\mathbf x_f) + \\bigl(1-\\tfrac{1}{2q}\\bigr)f_{\\bar i}^{post}(\\mathbf x_f). $$\n",
" ✓ Bouzidi, Firdaouss, Lallemand, *Phys. Fluids* **13**, 3452 (2001); реализация —\n",
" `solver_2x_sdf/boundary.py` (`build_bc`/`apply_bc`).\n",
"- **Zou–He** — заданные скорость/давление на входе/выходе (восстановление недостающих\n",
" популяций из $\\rho,\\mathbf u$ + bounce-back неравновесной части; второй порядок).\n",
" ✓ Zou & He, *Phys. Fluids* **9**, 1591 (1997); реализация — `channel_bc`. На давление-выходе\n",
" поперечная скорость задаётся режимом `outlet_uy`: классическое жёсткое $u_y=0$ либо\n",
" нуль-градиент (мягкий выход — не отражает вихри дорожки; см. факторное исследование,\n",
" раздел 24).\n",
"- **Free-slip (specular)** — скользящая стенка: касательный импульс сохраняется, нормальный\n",
" отражается; массу не дрейфует. Реализация — `free_slip_walls`.\n",
"- **Sponge / absorbing-слой** — плавный рост вязкости перед выходом; гасит вихри и акустику до\n",
" отражающей границы. В финальной модели **добавлен** по итогам факторного исследования\n",
" (раздел 25): без него длинные домены с парой «вход-скорость / выход-давление» работают как\n",
" недодемпфированный акустический резонатор. Использовался и в прежней 3D-реализации.\n",
"- **Grad / регуляризованные** — реконструкция $f^{neq}$ через $\\Pi^{neq}$; в 2D-модели\n",
" не понадобились.\n",
"\n",
"## 16. Зачем SDF: лесенка против гладкой границы\n",
"\n",
"Ступенчатая маска (часть II, демо-4) приближает окружность «лесенкой»: положение стенки\n",
"скачет на $O(\\Delta x)$, что шумит в силе и завышает $C_d$. **SDF** (signed distance field,\n",
"$\\varphi<0$ в теле) даёт точную долю пересечения каждого линка $q=\\varphi_f/(\\varphi_f-\\varphi_s)$,\n",
"и Bouzidi восстанавливает отражение **в правильной точке** — граница эффективно гладкая\n",
"при том же разрешении.\n",
"\n",
"Сравнение реализаций без SDF (bounce-back) и с SDF+Bouzidi на одинаковой сетке:\n",
"\n",
"![Зонды: AMR с SDF](figures/11f_amr_sdf_probe_comparison.png)\n",
"\n",
"![Сводное сравнение SDF vs ступенчатая граница](figures/11g_sdf_vs_nosdf.png)\n",
"\n",
"![Цилиндр 2× AMR + SDF](anim/amr2x_cyl_sdf.gif)"
]
},
{
"cell_type": "markdown",
"id": "9d235a12",
"metadata": {},
"source": [
"---\n",
"# Часть V. Эволюция GPU-макета\n",
"\n",
"## 17. Поколения\n",
"\n",
"| Поколение | Файлы | Что нового | Известные проблемы (чинились в следующем) |\n",
"|---|---|---|---|\n",
"| 0. CPU-прототипы | `_amr_anims.py`, `_amr_anims_sdf.py` | первая полная связка: KBC + AMR (none/2×/2×+4×) на цилиндре; ветка с SDF+Bouzidi | numpy-CPU медленный; сила в BB-ветке считалась из post-collision |\n",
"| 1. Базовый GPU | `amr_gpu_core.py` + `amr_cylinder_gpu.py`, `amr_cylinder_sdf_gpu.py` | перенос на CuPy (~100× быстрее), оба типа границы в одном ядре | **сила `force_on`: оба члена из post-collision → Cd отрицательный** |\n",
"| 2. Оптимизированный GPU | `amr_opt/*` | **фикс силы** (второй член из post-streaming); `feq` без `pow`; `cp.fuse`; **CUDA-графы** с рантайм-самопроверкой; GPU-only | монолитные скрипты, параметры захардкожены |\n",
"| 3. Финальная модель | `solver_2x_sdf/*` | модульная архитектура; **Zou-He вход + давление-выход**; **free-slip стенки**; GMEM-сила с торком; **кросс-чек ∮σ·n ds**; серия по блокировке | — |\n",
"\n",
"## 18. Хронология багов и фиксов (чему научил макет)\n",
"\n",
"1. **Сила: второй член — из поля ПОСЛЕ стриминга.** В обмене импульсом\n",
" $F=\\sum c_i(f_i^{post}+f_{\\bar i})$ популяция $f_{\\bar i}$ — та, что *вернулась* в жидкий\n",
" узел после отражения. Если взять её из post-collision поля (как в поколении 1), ведущий\n",
" симметричный член $\\sim2\\rho w_i$ сокращается: $C_d$ выходил **отрицательным** и\n",
" схлопывался к нулю при измельчении. Урок: считывание силы валидируется так же строго,\n",
" как сам решатель.\n",
"2. **Дрейф массы из-за ГУ выхода.** Грубый zero-gradient выход не выпускал поток: масса и\n",
" противодавление копились ($\\langle\\rho\\rangle\\uparrow$), поток глох, $C_d$ и rms $C_l$\n",
" уползали к нулю за 100k шагов. Вскрыто **диагностикой по окнам времени** (тренды\n",
" $\\langle\\rho\\rangle$, $C_d/\\rho$). Фикс: **Zou-He скоростной вход + давление-выход** —\n",
" масса заякорена ($\\langle\\rho\\rangle\\approx1.000$).\n",
"3. **Free-slip стенки канала** (specular) вместо no-slip: нет паразитного пограничного слоя\n",
" на искусственных стенках, блокировка уменьшается; цилиндр остаётся no-slip (Bouzidi).\n",
"4. **Стартовое возмущение** — короткий поперечный sin-импульс входа после разгона: вихревая\n",
" дорожка запускается за единицы периодов вместо тысяч шагов симметричного «застоя».\n",
"5. **Зонд скорости — на тонком уровне L1** (меньше численного размытия вихрей) и **оценка St\n",
" с параболической интерполяцией пика FFT** (иначе St «застревает» на шаге сетки частот).\n",
"6. **CUDA-графы**: захват шага требовал убрать все host→device на горячем пути —\n",
" `cupy.stack` заменён преаллокацией (`backend.pack`), проектор KBC переписан без cuBLAS\n",
" (gemm тащит alpha/beta с хоста), границы clip запечены литералами в ядро. На шаге $t=1$ —\n",
" **рантайм-самопроверка** граф vs eager с автооткатом.\n",
"7. **Осреднение силы по подшагам L1 и момент $C_m$** — финальная ревизия считывания\n",
" (часть VI): сила меряется на каждом подшаге тонкого уровня, торк — с точкой приложения\n",
" на пересечении линка со стенкой."
]
},
{
"cell_type": "markdown",
"id": "e7b57ba6",
"metadata": {},
"source": [
"---\n",
"# Часть VI. Финальная модель `solver_2x_sdf`\n",
"\n",
"## 19. Архитектура\n",
"\n",
"Обтекание цилиндра: грубый уровень **L0** (174×90, $D=16$, $\\mathrm{Re}=150$, $U=0.07$) +\n",
"вложенный патч **L1** (×2 — тело и ближний след). Граница цилиндра — SDF+Bouzidi (no-slip);\n",
"вход/выход — Zou-He (скорость/давление); верх/низ — free-slip. Только GPU/CuPy, шаг целиком\n",
"захватывается в CUDA-граф.\n",
"\n",
"| Модуль | Ответственность |\n",
"|---|---|\n",
"| `config.py` | параметры + производные ($\\tau$, $\\beta$, патч из $N_y$); env-переопределения |\n",
"| `lattice.py` | D2Q9 + KBC-проектор $P_s$ |\n",
"| `equilibrium.py` | product-form $f^{eq}$, макромоменты |\n",
"| `collision.py` | KBC-N1 (единственный оператор) |\n",
"| `streaming.py` | pull-перенос |\n",
"| `geometry.py` | маски/SDF для L0 и L1 |\n",
"| `boundary.py` | Bouzidi + Zou-He + free-slip |\n",
"| `forces.py` | GMEM-сила и момент (считывание №1) |\n",
"| `forces_stress.py` | ∮σ·n ds по контуру (считывание №2, кросс-чек) |\n",
"| `amr.py` | связка L0↔L1 (часть III) |\n",
"| `solver.py` | оркестратор: persistent-буферы, CUDA-граф с самопроверкой |\n",
"| `diagnostics.py` | St (параболический пик), таблицы, кросс-чек, фиты серии по $\\beta_{бл}$ |\n",
"| `run.py` / `run_blockage.py` / `run_factors.py` | одиночный прогон / серия по блокировке / факторное исследование (реестр `out/factors.csv`) |\n",
"\n",
"## 20. Считывание силы №1: галилей-инвариантный обмен импульсом (GMEM)\n",
"\n",
"Для каждого линка жидкость→тело (Bouzidi-маска):\n",
"$$ \\Delta\\mathbf F = (\\mathbf c_i - \\mathbf u_w)\\,f_i^{post}(\\mathbf x_f)\n",
" - (\\mathbf c_{\\bar i} - \\mathbf u_w)\\,f_{\\bar i}^{stream}(\\mathbf x_f), $$\n",
"где $f_i^{post}$ — популяция, уходящая в стенку (после столкновения), $f_{\\bar i}^{stream}$ —\n",
"вернувшаяся (после стриминга/Bouzidi), $\\mathbf u_w$ — скорость стенки (для неподвижного\n",
"цилиндра $0$, но члены с $\\mathbf u_w$ делают оценку **галилей-инвариантной** и готовой к\n",
"подвижным телам — цель SimV4). ✓ Wen, Zhang, Tu, Wang, Fang, *J. Comput. Phys.* **266**,\n",
"161 (2014).\n",
"\n",
"**Момент (торк)**: $T_z=\\sum_{links} (\\mathbf r_w\\times\\Delta\\mathbf F)_z$ с точкой приложения\n",
"на **пересечении линка со стенкой** $\\mathbf r_w=\\mathbf r_f+q\\,\\mathbf c_i$ ($q$ уже лежит в\n",
"структуре Bouzidi). $C_m = 2T_z/(U^2D_{L1}^2)$; для кругового цилиндра $\\langle C_m\\rangle\\approx0$ —\n",
"встроенный **контроль симметрии** считывания.\n",
"\n",
"Сила меряется на **каждом** подшаге L1 и усредняется (анти-алиасинг быстрых мод).\n",
"\n",
"## 21. Считывание силы №2: баланс импульса контрольного объёма (кросс-чек)\n",
"\n",
"Независимая оценка по замкнутому контуру (окружность $R+\\delta$, $\\delta=2$ тонкие ячейки —\n",
"вне зоны Bouzidi-реконструкции). Полный баланс импульса контрольного объёма:\n",
"$$ \\mathbf F_{тела} = \\oint \\bigl[\\sigma\\cdot\\mathbf n\n",
" - \\rho\\,\\mathbf u\\,(\\mathbf u\\cdot\\mathbf n)\\bigr]\\, ds\n",
" \\;-\\;\\underbrace{\\frac{d}{dt}\\!\\int_{кольцо}\\rho\\mathbf u\\,dV}_{\\to\\,0\\ в\\ среднем},\\qquad\n",
" \\sigma_{\\alpha\\beta} = -p'\\,\\delta_{\\alpha\\beta}\n",
" - \\Bigl(1-\\frac{1}{2\\tau_1}\\Bigr)\\,\\Pi^{neq,dev}_{\\alpha\\beta}. $$\n",
"Тонкости:\n",
"- давление без константы ($\\oint p_0\\mathbf n\\,ds\\equiv0$);\n",
"- строго **девиаторная** проекция $\\Pi^{neq}=\\sum_i \\mathbf c_i\\mathbf c_i(f_i-f_i^{eq})$ —\n",
" в KBC след релаксирует со стабилизатором $\\gamma\\beta$, а не $1/\\tau$, тогда как\n",
" сдвиг-моменты — с точным $2\\beta=1/\\tau$;\n",
"- **конвективный член** $-\\rho\\mathbf u(\\mathbf u\\cdot\\mathbf n)$ зануляется только при\n",
" $\\delta\\to0$ (no-slip); в первой версии кросс-чека он был опущен, что давало\n",
" **систематический сдвиг $+4\\%$ по $\\langle C_d\\rangle$ на всех сетках** (см. таблицы\n",
" разделов 22–23) — диагностическая ценность: смещение одинаково при любом $N_y$, т.е. это\n",
" свойство эстиматора, а не течения. Добавлен в `forces_stress.py`;\n",
"- сравнение — по средним ($\\langle C_d\\rangle$, rms $C_l$): мгновенные ряды сдвинуты по фазе\n",
" инерцией кольца $R..R{+}\\delta$ (член $d/dt$).\n",
"\n",
"**Согласие двух независимых методов = считывание силы корректно** — расхождение с\n",
"литературой тогда лежит в физике постановки, что и проверяют эксперименты разделов 23–24."
]
},
{
"cell_type": "markdown",
"id": "ccac6a82",
"metadata": {},
"source": [
"## 22. Результаты основного прогона (174×90, 100k шагов, 2026-06-09)\n",
"\n",
"Прогон на GPU-сервере: CuPy float32, CUDA-графы активны (self-check $\\Delta=0$), 271 с на\n",
"100k шагов L0 (вместе с L1-подшагами и гифкой).\n",
"\n",
"![Финальная модель: |ω| с патчем L1](solver_2x_sdf/out/cyl_2x_sdf.gif)\n",
"\n",
"![Ряды Cd/Cl/Cm, кросс-чек, спектр, ⟨ρ⟩(t)](solver_2x_sdf/out/series_2x_sdf.png)\n",
"\n",
"**Валидация** ($\\beta_{бл}=D/N_y=0.178$, осреднение по второй половине ряда):\n",
"\n",
"| | raw | ×(1−β)$^k$ | лит. безгранич. (Re≈150) |\n",
"|---|---|---|---|\n",
"| St | 0.221 | **0.181** | **0.183** ✓ (1%) |\n",
"| $C_d$ | 1.799 | 1.216 | 1.330 |\n",
"| rms $C_l$ | 0.7051 | 0.4767 | ~0.3 |\n",
"| $\\langle C_m\\rangle$ | **−0.00003** | | 0 ✓ |\n",
"\n",
"**Кросс-чек считывания** (версия ещё без конвективного члена — см. раздел 21):\n",
"\n",
"| метод | $\\langle C_d\\rangle$ | rms $C_l$ | $\\langle C_m\\rangle$ |\n",
"|---|---|---|---|\n",
"| GMEM (обмен импульсом) | 1.799 | 0.7051 | −0.00003 |\n",
"| ∮σ·n ds (контур R+δ) | 1.872 | 0.7252 | −0.00003 |\n",
"| расхождение | **4.1%** | 2.8% | — |\n",
"\n",
"Диагностика сходимости: $\\langle\\rho\\rangle = 1.001$ стабильна (масса заякорена); оконные\n",
"средние $C_d$ в окнах 6–10: 1.797, 1.813, 1.795, 1.797, 1.791 — плато; rms $C_l$ насыщен.\n",
"\n",
"**Выводы по считыванию** (всё подтверждается):\n",
"1. Два независимых метода согласуются в пределах 4% (систематика +4% объяснена опущенным\n",
" конвективным членом и закрыта — раздел 21).\n",
"2. $\\langle C_m\\rangle\\approx-3\\cdot10^{-5}$ при масштабе $C_l\\sim0.7$ — симметрия в норме.\n",
"3. $C_d=1.799$ воспроизводит предыдущую итерацию бит-в-бит: осреднение силы по подшагам L1\n",
" не сместило среднее (регрессии нет, ряды стали глаже).\n",
"4. Следовательно $C_d\\approx1.8$ — **истинная сила в смоделированной постановке**; вопрос\n",
" к расхождению с литературой — вопрос к *постановке*, а не к считыванию.\n",
"\n",
"## 23. Эксперимент №0: серия по боковой блокировке — гипотеза ОПРОВЕРГНУТА\n",
"\n",
"**Гипотеза**: завышение $C_d$/rms $C_l$ объясняется боковым стеснением канала; тогда при\n",
"$D=16$ фикс. и $N_y\\uparrow$ величины должны монотонно стремиться к безграничной литературе,\n",
"а экстраполяция $\\beta\\to0$ — попасть в $C_d\\approx1.33$, rms $C_l\\approx0.3$.\n",
"\n",
"**Постановка**: $N_y\\in\\{90,128,180\\}$ → $\\beta\\in\\{0.178,0.125,0.089\\}$; центр и патч\n",
"выводятся из $N_y$; всё остальное фиксировано. 3×100k шагов (по ~282 с).\n",
"\n",
"**Результат** (`python run_blockage.py`, 2026-06-09):\n",
"\n",
"| $N_y$ | β | St | St(1−β) | $\\langle C_d\\rangle$ | rms $C_l$ | $\\langle C_m\\rangle$ | $C_d$ σ·n | dCd |\n",
"|---|---|---|---|---|---|---|---|---|\n",
"| 90 | 0.178 | 0.221 | 0.181 | 1.799 | 0.7051 | −0.00003 | 1.872 | 4.1% |\n",
"| 128 | 0.125 | 0.196 | 0.171 | 1.771 | 0.6734 | −0.00007 | 1.838 | 3.8% |\n",
"| 180 | 0.089 | 0.193 | 0.176 | **1.870** | **0.8354** | +0.00043 | 1.937 | 3.6% |\n",
"\n",
"Экстраполяция $\\beta\\to0$ (интерсепт линейного / квадратичного фита, спред = неопределённость):\n",
"\n",
"| | лин. | квадр. | спред | лит. |\n",
"|---|---|---|---|---|\n",
"| $C_d(0)$ | 1.905 | 1.855 | 0.050 | 1.33 |\n",
"| rms $C_l(0)$ | 0.910 | 0.818 | 0.091 | ~0.3 |\n",
"| St(0) | 0.161 | **0.181** | 0.020 | **0.183** |\n",
"\n",
"![Серия по блокировке: Cd(β), rms Cl(β), St(β) с экстраполяцией](solver_2x_sdf/out/blockage_extrapolation.png)\n",
"\n",
"**Анализ:**\n",
"1. $C_d(\\beta)$ **немонотонен** (1.80 → 1.77 → 1.87), rms $C_l$ при самом широком канале даже\n",
" вырос — экстраполяция «$C_d(0)\\approx1.9$» физически абсурдна и означает одно: **β — не\n",
" управляющий параметр**. Поправка $(1-\\beta)^2$ из предыдущей итерации была совпадением на\n",
" одной точке ($N_y=90$).\n",
"2. Это согласуется с классической теорией продувок: поправки Аллена–Винченти\n",
" (solid + wake blockage) при $\\beta=0.178$ дают завышение лишь ~15–18%, при $\\beta=0.089$ —\n",
" ~10%. Боковое стеснение объясняло максимум половину превышения даже на исходной сетке.\n",
"3. **St — единственная величина с правильным поведением**: raw 0.221→0.196→0.193 монотонно\n",
" стремится к 0.183 сверху; квадратичная экстраполяция даёт **0.181 ≈ лит. 0.183**.\n",
" Кинематика вихреобразования (частота, эффективное Re) верна; завышены именно силовые\n",
" (давленческие) амплитуды.\n",
"4. **Улика — поведение $N_y=180$**: низкочастотная модуляция с размахом оконных $C_d$\n",
" 1.68–1.98 и rms $C_l$ 0.58–0.96 (период ~20–30k шагов; на 50k установившегося режима —\n",
" всего 2–3 цикла, статистика среднего не набрана; $\\langle C_m\\rangle$ на порядок больше,\n",
" чем на узких каналах). Чем шире канал, тем свободнее меандрирует дорожка — и тем сильнее\n",
" она бьётся о **жёсткое условие $u_y=0$ на выходе**, порождая отражения и обратную связь.\n",
"5. **Диагноз**: доминируют **продольные ограничения**, которые серия держала константой:\n",
" вход 2.5D до тела (жёсткий равномерный профиль), выход 8.3D (Zou-He давление + $u_y=0$ —\n",
" отражает вихри), стык L1→L0 на 6.25D. Их изолирует эксперимент №1.\n",
"\n",
"## 24. Эксперимент №1: факторное исследование продольных границ\n",
"\n",
"**Метод**: каждый подозреваемый фактор варьируется **отдельно** при фиксированной боковой\n",
"блокировке ($N_y=90$, $\\beta=0.178$) и 100k шагах — изоляция вкладов входа, выхода и\n",
"выходного ГУ. Реализация: `config.py` выводит x-границы патча из `cx` (патч едет за телом),\n",
"`boundary.channel_bc` получил режим `outlet_uy`:\n",
"- `\"zero\"` — классический Zou-He: $u_y=0$ жёстко (как во всех прогонах выше);\n",
"- `\"extrapolate\"` — $u_y$ нуль-градиент (берётся из столбца $x{=}-2$), якорь $\\rho=1$\n",
" сохраняется; алгебраически: $f_6 \\mathrel{+}= \\tfrac12\\rho u_y$, $f_7 \\mathrel{-}= \\tfrac12\\rho u_y$\n",
" при тех же $f_3$ и $u_x$ (балансы массы и импульса выполняются точно).\n",
"\n",
"| Фактор | Nx | cx | вход | выход | $u_y$-выход | что изолирует |\n",
"|---|---|---|---|---|---|---|\n",
"| B0_baseline | 174 | 40 | 2.5D | 8.3D | zero | контроль (+ кросс-чек с конвективным членом) |\n",
"| F1_outlet_far | 300 | 40 | 2.5D | 16.2D | zero | близость выхода |\n",
"| F2_inlet_far | 230 | 96 | 6.0D | 8.3D | zero | близость входа |\n",
"| F3_inout_far | 390 | 96 | 6.0D | 18.3D | zero | оба продольных расстояния |\n",
"| F4_outlet_uy | 174 | 40 | 2.5D | 8.3D | extrapolate | кинематическое отражение вихрей ГУ |\n",
"| F5_combo | 390 | 96 | 6.0D | 18.3D | extrapolate | «чистая» постановка |\n",
"\n",
"Метрики каждого прогона: St, $\\langle C_d\\rangle$, rms $C_l$, $\\langle C_m\\rangle$, кросс-чек\n",
"dCd (уже с конвективным членом), **std оконных средних** (мера НЧ-модуляции), $\\langle\\rho\\rangle$.\n",
"Каждый прогон фиксируется строкой в накопительном реестре `solver_2x_sdf/out/factors.csv`\n",
"(полная конфигурация + результаты + время) — документация всех изменений постановки.\n",
"\n",
"**Результаты** (`python run_factors.py`, 2026-06-10, 6×100k шагов по ~280 с):\n",
"\n",
"| Фактор | вход | выход | $u_y$-выход | St(1−β) | $\\langle C_d\\rangle$ | rms $C_l$ | dCd кросс | $\\langle\\rho\\rangle_{фин}$ | модуляция | статус |\n",
"|---|---|---|---|---|---|---|---|---|---|---|\n",
"| B0_baseline | 2.5D | 8.3D | zero | 0.181 | 1.799 | 0.705 | **1.0%** | 1.000 | 0.024 | ok |\n",
"| F1_outlet_far | 2.5D | 16.2D | zero | 0.163 | 2.838 | 6.794 | 1.6% | **1.663** | 0.225 | режим смещён |\n",
"| F2_inlet_far | 6.0D | 8.3D | zero | 0.138 | 2.229 | 6.120 | 9.7% | **1.655** | 0.306 | режим смещён (срыв на ~75k) |\n",
"| F3_inout_far | 6.0D | 18.3D | zero | 0.160 | 2.577 | 5.348 | 2.0% | **1.660** | 0.269 | режим смещён (срыв на ~20k) |\n",
"| **F4_outlet_uy** | 2.5D | 8.3D | extrapolate | **0.183** | **1.754** | **0.557** | **0.6%** | **1.0000** | **0.023** | **ok ✓** |\n",
"| F5_combo | 6.0D | 18.3D | extrapolate | — | — | — | — | NaN | — | **взрыв на 4.5k** |\n",
"\n",
"![Факторное исследование: Cd, rms Cl и НЧ-модуляция по факторам](solver_2x_sdf/out/factors_summary.png)\n",
"\n",
"**Анализ:**\n",
"1. **Конвективный член подтверждён**: кросс-чек B0 сжался с 4.1% до **1.0%** (F4 — 0.6%) при\n",
" неизменной физике. Считывание силы теперь согласовано двумя методами на уровне ~1%.\n",
"2. **F4 (мягкий $u_y$-выход) — выигрыш по всем метрикам**: rms $C_l$ 0.705→0.557 (−21%),\n",
" $\\langle C_d\\rangle$ 1.799→1.754, $\\langle\\rho\\rangle=1.0000$ (точнее базы), нулевая\n",
" НЧ-модуляция (окна 6–10: $C_d$ 1.753–1.755, идеальное плато) и **St·(1−β)=0.1828 ≈ лит.\n",
" 0.183 точно**. Жёсткое $u_y=0$ действительно кинематически отражало вихри.\n",
"3. **Главное открытие — длинные домены без демпфера невалидны.** Все три (F1/F2/F3, $u_y=0$)\n",
" уходят на смещённую ветвь $\\langle\\rho\\rangle\\approx1.66$ (F1 и F3 — за ~20k шагов, F2 —\n",
" на ~75k), где $C_d$, $C_l$ бессмысленны для сравнения (у F2 разваливается и кросс-чек:\n",
" 9.7% — поле у контура нестационарно «звенит»). Механизм: канал «вход-скорость +\n",
" выход-давление» — **недодемпфированный акустический резонатор**; затухание продольной моды\n",
" $\\propto\\nu(\\pi/N_x)^2$ падает в ~5 раз при $N_x$ 174→390. Стартовый транзиент раскачивает\n",
" моду, вход с фиксированной скоростью качает массу $\\propto\\rho_{in}$ (положительная обратная\n",
" связь) — система садится на новую ветвь равновесия потоков массы. **F5** — чистая картина\n",
" роста: $\\langle\\rho\\rangle$ по окнам осциллирует 1.04→0.93→1.05 с периодом\n",
" $\\approx 2N_x/c_s\\approx1350$ шагов (точно акустический период туда-обратно) → NaN на 4.5k.\n",
"4. При этом rms $u_y$ зонда нормальный (0.033–0.046) во **всех** прогонах — гидродинамика\n",
" дорожки жива; ломаются акустика и массовый баланс. Это ретроспективно объясняет и «загадку»\n",
" эксперимента №0: $N_y=180$ был зачатком той же моды (НЧ-модуляция), но поперечное расширение\n",
" менее опасно продольного.\n",
"\n",
"**Урок**: продольное удлинение требует **поглощающего слоя** — стандартное лекарство из\n",
"каталога ГУ (раздел 15), упоминавшееся ещё в прежней 3D-реализации.\n",
"\n",
"## 25. Эксперимент №2: губка (absorbing layer) перед выходом\n",
"\n",
"Реализация: в последних `sponge_len` столбцах L0 вязкость плавно (smoothstep) растёт до\n",
"$\\nu\\cdot$`sponge_nu_mult` (по умолчанию ×30 — локальное Re падает до ~5, вихри и акустика\n",
"диссипируют до прихода на выход). Технически $\\beta$ на L0 становится **полем** $\\beta(x)$\n",
"(KBC это допускает: $\\gamma$ и так поузловой; CUDA-граф не страдает — поле предвычислено);\n",
"патч L1 губку не видит (гарантировано assert'ом). Ручки: `AMR_SPONGE_LEN`, `AMR_SPONGE_NU`.\n",
"\n",
"| Фактор | геометрия | $u_y$-выход | губка | что проверяет |\n",
"|---|---|---|---|---|\n",
"| F6_sponge | 174×90 (база) | extrapolate | 32 | валидация губки против F4 (не портит ли короткий домен) |\n",
"| F7_long_sponge | 390×90, вход 6D / выход 18.3D | extrapolate | 32 | **целевая «чистая» постановка** |\n",
"| F8_long_sp_uy0 | 390×90 | zero | 32 | изоляция: достаточно ли губки без мягкого $u_y$ |\n",
"\n",
"> ⏳ *Числа появятся после `python run_factors.py` с факторами F6–F8 (3×100k ≈ 15 мин).*\n",
"\n",
"**Критерии**: у всех $\\langle\\rho\\rangle\\approx1.000$ и нет НЧ-модуляции; F7 — ожидание\n",
"$\\langle C_d\\rangle\\approx1.5$–1.6 (остаётся боковая блокировка β=0.178: по Аллену–Винченти\n",
"+15–18% к безграничному 1.33) и rms $C_l<0.45$. Если критерии выполнены — **эксперимент №3**:\n",
"повторная серия по β ($N_y=90/128/180$) на конфигурации F7 → финальная экстраполяция к\n",
"безграничному пределу. Остаточное расхождение после неё — фактор разрешения (D-рефайнмент)."
]
},
{
"cell_type": "markdown",
"id": "0afa973f",
"metadata": {},
"source": [
"---\n",
"# Часть VII. Перенос на D3Q27 и связь с целевой реализацией\n",
"\n",
"Вся математика KBC переносится в 3D **без изменений по сути**: решётка D3Q27 (веса\n",
"$8/27, 2/27, 1/54, 1/216$ — изотропия 4-го порядка, предпочтительна для KBC/турбулентности),\n",
"моментный базис 27×27, сдвиговая часть $s$ — девиаторный тензор (5 независимых компонент).\n",
"Формула $\\gamma$ и оператор $f\\leftarrow f-\\beta(2\\Delta s+\\gamma\\Delta h)$ идентичны.\n",
"\n",
"![Решётка D3Q27](figures/11_d3q27.png)\n",
"\n",
"Чем настоящий KBC отличается от прежнего подхода в 3D-движке (poly eq + TRT + α-лимитер +\n",
"Smagorinsky):\n",
"\n",
"| Аспект | Настоящий KBC | Прежний подход |\n",
"|---|---|---|\n",
"| Равновесие | энтропийное product-form | полиномиальное $O(u^2)$ |\n",
"| Столкновение | энтропийный MRT, $k/s/h$ | TRT (симм./антисимм.) |\n",
"| Стабилизация | $\\gamma$ из энтропии | α-лимитер положительности |\n",
"| H-теорема | встроена в вывод $\\gamma$ | контролируется отдельно |\n",
"| Турбулентность | implicit LES (параметр-free) | явный Smagorinsky |\n",
"| Решётка | D3Q27 | D3Q27 (совпадает) |\n",
"\n",
"**Дорожная карта переноса** (всё отработано на 2D-макете): (1) product-form $f^{eq}$;\n",
"(2) проекторы $P_s$ для D3Q27 (предвычислить); (3) per-node $\\gamma$; (4) столкновение;\n",
"(5) SDF+Bouzidi и GMEM-сила с $\\mathbf u_w$ на линке (подвижные тела) + кросс-чек ∮σ·n dA по\n",
"SDF-изоповерхности; (6) AMR-связка $\\tau_f=2\\tau_c-\\tfrac12$ с временно́й интерполяцией."
]
},
{
"cell_type": "markdown",
"id": "d0691787",
"metadata": {},
"source": [
"---\n",
"# Часть VIII. Сверка формул с первоисточниками\n",
"\n",
"Все формулы, ранее помеченные «⚠ сверить с оригиналом», проверены по первоисточникам\n",
"(плюс численная самопроверка каждой в `demos_gpu/checks_gpu.py`):\n",
"\n",
"| Формула | Источник | Статус |\n",
"|---|---|---|\n",
"| Product-form $f^{eq}$: $A(u)=2-\\sqrt{1+3u^2}$, $B(u)=\\frac{2u+\\sqrt{1+3u^2}}{1-u}$ | Bösch/Chikatamarla/Karlin, PRE **92**, 043309 (2015), ур. (7)–(11); Ansumali/Karlin/Öttinger, EPL **63**, 798 (2003) | ✓ дословно |\n",
"| $f' = f-\\beta(2\\Delta s+\\gamma\\Delta h)$, $\\beta=1/(2\\tau)$, mirror-форма | Karlin/Bösch/Chikatamarla, PRE **90**, 031302(R) (2014) | ✓ дословно |\n",
"| $\\gamma^\\*=\\frac1\\beta-(2-\\frac1\\beta)\\frac{\\langle\\Delta s|\\Delta h\\rangle}{\\langle\\Delta h|\\Delta h\\rangle}$, $\\langle X|Y\\rangle=\\sum X_iY_i/f_i^{eq}$ | там же; PRE **92**, ур. (24)–(25) | ✓ дословно |\n",
"| Состав $s=\\{N,\\Pi_{xy}\\}$ (девиатор; «KBC-d»/N1) | PRE **92** (семейство $s\\in\\{d,d{+}t,d{+}q,d{+}t{+}q\\}$); Dorschner et al., JFM **801**, 623 (2016) | ✓ |\n",
"| AMR: $\\beta_f=\\frac{1}{1+r(1/\\beta_c-1)}$ ⇔ $\\tau_f=2\\tau_c-\\frac12$; $f^{neq}\\cdot\\frac{\\beta_c}{r\\beta_f}=\\frac{\\tau_f}{2\\tau_c}f^{neq}$ | Dorschner/Frapolli/Chikatamarla/Karlin, PRE **94**, 053311 (2016), ур. (34), (40) | ✓ дословно |\n",
"| Временна́я интерполяция ghost-границы | Lagrava/Malaspinas/Latt/Chopard, JCP **231**, 4808 (2012) | ✓ |\n",
"| Bouzidi: ветви $q<\\frac12$ / $q\\ge\\frac12$ | Bouzidi/Firdaouss/Lallemand, Phys. Fluids **13**, 3452 (2001) | ✓ |\n",
"| Zou-He: скоростной вход / давление-выход | Zou & He, Phys. Fluids **9**, 1591 (1997) | ✓ |\n",
"| GMEM: $(\\mathbf c_i-\\mathbf u_w)f_i^{post}-(\\mathbf c_{\\bar i}-\\mathbf u_w)f_{\\bar i}^{stream}$ | Wen/Zhang/Tu/Wang/Fang, JCP **266**, 161 (2014) | ✓ |\n",
"| $\\sigma^v=-(1-\\frac{1}{2\\tau})\\Pi^{neq}$ (Чепмен–Энског) | стандарт (Krüger et al., *The Lattice Boltzmann Method*, 2017) | ✓ |\n",
"\n",
"**Литература:**\n",
"- I. V. Karlin, F. Bösch, S. S. Chikatamarla, *Gibbs' principle for the lattice-kinetic theory\n",
" of fluid dynamics*, Phys. Rev. E **90**, 031302(R) (2014).\n",
"- F. Bösch, S. S. Chikatamarla, I. V. Karlin, *Entropic multirelaxation lattice Boltzmann\n",
" models for turbulent flows*, Phys. Rev. E **92**, 043309 (2015) (arXiv:1507.02518).\n",
"- S. Ansumali, I. V. Karlin, H. C. Öttinger, *Minimal entropic kinetic models for\n",
" hydrodynamics*, Europhys. Lett. **63**, 798 (2003).\n",
"- B. Dorschner, F. Bösch, S. S. Chikatamarla, K. Boulouchos, I. V. Karlin, *Entropic\n",
" multi-relaxation time lattice Boltzmann model for complex flows*, J. Fluid Mech. **801**,\n",
" 623–651 (2016).\n",
"- B. Dorschner, N. Frapolli, S. S. Chikatamarla, I. V. Karlin, *Grid refinement for entropic\n",
" lattice Boltzmann models*, Phys. Rev. E **94**, 053311 (2016) (arXiv:1608.06915).\n",
"- D. Lagrava, O. Malaspinas, J. Latt, B. Chopard, *Advances in multi-domain lattice Boltzmann\n",
" grid refinement*, J. Comput. Phys. **231**, 4808 (2012).\n",
"- M. Bouzidi, M. Firdaouss, P. Lallemand, *Momentum transfer of a Boltzmann-lattice fluid with\n",
" boundaries*, Phys. Fluids **13**, 3452 (2001).\n",
"- Q. Zou, X. He, *On pressure and velocity boundary conditions for the lattice Boltzmann BGK\n",
" model*, Phys. Fluids **9**, 1591 (1997).\n",
"- B. Wen, C. Zhang, Y. Tu, C. Wang, H. Fang, *Galilean invariant fluid–solid interfacial\n",
" dynamics in lattice Boltzmann simulations*, J. Comput. Phys. **266**, 161 (2014).\n",
"- Z. Guo, C. Zheng, B. Shi, *Discrete lattice effects on the forcing term in the lattice\n",
" Boltzmann method*, Phys. Rev. E **65**, 046308 (2002).\n",
"- U. Ghia, K. N. Ghia, C. T. Shin, *High-Re solutions for incompressible flow using the\n",
" Navier-Stokes equations and a multigrid method*, J. Comput. Phys. **48**, 387 (1982)."
]
},
{
"cell_type": "markdown",
"id": "a8cc8c0d",
"metadata": {},
"source": [
"---\n",
"# Часть IX. Итоги\n",
"\n",
"- **KBC** = энтропийный MRT-LBM: сохраняющиеся моды не трогаем, сдвиг релаксируем фиксированным\n",
" $2\\beta$ (вязкость), высшие моды — адаптивным $\\gamma$ из условия неувеличения энтропии.\n",
" При $\\gamma=2$ — в точности BGK. Все формулы сверены с первоисточниками и самопроверены\n",
" численно.\n",
"- **Бенчмарки** (на компонентах финальной модели): вязкость по TGV (<6%, факт ≲1%), H-теорема,\n",
" устойчивость на сдвиговом слое (BGK разрушается — KBC держит), каверна ≈ Ghia, дорожки\n",
" Кармана на четырёх телах.\n",
"- **Измельчение сетки**: связка $\\tau_f=2\\tau_c-\\tfrac12$ + масштабирование $f^{neq}$ +\n",
" временна́я интерполяция ghost; вложенный 2×+4× даёт точность мелкой сетки за в разы меньшую\n",
" работу.\n",
"- **Финальная модель** `solver_2x_sdf`: KBC-N1 + SDF/Bouzidi + Zou-He + free-slip + CUDA-графы.\n",
" Главные уроки эволюции: второй член силы — из post-streaming; дрейф массы лечится\n",
" давлением-выходом; зонд и сила — на тонком уровне; графы требуют чистоты горячего пути.\n",
"- **Считывание сил ДОКАЗАНО корректным** (разделы 22, 24): два независимых метода — GMEM с\n",
" торком и баланс импульса ∮[σ·n−ρu(u·n)]ds — после добавления конвективного члена согласуются\n",
" на уровне **0.6–1.0%** по $\\langle C_d\\rangle$; $\\langle C_m\\rangle\\approx0$; масса стабильна.\n",
"- **Исследование факторов постановки** (разделы 23–25):\n",
" - эксперимент №0 (серия по β) **опроверг** гипотезу бокового стеснения: $C_d(\\beta)$\n",
" немонотонен; при этом **St(β→0)=0.181 ≈ лит. 0.183** — кинематика верна;\n",
" - эксперимент №1 (6 факторов): **мягкий $u_y$-выход (F4) улучшил всё** (rms $C_l$ −21%,\n",
" St·(1−β)=0.183 точно, идеальное плато) — жёсткое $u_y=0$ отражало вихри. **Длинные домены\n",
" без демпфера невалидны**: пара «вход-скорость / выход-давление» — недодемпфированный\n",
" акустический резонатор (затухание ∝ν(π/Nx)²); масса накачивается до смещённой ветви\n",
" ⟨ρ⟩≈1.66 либо взрыв (F5, период осцилляций ⟨ρ⟩ ≈ 2Nx/c_s — прямая улика);\n",
" - эксперимент №2 (в работе): **губка перед выходом** (β(x)-поле на L0) — F6/F7/F8; затем\n",
" эксперимент №3 — серия по β на чистой постановке F7 → финальная экстраполяция.\n",
"- **Реестр всех прогонов** — `solver_2x_sdf/out/factors.csv`: полная конфигурация + метрики\n",
" каждого изменения постановки (готовый материал для отдельного исследования факторов).\n",
"- **Дальше**: эксперименты №2–3 → финальные числа; при остаточном превышении — фактор\n",
" разрешения (D-рефайнмент). Затем перенос отработанной схемы на D3Q27/GPU в основной движок\n",
" (часть VII)."
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
+160
View File
@@ -0,0 +1,160 @@
# solver_2x_sdf — модель цилиндра 2×+SDF, разложенная по компонентам
Самодостаточный LBM-решатель (D2Q9, KBC) для обтекания цилиндра: грубый уровень **L0**
плюс один вложенный патч **L1** (измельчение ×2). Граница цилиндра — **SDF + Bouzidi (no-slip)**;
вход/выход — **Zou-He** (скорость/давление); верх/низ канала — **free-slip (specular)**.
Только GPU/CuPy — **CPU-фолбэка нет**. Запуск — из этой папки: `python run.py`
(серия по блокировке: `python run_blockage.py`).
## Карта файлов (каждый компонент изолирован и легко меняется)
| Файл | Ответственность |
|---|---|
| `backend.py` | жёсткое требование CuPy/GPU, `DTYPE`, device-скаляры, `to_cpu` |
| `config.py` | все параметры модели + производные (τ, β, R, DL, cy/патч из Ny); `from_env()` |
| `lattice.py` | D2Q9 (c_i, w_i, OPP) + KBC-проектор `Ps` на сдвиг-моменты |
| `equilibrium.py` | `feq` (product-form, без `pow`), `macros` (ρ, u) |
| `collision.py` | **столкновение**: `kbc_collide` (энтропийный KBC-N1) — единственный оператор |
| `streaming.py` | **перенос**: `stream` (пул-схема через roll) |
| `geometry.py` | маски тела/стенок и SDF для L0 и L1 |
| `boundary.py` | `build_bc`/`apply_bc` (SDF+Bouzidi) + `channel_bc` (Zou-He) + `free_slip_walls` |
| `forces.py` | **силы**: галилей-инвариантный обмен импульсом `force_gmem` (Fx, Fy, **торк Tz**) + `coefficients` (Cd, Cl, **Cm**) |
| `forces_stress.py` | **кросс-чек силы**: ∮σ·n ds по контуру R+δ вокруг тела (независимый эстиматор) |
| `amr.py` | связка L0↔L1: `patch`/`ghost`/`fill`/`restrict` |
| `solver.py` | оркестратор: persistent-буферы, `step()`, `run()` (+ CUDA-граф) |
| `diagnostics.py` | `analyze`, St (параболическая интерполяция пика), кросс-чек, фиты серии по β |
| `visualization.py` | гифка |ω|, png-панель рядов (`series_2x_sdf.png`), прогресс-бар |
| `run.py` | точка входа (один прогон) |
| `run_blockage.py` | серия по блокировке Ny↑ → β↓, экстраполяция β→0, сводный npz+png |
| `run_factors.py` | факторное исследование постановки (вход/выход/uy-выход); реестр `out/factors.csv` |
## Где какая математика (для ревизии)
- **Столкновение** — `collision.py`. KBC-N1: `f ← f − β(2Δs + γΔh)`, где `Δs = Ps·(f−feq)` —
проекция на сдвиг-моменты, `γ` — энтропийный лимитер. Чтобы поэкспериментировать:
переключить `cfg.collision="bgk"`, либо добавить TRT новой функцией и зарегистрировать в `get`.
- **Перенос** — `streaming.py`. Чистая пул-схема; на физику влияет только корректность сдвигов.
- **Силы** — `forces.py`. Обмен импульсом по линкам тела:
`F = Σ_links c_i (f_i^{после столкн.} + f_ī^{после стриминга})`. Второй член — из поля ПОСЛЕ
стриминга (вернувшаяся Bouzidi-популяция); иначе ведущий симметричный член сокращается и Cd врёт.
Сила/момент меряются на **каждом** подшаге L1 и усредняются (анти-алиасинг; средний Cd не меняется).
**Торк** `Tz` — вокруг центра тела, точка приложения — пересечение линка со стенкой `r_f + q·c_i`
(Bouzidi-доля q уже лежит в BC); `Cm = 2Tz/(U²·DL²)`, для цилиндра ⟨Cm⟩≈0 — контроль симметрии.
- **Кросс-чек силы** — `forces_stress.py`. Независимый эстиматор — баланс импульса
контрольного объёма: `F = ∮[σ·n − ρu(u·n)] ds` по окружности R+δ (δ=2 тонкие ячейки);
`σ = −p′·I − (1−1/(2τ₁))·Π^neq_dev` (строго девиаторная проекция: след Π^neq в KBC
релаксирует с γβ, а не 1/τ). Конвективный член −ρu(u·n) обязателен при δ>0 — без него
систематика +4% по ⟨Cd⟩. Считается раз в `stress_every` шагов **вне CUDA-графа**; сравнение
по средним (⟨Cd⟩, rms Cl) — мгновенные ряды сдвинуты по фазе кольцом R..R+δ.
Согласие двух методов = считывание силы корректно.
- **Серия по блокировке** — `run_blockage.py`. D фиксирован, Ny ∈ {90,128,180} → β ∈ {0.178,
0.125, 0.089}; `cy` и y-границы патча выводятся из Ny автоматически (`config.__post_init__`).
Экстраполяция к β→0 двумя фитами (a+b·β и a+b·β²; спред интерсептов — неопределённость) —
прямое сравнение с безграничной литературой без ручной поправки (1−β)².
## ⚠ Сверка с первоисточниками (2026-08-14): найден дефект, все числа ниже требуют перепрогона
Сверка реализации со статьями в `docs/origins` (Karlin/Bösch/Chikatamarla, PRE **90**, 031302(R)
2014 — принцип; Bösch/Chikatamarla/Karlin, PRE **92**, 043309 2015 — алгоритм; arXiv:1507.02509 —
2D-реализация). **Ядро KBC-N1 подтверждено точным**: проектор `Ps` совпадает с аналитической
s-частью из ур.(10) 2D-статьи (ранг 2, идемпотентен, несёт ровно {N, Π_xy}); равновесие —
точный энтропийный максимизатор (сходится с численной максимизацией S при фикс. ρ, ρu);
γ по замкнутой формуле ур.(17)/(39) совпадает с корнем точного ур.(15); сдвиг-моменты
релаксируют ровно с 2β при любом γ; вязкость по затуханию сдвиговой волны воспроизводится
с ошибкой <0.1%.
**Дефект — порог `GEPS` в `kbc_collide`.** Он был АБСОЛЮТНЫМ (1e-6 в float32, режим по умолчанию),
тогда как `den = ⟨Δh|Δh⟩` квадратична по неравновесию и при разрешённой физике равна ~1e-7…1e-9.
Замерено на боевой конфигурации: откат `γ←2` срабатывал на **77–99% узлов**, то есть решатель
считал **чистый LBGK**, а не KBC. На эталоне 2D-статьи (двойной периодический сдвиговый слой,
Re=30000, N=128, разд. VII) это давало **+9.3%** по энстрофии на t=t_c (0.6599 против эталонных
0.6035); после исправления fp32 даёт 0.6038. Порог сделан относительным (`B.GREL`, доля от ‖Δ‖²);
на тех же полях он теперь не срабатывает нигде (min den/‖Δ‖² ≈ 1.5e-3 против порога 1e-8).
**Следствие: все количественные результаты ниже (Cd, rms Cl, St, серия по блокировке,
факторное исследование №0/№1) получены на дефектной ветке и подлежат перепрогону.**
Качественные выводы про ГУ (мода акустического резонатора, роль конвективного члена
в кросс-чеке) дефектом не затрагиваются.
Исправлено там же:
- **углы домена**: `stream()` на `roll` периодичен по обеим осям, а Zou-He стоял на срезе `1:-1` —
в 4 угловых узлах вход был напрямую связан с выходом (`f[1,0,0] == post[1,0,-1]`, величина ~0.12).
Порядок изменён на `free_slip_walls` → `channel_bc` по всему столбцу; заворот закрыт полностью.
- **диагностика ⟨ρ⟩** считалась по всему домену, включая фиктивные узлы внутри цилиндра
(смещение −5.4e-4 — ровно тот разряд, в котором ⟨ρ⟩ и цитируется). Теперь только по жидкости.
- **`refine` был зашит как 2** в `tau1`, `R01` и радиусе тела на L1/контуре σ·n. Обобщено
(при r=2 значения бит-в-бит прежние; при r=3 вязкость уровней теперь согласована).
**Отдельно — не дефект, а следствие выбора модели.** В KBC-N1 след тензора напряжений T отнесён
к h-части, поэтому объёмная вязкость плавает: ξ = c_s²(1/(γβ) − ½) (ур.36 статьи 2015, ур.57
2D-статьи). Замер на боевой конфигурации: γ ∈ [−4.1, 4.6], медиана ξ/ν ≈ 17, и на **2.1% узлов
ξ < 0** — акустическое АНТИдемпфирование. Это прямо релевантно диагнозу «недодемпфированный
акустический резонатор» ниже: KBC-N2 (T в s-части) даёт ξ = ν фиксированно и является
каноническим средством именно от этой проблемы. Модель не менялась — решение за автором.
## Статус: считывание силы валидировано; идёт факторное исследование постановки
Оператор столкновения зафиксирован — **KBC-N1 с энтропийным стабилизатором** (канонический
Karlin/Bösch/Chikatamarla 2014), без замен (никаких BGK/TRT).
**Полная документация исследования — в ноутбуке `../kbc_lbm.ipynb` (части VI, разделы 22–24).**
**Считывание силы доказано корректным** (прогон 100k, 174×90, 2026-06-09):
- кросс-чек GMEM vs ∮σ·n: dCd=4.1% (систематика объяснена конвективным членом −ρu(u·n),
добавлен в `forces_stress.py` — ожидаемое расхождение ≲1%);
- ⟨Cm⟩=−0.00003 (≈0 — симметрия), ⟨ρ⟩=1.001 стабильна, Cd=1.799 воспроизводит прежнюю
итерацию бит-в-бит (осреднение по подшагам не сместило среднее).
**Эксперимент №0 (серия по блокировке) — гипотеза бокового стеснения ОПРОВЕРГНУТА:**
Cd(β) немонотонен: 1.799 / 1.771 / **1.870** при Ny=90/128/180; rms Cl на широком канале
вырос (0.835), НЧ-модуляция (окна Cd 1.68–1.98). При этом **St(β→0)=0.181 ≈ лит. 0.183** —
кинематика верна, завышены давленческие амплитуды. Диагноз: продольные границы
(вход 2.5D с жёстким профилем; выход 8.3D Zou-He с жёстким uy=0 — отражает вихри).
**Эксперимент №1 (прогнан 2026-06-10) — факторное исследование** `run_factors.py`, 6 прогонов
при Ny=90 фикс. Итоги (полный разбор — ноутбук, раздел 24; реестр — `out/factors.csv`):
- конвективный член подтверждён: кросс-чек B0 4.1%→**1.0%**, F4 — **0.6%**;
- **F4 (мягкий uy-выход) — лучший по всем метрикам**: Cd 1.799→1.754, rms Cl 0.705→**0.557**,
St·(1−β)=**0.183** (точно лит.), ⟨ρ⟩=1.0000, нулевая НЧ-модуляция;
- **длинные домены без демпфера НЕвалидны** (F1/F2/F3 → смещённый режим ⟨ρ⟩≈1.66; F5 → NaN):
пара «вход-скорость / выход-давление» — недодемпфированный акустический резонатор
(затухание моды ∝ν(π/Nx)²; период осцилляций ⟨ρ⟩ у F5 ≈ 2Nx/c_s — прямая улика).
**Эксперимент №2 (в работе) — губка перед выходом**: плавный рост ν (×30, smoothstep) в
последних `sponge_len` столбцах L0; β(x) — поле (graph-safe), патч L1 губки не видит.
Факторы: F6_sponge (база+uy-extrap+губка 32 — валидация против F4), F7_long_sponge
(390×90, вход 6D/выход 18.3D — целевая постановка), F8_long_sp_uy0 (изоляция вклада губки).
Критерии: ⟨ρ⟩≈1.000 у всех; F7: Cd≈1.5–1.6, rms Cl<0.45. Затем эксперимент №3 — серия по β
на конфигурации F7.
| прогон | команда | артефакты в `out/` |
|---|---|---|
| смоук | `AMR_STEPS=2000 python run.py` | graph self-check OK, Cm≈0, кросс-чек ≲1% (с конв. членом) |
| основной | `python run.py` | `cyl_2x_sdf.gif`, `series_2x_sdf.png`, `probe_2x_sdf.npz` |
| серия по β | `python run_blockage.py` | `probe_blockage_Ny*.npz`, `blockage_summary.npz`, `blockage_extrapolation.png` |
| **факторы №2** | `AMR_FACTORS=F6_sponge,F7_long_sponge,F8_long_sp_uy0 python run_factors.py` | `factor_*.npz`, **`factors.csv`** (реестр; при смене схемы старый → `factors_vN.csv`), `factors_summary.png` |
Env-ручки: `AMR_NX`, `AMR_CX` (продольная геометрия; x-границы патча L1 выводятся из cx),
`AMR_OUTLET_UY=zero|extrapolate`, `AMR_SPONGE_LEN`/`AMR_SPONGE_NU` (губка),
`AMR_FACTORS=...` (подмножество факторов).
**Что привело сюда (хронология фиксов):**
1. Зонд скорости → L1; оценка St с параболической интерполяцией пика.
2. Сила → галилей-инвариантный `force_gmem` (Wen 2014), готов к подвижным телам.
3. Стартовое возмущение (поперечный sin-импульс входа) — запуск дорожки за единицы циклов.
4. Диагностика по окнам (`convergence_report`, трекинг ⟨ρ⟩) — вскрыла дрейф массы.
5. **Ключевой фикс:** грубое ГУ выхода (zero-gradient) копило массу → поток глох (Cd/rms→0 за 100k).
Заменено на **Zou-He скоростной вход + давление-выход** (`boundary.channel_bc`) — масса заякорена.
6. **Free-slip стенки** (`boundary.free_slip_walls`, specular) + умеренно расширенный домен (меньше
блокировка). Цилиндр остаётся no-slip.
7. Cm + осреднение по подшагам + кросс-чек ∮σ·n ds + серия по блокировке (текущая итерация).
**Прямой raw-матч на одной сетке непрактичен** (нужен `β<0.03 → Ny≈500+`); серия из трёх умеренных
Ny с экстраполяцией даёт безпоправочное сравнение за ~3 стоимости одного прогона (патч L1 —
основная работа — от Ny не зависит).
## CUDA Graphs
По умолчанию ВКЛ (`cfg.use_cuda_graph`, env `AMR_CUDAGRAPH=0` — выкл). **Захватываются успешно** (после
замены `cupy.stack`→`backend.pack`, отказа от cuBLAS в проекторе KBC и clip-литералов в ядре —
всё это убрало host→device при capture). На шаге t=1 — рантайм-самопроверка (граф vs eager); при
расхождении/сбое — откат на eager с печатью трейсбэка.
+54
View File
@@ -0,0 +1,54 @@
# AMR-связка L0 (грубый) ↔ L1 (тонкий, измельчение r).
#
# Идея: на тонком патче считаем r подшагов по времени на каждый шаг L0. Границы патча («ghost»)
# заполняем интерполяцией с грубого уровня (по пространству + по времени между «до» и «после» шага L0),
# а внутренность патча после подшагов проецируем обратно на грубый уровень (рестрикция).
# Неравновесная часть масштабируется множителями Rcf (грубый→тонкий) и Rfc (тонкий→грубый),
# т.к. при смене τ масштаб неравновесия меняется.
import backend as B
import lattice as L
from equilibrium import feq, macros
cp = B.cp; xp = B.xp; DTYPE = B.DTYPE
def patch(pNx, pNy, ax, bx, ay, by, r):
"""Описание патча: размеры (Nfy,Nfx), индексы/веса билинейной интерполяции с грубого уровня,
срезы для рестрикции каждого r-го узла."""
Wx, Wy = bx - ax, by - ay
Nfx, Nfy = r*Wx + 1, r*Wy + 1
FX, FY = cp.meshgrid(ax + cp.arange(Nfx)/r, ay + cp.arange(Nfy)/r) # координаты тонких узлов в L0
x0 = cp.floor(FX).astype(cp.int64); y0 = cp.floor(FY).astype(cp.int64)
x1 = cp.minimum(x0 + 1, pNx - 1); y1 = cp.minimum(y0 + 1, pNy - 1)
return dict(Nfx=int(Nfx), Nfy=int(Nfy), ax=ax, bx=bx, ay=ay, by=by, r=r,
x0=x0, y0=y0, x1=x1, y1=y1,
tx=(FX - x0).astype(DTYPE), ty=(FY - y0).astype(DTYPE),
slx=slice(r, r*Wx, r), sly=slice(r, r*Wy, r))
def pint(fld, P):
"""Билинейная интерполяция поля fld (грубого) в узлы тонкого патча."""
return (fld[..., P["y0"], P["x0"]]*(1 - P["tx"])*(1 - P["ty"])
+ fld[..., P["y0"], P["x1"]]*P["tx"]*(1 - P["ty"])
+ fld[..., P["y1"], P["x0"]]*(1 - P["tx"])*P["ty"]
+ fld[..., P["y1"], P["x1"]]*P["tx"]*P["ty"])
def ghost(pf, P, Rcf):
"""Граничные значения тонкого уровня из грубого поля pf: равновесие по интерполированным ρ,u
плюс масштабированная неравновесная часть Rcf·neq.
u в feq передаём кортежем (без cupy.stack) — H2D ломает CUDA-graph capture."""
r, u = macros(pf); neq = pf - feq(r, u)
return feq(pint(r, P), (pint(u[0], P), pint(u[1], P))) + Rcf*pint(neq, P)
def fill(cf, gh):
"""Записать ghost-значения gh в рамку (1 ряд) тонкого поля cf."""
cf[:, 0, :] = gh[:, 0, :]; cf[:, -1, :] = gh[:, -1, :]
cf[:, :, 0] = gh[:, :, 0]; cf[:, :, -1] = gh[:, :, -1]
return cf
def restrict(cf, pf, P, Rfc, fluid):
"""Спроецировать тонкое поле cf обратно на грубое pf (каждый r-й узел), масштабируя neq на Rfc.
Меняется только внутренняя жидкая часть перекрытия (маска fluid)."""
r, u = macros(cf); neq = cf - feq(r, u); slx, sly = P["slx"], P["sly"]
nv = feq(r[sly, slx], u[:, sly, slx]) + Rfc*neq[:, sly, slx]
cur = pf[:, P["ay"]+1:P["by"], P["ax"]+1:P["bx"]]
pf[:, P["ay"]+1:P["by"], P["ax"]+1:P["bx"]] = xp.where(fluid[None], nv, cur)
return pf
+40
View File
@@ -0,0 +1,40 @@
# Бэкенд: ТОЛЬКО GPU/CuPy. CPU-фолбэка нет (по требованию). Если CuPy/GPU недоступны — жёсткая ошибка.
# Здесь же — общие device-константы (DTYPE, скаляры 0/1/2) и помощник переноса на host для вывода.
import os
import cupy as cp
try:
_probe = (cp.zeros(2) + 1).sum(); cp.cuda.Device().synchronize() # реальная операция на device
except Exception as e:
raise RuntimeError("solver_2x_sdf требует рабочую GPU + CuPy (CPU-фолбэк удалён намеренно).") from e
xp = cp # единый алиас вычислительного бэкенда
DTYPE = cp.float64 if os.environ.get("AMR_FP64") else cp.float32
# Порог вырожденности знаменателя γ. ОТНОСИТЕЛЬНЫЙ (доля от ‖Δ‖² = ⟨Δ|Δ⟩), а НЕ абсолютный.
# Почему: den = ⟨Δh|Δh⟩ — квадратичная по неравновесию величина, у развитого следа она
# ~1e-7…1e-9 при вполне разрешённой физике. Прежний абсолютный порог 1e-6 (fp32) срабатывал
# на 77–99% узлов и молча подменял γ на 2, т.е. гнал ЧИСТЫЙ LBGK вместо KBC (замерено:
# энстрофия сдвигового слоя Re=3e4 уходила на +9%). den — сумма НЕотрицательных слагаемых,
# её относительная точность ~eps типа, поэтому дробный порог одинаков для fp32/fp64.
# 1e-8 на шесть порядков ниже наблюдаемого минимума den/‖Δ‖² (~5e-4) — срабатывает только на
# истинном вырождении (Δh ≡ 0), где γ всё равно ни на что не влияет, т.к. умножается на Δh.
GREL = 1e-8
BACKEND = f"CuPy/GPU dtype={'float64' if DTYPE == cp.float64 else 'float32'}"
# device-скаляры — не создаём host→device на горячем пути (важно для CUDA-graph capture)
ZERO = cp.zeros((), DTYPE)
ONE = cp.asarray(1.0, DTYPE)
TWO = cp.asarray(2.0, DTYPE)
def to_cpu(a):
"""Перенос на host (numpy) — только для диагностики/визуализации, не в солвере."""
return cp.asnumpy(a)
def pack(comps):
"""Собрать кортеж одинаковых по форме массивов в один (len,*shape) БЕЗ cupy.stack.
cupy.stack/concatenate грузит на device host-массив указателей (H2D) → запрещено при CUDA-graph
capture. Здесь: преаллокация + присваивание срезов — только device→device."""
out = xp.empty((len(comps),) + comps[0].shape, dtype=comps[0].dtype)
for i, c in enumerate(comps):
out[i] = c
return out
+104
View File
@@ -0,0 +1,104 @@
# Граничные условия: (1) тело/стенки — SDF + интерполированный отскок Bouzidi; (2) вход/выход канала.
#
# Bouzidi (линейный): для линка из жидкого узла x_f в твёрдый по направлению i вводится
# доля пересечения q = |x_f→стенка| / |x_f→x_solid| ∈ (0,1):
# q < 1/2 (есть «дальний» жидкий сосед x_f − c_i): f_ī = 2q·f_i^post + (1−2q)·f_i^post(дальний)
# q ≥ 1/2: f_ī = (1/2q)·f_i^post + (1−1/2q)·f_ī^post
# Здесь f_i^post — после столкновения, f_ī — то, что вернётся в x_f после переноса.
import backend as B
import lattice as L
cp = B.cp; xp = B.xp; DTYPE = B.DTYPE
Q = L.Q; Cx = L.Cx; Cy = L.Cy; OPP = L.OPP
def build_bc(solid, phi, body):
"""Сборка структуры ГУ (один раз). Для каждого направления i (1..8):
mask — жидкий узел, чей сосед по +c_i твёрдый;
q — доля пересечения по SDF (клип [0.02,0.98]);
xff — есть ли «дальний» жидкий сосед (для линейной формулы при q<1/2);
cl — подмаска линков именно ТЕЛА body (для расчёта силы)."""
fluid = ~solid; bc = []
for i in range(1, Q):
nb_solid = cp.roll(solid, (-Cy[i], -Cx[i]), (0, 1)); mask = fluid & nb_solid
cl = mask & cp.roll(body, (-Cy[i], -Cx[i]), (0, 1))
phinb = cp.roll(phi, (-Cy[i], -Cx[i]), (0, 1))
qq = phi / (phi - phinb) # деление; nan/inf маскируются ниже
q = xp.where(mask, cp.clip(qq, 0.02, 0.98), cp.asarray(0.5, DTYPE))
xff = mask & (~cp.roll(solid, (Cy[i], Cx[i]), (0, 1)))
bc.append((i, OPP[i], mask, q.astype(DTYPE), xff, cl))
return bc
def apply_bc(f, fpost, bc):
"""Применить Bouzidi ко всем линкам (полностью через where, GPU-friendly).
f — поле ПОСЛЕ стриминга (его и правим); fpost — поле ПОСЛЕ столкновения (до стриминга)."""
for (i, ib, mask, q, xff, cl) in bc:
fi = fpost[i]; fib = fpost[ib]
fiback = cp.roll(fpost[i], (Cy[i], Cx[i]), (0, 1)) # популяция «дальнего» соседа
near = mask & (q < 0.5) & xff
bad = mask & (q < 0.5) & (~xff) # нет дальнего соседа → простой отскок
far = mask & (q >= 0.5)
new = f[ib]
new = xp.where(near, 2*q*fi + (1 - 2*q)*fiback, new)
new = xp.where(far, (1/(2*q))*fi + (1 - 1/(2*q))*fib, new)
new = xp.where(bad, fi, new)
f[ib] = new
return f
def channel_bc(f, uin, rho_out=1.0, outlet_uy="zero"):
"""Корректные ГУ канала (Zou & He 1997), нумерация D2Q9: 1=E,2=N,3=W,4=S,5=NE,6=NW,7=SW,8=SE.
ВХОД (запад, x=0): скоростной Zou-He — задаём u=(ux,uy) из uin, плотность ρ ПЛАВАЕТ; достраиваем
приходящие извне популяции 1,5,8 из известных 0,2,3,4,6,7.
ВЫХОД (восток, x=-1): давление Zou-He — задаём ρ=rho_out, скорость ux плавает; достраиваем 3,6,7.
Поперечная скорость на выходе — по outlet_uy:
"zero" — классический Zou-He: uy=0 (жёстко). Вихри дорожки приходят на выход с
uy~±0.3U → принудительное зануление отражает их назад к телу (завышает
rms Cl и модулирует Cd — см. факторное исследование в ноутбуке);
"extrapolate" — uy берётся из соседнего столбца x=-2 (нуль-градиент поперечной скорости);
ρ-якорь (масса) сохраняется, кинематическое отражение снимается.
Зачем давление-выход вообще: грубый zero-gradient по f не выпускал поток → масса/противодавление
копились, поток глох (⟨ρ⟩↑, Cd/rmsCl→0). Давление-выход якорит массу, скоростной вход её не
пересоздаёт → дрейфа нет. Всё на срезах (graph-safe, без host→device).
ПОРЯДОК ВЫЗОВА: строго ПОСЛЕ free_slip_walls и покрывая ВЕСЬ столбец (включая ряды 0 и Ny−1).
Причина: stream() сделан через roll и потому периодичен по ОБЕИМ осям. Заворот по y снимают
стенки, заворот по x — этот ГУ. Если оставить углы (ряды 0 и Ny−1) без Zou-He, в них популяции
с cx=+1 на входе приходят прямо из столбца выхода (замерено: f[1,0,0] == post[1,0,-1], величина
~0.12) — вход и выход оказываются физически связаны в 4 узлах. Известные популяции, по которым
здесь считаются ρ (вход) и ux (выход), x-заворота не содержат ни в одном ряду, а те, что
заворот содержат, — это ровно неизвестные, которые ГУ и перезаписывает; поэтому такой порядок
закрывает углы полностью."""
# --- вход (запад): скоростной Zou-He ---
ux = uin[0, :, 0]; uy = uin[1, :, 0]
f0 = f[0, :, 0]; f2 = f[2, :, 0]; f3 = f[3, :, 0]
f4 = f[4, :, 0]; f6 = f[6, :, 0]; f7 = f[7, :, 0]
rho = (f0 + f2 + f4 + 2.0*(f3 + f6 + f7)) / (1.0 - ux)
f[1, :, 0] = f3 + (2.0/3.0)*rho*ux
f[5, :, 0] = f7 - 0.5*(f2 - f4) + (1.0/6.0)*rho*ux + 0.5*rho*uy
f[8, :, 0] = f6 + 0.5*(f2 - f4) + (1.0/6.0)*rho*ux - 0.5*rho*uy
# --- выход (восток): давление Zou-He ---
g0 = f[0, :, -1]; g1 = f[1, :, -1]; g2 = f[2, :, -1]
g4 = f[4, :, -1]; g5 = f[5, :, -1]; g8 = f[8, :, -1]
uxo = (g0 + g2 + g4 + 2.0*(g1 + g5 + g8)) / rho_out - 1.0
if outlet_uy == "extrapolate":
# нуль-градиент поперечной скорости: uy с предвыходного столбца (он полностью известен)
col = f[:, :, -2]
uyo = ((col[2] + col[5] + col[6]) - (col[4] + col[7] + col[8])) / col.sum(0)
else:
uyo = 0.0
f[3, :, -1] = g1 - (2.0/3.0)*rho_out*uxo
f[6, :, -1] = g8 - 0.5*(g2 - g4) - (1.0/6.0)*rho_out*uxo + 0.5*rho_out*uyo
f[7, :, -1] = g5 + 0.5*(g2 - g4) - (1.0/6.0)*rho_out*uxo - 0.5*rho_out*uyo
return f
def free_slip_walls(f):
"""FREE-SLIP (скользящие) стенки канала: верх/низ — зеркальное (specular) отражение.
Сохраняет касательный (x) импульс (нет трения, нет пограничного слоя) и нормальный заворачивает;
масса сохраняется (копирование популяций) → дрейфа не вносит. Применять ПОСЛЕ стриминга.
Нумерация: 2=(0,+1) 4=(0,-1) 5=(+1,+1) 6=(-1,+1) 7=(-1,-1) 8=(+1,-1).
Нижняя стенка (row 0): достраиваем вверх-идущие из вниз-идущих (зеркало по y, x сохранён).
Верхняя стенка (row -1): достраиваем вниз-идущие из вверх-идущих.
ВНИМАНИЕ: это только искусственные стенки канала; цилиндр остаётся no-slip (Bouzidi).
ПОРЯДОК: вызывать ПЕРЕД channel_bc (см. его docstring — так закрываются углы домена)."""
f[2, 0, :] = f[4, 0, :]; f[5, 0, :] = f[8, 0, :]; f[6, 0, :] = f[7, 0, :]
f[4, -1, :] = f[2, -1, :]; f[7, -1, :] = f[6, -1, :]; f[8, -1, :] = f[5, -1, :]
return f
+42
View File
@@ -0,0 +1,42 @@
# Оператор СТОЛКНОВЕНИЯ — KBC-N1 (энтропийный), единственный оператор модели.
#
# KBC-N1 (Karlin/Bösch/Chikatamarla 2014):
# Δ = f − feq (неравновесная часть)
# Δs = Ps·Δ (проекция на сдвиг-моменты {cx²−cy², cxcy})
# Δh = Δ − Δs (остальная часть)
# γ = 1/β − (2 − 1/β)·⟨Δs,Δh⟩ / ⟨Δh,Δh⟩, ⟨a,b⟩ = Σ_i a_i b_i / feq_i (H-теорема)
# f_post = f − β(2Δs + γΔh), β = 1/(2τ)
# γ — энтропийный стабилизатор: подстраивает диссипацию неhydro-мод под энтропийное ограничение
# (поэтому KBC устойчив при τ→1/2). Сдвиг-моменты (вязкость) релаксируют с правильным 2β = 1/τ,
# то есть физическая вязкость задаётся только τ — стабилизатор её не меняет.
#
# Порядок шагов дословно совпадает с алгоритмом статьи (Bösch/Chikatamarla/Karlin, PRE 92,
# 043309 (2015), разд. III, листинг после ур.39): ρ,u → feq → Δs → Δh = f − feq − Δs → γ → релаксация.
# Вырожденный случай ⟨Δh|Δh⟩→0 отсекается ОТНОСИТЕЛЬНЫМ порогом B.GREL (см. backend.py):
# абсолютный порог здесь недопустим — den квадратична по неравновесию и физически мала.
import backend as B
import lattice as L
cp = B.cp; xp = B.xp
Q = L.Q; Ps = L.Ps; GREL = B.GREL; ONE = B.ONE; TWO = B.TWO
def _project_shift(dfn):
"""Δs = Ps·Δ без cuBLAS (gemm при CUDA-graph capture тащит alpha/beta host→device).
Реализовано как Σ_k Ps[i,k]·Δ[k] через broadcast+редукцию — только обычные ядра."""
dfn2d = dfn.reshape(Q, -1) # (Q, N)
return (Ps[:, :, None] * dfn2d[None]).sum(1).reshape(dfn.shape)
def kbc_collide(f, fe, beta):
dfn = f - fe
ds = _project_shift(dfn)
dh = dfn - ds; inv = 1.0 / fe
num = (ds*dh*inv).sum(0); den = (dh*dh*inv).sum(0)
nrm = (dfn*dfn*inv).sum(0) # ‖Δ‖² — масштаб неравновесия узла
ok = den > GREL*nrm # относительный порог, не абсолютный
den_safe = xp.where(ok, den, ONE) # избегаем 0/0 (обе ветки where считаются)
binv = 1.0 / beta
g = xp.where(ok, binv - (2.0 - binv)*num/den_safe, TWO)
return f - beta*(2.0*ds + g[None]*dh)
# единственный оператор столкновения модели
collide = kbc_collide
+138
View File
@@ -0,0 +1,138 @@
# Конфигурация модели 2×+SDF. Всё в одном месте, чтобы математику/режимы было легко менять.
# Единицы — решёточные (lu — длина воксель, ts — шаг по времени).
import os
from dataclasses import dataclass
from typing import Optional
@dataclass
class Config:
# --- сетка / геометрия L0 (умеренно расширены: меньше блокировка + больше следа) ---
Nx: int = 174 # домен по x (цилиндр на 40 → ~8.4D вниз по потоку до выхода)
Ny: int = 90 # домен по y (стенки free-slip; блокировка β=D/Ny — варьируется серией)
D: int = 16 # диаметр цилиндра на грубом уровне L0
cx: int = 40
cy: Optional[int] = None # центр по вертикали; по умолчанию Ny//2 (см. __post_init__)
# --- физика ---
U: float = 0.07 # скорость набегающего потока
Re: float = 150.0 # число Рейнольдса по D и U
# --- время ---
steps: int = 100000
ramp: int = 1000 # smoothstep-разгон входной скорости (гасит импульсный старт)
frame_every: int = 34 # период сохранения кадров для гифки
# --- патч уровня L1 (в координатах L0); refine=2 → разрешение Δx/2 ---
ax1: Optional[int] = None # по умолчанию cx − 1.5D (запас перед телом; см. __post_init__)
bx1: Optional[int] = None # по умолчанию cx + 6.25D (расширен в след)
ay1: Optional[int] = None # по умолчанию cy − 2D (центрирован по cy)
by1: Optional[int] = None # по умолчанию cy + 2D
refine: int = 2 # коэффициент измельчения L1 (модель «2×» рассчитана на 2)
# --- зонд скорости в следе: px = cx + probe_dx*D, py = cy (берётся с тонкой сетки L1) ---
probe_dx: int = 3
# --- стартовое возмущение (триггер вихревой дорожки): поперечный sin-импульс входа ---
pert_amp: float = 0.1 # амплитуда u_y входа, доля от U (0 — выключить)
pert_dur: int = 300 # длительность импульса в шагах; окно [ramp, ramp+pert_dur]
# --- кросс-чек силы: интеграл тензора напряжений σ·n по окружности R1+δ на L1 (forces_stress.py) ---
stress_every: int = 50 # период выборки в шагах L0 (0 — выключить кросс-чек)
stress_ntheta: int = 256 # число точек контура по углу
stress_delta: float = 2.0 # отступ контура от поверхности (в тонких ячейках L1);
# δ=2 → билинейный стенсиль не трогает Bouzidi-зону (q_max≈0.98)
# --- ГУ выхода: режим поперечной скорости на Zou-He давление-выходе ---
# "zero" — классика: uy=0 на выходе (жёстко; отражает вихри дорожки)
# "extrapolate" — uy берётся из соседнего столбца (нуль-градиент); ρ-якорь сохраняется
outlet_uy: str = "zero"
# --- губка (absorbing layer) перед выходом: плавный рост вязкости в последних sponge_len
# столбцах L0 (smoothstep, ν → ν·sponge_nu_mult). Гасит вихри И акустику до прихода на
# давление-выход. Зачем: канал «вход-скорость + выход-давление» — недодемпфированный
# акустический резонатор (затухание моды ∝ ν(π/Nx)²); на длинных доменах мода раскачивается
# стартовым транзиентом → смещённый режим ⟨ρ⟩≈1.66 или взрыв (факторное исследование,
# эксперимент №1: F1/F2/F3/F5). 0 — выключена.
sponge_len: int = 0
sponge_nu_mult: float = 30.0
# --- исполнение ---
use_cuda_graph: bool = True
def __post_init__(self):
# производная геометрия по вертикали: центр и патч следуют за Ny (для серии по блокировке)
if self.cy is None:
self.cy = self.Ny // 2
if self.ay1 is None:
self.ay1 = self.cy - 2 * self.D
if self.by1 is None:
self.by1 = self.cy + 2 * self.D
# производная геометрия по горизонтали: патч следует за cx (для факторов вход/выход)
if self.ax1 is None:
self.ax1 = self.cx - (3 * self.D) // 2
if self.bx1 is None:
self.bx1 = self.cx + (25 * self.D) // 4
assert 1 <= self.ay1 < self.by1 <= self.Ny - 2, (
f"патч L1 [{self.ay1},{self.by1}] должен лежать строго внутри (0,{self.Ny-1}) — "
f"specular-ряды стенок не накрывать; минимально допустимое Ny ≈ {4*self.D + 6}")
assert 2 <= self.ax1 < self.cx < self.bx1 <= self.Nx - 2, (
f"патч L1 [{self.ax1},{self.bx1}] должен лежать строго внутри (1,{self.Nx-1}) "
f"и накрывать тело (cx={self.cx})")
assert self.outlet_uy in ("zero", "extrapolate"), self.outlet_uy
if self.sponge_len:
assert self.Nx - 1 - self.sponge_len > self.bx1, (
f"губка (последние {self.sponge_len} столбцов) не должна накрывать патч L1 "
f"(bx1={self.bx1}, Nx={self.Nx})")
# ---------- производные величины ----------
@property
def nu(self): return self.U * self.D / self.Re # кинематическая вязкость (lu²/ts)
@property
def tau0(self): return self.nu / (1.0/3.0) + 0.5 # время релаксации на L0 (cs²=1/3)
@property
def tau1(self): # связка τ при измельчении в r раз: ν одна и та же ⇒ τ_f = r(τ_c − ½) + ½
return self.refine * (self.tau0 - 0.5) + 0.5
@property
def beta0(self): return 1.0 / (2.0 * self.tau0) # β = 1/(2τ) для KBC/BGK на L0
@property
def beta1(self): return 1.0 / (2.0 * self.tau1) # β на L1
@property
def R01(self): # масштаб неравновесной части грубый→тонкий: f^neq ∝ τ·δt ⇒ (τ_f/τ_c)/r
return self.tau1 / (self.refine * self.tau0)
@property
def Rf01(self): return 1.0 / self.R01 # тонкий→грубый
@property
def DL(self): return self.refine * self.D # эффективный диаметр у тела (на L1)
@property
def px(self): return self.cx + self.probe_dx * self.D
@property
def py(self): return self.cy
def from_env(**overrides):
"""Создать Config с учётом переменных окружения и явных overrides.
Геометрия (AMR_NY/AMR_NX/AMR_CX) попадает в overrides ДО конструктора —
производные cy/ay1/by1/ax1/bx1 строятся в __post_init__."""
if "Ny" not in overrides and os.environ.get("AMR_NY"):
overrides["Ny"] = int(os.environ["AMR_NY"])
if "Nx" not in overrides and os.environ.get("AMR_NX"):
overrides["Nx"] = int(os.environ["AMR_NX"])
if "cx" not in overrides and os.environ.get("AMR_CX"):
overrides["cx"] = int(os.environ["AMR_CX"])
if "outlet_uy" not in overrides and os.environ.get("AMR_OUTLET_UY"):
overrides["outlet_uy"] = os.environ["AMR_OUTLET_UY"]
if "sponge_len" not in overrides and os.environ.get("AMR_SPONGE_LEN"):
overrides["sponge_len"] = int(os.environ["AMR_SPONGE_LEN"])
if "sponge_nu_mult" not in overrides and os.environ.get("AMR_SPONGE_NU"):
overrides["sponge_nu_mult"] = float(os.environ["AMR_SPONGE_NU"])
cfg = Config(**overrides)
if os.environ.get("AMR_STEPS"):
cfg.steps = int(os.environ["AMR_STEPS"])
if "AMR_CUDAGRAPH" in os.environ:
cfg.use_cuda_graph = (os.environ["AMR_CUDAGRAPH"] != "0")
if os.environ.get("AMR_STRESS_EVERY"):
cfg.stress_every = int(os.environ["AMR_STRESS_EVERY"])
if os.environ.get("AMR_STRESS_NTHETA"):
cfg.stress_ntheta = int(os.environ["AMR_STRESS_NTHETA"])
if os.environ.get("AMR_STRESS_DELTA"):
cfg.stress_delta = float(os.environ["AMR_STRESS_DELTA"])
return cfg
+140
View File
@@ -0,0 +1,140 @@
# Диагностика по собранным рядам сил/скорости. numpy здесь — только host-постобработка малых рядов
# (FFT/статистика), это НЕ вычислительный бэкенд солвера.
import numpy as np
import backend as B
def strouhal(uy_host, D, U, pad=8192):
"""Число Струхаля из ряда поперечной скорости в следе.
УЛУЧШЕНО: параболическая (3-точечная) интерполяция пика спектра → суб-биновая точность.
(Раньше брался ближайший FFT-бин: шаг по St ≈ D/(U·pad) ≈ 0.028, из-за чего St 'застревал'.)"""
sig = uy_host[len(uy_host)//2:] # установившийся режим — вторая половина
sig = sig - sig.mean()
amp = np.abs(np.fft.rfft(sig, n=pad))
if len(amp) <= 3:
return 0.0, np.fft.rfftfreq(pad, 1.0), amp
k = 1 + int(np.argmax(amp[1:])) # индекс пика (без DC)
if 1 <= k < len(amp) - 1: # параболическая интерполяция вершины
a0, a1, a2 = amp[k-1], amp[k], amp[k+1]
denom = (a0 - 2*a1 + a2)
delta = 0.5*(a0 - a2)/denom if denom != 0 else 0.0
else:
delta = 0.0
f_peak = (k + delta) / pad
return f_peak * D / U, np.fft.rfftfreq(pad, 1.0), amp
def analyze(res, cfg):
Fx = B.to_cpu(res["Fx"]); Fy = B.to_cpu(res["Fy"]); uy = B.to_cpu(res["uy"])
DL, U, D = res["DL"], cfg.U, cfg.D
n = len(uy); h = slice(n//2, n)
Cd = 2.0*Fx/(U**2*DL); Cl = 2.0*Fy/(U**2*DL)
St, freqs, amp = strouhal(uy, D, U)
a = dict(Cd=float(Cd[h].mean()), Clrms=float(Cl[h].std()), St=float(St),
uyrms=float(uy[h].std()), Cd_s=Cd, Cl_s=Cl, uy=uy, freqs=freqs, amp=amp, h0=n//2)
if "Tz" in res: # момент: Cm = 2Tz/(U²·DL²); для цилиндра ⟨Cm⟩≈0
Cm = 2.0*B.to_cpu(res["Tz"])/(U**2*DL*DL)
a["Cm"] = float(Cm[h].mean()); a["Cmrms"] = float(Cm[h].std()); a["Cm_s"] = Cm
K = int(res.get("stress_every", 0) or 0) # кросс-чек σ·n (редкая сетка по времени)
if K and len(res.get("Fx_st", ())) > 3:
Fxs = B.to_cpu(res["Fx_st"]); Fys = B.to_cpu(res["Fy_st"]); Tzs = B.to_cpu(res["Tz_st"])
m = len(Fxs); hs = slice(m//2, m)
Cds = 2.0*Fxs/(U**2*DL); Cls = 2.0*Fys/(U**2*DL); Cms = 2.0*Tzs/(U**2*DL*DL)
a.update(Cd_st=float(Cds[hs].mean()), Clrms_st=float(Cls[hs].std()),
Cm_st=float(Cms[hs].mean()), Cd_st_s=Cds, Cl_st_s=Cls, stress_every=K)
return a
def print_table(a, cfg):
# поправка на блокировку канала (приведение к скорости в зазоре U/(1−β)):
# St — кинематическая → ×(1−β); Cd, rms Cl ~ скорость² → ×(1−β)²
beta = cfg.D / cfg.Ny
St_c = a['St'] * (1 - beta)
Cd_c = a['Cd'] * (1 - beta)**2
Cl_c = a['Clrms'] * (1 - beta)**2
cm = a.get('Cm')
cms = f"{cm:>10.5f}" if cm is not None else f"{'—':>10}"
print("\n=== Модель 2×+SDF — установившийся режим ===")
print(f"{'версия':18}{'D у тела':>9}{'St':>8}{'<Cd>':>9}{'rms Cl':>9}{'<Cm>':>10}{'rms u_y':>9}")
print(f"{'2×+SDF (raw)':18}{cfg.DL:>9}{a['St']:>8.3f}{a['Cd']:>9.3f}{a['Clrms']:>9.4f}{cms}{a['uyrms']:>9.4f}")
print(f"{'2×+SDF (×блок.)':18}{cfg.DL:>9}{St_c:>8.3f}{Cd_c:>9.3f}{Cl_c:>9.4f}{'—':>10}{'—':>9}")
print(f"{'лит. Re≈150':18}{'—':>9}{0.183:>8.3f}{1.330:>9.3f}{'~0.3':>9}{0.0:>10.1f}{'':>9}")
print(f"(блокировка β=D/Ny={beta:.3f}; поправка: St×(1−β), Cd/rmsCl×(1−β)². "
"Лит. — безграничный цилиндр; ⟨Cm⟩≈0 — контроль симметрии; см. README)")
def print_crosscheck(a):
"""Сравнение двух НЕЗАВИСИМЫХ считываний силы: GMEM (обмен импульсом по линкам) vs ∮σ·n ds
(интеграл тензора напряжений по контуру R+δ). Сходство — прямое свидетельство корректности
считывания; сравниваем средние (мгновенные ряды σ·n сдвинуты по фазе кольцом R..R+δ)."""
if "Cd_st" not in a:
print("\n[кросс-чек σ·n: выключен (stress_every=0) или слишком мало точек]")
return
dcd = abs(a["Cd_st"] - a["Cd"]) / max(abs(a["Cd"]), 1e-12)
dcl = abs(a["Clrms_st"] - a["Clrms"]) / max(abs(a["Clrms"]), 1e-12)
cm = a.get("Cm", float("nan"))
print("\n=== Кросс-чек считывания силы: GMEM vs ∮σ·n ds (установившийся режим) ===")
print(f"{'метод':26}{'<Cd>':>9}{'rms Cl':>9}{'<Cm>':>10}")
print(f"{'GMEM (обмен импульсом)':26}{a['Cd']:>9.3f}{a['Clrms']:>9.4f}{cm:>10.5f}")
print(f"{'∮σ·n ds (контур R+δ)':26}{a['Cd_st']:>9.3f}{a['Clrms_st']:>9.4f}{a['Cm_st']:>10.5f}")
verdict = "СОГЛАСОВАНО" if (dcd < 0.05 and dcl < 0.10) else "ПРОВЕРИТЬ"
print(f"расхождение: dCd={dcd*100:.1f}% (порог 5%), d(rmsCl)={dcl*100:.1f}% (порог 10%) → {verdict}")
def fit_blockage(betas, vals):
"""Экстраполяция величины серии к β→0 двумя моделями: val ≈ a + b·β и val ≈ a + b·β².
(Теория solid-blockage даёт ведущий член O(β²), практика поправки (1−β)² содержит и линейный.)
Спред интерсептов |a_lin − a_quad| — оценка модельной неопределённости экстраполяции."""
b = np.asarray(betas, float); v = np.asarray(vals, float)
sl, al = np.polyfit(b, v, 1)
sq, aq = np.polyfit(b**2, v, 1)
return dict(lin=(float(al), float(sl)), quad=(float(aq), float(sq)),
spread=float(abs(al - aq)))
def print_blockage_table(rows, fits):
"""Сводка серии по блокировке. rows — список dict(Ny, beta, St, Cd, Clrms, Cm, Cd_st);
fits — dict('Cd'/'Clrms'/'St' → результат fit_blockage)."""
print("\n=== Серия по блокировке: D фикс., Ny варьируется ===")
print(f"{'Ny':>5}{'β=D/Ny':>9}{'St':>8}{'St(1−β)':>9}{'<Cd>':>9}{'rms Cl':>9}{'<Cm>':>10}{'Cd σ·n':>9}")
for r in rows:
cdst = f"{r['Cd_st']:>9.3f}" if r.get("Cd_st") is not None else f"{'—':>9}"
print(f"{r['Ny']:>5}{r['beta']:>9.3f}{r['St']:>8.3f}{r['St']*(1-r['beta']):>9.3f}"
f"{r['Cd']:>9.3f}{r['Clrms']:>9.4f}{r['Cm']:>10.5f}{cdst}")
print("экстраполяция β→0 (интерсепт лин. фита / квадр. фита; спред — неопределённость):")
print(f" Cd(0) = {fits['Cd']['lin'][0]:.3f} / {fits['Cd']['quad'][0]:.3f} "
f"(спред {fits['Cd']['spread']:.3f}; лит. безгранич. 1.33)")
print(f" rms Cl(0) = {fits['Clrms']['lin'][0]:.3f} / {fits['Clrms']['quad'][0]:.3f} "
f"(спред {fits['Clrms']['spread']:.3f}; лит. ~0.3)")
print(f" St(0) = {fits['St']['lin'][0]:.3f} / {fits['St']['quad'][0]:.3f} "
f"(спред {fits['St']['spread']:.3f}; лит. 0.183)")
def convergence_report(res, cfg, nwin=10):
"""Эволюция по окнам времени: видно, НАСЫЩЕНО ли решение или ДРЕЙФУЕТ.
Ключевое: ⟨ρ⟩ (средняя плотность) и Cd, нормированный на реальную ⟨ρ⟩ (дрейф-устойчивый Cd).
Если ⟨ρ⟩ уплывает от 1 — это дрейф массы из-за ГУ входа/выхода, а Cd_raw искажён нормировкой на ρ=1."""
Fx = B.to_cpu(res["Fx"]); Fy = B.to_cpu(res["Fy"]); uy = B.to_cpu(res["uy"]); rho = B.to_cpu(res["rho"])
n = len(Fx); U = cfg.U; DL = res["DL"]
if n < nwin * 2:
return
Cd = 2.0*Fx/(U**2*DL); Cl = 2.0*Fy/(U**2*DL)
w = n // nwin
print("\n=== Сходимость по времени / дрейф (по окнам) ===")
print(f"{'окно':>4}{'шаги':>16}{'<ρ>':>8}{'<Cd>raw':>9}{'<Cd>/ρ':>9}{'rms Cl':>9}{'rms u_y':>9}")
rows = []
for k in range(nwin):
s = slice(k*w, (k+1)*w if k < nwin-1 else n)
rm = float(rho[s].mean()); cdr = float(Cd[s].mean())
cdn = cdr / rm if rm != 0 else float("nan")
clr = float(Cl[s].std()); uyr = float(uy[s].std())
rows.append((rm, cdr, cdn, clr, uyr))
print(f"{k+1:>4}{f'{k*w}-{(k+1)*w}':>16}{rm:>8.3f}{cdr:>9.3f}{cdn:>9.3f}{clr:>9.4f}{uyr:>9.4f}")
# вердикт по последним двум окнам
(rm1, cdr1, cdn1, clr1, _), (rm0, _, _, clr0, _) = rows[-1], rows[-2]
drho = rm1 - rm0; dcl = clr1 - clr0
verdict = []
if abs(rm1 - 1.0) > 0.02:
# «стабильна, но не там»: система села на смещённую ветвь (акустическая накачка массы;
# ⟨Cd⟩/Cl на ней НЕвалидны для сравнения с литературой) — см. факторное исследование
verdict.append(f"плотность: ⟨ρ⟩={rm1:.3f} → РЕЖИМ СМЕЩЁН (⟨ρ⟩ далеко от 1; прогон невалиден)")
else:
verdict.append(f"плотность: ⟨ρ⟩={rm1:.3f}, Δ за окно={drho:+.4f} → "
+ ("ДРЕЙФ массы (правь ГУ выхода: давление/Zou-He)" if abs(drho) > 1e-3 else "стабильна"))
verdict.append(f"rms Cl: Δ за окно={dcl:+.4f} → "
+ ("ещё растёт (не насыщено)" if dcl > 1e-3 else "насыщено/стабильно"))
verdict.append(f"дрейф-устойчивый Cd (⟨Cd⟩/ρ) в последнем окне = {cdn1:.3f}")
print("вердикт: " + "; ".join(verdict))
+45
View File
@@ -0,0 +1,45 @@
# Равновесие и макропеременные.
#
# Используется ЭНТРОПИЙНОЕ равновесие в product-form (как в KBC), а не усечённый полином:
# feq_i = w_i · ρ · Π_d (2 − sqrt(1+3 u_d²)) · ((2 u_d + sqrt(1+3 u_d²))/(1 − u_d))^{c_{i,d}}
# Показатели c_{i,d} ∈ {−1,0,+1}, поэтому степень НЕ нужна — берём q или 1/q (быстрее и совместимо
# с CUDA-graph: нет pow и нет host→device для границ clip).
import backend as B
import lattice as L
cp = B.cp; xp = B.xp
CXa = L.CXa; CYa = L.CYa
def _feq9_body(rho, ux, uy):
ux = cp.minimum(cp.maximum(ux, -0.95), 0.95) # clip скоростей литералами (запекается в кернел)
uy = cp.minimum(cp.maximum(uy, -0.95), 0.95)
sx = cp.sqrt(1.0 + 3.0*ux*ux); sy = cp.sqrt(1.0 + 3.0*uy*uy)
base = rho * (2.0 - sx) * (2.0 - sy)
qx = (2.0*ux + sx) / (1.0 - ux); qy = (2.0*uy + sy) / (1.0 - uy)
ix = 1.0/qx; iy = 1.0/qy
return (base*(4.0/9.0), # ( 0, 0)
base*(1.0/9.0)*qx, base*(1.0/9.0)*qy, # (+1,0) (0,+1)
base*(1.0/9.0)*ix, base*(1.0/9.0)*iy, # (-1,0) (0,-1)
base*(1.0/36.0)*qx*qy, base*(1.0/36.0)*ix*qy, # (+1,+1) (-1,+1)
base*(1.0/36.0)*ix*iy, base*(1.0/36.0)*qx*iy) # (-1,-1) (+1,-1)
_feq9 = cp.fuse()(_feq9_body) # одно слитное ядро (если cp.fuse доступен)
_FUSE_OK = [True]
def feq(rho, u):
"""Равновесные популяции (Q,Ny,Nx) по плотности rho и скорости u (индексируется [0],[1]).
Сборка через B.pack (без cupy.stack) — иначе H2D ломает CUDA-graph capture."""
if _FUSE_OK[0]:
try:
return B.pack(_feq9(rho, u[0], u[1]))
except Exception as e: # откат на не-fused (тоже без pow), один раз
print(f"[equilibrium] cp.fuse отключён ({type(e).__name__}: {e}); обычный путь")
_FUSE_OK[0] = False
return B.pack(_feq9_body(rho, u[0], u[1]))
def macros(f):
"""Макропеременные: ρ = Σ_i f_i, u = (Σ_i c_i f_i)/ρ. Возвращает (rho, u=(2,Ny,Nx))."""
r = f.sum(0)
ux = (CXa[:, None, None] * f).sum(0) / r
uy = (CYa[:, None, None] * f).sum(0) / r
return r, B.pack((ux, uy))
+52
View File
@@ -0,0 +1,52 @@
# СИЛА и МОМЕНТ на теле — галилей-инвариантный обмен импульсом (GMEM, Wen et al. 2014).
#
# Для каждого линка жидкость→тело по направлению i (маска cl):
# ΔF = (c_i − u_w)·f_i^{после столкновения}(x_f) − (c_ī − u_w)·f_ī^{после стриминга}(x_f)
# где u_w — скорость стенки на линке (для неподвижного цилиндра = 0). Т.к. c_ī = −c_i, по компонентам:
# F_x += (Cx_i − u_wx)·f_i + (Cx_i + u_wx)·f_ī
# F_y += (Cy_i − u_wy)·f_i + (Cy_i + u_wy)·f_ī
#
# МОМЕНТ (торк) вокруг центра тела: T_z = Σ_links (r_w × ΔF)_z, где точка приложения —
# ПЕРЕСЕЧЕНИЕ линка со стенкой r_w = r_f + q·c_i (q — Bouzidi-доля, уже лежит в bc).
# Узел жидкости дал бы ошибку плеча до 1 ячейки; q бесплатен. Для кругового цилиндра
# ⟨Cm⟩ ≈ 0 — это контроль симметрии считывания; для подвижных/вращающихся тел (цель SimV4) —
# рабочая величина.
#
# Свойства:
# * f_i — популяция, уходящая в стенку (после столкновения); f_ī — вернувшаяся (после стриминга/Bouzidi).
# f_ī берётся из поля ПОСЛЕ стриминга — иначе ведущий симметричный член ~2ρw_i сокращается и Cd врёт.
# * При u_w=0 сводится к классическому Σ c_i (f_i + f_ī); члены с u_w делают оценку галилей-инвариантной
# и ГОТОВОЙ к подвижным/вращающимся телам (цель проекта SimV4) — туда передаётся u_w на линке.
#
# Независимый кросс-чек этой оценки — интегрирование тензора напряжений по контуру вокруг тела:
# см. forces_stress.py (σ = −p′·I + σ^v из неравновесных моментов; вызывается редко, вне CUDA-графа).
import backend as B
import lattice as L
xp = B.xp; ZERO = B.ZERO
Cx = L.Cx; Cy = L.Cy
def force_gmem(fpost, fnew, bc, rxy=None, u_wall=(0.0, 0.0)):
"""GMEM по линкам тела (маска cl). Возвращает (Fx, Fy, Tz) — device-скаляры.
rxy=(rx,ry) — координаты узлов решётки относительно центра тела (для торка); None → Tz=0.
u_wall — скорость стенки. Graph-safe: только where/арифметика с литералами."""
uwx, uwy = u_wall
Fx = ZERO; Fy = ZERO; Tz = ZERO
if rxy is not None:
rx, ry = rxy
for (i, ib, mask, q, xff, cl) in bc:
fi = fpost[i]; fib = fnew[ib]
dFx = xp.where(cl, (Cx[i] - uwx)*fi + (Cx[i] + uwx)*fib, ZERO)
dFy = xp.where(cl, (Cy[i] - uwy)*fi + (Cy[i] + uwy)*fib, ZERO)
Fx = Fx + dFx.sum(); Fy = Fy + dFy.sum()
if rxy is not None: # плечо до точки пересечения линка со стенкой
Tz = Tz + ((rx + q*Cx[i])*dFy - (ry + q*Cy[i])*dFx).sum()
return Fx, Fy, Tz
def coefficients(Fx, Fy, U, DL, Tz=None):
"""Безразмерные коэффициенты: Cd = 2Fx/(U²·DL), Cl = 2Fy/(U²·DL), Cm = 2Tz/(U²·DL²).
DL — диаметр у тела (на L1); сила/плечо тоже в единицах L1, так что нормировка согласована."""
Cd = 2.0*Fx/(U**2*DL); Cl = 2.0*Fy/(U**2*DL)
if Tz is None:
return Cd, Cl
return Cd, Cl, 2.0*Tz/(U**2*DL*DL)
@@ -0,0 +1,80 @@
# НЕЗАВИСИМЫЙ КРОСС-ЧЕК силы/момента: баланс импульса контрольного объёма, ограниченного
# замкнутым контуром (окружностью) вокруг тела на тонком уровне L1.
#
# F_тела = ∮ [σ·n − ρ·u·(u·n)] ds − d/dt ∫_кольцо ρu dV, σ = −p′·I + σ^v
#
# * давление: p = ρ·c_s²; интегрируем p′ = (ρ − ⟨ρ⟩_контур)·c_s² — константа давления по замкнутому
# контуру даёт ∮ p0·n ds ≡ 0, а вычитание убирает катастрофическое сокращение в float32;
# * вязкая часть из неравновесных моментов (Чепмен–Энског): σ^v_αβ = −(1 − 1/(2τ))·Π^neq_αβ,
# Π^neq_αβ = Σ_i c_iα c_iβ (f_i − f_i^eq).
# ВАЖНО (KBC): след Π^neq релаксирует не с 1/τ, а с γβ (поле-зависимый стабилизатор), поэтому
# берём строго ДЕВИАТОРНУЮ проекцию (след исключён) — сдвиг-моменты в KBC релаксируют с точным
# 2β = 1/τ, для них префактор корректен. Bulk-часть в силу трения и не входит.
# * КОНВЕКТИВНЫЙ член −ρu(u·n): поток импульса через контур. Зануляется только при δ→0 (no-slip
# на поверхности); при δ=2 скорость на контуре уже заметна, и без этого члена кросс-чек давал
# систематический сдвиг +4% по ⟨Cd⟩ на всех сетках (факторное исследование, эксперимент №0).
# * член d/dt ∫ρu dV по кольцу R..R1+δ НЕ считается: в среднем по времени он зануляется,
# на мгновенных рядах даёт малый сдвиг фазы → кросс-чек ведётся по средним (⟨Cd⟩, rms Cl).
# * контур — окружность радиуса R1+δ (δ = cfg.stress_delta тонких ячеек, по умолчанию 2):
# билинейный стенсиль выборки не задевает первое жидкое кольцо у стенки, где популяции
# реконструированы Bouzidi (q_max ≈ 0.98 < 1).
#
# Вызывается РЕДКО (каждые cfg.stress_every шагов) в Solver.run() ВНЕ step()/CUDA-графа.
import math
import backend as B
import lattice as L
import equilibrium as E
cp = B.cp; xp = B.xp; DTYPE = B.DTYPE
CS2 = L.CS2
def build_probe(cfg):
"""Прекомпьют контура (один раз, вне захвата): N=stress_ntheta точек на окружности R1+δ
вокруг центра тела на L1; индексы/веса билинейной интерполяции, нормали, плечи, элемент дуги."""
r = cfg.refine
R1 = r * float(cfg.D) / 2.0 # радиус цилиндра на L1 (тонкие ячейки)
Rp = R1 + float(cfg.stress_delta)
ccx = (cfg.cx - cfg.ax1) * r; ccy = (cfg.cy - cfg.ay1) * r
N = int(cfg.stress_ntheta)
th = (2.0 * math.pi / N) * cp.arange(N)
nx = cp.cos(th).astype(DTYPE); ny = cp.sin(th).astype(DTYPE)
px = ccx + Rp * nx; py = ccy + Rp * ny
x0 = cp.floor(px).astype(cp.int64); y0 = cp.floor(py).astype(cp.int64)
# контур обязан лежать внутри патча L1 (он центрирован вокруг тела — выполняется с запасом)
Nfx = (cfg.bx1 - cfg.ax1) * r + 1; Nfy = (cfg.by1 - cfg.ay1) * r + 1
assert int(x0.min()) >= 0 and int(x0.max()) + 1 <= Nfx - 1, "контур σ·n вышел за патч L1 по x"
assert int(y0.min()) >= 0 and int(y0.max()) + 1 <= Nfy - 1, "контур σ·n вышел за патч L1 по y"
return dict(x0=x0, y0=y0, x1=x0 + 1, y1=y0 + 1,
tx=(px - x0).astype(DTYPE), ty=(py - y0).astype(DTYPE),
nx=nx, ny=ny, rx=(Rp * nx), ry=(Rp * ny), ds=2.0 * math.pi * Rp / N)
def _sample(fld, pr):
"""Билинейная выборка 2D-поля в точках контура (аналог amr.pint, но по 1D-набору точек)."""
return (fld[pr["y0"], pr["x0"]] * (1 - pr["tx"]) * (1 - pr["ty"])
+ fld[pr["y0"], pr["x1"]] * pr["tx"] * (1 - pr["ty"])
+ fld[pr["y1"], pr["x0"]] * (1 - pr["tx"]) * pr["ty"]
+ fld[pr["y1"], pr["x1"]] * pr["tx"] * pr["ty"])
def force_stress(F1, pr, tau1):
"""Сила/момент на тело из баланса импульса ∮[σ·n − ρu(u·n)]ds по контуру pr на состоянии F1
(L1, после стриминга). Возвращает (Fx, Fy, Tz) — device-скаляры. tau1 — релаксация на L1."""
rho, u = E.macros(F1)
fneq = F1 - E.feq(rho, u)
CX = L.CXa[:, None, None]; CY = L.CYa[:, None, None]
Pxx = (CX * CX * fneq).sum(0)
Pyy = (CY * CY * fneq).sum(0)
Pxy = (CX * CY * fneq).sum(0)
rho_s = _sample(rho, pr)
ux_s = _sample(u[0], pr); uy_s = _sample(u[1], pr)
Pxx_s = _sample(Pxx, pr); Pyy_s = _sample(Pyy, pr); Pxy_s = _sample(Pxy, pr)
p = (rho_s - rho_s.mean()) * CS2 # p′: давление без константы (см. шапку)
pref = 1.0 - 1.0 / (2.0 * tau1)
tr = 0.5 * (Pxx_s + Pyy_s) # девиаторная проекция (след исключён)
sxx = -pref * (Pxx_s - tr); syy = -pref * (Pyy_s - tr); sxy = -pref * Pxy_s
un = ux_s * pr["nx"] + uy_s * pr["ny"] # нормальная скорость на контуре
flux = rho_s * un # ρ(u·n): конвективный вынос импульса
tx = (-p + sxx) * pr["nx"] + sxy * pr["ny"] - flux * ux_s # t = σ·n − ρu(u·n)
ty = sxy * pr["nx"] + (-p + syy) * pr["ny"] - flux * uy_s
Fx = tx.sum() * pr["ds"]; Fy = ty.sum() * pr["ds"]
Tz = (pr["rx"] * ty - pr["ry"] * tx).sum() * pr["ds"] # давление в торк не входит (n ∥ r)
return Fx, Fy, Tz
+47
View File
@@ -0,0 +1,47 @@
# Геометрия: маски тела/стенок и знаковое поле расстояний (SDF) для L0 и патча L1.
# Чистый CuPy. Соглашение по SDF: phi>0 в жидкости, phi<0 в теле; |phi| ≈ расстоянию до границы.
import backend as B
import lattice as L
import amr
cp = B.cp; xp = B.xp; DTYPE = B.DTYPE
def _cmask(NX, NY, ccx, ccy, R):
"""Булева маска круга радиуса R (центр ccx,ccy) на сетке (NY,NX)."""
Y, X = cp.meshgrid(cp.arange(NY), cp.arange(NX), indexing="ij")
return (X - ccx)**2 + (Y - ccy)**2 <= R*R
def _csdf(NX, NY, ccx, ccy, R):
"""SDF круга: sqrt((x−ccx)²+(y−ccy)²) − R (отрицательно внутри)."""
Y, X = cp.meshgrid(cp.arange(NY), cp.arange(NX), indexing="ij")
return cp.sqrt((X - ccx).astype(DTYPE)**2 + (Y - ccy).astype(DTYPE)**2) - R
class Geometry:
"""Контейнер масок/SDF/патча. Поля *_np — host-копии для визуализации."""
pass
def build(cfg):
Nx, Ny, D, cx, cy = cfg.Nx, cfg.Ny, cfg.D, cfg.cx, cfg.cy
r = cfg.refine
ax1, bx1, ay1, by1 = cfg.ax1, cfg.bx1, cfg.ay1, cfg.by1
g = Geometry()
# --- L0: только цилиндр как твёрдое тело (стенки канала верх/низ — free-slip, не solid) ---
g.cyl0 = _cmask(Nx, Ny, cx, cy, D/2.0)
g.solid0 = g.cyl0 # стенки обрабатываются specular-отражением, см. boundary.free_slip_walls
g.phi0 = _csdf(Nx, Ny, cx, cy, D/2.0) # SDF только цилиндра (для Bouzidi тела)
# --- патч L1 (измельчение r) над областью [ax1,bx1]×[ay1,by1] ---
g.P1 = amr.patch(Nx, Ny, ax1, bx1, ay1, by1, r)
Nfx, Nfy = g.P1["Nfx"], g.P1["Nfy"]
# цилиндр на L1: центр и радиус масштабируются на r (диаметр r·D → радиус r·D/2)
R1 = r * D / 2.0
g.solid1 = _cmask(Nfx, Nfy, (cx - ax1)*r, (cy - ay1)*r, R1)
g.phi1 = _csdf(Nfx, Nfy, (cx - ax1)*r, (cy - ay1)*r, R1)
# маска жидких узлов для рестрикции L1→L0 (внутренняя часть патча на L0)
g.fl1 = ~g.solid0[ay1+1:by1, ax1+1:bx1]
# host-копии для визуализации
g.solid0_np = B.to_cpu(g.solid0); g.solid1_np = B.to_cpu(g.solid1)
return g
+34
View File
@@ -0,0 +1,34 @@
# Решётка D2Q9 и KBC-проектор. Чистый CuPy; всё считается один раз при импорте.
#
# Скорости e_i и веса w_i (стандартная нумерация D2Q9):
# 0:( 0, 0) 1:(+1,0) 2:(0,+1) 3:(-1,0) 4:(0,-1)
# 5:(+1,+1) 6:(-1,+1) 7:(-1,-1) 8:(+1,-1)
# OPP[i] — индекс противоположного направления.
import backend as B
cp = B.cp; xp = B.xp; DTYPE = B.DTYPE
Q = 9
Cx = [0, 1, 0, -1, 0, 1, -1, -1, 1] # python-инты (для roll/индексации)
Cy = [0, 0, 1, 0, -1, 1, 1, -1, -1]
OPP = [0, 3, 4, 1, 2, 7, 8, 5, 6]
_Wlist = [4/9, 1/9, 1/9, 1/9, 1/9, 1/36, 1/36, 1/36, 1/36]
CS2 = 1.0 / 3.0 # квадрат скорости звука решётки
# device-векторы
Wa = cp.asarray(_Wlist, DTYPE)
CXa = cp.asarray(Cx, DTYPE)
CYa = cp.asarray(Cy, DTYPE)
def _build_projector():
"""Проектор Ps на сдвиг-моменты {cx²−cy², cx·cy} (ядро KBC-N1).
Ps = M⁻¹ · diag(оставить только эти 2 момента) · M, где M — моментный базис."""
cxv = cp.asarray(Cx, cp.float64); cyv = cp.asarray(Cy, cp.float64)
M = cp.zeros((Q, Q), cp.float64)
M[0] = 1.0; M[1] = cxv; M[2] = cyv; M[3] = 3.0*(cxv**2 + cyv**2) - 2.0
M[4] = cxv**2 - cyv**2; M[5] = cxv*cyv
M[6] = cxv**2*cyv; M[7] = cxv*cyv**2; M[8] = cxv**2*cyv**2
Dm = cp.zeros((Q, Q), cp.float64); Dm[4, 4] = Dm[5, 5] = 1.0
return (cp.linalg.inv(M) @ Dm @ M)
Ps = _build_projector().astype(DTYPE) # (Q,Q) проектор на сдвиг
+53
View File
@@ -0,0 +1,53 @@
# Точка входа модели 2×+SDF. Запуск из этой папки:
# python run.py
# AMR_STEPS=2000 python run.py # короткий прогон
# AMR_CUDAGRAPH=0 python run.py # без CUDA-графов
# AMR_FP64=1 python run.py # двойная точность
import os, time
import config as C
import backend as B
import solver as S
import diagnostics as Diag
import visualization as Viz
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "out"); os.makedirs(OUT, exist_ok=True)
def main():
cfg = C.from_env()
print(f"[2×+SDF] backend={B.BACKEND}; домен {cfg.Nx}x{cfg.Ny}; D={cfg.D}; steps={cfg.steps}; "
f"collision=kbc; force=gmem; "
f"graphs={'on' if cfg.use_cuda_graph else 'off'}; "
f"L1 {cfg.refine}× = {(cfg.bx1-cfg.ax1)*cfg.refine+1}x{(cfg.by1-cfg.ay1)*cfg.refine+1}; "
f"вход {cfg.cx/cfg.D:.1f}D / выход {(cfg.Nx-1-cfg.cx)/cfg.D:.1f}D; outlet_uy={cfg.outlet_uy}")
t0 = time.perf_counter()
sim = S.Solver(cfg)
res = sim.run(Viz.make_progress("2x+sdf"))
print(" время:", round(time.perf_counter() - t0, 1), "s")
a = Diag.analyze(res, cfg)
Diag.print_table(a, cfg)
Diag.print_crosscheck(a)
Diag.convergence_report(res, cfg)
Viz.save_gif(res, sim.geo, cfg, os.path.join(OUT, "cyl_2x_sdf.gif"))
Viz.save_series_panel(res, a, cfg, os.path.join(OUT, "series_2x_sdf.png"))
import numpy as np
np.savez(os.path.join(OUT, "probe_2x_sdf.npz"),
# сводные числа (самоописываемость для ноутбука: он только читает артефакты)
St=a["St"], Cd=a["Cd"], Clrms=a["Clrms"], uyrms=a["uyrms"],
Cm=a.get("Cm", float("nan")),
Cd_st=a.get("Cd_st", float("nan")), Clrms_st=a.get("Clrms_st", float("nan")),
Cm_st=a.get("Cm_st", float("nan")),
Ny=cfg.Ny, beta=cfg.D/cfg.Ny, D=cfg.D, U=cfg.U, Re=cfg.Re, steps=cfg.steps,
Nx=cfg.Nx, cx=cfg.cx, outlet_uy=cfg.outlet_uy,
stress_every=res["stress_every"],
# ряды
Cl=a["Cl_s"], uy=a["uy"], Fx=B.to_cpu(res["Fx"]), Fy=B.to_cpu(res["Fy"]),
Tz=B.to_cpu(res["Tz"]), rho=B.to_cpu(res["rho"]),
Fx_st=B.to_cpu(res["Fx_st"]), Fy_st=B.to_cpu(res["Fy_st"]), Tz_st=B.to_cpu(res["Tz_st"]))
print("DONE (2×+SDF)")
if __name__ == "__main__":
main()
+95
View File
@@ -0,0 +1,95 @@
# Серия по блокировке: D фиксирован, Ny растёт → β=D/Ny падает; экстраполяция β→0 даёт
# прямое сравнение с БЕЗГРАНИЧНОЙ литературой (Re=150: Cd≈1.33, St≈0.183, rms Cl≈0.3)
# без ручной поправки (1−β)². Запуск из этой папки:
# python run_blockage.py
# AMR_NY_LIST=90,128,180 python run_blockage.py # свой список Ny
# AMR_STEPS=20000 AMR_NY_LIST=128 python run_blockage.py # смоук
# Стоимость прогона ≈ как у run.py: патч L1 (основная работа) от Ny не зависит (высота 4D фикс.).
import os, time
import numpy as np
import config as C
import backend as B
import solver as S
import diagnostics as Diag
import visualization as Viz
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "out"); os.makedirs(OUT, exist_ok=True)
LIT = dict(Cd=1.33, Clrms=0.3, St=0.183) # безграничный цилиндр, Re≈150
def run_one(Ny):
cfg = C.from_env(Ny=Ny, frame_every=0) # серия без кадров/гифок (только ряды)
print(f"\n[блокировка] Ny={cfg.Ny} (β={cfg.D/cfg.Ny:.3f}); домен {cfg.Nx}x{cfg.Ny}; "
f"патч y=[{cfg.ay1},{cfg.by1}]; steps={cfg.steps}")
t0 = time.perf_counter()
sim = S.Solver(cfg)
res = sim.run(Viz.make_progress(f"Ny={cfg.Ny}"))
print(" время:", round(time.perf_counter() - t0, 1), "s")
a = Diag.analyze(res, cfg)
Diag.print_table(a, cfg)
Diag.print_crosscheck(a)
Diag.convergence_report(res, cfg)
np.savez(os.path.join(OUT, f"probe_blockage_Ny{cfg.Ny}.npz"),
St=a["St"], Cd=a["Cd"], Clrms=a["Clrms"], uyrms=a["uyrms"],
Cm=a.get("Cm", float("nan")),
Cd_st=a.get("Cd_st", float("nan")), Clrms_st=a.get("Clrms_st", float("nan")),
Ny=cfg.Ny, beta=cfg.D/cfg.Ny, D=cfg.D, U=cfg.U, Re=cfg.Re, steps=cfg.steps,
Cl=a["Cl_s"], uy=a["uy"], rho=B.to_cpu(res["rho"]),
Fx=B.to_cpu(res["Fx"]), Fy=B.to_cpu(res["Fy"]), Tz=B.to_cpu(res["Tz"]))
row = dict(Ny=cfg.Ny, beta=cfg.D/cfg.Ny, St=a["St"], Cd=a["Cd"], Clrms=a["Clrms"],
Cm=a.get("Cm", float("nan")), Cd_st=a.get("Cd_st"))
# освободить VRAM перед следующим прогоном (буферы, BC, захваченный CUDA-граф)
del sim, res, a
B.cp.get_default_memory_pool().free_all_blocks()
return row
def save_extrapolation_figure(rows, fits, path):
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt
betas = np.array([r["beta"] for r in rows]); bb = np.linspace(0, betas.max()*1.05, 100)
panels = [("Cd", "⟨Cd⟩", LIT["Cd"], "лит. 1.33"),
("Clrms", "rms Cl", LIT["Clrms"], "лит. ~0.3"),
("St", "St", LIT["St"], "лит. 0.183")]
fig, axs = plt.subplots(1, 3, figsize=(13.5, 4.2), dpi=110)
for ax, (key, lab, lit, litlab) in zip(axs, panels):
vals = np.array([r[key] for r in rows]); ft = fits[key]
ax.plot(betas, vals, "o", ms=7, color="#d32f2f", label="серия (raw)")
al, sl = ft["lin"]; aq, sq = ft["quad"]
ax.plot(bb, al + sl*bb, "-", lw=1.2, color="#1976d2", label=f"a+b·β → {al:.3f}")
ax.plot(bb, aq + sq*bb**2, "--", lw=1.2, color="#388e3c", label=f"a+b·β² → {aq:.3f}")
ax.axhline(lit, color="gray", lw=1.0, ls=":", label=litlab)
if key == "St": # для St полезен и кинематически поправленный вид
ax.plot(betas, vals*(1-betas), "s", ms=6, mfc="none", color="#7b1fa2", label="St·(1−β)")
ax.set_xlabel("β = D/Ny"); ax.set_ylabel(lab); ax.set_xlim(0, bb[-1])
ax.legend(fontsize=8); ax.set_title(f"{lab}(β): экстраполяция β→0", fontsize=10)
fig.suptitle("Серия по блокировке: стеснение каналом — свойство ПОСТАНОВКИ, не считывания силы",
fontsize=11)
fig.tight_layout(rect=(0, 0, 1, 0.93))
fig.savefig(path, bbox_inches="tight"); plt.close(fig)
print("saved", os.path.basename(path))
def main():
ny_list = [int(s) for s in os.environ.get("AMR_NY_LIST", "90,128,180").split(",")]
print(f"[серия по блокировке] backend={B.BACKEND}; Ny={ny_list}")
rows = [run_one(Ny) for Ny in ny_list]
if len(rows) < 2:
print("\n(одна точка — фит/экстраполяция пропущены)")
return
betas = [r["beta"] for r in rows]
fits = {key: Diag.fit_blockage(betas, [r[key] for r in rows]) for key in ("Cd", "Clrms", "St")}
Diag.print_blockage_table(rows, fits)
np.savez(os.path.join(OUT, "blockage_summary.npz"),
Ny=np.array([r["Ny"] for r in rows]), beta=np.array(betas),
St=np.array([r["St"] for r in rows]), Cd=np.array([r["Cd"] for r in rows]),
Clrms=np.array([r["Clrms"] for r in rows]), Cm=np.array([r["Cm"] for r in rows]),
Cd_st=np.array([r["Cd_st"] if r["Cd_st"] is not None else np.nan for r in rows]),
Cd0_lin=fits["Cd"]["lin"][0], Cd0_quad=fits["Cd"]["quad"][0],
Cl0_lin=fits["Clrms"]["lin"][0], Cl0_quad=fits["Clrms"]["quad"][0],
St0_lin=fits["St"]["lin"][0], St0_quad=fits["St"]["quad"][0],
lit_Cd=LIT["Cd"], lit_Clrms=LIT["Clrms"], lit_St=LIT["St"])
save_extrapolation_figure(rows, fits, os.path.join(OUT, "blockage_extrapolation.png"))
print("DONE (серия по блокировке)")
if __name__ == "__main__":
main()
+184
View File
@@ -0,0 +1,184 @@
# ФАКТОРНОЕ ИССЛЕДОВАНИЕ постановки: какие границы расчётной области отвечают за завышение
# Cd / rms Cl относительно безграничной литературы (Re=150: Cd≈1.33, rms Cl≈0.3, St≈0.183).
#
# Контекст (эксперимент №0 — серия по боковой блокировке, см. ноутбук, раздел 23): Cd(β)
# немонотонен (1.80 → 1.77 → 1.87 при β = 0.178 → 0.125 → 0.089) → боковое стеснение НЕ
# доминирующий фактор. Подозреваемые — продольные границы, которые серия держала константой:
# вход 2.5D (жёсткий равномерный профиль), выход 8.3D (Zou-He давление с жёстким uy=0,
# отражает вихри дорожки), и их связка.
#
# Здесь каждый фактор варьируется ОТДЕЛЬНО при фиксированной боковой блокировке (Ny=90):
# изоляция вклада входа, выхода и выходного ГУ. Все результаты накапливаются в out/factors.csv
# (одна строка на прогон, с полной конфигурацией) — реестр для документирования исследования.
#
# Запуск: python run_factors.py
# AMR_FACTORS=B0_baseline,F4_outlet_uy python run_factors.py # подмножество факторов
# AMR_STEPS=20000 python run_factors.py # смоук
import os, csv, time
import numpy as np
import config as C
import backend as B
import solver as S
import diagnostics as Diag
import visualization as Viz
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "out"); os.makedirs(OUT, exist_ok=True)
LIT = dict(Cd=1.33, Clrms=0.3, St=0.183)
# (имя, overrides конфига, что изолирует)
# Эксперимент №1 (B0–F5, прогнан 2026-06-10): итоги — мягкий uy-выход (F4) выигрывает по всем
# метрикам; ВСЕ длинные домены без демпфера уходят в смещённый режим ⟨ρ⟩≈1.66 (акустический
# резонатор «вход-скорость/выход-давление» + накачка массы входом) или взрываются (F5).
# Эксперимент №2 (F6–F8): губка перед выходом — лечит акустику, открывает длинные домены.
FACTORS = [
("B0_baseline", dict(),
"база 174x90: вход 2.5D, выход 8.3D, uy=0 (контроль; кросс-чек уже с конвективным членом)"),
("F1_outlet_far", dict(Nx=300),
"выход 8.3D -> 16.2D (вход 2.5D как в базе) — изолирует близость выхода"),
("F2_inlet_far", dict(Nx=230, cx=96),
"вход 2.5D -> 6.0D (выход 8.3D как в базе) — изолирует близость входа"),
("F3_inout_far", dict(Nx=390, cx=96),
"вход 6.0D + выход 18.3D — оба продольных фактора вместе"),
("F4_outlet_uy", dict(outlet_uy="extrapolate"),
"мягкий выход: uy нуль-градиент вместо uy=0 (геометрия базовая) — изолирует отражение вихрей"),
("F5_combo", dict(Nx=390, cx=96, outlet_uy="extrapolate"),
"вход 6.0D + выход 18.3D + мягкий uy-выход — всё вместе"),
# --- эксперимент №2: губка (absorbing layer) ---
("F6_sponge", dict(outlet_uy="extrapolate", sponge_len=32),
"база 174x90 + мягкий uy + ГУБКА 32 столбца (nu x30) — валидация губки против F4"),
("F7_long_sponge", dict(Nx=390, cx=96, outlet_uy="extrapolate", sponge_len=32),
"вход 6D + выход 18.3D + мягкий uy + губка — целевая «чистая» постановка"),
("F8_long_sp_uy0", dict(Nx=390, cx=96, sponge_len=32),
"длинный домен + губка, но ЖЁСТКОЕ uy=0 — изолирует вклад губки от uy-режима"),
]
CSV_FIELDS = ["when", "name", "status", "steps", "Nx", "Ny", "cx", "in_D", "out_D", "outlet_uy",
"sponge_len", "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"]
def window_stats(res, cfg, nwin=10):
"""Срединная изменчивость: std оконных средних Cd и оконных rms Cl (окна 2..nwin,
первое окно с разгоном пропускается). Метрика низкочастотной модуляции дорожки."""
Fx = B.to_cpu(res["Fx"]); Fy = B.to_cpu(res["Fy"])
Cd = 2.0*Fx/(cfg.U**2*res["DL"]); Cl = 2.0*Fy/(cfg.U**2*res["DL"])
n = len(Cd); w = n // nwin
cds = [float(Cd[k*w:(k+1)*w].mean()) for k in range(1, nwin)]
cls = [float(Cl[k*w:(k+1)*w].std()) for k in range(1, nwin)]
return float(np.std(cds)), float(np.std(cls))
def run_factor(name, overrides, desc):
cfg = C.from_env(frame_every=0, **overrides)
in_D = cfg.cx / cfg.D; out_D = (cfg.Nx - 1 - cfg.cx) / cfg.D
print(f"\n=== Фактор {name}: {desc}")
print(f" домен {cfg.Nx}x{cfg.Ny}; вход {in_D:.1f}D / выход {out_D:.1f}D; "
f"outlet_uy={cfg.outlet_uy}; губка={cfg.sponge_len or '—'}; "
f"патч x=[{cfg.ax1},{cfg.bx1}] y=[{cfg.ay1},{cfg.by1}]; steps={cfg.steps}")
t0 = time.perf_counter()
sim = S.Solver(cfg)
res = sim.run(Viz.make_progress(name[:8]))
wall = time.perf_counter() - t0
print(" время:", round(wall, 1), "s")
status = "ok" if len(res["Fx"]) == cfg.steps + 1 else f"BLEW_UP@{len(res['Fx'])}"
a = Diag.analyze(res, cfg)
Diag.print_table(a, cfg)
Diag.print_crosscheck(a)
Diag.convergence_report(res, cfg)
cd_ws, cl_ws = window_stats(res, cfg)
rho_last = float(B.to_cpu(res["rho"])[-1])
np.savez(os.path.join(OUT, f"factor_{name}.npz"),
St=a["St"], Cd=a["Cd"], Clrms=a["Clrms"], uyrms=a["uyrms"],
Cm=a.get("Cm", float("nan")),
Cd_st=a.get("Cd_st", float("nan")), Clrms_st=a.get("Clrms_st", float("nan")),
Nx=cfg.Nx, Ny=cfg.Ny, cx=cfg.cx, D=cfg.D, U=cfg.U, Re=cfg.Re, steps=cfg.steps,
outlet_uy=cfg.outlet_uy, beta=cfg.D/cfg.Ny,
Cl=a["Cl_s"], uy=a["uy"], rho=B.to_cpu(res["rho"]),
Fx=B.to_cpu(res["Fx"]), Fy=B.to_cpu(res["Fy"]), Tz=B.to_cpu(res["Tz"]))
beta = cfg.D / cfg.Ny
dcd = abs(a.get("Cd_st", float("nan")) - a["Cd"]) / a["Cd"] * 100.0
dcl = abs(a.get("Clrms_st", float("nan")) - a["Clrms"]) / max(a["Clrms"], 1e-12) * 100.0
row = dict(when=time.strftime("%Y-%m-%d %H:%M:%S"), name=name, status=status, steps=cfg.steps,
Nx=cfg.Nx, Ny=cfg.Ny, cx=cfg.cx, in_D=round(in_D, 2), out_D=round(out_D, 2),
outlet_uy=cfg.outlet_uy, sponge_len=cfg.sponge_len, beta=round(beta, 4),
St=round(a["St"], 4), St_corr=round(a["St"]*(1-beta), 4),
Cd=round(a["Cd"], 4), Clrms=round(a["Clrms"], 4),
Cm=round(a.get("Cm", float("nan")), 6),
Cd_st=round(a.get("Cd_st", float("nan")), 4),
Clrms_st=round(a.get("Clrms_st", float("nan")), 4),
dCd_pct=round(dcd, 2), dCl_pct=round(dcl, 2),
rho_last=round(rho_last, 5), cd_win_std=round(cd_ws, 4),
cl_win_std=round(cl_ws, 4), wall_s=round(wall, 1), desc=desc)
del sim, res, a
B.cp.get_default_memory_pool().free_all_blocks()
return row
def append_csv(rows):
path = os.path.join(OUT, "factors.csv")
if os.path.exists(path):
with open(path, encoding="utf-8") as fh:
header = fh.readline().strip()
if header != ",".join(CSV_FIELDS):
# схема реестра изменилась — старый файл сохраняем (история не теряется)
k = 1
while os.path.exists(os.path.join(OUT, f"factors_v{k}.csv")):
k += 1
os.rename(path, os.path.join(OUT, f"factors_v{k}.csv"))
print(f"[реестр] схема CSV изменилась — прежний файл сохранён как factors_v{k}.csv")
new = not os.path.exists(path)
with open(path, "a", newline="", encoding="utf-8") as fh:
wr = csv.DictWriter(fh, fieldnames=CSV_FIELDS)
if new:
wr.writeheader()
for r in rows:
wr.writerow(r)
print("CSV:", path, f"(+{len(rows)} строк)")
def save_summary_figure(rows, path):
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt
names = [r["name"] for r in rows]
xs = np.arange(len(rows))
fig, axs = plt.subplots(1, 3, figsize=(15, 4.6))
ax = axs[0]
ax.bar(xs, [r["Cd"] for r in rows], color="#1f6feb", label="⟨Cd⟩ GMEM")
ax.plot(xs, [r["Cd_st"] for r in rows], "o", color="#ff8f00", label="⟨Cd⟩ ∮[σ·n−ρu(u·n)]ds")
ax.axhline(LIT["Cd"], color="#d1495b", ls="--", label=f"лит. безгранич. {LIT['Cd']}")
ax.set_ylabel("⟨Cd⟩"); ax.set_title("Сопротивление по факторам")
ax = axs[1]
ax.bar(xs, [r["Clrms"] for r in rows], color="#2e7d32")
ax.axhline(LIT["Clrms"], color="#d1495b", ls="--", label=f"лит. ~{LIT['Clrms']}")
ax.set_ylabel("rms Cl"); ax.set_title("Амплитуда подъёмной силы")
ax = axs[2]
ax.bar(xs, [r["cd_win_std"] for r in rows], color="#7b1fa2")
ax.set_ylabel("std оконных ⟨Cd⟩"); ax.set_title("НЧ-модуляция (меньше — стабильнее)")
for ax in axs:
ax.set_xticks(xs)
ax.set_xticklabels([f"{r['name']}\nвх {r['in_D']}D / вых {r['out_D']}D\n"
f"uy:{r['outlet_uy']} губ:{r['sponge_len']}" for r in rows], fontsize=7)
ax.legend(fontsize=8)
fig.suptitle("Факторное исследование постановки (Ny=90, β=0.178 фикс., 100k шагов)", fontsize=12)
fig.tight_layout(rect=(0, 0, 1, 0.94))
fig.savefig(path, bbox_inches="tight"); plt.close(fig)
print("saved", os.path.basename(path))
def main():
sel = os.environ.get("AMR_FACTORS")
todo = FACTORS if not sel else [f for f in FACTORS if f[0] in sel.split(",")]
print(f"[факторы] backend={B.BACKEND}; прогонов: {len(todo)}")
rows = [run_factor(*f) for f in todo]
print("\n=== Сводка факторного исследования ===")
hdr = (f"{'фактор':15}{'вх,D':>6}{'вых,D':>7}{'uy':>12}{'губка':>6}{'St':>7}{'<Cd>':>8}"
f"{'rms Cl':>8}{'Cd σ·n':>8}{'dCd%':>6}{'<ρ>фин':>8}{'модул.':>8} статус")
print(hdr)
for r in rows:
print(f"{r['name']:15}{r['in_D']:>6}{r['out_D']:>7}{r['outlet_uy']:>12}{r['sponge_len']:>6}"
f"{r['St']:>7.3f}{r['Cd']:>8.3f}{r['Clrms']:>8.4f}{r['Cd_st']:>8.3f}{r['dCd_pct']:>6.1f}"
f"{r['rho_last']:>8.4f}{r['cd_win_std']:>8.4f} {r['status']}")
print(f"{'лит. безгранич.':15}{'':>6}{'':>7}{'':>12}{'':>6}{LIT['St']:>7.3f}{LIT['Cd']:>8.3f}{LIT['Clrms']:>8.4f}")
append_csv(rows)
if len(rows) > 1:
save_summary_figure(rows, os.path.join(OUT, "factors_summary.png"))
print("DONE (факторы)")
if __name__ == "__main__":
main()
+191
View File
@@ -0,0 +1,191 @@
# Оркестратор модели 2×+SDF: держит persistent-буферы, собирает один шаг из компонентов и гоняет цикл.
# Один шаг = замкнутая step() над фикс. буферами без host→device (готова к захвату в CUDA-граф).
import math
import backend as B
import lattice as L
import geometry as G
import equilibrium as E
import collision as Coll
import streaming as Strm
import boundary as Bnd
import forces as Fz
import forces_stress as FzS
import amr as Amr
cp = B.cp; xp = B.xp; DTYPE = B.DTYPE; to_cpu = B.to_cpu
CYa = L.CYa
def _capture(step_fn):
"""Захват одного шага в CUDA-граф. При сбое гарантированно выводит поток из режима записи."""
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
return s.end_capture()
class Solver:
def __init__(self, cfg):
self.cfg = cfg
self.collide = Coll.collide # KBC-N1 (единственный оператор столкновения)
self.force = Fz.force_gmem # галилей-инвариантный обмен импульсом
self.geo = G.build(cfg)
self.P1 = self.geo.P1
self.beta1 = cfg.beta1
if cfg.sponge_len:
# губка: τ(x) = τ0 + (mult−1)·ν·s(x)/cs², s — smoothstep 0→1 в последних sponge_len
# столбцах. β становится ПОЛЕМ (Nx,) → broadcast в kbc_collide; патч L1 губки не видит
# (гарантировано assert'ом в config), там остаётся скалярный beta1.
xs = cp.arange(cfg.Nx, dtype=DTYPE)
s = cp.clip((xs - (cfg.Nx - 1 - cfg.sponge_len)) / float(cfg.sponge_len), 0.0, 1.0)
s = s*s*(3.0 - 2.0*s)
tau_x = cfg.tau0 + (cfg.sponge_nu_mult - 1.0) * cfg.nu / (1.0/3.0) * s
self.beta0 = (1.0 / (2.0 * tau_x)).astype(DTYPE)[None, :] # (1,Nx) → broadcast по y
else:
self.beta0 = cfg.beta0
self.R01 = cfg.R01; self.Rf01 = cfg.Rf01
self.refine = cfg.refine
# ЗОНД скорости в следе: физическая точка (px,py) в координатах L0.
# Берём её с ТОНКОЙ сетки L1 (меньше численного размытия вихрей → чище St / rms u_y),
# если точка внутри патча; иначе fallback на L0. Индексы фиксированы → graph-safe.
self.probe_l1 = (cfg.ax1 <= cfg.px <= cfg.bx1) and (cfg.ay1 <= cfg.py <= cfg.by1)
if self.probe_l1:
self.pfx = (cfg.px - cfg.ax1) * cfg.refine
self.pfy = (cfg.py - cfg.ay1) * cfg.refine
else:
self.pfx = cfg.px; self.pfy = cfg.py
# ГУ (SDF/Bouzidi) на L0 (цилиндр+стенки) и L1 (цилиндр)
self.BC0 = Bnd.build_bc(self.geo.solid0, self.geo.phi0, self.geo.cyl0)
self.BC1 = Bnd.build_bc(self.geo.solid1, self.geo.phi1, self.geo.solid1)
self.fl1 = self.geo.fl1
# persistent-состояние
Ny, Nx = cfg.Ny, cfg.Nx; Nfy, Nfx = self.P1["Nfy"], self.P1["Nfx"]
self.F0 = E.feq(xp.ones((Ny, Nx), DTYPE), xp.zeros((2, Ny, Nx), DTYPE)).copy()
self.F1 = E.feq(xp.ones((Nfy, Nfx), DTYPE), xp.zeros((2, Nfy, Nfx), DTYPE)).copy()
self.onecol = xp.ones((Ny, 1), DTYPE)
self.uin = xp.zeros((2, Ny, 1), DTYPE) # вход; обновляется СНАРУЖИ захвата
self.Fx_s = xp.zeros((), DTYPE); self.Fy_s = xp.zeros((), DTYPE); self.uy_s = xp.zeros((), DTYPE)
self.Tz_s = xp.zeros((), DTYPE)
# координаты узлов L1 относительно центра тела — плечи торка (прекомпьют вне захвата)
ccx1 = (cfg.cx - cfg.ax1) * cfg.refine; ccy1 = (cfg.cy - cfg.ay1) * cfg.refine
Yf, Xf = cp.meshgrid(cp.arange(Nfy), cp.arange(Nfx), indexing="ij")
self.rxy1 = ((Xf - ccx1).astype(DTYPE), (Yf - ccy1).astype(DTYPE))
# контур кросс-чека силы σ·n (forces_stress; считается редко, вне CUDA-графа)
self.sprobe = FzS.build_probe(cfg) if cfg.stress_every else None
# разгон входа на device: U*smoothstep(t/ramp)
ts = cp.minimum(cp.arange(cfg.steps + 1) / cfg.ramp, 1.0)
self.ramp_u = (cfg.U * (ts*ts*(3 - 2*ts))).astype(DTYPE)
# стартовое возмущение: поперечный sin-импульс u_y входа в окне [ramp, ramp+pert_dur] (триггер схода)
uyp = cp.zeros(cfg.steps + 1, DTYPE)
if cfg.pert_dur > 0 and cfg.pert_amp != 0.0:
k = cp.arange(cfg.steps + 1)
inw = (k >= cfg.ramp) & (k < cfg.ramp + cfg.pert_dur)
ph = (k - cfg.ramp).astype(DTYPE) / float(cfg.pert_dur)
uyp = cp.where(inw, (cfg.pert_amp * cfg.U) * cp.sin(math.pi * ph), 0.0).astype(DTYPE)
self.ramp_uy = uyp
# ⟨ρ⟩ — диагностика дрейфа массы. Считать ТОЛЬКО по жидкости: узлы внутри цилиндра —
# фиктивные (их популяции не имеют физического смысла, ГУ их не трогает), а их плотность
# уезжает независимо. При D=12 на 120×60 это 1.6% узлов и смещение ⟨ρ⟩ на −5.4e-4 —
# ровно тот разряд, в котором ⟨ρ⟩ и цитируется в отчётах (1.0000 / 1.001).
self.fluid0 = (~self.geo.solid0).astype(DTYPE)
self._ncells = float(self.fluid0.sum())
def step(self):
F0 = self.F0; F1 = self.F1; P1 = self.P1
feq = E.feq; macros = E.macros; stream = Strm.stream
# ----- уровень 0 -----
rho, u0 = macros(F0)
pre = F0.copy()
post0 = self.collide(F0, feq(rho, u0), self.beta0)
f0 = stream(post0); f0 = Bnd.apply_bc(f0, post0, self.BC0)
# порядок важен: стенки снимают y-заворот roll, затем Zou-He закрывает x-заворот НА ВЕСЬ
# столбец, включая угловые узлы (иначе вход и выход связаны напрямую — см. boundary.py)
f0 = Bnd.free_slip_walls(f0) # верх/низ — скользящие стенки (specular)
f0 = Bnd.channel_bc(f0, self.uin, outlet_uy=self.cfg.outlet_uy) # Zou-He вход/выход
# ----- уровень 1: refine подшагов по времени с временно́й интерполяцией ghost -----
g1o = Amr.ghost(pre, P1, self.R01); g1n = Amr.ghost(f0, P1, self.R01)
f1 = F1
# сила/момент: меряем на КАЖДОМ подшаге L1 и усредняем (анти-алиасинг; раньше — мгновенная
# на последнем подшаге). Средний Cd не меняется, ряды становятся глаже.
fxa = B.ZERO; fya = B.ZERO; tza = B.ZERO
for s1 in range(self.refine):
r1, u1 = macros(f1); post1 = self.collide(f1, feq(r1, u1), self.beta1)
f1 = stream(post1); f1 = Bnd.apply_bc(f1, post1, self.BC1)
fx, fy, tz = self.force(post1, f1, self.BC1, self.rxy1)
fxa = fxa + fx; fya = fya + fy; tza = tza + tz
w = (s1 + 1) / self.refine # временна́я интерполяция ghost: o→n
f1 = Amr.fill(f1, (1 - w)*g1o + w*g1n)
self.Fx_s[...] = fxa / self.refine; self.Fy_s[...] = fya / self.refine
self.Tz_s[...] = tza / self.refine
f0 = Amr.restrict(f1, f0, P1, self.Rf01, self.fl1)
F1[...] = f1
# зонд скорости в следе (с тонкой сетки L1, если точка внутри патча) + запись состояния
psrc = f1 if self.probe_l1 else f0
col = psrc[:, self.pfy, self.pfx]; self.uy_s[...] = (CYa*col).sum() / col.sum()
F0[...] = f0
def run(self, progress=None):
cfg = self.cfg
Fx = xp.zeros(cfg.steps + 1, DTYPE); Fy = xp.zeros(cfg.steps + 1, DTYPE); uy = xp.zeros(cfg.steps + 1, DTYPE)
Tz = xp.zeros(cfg.steps + 1, DTYPE) # момент (торк) вокруг центра тела
rho = xp.zeros(cfg.steps + 1, DTYPE) # ⟨ρ⟩(t) — диагностика дрейфа массы
# кросс-чек σ·n: редкая выборка (каждые stress_every шагов), вне CUDA-графа
nst = (cfg.steps // cfg.stress_every + 1) if cfg.stress_every else 0
Fx_st = xp.zeros(nst, DTYPE); Fy_st = xp.zeros(nst, DTYPE); Tz_st = xp.zeros(nst, DTYPE)
kst = 0
frames = []
graph = None; graph_failed = False
def snap(): return (self.F0.copy(), self.F1.copy())
def restore(s): self.F0[...] = s[0]; self.F1[...] = s[1]
def maxdiff(s): return max(float(xp.abs(s[0] - self.F0).max()), float(xp.abs(s[1] - self.F1).max()))
for t in range(cfg.steps + 1):
self.uin[0, :, 0] = self.ramp_u[t]
self.uin[1, :, 0] = self.ramp_uy[t] # поперечный импульс-триггер (0 вне окна возмущения)
did = False
if t == 1 and cfg.use_cuda_graph and not graph_failed:
# захват + РАНТАЙМ-САМОПРОВЕРКА (граф vs eager на одном шаге); при расхождении — откат
try:
graph = _capture(self.step)
before = snap(); graph.launch(); gres = snap()
restore(before); self.step()
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
except Exception as e:
print(f"\n[graph] недоступны ({type(e).__name__}: {e}) — работаю eager")
import traceback as _tb; _tb.print_exc()
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:
self.step()
Fx[t] = self.Fx_s; Fy[t] = self.Fy_s; Tz[t] = self.Tz_s; uy[t] = self.uy_s
rho[t] = (self.F0.sum(0) * self.fluid0).sum() / self._ncells # ⟨ρ⟩ по жидкости (дрейф массы)
if cfg.stress_every and t % cfg.stress_every == 0:
# eager-вычисление на текущем F1 после launch/step — упорядочено на стриме,
# сам захваченный граф не трогаем (паттерн как у rho[t] выше)
sfx, sfy, stz = FzS.force_stress(self.F1, self.sprobe, cfg.tau1)
Fx_st[kst] = sfx; Fy_st[kst] = sfy; Tz_st[kst] = stz; kst += 1
if progress is not None:
progress(t, cfg.steps)
if t % 500 == 0 and not bool(xp.isfinite(self.F0).all()):
print("BLEW UP", t)
Fx = Fx[:t]; Fy = Fy[:t]; Tz = Tz[:t]; uy = uy[:t]; rho = rho[:t]
Fx_st = Fx_st[:kst]; Fy_st = Fy_st[:kst]; Tz_st = Tz_st[:kst]
break
if cfg.frame_every and t % cfg.frame_every == 0:
frames.append((t, to_cpu(E.macros(self.F0)[1]), to_cpu(E.macros(self.F1)[1])))
return dict(Fx=Fx, Fy=Fy, Tz=Tz, uy=uy, rho=rho, frames=frames, DL=cfg.DL,
Fx_st=Fx_st, Fy_st=Fy_st, Tz_st=Tz_st, stress_every=cfg.stress_every)
+17
View File
@@ -0,0 +1,17 @@
# ПЕРЕНОС (streaming): f_i(x, t+1) = f_i(x − c_i, t).
# Реализован пул-схемой через cp.roll по осям (y, x). 9 сдвигов в новый буфер.
#
# Заметка для ревизии: roll создаёт по ядру на направление (9 ядер). Это НЕ влияет на физику,
# только на число запусков ядер. Главный способ убрать накладные расходы — CUDA-graph (см. solver.py),
# а не переписывание переноса. Альтернатива (один gather-кернел по предвыч. индексам) — точка расширения.
import backend as B
import lattice as L
xp = B.xp
Q = L.Q; Cx = L.Cx; Cy = L.Cy
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
@@ -0,0 +1,92 @@
# Визуализация (host): гифка |ω| с наложением тонкого патча L1 и прогресс-бар.
# matplotlib/PIL работают только с host-массивами — кадры уже перенесены на host в solver.run().
import os
import time as _time
import numpy as np
def make_progress(label, width=32, every=0.1):
"""ASCII-шкала прогресса по выполненным шагам из заданных (обновление на месте через \\r)."""
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:8}[{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
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 save_series_panel(res, a, cfg, path):
"""Готовая png-панель для ноутбука (он код не исполняет — только вставляет картинки):
(1) ряды Cd(t)/Cl(t) + редкие точки кросс-чека ∮σ·n ds; (2) Cm(t) — контроль симметрии;
(3) спектр зонда u_y с пиком St; (4) ⟨ρ⟩(t) — контроль дрейфа массы."""
import backend as B
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt
Cd = a["Cd_s"]; Cl = a["Cl_s"]; n = len(Cd); h0 = a["h0"]; t = np.arange(n)
fig, axs = plt.subplots(2, 2, figsize=(12.5, 7.2), dpi=110)
ax = axs[0, 0]
ax.plot(t, Cd, lw=0.7, color="#d32f2f", label=f"Cd (GMEM), ⟨Cd⟩={a['Cd']:.3f}")
ax.plot(t, Cl, lw=0.7, color="#1976d2", label=f"Cl (GMEM), rms={a['Clrms']:.3f}")
if "Cd_st_s" in a:
K = a["stress_every"]; ts = np.arange(len(a["Cd_st_s"])) * K
ax.plot(ts, a["Cd_st_s"], ".", ms=3, color="#ff8f00", label=f"Cd ∮σ·n, ⟨⟩={a['Cd_st']:.3f}")
ax.plot(ts, a["Cl_st_s"], ".", ms=3, color="#4fc3f7", label="Cl ∮σ·n")
ax.axvline(h0, color="gray", lw=0.8, ls="--")
ax.set_xlabel("шаг L0"); ax.set_ylabel("Cd, Cl"); ax.legend(fontsize=8, loc="upper right")
ax.set_title("Сила: GMEM (линии) vs ∮σ·n ds (точки)", fontsize=10)
ax = axs[0, 1]
if "Cm_s" in a:
ax.plot(t, a["Cm_s"], lw=0.7, color="#388e3c")
ax.axhline(0.0, color="gray", lw=0.8)
ax.set_title(f"Cm(t): ⟨Cm⟩={a['Cm']:.5f} (контроль симметрии, ожид. ≈0)", fontsize=10)
ax.set_xlabel("шаг L0"); ax.set_ylabel("Cm")
ax = axs[1, 0]
ax.plot(a["freqs"]*cfg.D/cfg.U, a["amp"], lw=0.9, color="#7b1fa2")
ax.axvline(a["St"], color="#d32f2f", lw=1.0, ls="--", label=f"St={a['St']:.3f}")
ax.set_xlim(0, 0.6); ax.set_xlabel("St = f·D/U"); ax.set_ylabel("|FFT(u_y)|")
ax.legend(fontsize=9); ax.set_title("Спектр зонда в следе (вторая половина ряда)", fontsize=10)
ax = axs[1, 1]
rho = B.to_cpu(res["rho"]) if not isinstance(res["rho"], np.ndarray) else res["rho"]
ax.plot(np.arange(len(rho)), rho, lw=0.9, color="#5d4037")
ax.axhline(1.0, color="gray", lw=0.8)
ax.set_xlabel("шаг L0"); ax.set_ylabel("⟨ρ⟩")
ax.set_title(f"⟨ρ⟩(t): дрейф массы (финально {float(rho[-1]):.4f})", fontsize=10)
fig.suptitle(f"2×+SDF · Ny={cfg.Ny} (β={cfg.D/cfg.Ny:.3f}) · Re={cfg.Re:g} · {len(rho)-1} шагов",
fontsize=11)
fig.tight_layout(rect=(0, 0, 1, 0.96))
os.makedirs(os.path.dirname(path), exist_ok=True)
fig.savefig(path, bbox_inches="tight"); plt.close(fig)
print("saved", os.path.basename(path))
def save_gif(res, geo, cfg, path, fps=24):
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt, matplotlib.patches as mpatches
from PIL import Image
s0 = geo.solid0_np; s1 = geo.solid1_np
Nx, Ny = cfg.Nx, cfg.Ny; ax1, bx1, ay1, by1 = cfg.ax1, cfg.bx1, cfg.ay1, cfg.by1
frames = res["frames"]
vm = np.nanpercentile(np.abs(np.where(s0, 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) in frames:
fig = plt.figure(figsize=(11.2, 5.7), dpi=96); ax = fig.add_subplot(111)
ax.imshow(np.abs(np.where(s0, 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(s1, 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")
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, "L0: Δ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, "L1: Δx/2 (тело+след)", color="#aed581", fontsize=9, weight="bold")
ax.set_title(f"2×+SDF · |\\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 = __import__("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))
os.makedirs(os.path.dirname(path), exist_ok=True)
pil[0].save(path, save_all=True, append_images=pil[1:], duration=int(1000/fps), loop=0, optimize=True)
print("saved", os.path.basename(path), len(frames), "кадров")
View File
+22
View File
@@ -0,0 +1,22 @@
@echo off
REM Portable launcher for SimVulcan. Resolves the exe path relative to this
REM script so the project folder can be moved without breaking the shortcut.
setlocal
set "SCRIPT_DIR=%~dp0"
set "EXE_DIR=%SCRIPT_DIR%build\vs2022\src\app\Release"
set "EXE=%EXE_DIR%\SimVulcan.exe"
if not exist "%EXE%" (
echo [ERROR] SimVulcan.exe not found at:
echo %EXE%
echo.
echo Build the project first:
echo cmake --preset windows-msvc-release
echo cmake --build --preset windows-msvc-release
pause
exit /b 1
)
cd /d "%EXE_DIR%"
start "" "%EXE%"
endlocal
+17
View File
@@ -0,0 +1,17 @@
# Collect all shader sources and feed them to the simv_shaders custom target.
file(GLOB_RECURSE SHADER_SOURCES CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/editor/*.vert"
"${CMAKE_CURRENT_SOURCE_DIR}/editor/*.frag"
)
if(SHADER_SOURCES)
# INCLUDE root is `shaders/` so that #include "common/foo.glsl" would resolve
# to shaders/common/foo.glsl if a shader needs a shared helper.
simv_compile_shaders(simv_shaders
${SHADER_SOURCES}
INCLUDES "${CMAKE_CURRENT_SOURCE_DIR}"
)
else()
# Empty placeholder so dependent targets don't break when no shaders exist yet.
add_custom_target(simv_shaders ALL COMMAND ${CMAKE_COMMAND} -E true)
endif()
+8
View File
@@ -0,0 +1,8 @@
#version 460
layout(location = 0) in vec3 vColor;
layout(location = 0) out vec4 outColor;
void main() {
outColor = vec4(vColor, 1.0);
}
+16
View File
@@ -0,0 +1,16 @@
#version 460
// Reference-grid / axis lines: per-vertex colour, transformed by the MVP.
layout(location = 0) in vec3 inPos;
layout(location = 1) in vec3 inColor;
layout(push_constant) uniform PC {
mat4 mvp;
} pc;
layout(location = 0) out vec3 vColor;
void main() {
vColor = inColor;
gl_Position = pc.mvp * vec4(inPos, 1.0);
}
+28
View File
@@ -0,0 +1,28 @@
#version 460
// Flat shading: the per-face normal is reconstructed from screen-space
// derivatives of the model-space position, so the vertex stream needs no
// normals. color.a selects shaded (1) vs constant colour (0, for wireframe).
layout(location = 0) in vec3 vModelPos;
layout(location = 0) out vec4 outColor;
layout(push_constant) uniform PC {
mat4 mvp;
vec4 color;
} pc;
void main() {
vec3 dx = dFdx(vModelPos);
vec3 dy = dFdy(vModelPos);
vec3 c = cross(dx, dy);
float len = length(c);
vec3 n = (len > 1e-8) ? c / len : vec3(0.0, 0.0, 1.0);
const vec3 L = normalize(vec3(0.4, 0.85, 0.55));
float diff = abs(dot(n, L)); // two-sided
float shade = clamp(0.25 + 0.8 * diff, 0.0, 1.0);
vec3 base = pc.color.rgb;
vec3 rgb = mix(base, base * shade, pc.color.a);
outColor = vec4(rgb, 1.0);
}
+17
View File
@@ -0,0 +1,17 @@
#version 460
// Transforms model vertices by the MVP push constant. The model-space position
// is passed through so the fragment shader can derive a flat normal from it.
layout(location = 0) in vec3 inPos;
layout(push_constant) uniform PC {
mat4 mvp;
vec4 color; // .rgb base colour, .a = lit flag (1 = shaded, 0 = flat)
} pc;
layout(location = 0) out vec3 vModelPos;
void main() {
vModelPos = inPos;
gl_Position = pc.mvp * vec4(inPos, 1.0);
}
+27
View File
@@ -0,0 +1,27 @@
add_executable(SimVulcan
main.cpp
)
target_link_libraries(SimVulcan PRIVATE
simv_core simv_vk simv_mesh simv_editor
glm::glm spdlog::spdlog
)
add_dependencies(SimVulcan simv_shaders)
simv_set_warnings(SimVulcan)
# Copy assets next to the executable.
add_custom_command(TARGET SimVulcan POST_BUILD
COMMAND ${CMAKE_COMMAND} -E rm -rf "$<TARGET_FILE_DIR:SimVulcan>/assets"
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_SOURCE_DIR}/assets"
"$<TARGET_FILE_DIR:SimVulcan>/assets"
COMMENT "Copying assets to output directory"
)
# Copy compiled SPIR-V next to the executable.
add_custom_command(TARGET SimVulcan POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/spirv"
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_BINARY_DIR}/spirv"
"$<TARGET_FILE_DIR:SimVulcan>/spirv"
COMMENT "Copying SPIR-V shaders to output directory"
)
+85
View File
@@ -0,0 +1,85 @@
// SimVulcan editor: a 3D viewport with an orbit camera, three reference grid
// planes through the origin, and .obj model loading/visualisation.
#include "core/App.h"
#include "core/Logger.h"
#include "vk/Renderer.h"
#include "mesh/Mesh.h"
#include "mesh/MeshDecimator.h"
#include "editor/Camera.h"
#include "editor/CameraInput.h"
#include "editor/EditorUI.h"
#include "editor/MeshLoadPanel.h"
#include <glm/geometric.hpp>
#include <glm/vec3.hpp>
#include <spdlog/spdlog.h>
#include <cstdio>
#include <exception>
#include <filesystem>
int main(int /*argc*/, char** /*argv*/) {
try {
simv::core::Logger::Instance().Init();
simv::core::App app;
simv::vk::Renderer& renderer = app.Renderer();
simv::editor::Camera camera;
simv::editor::EditorUI editorUI;
simv::editor::MeshLoadPanel meshPanel;
// Locate assets/meshes by probing a few ancestor directories (the binary
// runs from build/<preset>/src/app, with assets/ copied alongside it).
std::filesystem::path meshDir;
for (const auto& candidate : { "../../../../../assets/meshes",
"../../../../assets/meshes",
"../../../assets/meshes",
"../../assets/meshes",
"assets/meshes" }) {
if (std::filesystem::exists(candidate)) {
meshDir = std::filesystem::canonical(candidate);
break;
}
}
if (meshDir.empty()) meshDir = "assets/meshes";
meshPanel.SetMeshDirectory(meshDir);
meshPanel.SetOnLoaded([&](simv::mesh::Mesh m) {
simv::mesh::WeldVertices(m, 1e-4f);
simv::core::LogFmt(simv::core::LogCategory::MeshIO,
simv::core::LogLevel::Info,
"Mesh ready: {} verts, {} tris",
m.VertexCount(), m.TriangleCount());
renderer.SetMeshCpu(m);
// Frame the camera to the model's size (it is recentred on the origin).
const float radius = 0.5f * glm::length(m.bboxMax - m.bboxMin);
if (radius > 0.0f) camera.SetDistance(radius * 2.5f);
});
app.SetUiCallback([&]() {
editorUI.Draw(camera);
meshPanel.Draw();
simv::editor::ApplyMouseInput(camera);
renderer.SetRenderMode(static_cast<simv::vk::RenderMode>(editorUI.RenderMode()));
renderer.SetGridVisible(editorUI.GridVisible());
renderer.SetViewProj(camera.ViewProj(renderer.AspectRatio()));
});
const int rc = app.Run();
simv::core::Logger::Instance().Shutdown();
return rc;
} catch (const std::exception& e) {
spdlog::critical("Fatal: {}", e.what());
std::fprintf(stderr, "Fatal: %s\n", e.what());
return 1;
} catch (...) {
std::fprintf(stderr, "Fatal: unknown exception\n");
return 1;
}
}
+33
View File
@@ -0,0 +1,33 @@
#include "App.h"
#include "Window.h"
#include "vk/Renderer.h"
#include <spdlog/spdlog.h>
namespace simv::core {
App::App() {
_window = std::make_unique<Window>(WindowDesc{});
_renderer = std::make_unique<simv::vk::Renderer>(*_window);
}
App::~App() {
if (_renderer) _renderer->WaitIdle();
}
void App::SetUiCallback(UiCallback cb) {
_renderer->SetUiCallback(std::move(cb));
}
int App::Run() {
spdlog::info("SimVulcan running. Close the window to exit.");
while (!_window->ShouldClose()) {
_window->PollEvents();
_renderer->DrawFrame();
}
_renderer->WaitIdle();
return 0;
}
} // namespace simv::core
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <functional>
#include <memory>
namespace simv::vk { class Renderer; }
namespace simv::core {
class Window;
// Top-level lifetime owner. Creates the window and the Vulkan renderer, runs the
// frame loop, and forwards the UI callback. Contains no Vulkan code itself — all
// of that lives behind simv::vk::Renderer.
class App {
public:
App();
~App();
App(const App&) = delete;
App& operator=(const App&) = delete;
// ImGui panels are drawn from this callback each frame.
using UiCallback = std::function<void()>;
void SetUiCallback(UiCallback cb);
[[nodiscard]] simv::vk::Renderer& Renderer() noexcept { return *_renderer; }
int Run();
private:
std::unique_ptr<Window> _window;
std::unique_ptr<simv::vk::Renderer> _renderer;
};
} // namespace simv::core
+14
View File
@@ -0,0 +1,14 @@
add_library(simv_core STATIC
Logger.h
Logger.cpp
Window.h
Window.cpp
App.h
App.cpp
)
target_include_directories(simv_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/..)
target_link_libraries(simv_core
PUBLIC spdlog::spdlog
PRIVATE simv_vk glfw
)
simv_set_warnings(simv_core)
+129
View File
@@ -0,0 +1,129 @@
#include "Logger.h"
#include <spdlog/sinks/stdout_color_sinks.h>
#include <array>
#include <atomic>
#include <chrono>
#include <mutex>
namespace simv::core {
namespace {
constexpr std::array<const char*, LogCategoryCount> kCategoryNames = {{
"core", "vk", "mesh", "ui", "test",
}};
static_assert(kCategoryNames.size() == LogCategoryCount,
"kCategoryNames must have one entry per LogCategory");
struct CategoryEntry {
bool enabled {true};
LogLevel minLevel {LogLevel::Info};
double throttleSec{0.5};
std::atomic<std::int64_t> lastLogMs {INT64_MIN};
};
struct LoggerImpl {
std::array<CategoryEntry, LogCategoryCount> entries{};
std::shared_ptr<spdlog::logger> sink;
std::chrono::steady_clock::time_point epoch{std::chrono::steady_clock::now()};
std::mutex initMutex;
bool initialized{false};
};
LoggerImpl& Impl() {
static LoggerImpl impl;
return impl;
}
spdlog::level::level_enum ToSpd(LogLevel l) noexcept {
switch (l) {
case LogLevel::Trace: return spdlog::level::trace;
case LogLevel::Debug: return spdlog::level::debug;
case LogLevel::Info: return spdlog::level::info;
case LogLevel::Warn: return spdlog::level::warn;
case LogLevel::Error: return spdlog::level::err;
case LogLevel::Critical: return spdlog::level::critical;
default: return spdlog::level::off;
}
}
std::int64_t NowMs() noexcept {
using namespace std::chrono;
return duration_cast<milliseconds>(steady_clock::now() - Impl().epoch).count();
}
} // namespace
const char* CategoryName(LogCategory cat) noexcept {
const auto idx = static_cast<std::size_t>(cat);
return idx < LogCategoryCount ? kCategoryNames[idx] : "?";
}
Logger& Logger::Instance() noexcept {
static Logger inst;
return inst;
}
void Logger::Init() {
auto& impl = Impl();
std::lock_guard<std::mutex> lk(impl.initMutex);
if (impl.initialized) return;
impl.sink = spdlog::stdout_color_mt("simv");
impl.sink->set_pattern("[%H:%M:%S.%e] [%^%l%$] %v");
impl.sink->set_level(spdlog::level::trace);
impl.initialized = true;
}
void Logger::Shutdown() {
auto& impl = Impl();
std::lock_guard<std::mutex> lk(impl.initMutex);
if (!impl.initialized) return;
impl.sink.reset();
spdlog::drop("simv");
impl.initialized = false;
}
void Logger::SetMinLevel(LogCategory cat, LogLevel lvl) noexcept {
const auto idx = static_cast<std::size_t>(cat);
if (idx < LogCategoryCount) Impl().entries[idx].minLevel = lvl;
}
void Logger::SetThrottle(LogCategory cat, double seconds) noexcept {
const auto idx = static_cast<std::size_t>(cat);
if (idx < LogCategoryCount) Impl().entries[idx].throttleSec = seconds;
}
void Logger::SetEnabled(LogCategory cat, bool enabled) noexcept {
const auto idx = static_cast<std::size_t>(cat);
if (idx < LogCategoryCount) Impl().entries[idx].enabled = enabled;
}
void Logger::LogRaw(LogCategory cat, LogLevel lvl, std::string_view msg) noexcept {
auto& impl = Impl();
if (!impl.initialized) Init();
const auto idx = static_cast<std::size_t>(cat);
if (idx >= LogCategoryCount) return;
auto& e = impl.entries[idx];
if (!e.enabled) return;
if (static_cast<std::uint8_t>(lvl) < static_cast<std::uint8_t>(e.minLevel)) return;
// Warnings/errors bypass throttling.
const bool important = (lvl == LogLevel::Warn) || (lvl == LogLevel::Error)
|| (lvl == LogLevel::Critical);
if (!important && e.throttleSec > 0.0) {
const std::int64_t nowMs = NowMs();
const std::int64_t throttle = static_cast<std::int64_t>(e.throttleSec * 1000.0);
std::int64_t lastMs = e.lastLogMs.load(std::memory_order_relaxed);
if (lastMs != INT64_MIN && (nowMs - lastMs) < throttle) return;
e.lastLogMs.store(nowMs, std::memory_order_relaxed);
}
impl.sink->log(ToSpd(lvl), "[{}] {}", CategoryName(cat), msg);
}
} // namespace simv::core
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include <cstdint>
#include <string_view>
#include <spdlog/spdlog.h>
#include <spdlog/fmt/fmt.h>
namespace simv::core {
enum class LogLevel : std::uint8_t {
Trace, Debug, Info, Warn, Error, Critical, Off,
};
enum class LogCategory : std::uint8_t {
Core,
Vulkan,
MeshIO,
UI,
Test,
Count_, // sentinel
};
constexpr std::size_t LogCategoryCount = static_cast<std::size_t>(LogCategory::Count_);
const char* CategoryName(LogCategory cat) noexcept;
// Throttled, category-aware logger.
class Logger {
public:
static Logger& Instance() noexcept;
void Init(); // safe to call multiple times; first call wins
void Shutdown();
void SetMinLevel(LogCategory cat, LogLevel lvl) noexcept;
void SetThrottle(LogCategory cat, double minIntervalSeconds) noexcept;
void SetEnabled(LogCategory cat, bool enabled) noexcept;
void LogRaw(LogCategory cat, LogLevel lvl, std::string_view msg) noexcept;
private:
Logger() = default;
~Logger() = default;
Logger(const Logger&) = delete;
Logger& operator=(const Logger&) = delete;
};
template <class... Args>
void LogFmt(LogCategory cat, LogLevel lvl,
fmt::format_string<Args...> fmtStr, Args&&... args)
{
Logger::Instance().LogRaw(cat, lvl,
fmt::format(fmtStr, std::forward<Args>(args)...));
}
} // namespace simv::core
+72
View File
@@ -0,0 +1,72 @@
#include "Window.h"
#include <GLFW/glfw3.h>
#include <stdexcept>
namespace simv::core {
namespace {
int s_glfwRefCount = 0;
void GlfwErrorCallback(int code, const char* description) {
std::fprintf(stderr, "[GLFW] error %d: %s\n", code, description);
}
void EnsureGlfwInit() {
if (s_glfwRefCount++ == 0) {
glfwSetErrorCallback(GlfwErrorCallback);
if (glfwInit() != GLFW_TRUE) {
throw std::runtime_error("glfwInit failed");
}
}
}
void ReleaseGlfw() {
if (--s_glfwRefCount == 0) {
glfwTerminate();
}
}
} // namespace
Window::Window(const WindowDesc& desc) {
EnsureGlfwInit();
glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
glfwWindowHint(GLFW_RESIZABLE, desc.resizable ? GLFW_TRUE : GLFW_FALSE);
_handle = glfwCreateWindow(desc.width, desc.height, desc.title.c_str(),
nullptr, nullptr);
if (!_handle) {
ReleaseGlfw();
throw std::runtime_error("glfwCreateWindow failed");
}
}
Window::~Window() {
if (_handle) glfwDestroyWindow(_handle);
ReleaseGlfw();
}
bool Window::ShouldClose() const noexcept {
return glfwWindowShouldClose(_handle) == GLFW_TRUE;
}
void Window::PollEvents() const noexcept {
glfwPollEvents();
}
void Window::GetFramebufferSize(int& w, int& h) const noexcept {
glfwGetFramebufferSize(_handle, &w, &h);
}
std::vector<const char*> Window::RequiredInstanceExtensions() {
EnsureGlfwInit(); // glfwGetRequiredInstanceExtensions needs GLFW initialised
std::uint32_t count = 0;
const char** raw = glfwGetRequiredInstanceExtensions(&count);
std::vector<const char*> out(raw, raw + count);
ReleaseGlfw();
return out;
}
// Need to expose _handle storage — declared inline in header would also work,
// kept here so the GLFW header dependency stays in the .cpp.
} // namespace simv::core

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