Compare commits

..
160 changed files with 5801 additions and 11015 deletions
+45 -75
View File
@@ -112,21 +112,18 @@ from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
cfg = LBMConfig(nx=32, ny=24, viscosity=.1)
body = SimObject(0, geometry=CircleGeometry(16.0, 12.0, 2.15), center=(16.0, 12.0), radius=2.15,
surface_actuation=SurfaceActuation(0.7, 0.85, 0.002)) # angle, half-width, speed
with CudaContext(0) as ctx, FixedPatchStepper(cfg, context=ctx, refinements=[(8, 6, 24, 18)],
level_blocks=[None, (16, 20)],
level_objects=[(), (body,)]) as run:
with CudaContext(0) as ctx, FixedPatchStepper(cfg, (8, 6, 24, 18), context=ctx,
fine_objects=[body], fine_block=(16, 20)) as run:
rest = np.asarray(compute_feq_d2q9(1.0, 0.0, 0.0), np.float32)[:, None, None]
# (9, ny, nx) postcollision populations per level; non-leaf cells are discarded.
run.initialize(np.broadcast_to(rest, (9, *run.hierarchy.pairs[0].coarse_shape)),
np.broadcast_to(rest, (9, *run.hierarchy.pairs[0].fine_shape)))
run.initialize(np.broadcast_to(rest, (9, *run.patch.coarse_shape)),
np.broadcast_to(rest, (9, *run.patch.fine_shape)))
run.step(128) # coarse steps; fine level takes two per step
coarse_g, fine_g = run.download() # NaN on covered, ghost and solid cells
```
Scope and limits:
- The examples below use D2Q9, SRT, FP32, double buffer, unshifted, no LES or
forcing. Fixed 2:1 hierarchies use level lists of arbitrary depth; two- and
three-level examples below use that same constructor.
- D2Q9, SRT, FP32, double buffer, unshifted, no LES or forcing; fixed 2:1 patch(es), two levels (or one nested third).
- `coarse_boundary="periodic"` (default) or `"slot_reservoir"` (bottom slot, top
reservoir; patch needs `y0 >= 4`, `y1 <= ny-4`). Periodic mode ignores the wall/slot config fields.
- Object centers/radii use the coarse **cell-edge** frame (cell `I` spans `[I, I+1)`),
@@ -134,51 +131,46 @@ Scope and limits:
- Bodies must be stationary circles whose whole boundary stencil lies in the fine
active region, clear of the coarse/fine transfer cells; violations are rejected
before any GPU allocation.
- `level_blocks=[None, (by, bx)]` stores the fine level in fixed-size blocks; `by`/`bx` must
- `fine_block=(by, bx)` stores the fine level in fixed-size blocks; `by`/`bx` must
divide the WHOLE fine allocation, i.e. `2*(y1-y0)+8` by `2*(x1-x0)+8` including the
four-cell ghost band. It gives results identical to dense storage and is
infrastructure for future block AMR, not a speedup.
- Several regions: pass a sequence of `(x0, y0, x1, y1)` rectangles as
`refinements[0]`, with both levels blocked (`level_blocks`). Transfers are built once
from their union; adjacent, overlapping and concave unions are accepted when
their actual dependencies close. There is no fixed rectangle-gap rule.
The fine level is then one level-wide grid `(2*ny, 2*nx)`:
`run.hierarchy.pairs[0].fine_shape` gives it, `initialize`/`download` use it, and only the
grid-aligned tiles intersecting the union plus its four-cell ghost band are stored.
Block sizes must divide the level-wide grid; rectangle edges need not align
with tiles. Order of rectangles does not matter. L1 bodies/Jets (`level_objects[1]`/`level_jets[1]`) work
as for one patch: each circle (same coarse cell-edge frame) must lie wholly
inside the union's active region with
- Several separated patches: pass a sequence of `(x0, y0, x1, y1)`
rectangles as `bounds`, at least 4 coarse cells apart, with both `fine_block`
and `base_block`. The fine level is then one level-wide grid `(2*ny, 2*nx)`:
`run.patch.fine_shape` gives it, `initialize`/`download` use it, and only the
patch windows (active region plus 4-cell ghost band, origin `(2*y0-4, 2*x0-4)`)
are stored; each window must align with `fine_block`. Order of rectangles does
not matter. `fine_objects`/`jets` work as for one patch: each circle (same
coarse cell-edge frame) must lie wholly inside one patch's active region with
its boundary stencil clear of transfer cells; a jet may not select links of two
different bodies, even across regions. Wall-touching patches remain unsupported.
For nesting, add further `refinements` entries as shown below.
different bodies, even across patches. No wall-touching patches or nesting yet.
```python
cfg = LBMConfig(nx=32, ny=16, viscosity=.1)
rest = np.asarray(compute_feq_d2q9(1.0, 0.0, 0.0), np.float32)[:, None, None]
with CudaContext(0) as ctx, FixedPatchStepper(
cfg, context=ctx, refinements=[((4, 4, 12, 12), (16, 4, 24, 12))],
level_blocks=[(4, 4), (4, 4)]) as run:
run.initialize(np.broadcast_to(rest, (9, *run.hierarchy.pairs[0].coarse_shape)),
np.broadcast_to(rest, (9, *run.hierarchy.pairs[0].fine_shape))) # (9, 32, 64)
cfg, ((4, 4, 12, 12), (16, 4, 24, 12)), context=ctx,
fine_block=(4, 4), base_block=(4, 4)) as run:
run.initialize(np.broadcast_to(rest, (9, *run.patch.coarse_shape)),
np.broadcast_to(rest, (9, *run.patch.fine_shape))) # (9, 32, 64)
run.step(32)
coarse_g, fine_g = run.download() # coarse: NaN on every covered cell;
# fine: NaN on every non-ACTIVE cell
```
- Three-level nested windows: pass two scalar rectangles in `refinements` —
L1 in L0 cells, L2 in **L1 window cells** (the parent's fine grid including its
4-cell ghost band) — plus all three `level_blocks` entries. L2 must sit inside
L1's ACTIVE cells, away from L1's ghost band and transfer footprint (checked
before any GPU allocation). `initialize` and `download` take/return three
arrays; each level is NaN on its non-ACTIVE (and child-covered) cells. One
coarse step = 2 L1 steps = 4 L2 steps. One L1 window only in this scalar
chain. Optional stationary circles and Jets live on L2 via `level_objects[2]`
and `level_jets[2]`; L1 bodies/Jets retain their L1 meaning and must be empty
in this mode. All object and Jet geometry uses the **L0 edge frame**, not the
child bounds' L1 window frame. L2 has dx=dt=0.25, so prescribed lattice
velocity is unchanged. Each whole body and its BFL stencil must be inside L2
ACTIVE cells, outside the transfer footprint. A Jet may select one physical
body across multiple blocks, never two bodies.
- Three levels (one nested chain): add `child=(x0, y0, x1, y1)` in
**L1 window cells** (the patch's fine grid including its 4-cell ghost band) and
`child_block`, with `fine_block` and `base_block`. L2 must sit inside L1's
ACTIVE cells, away from L1's ghost band and transfer footprint (checked before
any GPU allocation). `initialize` and `download` take/return three arrays; each
level is NaN on its non-ACTIVE (and child-covered) cells. One coarse step = 2 L1
steps = 4 L2 steps. One L1 patch only. Optional stationary circles and Jets
live on L2 via `child_objects` and `child_jets`; `fine_objects`/`jets` retain
their L1 meaning and must be empty in this mode. All object and Jet geometry
uses the **L0 edge frame**, not the child bounds' L1 window frame. L2 has
dx=dt=0.25, so prescribed lattice velocity is unchanged. Each whole body and
its BFL stencil must be inside L2 ACTIVE cells, outside the transfer footprint.
A Jet may select one physical body across multiple blocks, never two bodies.
```python
from CelerisLab.body.geometry.circle import CircleGeometry
@@ -190,40 +182,24 @@ Scope and limits:
jet = Jet(Tube((6.5, 8.), 0., 3., .3), Parallel(0.), amplitude=.002)
rest = np.asarray(compute_feq_d2q9(1.0, 0.0, 0.0), np.float32)[:, None, None]
with CudaContext(0) as ctx, FixedPatchStepper(
cfg, context=ctx, refinements=[(4, 4, 12, 12), (8, 8, 16, 16)],
level_blocks=[(4, 4)] * 3, level_objects=[(), (), (body,)],
level_jets=[(), (), (jet,)]) as run:
shapes = (run.hierarchy.pairs[0].coarse_shape, run.hierarchy.pairs[0].fine_shape, run.hierarchy.pairs[1].fine_shape)
cfg, (4, 4, 12, 12), context=ctx, fine_block=(4, 4), base_block=(4, 4),
child=(8, 8, 16, 16), child_block=(4, 4),
child_objects=[body], child_jets=[jet]) as run:
shapes = (run.patch.coarse_shape, run.patch.fine_shape, run.child_patch.fine_shape)
run.initialize(*(np.broadcast_to(rest, (9, *s)) for s in shapes)) # 16², 24², 24²
run.step(4)
run.set_jet_amplitude(0, -.002, level=2) # explicit level selects the driven level
run.set_jet_amplitude(0, -.002, level=2) # default level=1 still addresses L1
run.step(4)
coarse_g, l1_g, l2_g = run.download() # solids/covered/nonleaves are NaN
```
- Level lists (the only constructor): `refinements[l]` describes level `l+1`
in allocated level-`l` integer cells; use a rectangle list for a union, or a
scalar rectangle for a single window (a scalar child requires a scalar
parent). `level_blocks`, `level_objects` and `level_jets` each have one entry
per level, including L0. Three or more levels require all-blocked storage and
bodies/Jets only on the finest level. Every adjacent pair must preserve
inherited ACTIVE coverage, valid donors/receivers, surviving pulls and the
upstream transfer footprint. Level `l` takes `2**l` steps per base interval.
This describes implemented topology and scheduling, not accuracy or
convergence qualification.
- Blocked body levels can enable `observe_raw_exchange=True` and call
`read_raw_exchange(body_id, level=...)` after a completed base interval.
It reduces unique cut-link observations into wall/aperture/total force,
torque and mass exchange in base-lattice units per unit depth, for the last
base interval (default) or `window="last_substep"`.
`observe_window_exchange=True`, alongside `observe_raw_exchange=True`, enables
`start_exchange_window()` / `read_exchange_window(body_id, level=...)` across
completed base intervals. These are raw boundary-reaction diagnostics, not a general
stress or nozzle-load accuracy claim; dense body layouts are unsupported.
- Not available: checkpoints, moving bodies, L1 bodies/Jets in a three-level
chain, automatic dynamic refinement, `Simulation` integration. Qualification
depends on the tested configuration; see [the validation scope](docs/validation/2d_scope.md).
CPU topology/schedule tests alone do not establish GPU numerical correctness,
general convergence or long-time stability.
- Not available: per-link surface exchange/force readout in blocked mode,
checkpoints, moving bodies, L1 bodies/Jets in a three-level chain,
several patches per level with nesting, dynamic refinement, `Simulation`
integration. Validation so far is storage/operator equivalence and short runs,
not accuracy, convergence or long-time stability. The new L2-body GPU gates
(layouts, masks, amplitude reversal, legacy equivalence) and independent
every-substep numerical replay remain required acceptance checks; CPU tests
alone do not establish GPU numerical correctness.
## Installation
@@ -394,12 +370,6 @@ before `run()`, the force will be reset to zero. For the common usage pattern
| `sim.load_checkpoint(path)` | Restore from HDF5 (config must match) |
| `sim.snapshot()` / `sim.restore()` | In-memory field snapshot |
In-memory snapshots save populations only. Restore keeps the current clock,
controls, observations, geometry and flags; use it only while geometry is
unchanged. This is not a restart or an FSI rollback. In particular, do not
restore a snapshot taken before a translating circle changed position.
This geometry restriction is a caller precondition, not a runtime check.
#### Low-level access
| Attribute | Description |
-57
View File
@@ -1,57 +0,0 @@
# Compatibility and API changes
Changes that can make a previously accepted setup or call behave differently.
Each entry names the commit, the affected entry points and the observable result.
## Redundant periodic stepper alias removed
`PeriodicPatchStepper` is no longer exported from `lbm.refinement_stepper`.
Use `FixedPatchStepper` with the same arguments, including explicit
`level_blocks`. The old name was an alias for that exact class; periodic
boundaries remain the default. Existing imports of the old name must be updated.
## 87c3a31: cut-link ownership is validated for every body
`validate_cut_link_ownership` (`body/preprocess/flag_overlay.py`) now runs for
passive bodies as well as Jet/aperture bodies. Every cut link must have an
active fluid receiver (FLUID set, SOLID clear), and each `(receiver, direction)`
population may have only one writer.
Rejected with `ValueError`, before flags, populations or device buffers change:
1. Overlapping or touching passive solids whose cut links write the same
population ("Boundary requires unique cut-link writers").
2. A body whose cut-link receiver is a non-fluid cell, e.g. a channel wall row
or the inlet/outlet column ("Cut links must have active fluid receivers").
Both were previously accepted silently for passive bodies; the interpolated
boundary then had undefined results. The rules apply to the cut links of all
bodies together, so a passive body overlapping a Jet body is rejected as well.
The check runs in `Simulation.initialize`, runtime body sync (`sync_bodies`),
checkpoint save and checkpoint load (`common/checkpoint.py`). Load validates
before restoring anything, so a checkpoint whose saved geometry violates either
rule no longer restores; rebuild the geometry with separated bodies.
## 87c3a31: `Simulation.read_surface_exchange` accepts Jet-driven bodies
`read_surface_exchange(id)` previously required `SurfaceActuation` on the body.
It now also accepts bodies that own Jet aperture links, including Jets with zero
or negative amplitude (the aperture test is geometric). Bodies with neither
`SurfaceActuation` nor aperture links still raise
`ValueError("Body has no surface actuation")`. The value is unchanged: the latest
step's wall/aperture exchange, not total nozzle force.
## 7afb5ad: `Simulation` fails closed after a Jet amplitude device-write failure
`Simulation.set_jet_amplitude` previously only documented that a device error
leaves the per-link velocity column undefined; the instance stayed usable.
Now any error after input validation (internal stream creation or the upload)
marks the Simulation failed. Every later device-touching public method,
including the `stream` property (guard added in the follow-up repair commit on
`claude/jet-update-consistency`), raises
`RuntimeError("Simulation failed (...); close() and recreate it")`. The host
amplitude keeps its previous value. `close()` still works and stays retryable;
cached host readers and host staging calls are not blocked. Invalid input
(`ValueError`/`IndexError`) is unchanged: it raises before any change and the
instance stays usable. `FixedPatchStepper.set_jet_amplitude` already failed the
instance (status `failed`) and is unchanged.
@@ -1,57 +0,0 @@
# Experimental projected G explosion
`FixedPatchStepper(..., explosion="projected_linear")` changes only the existing
G-to-G explosion reconstruction. The omitted/default `"constant"` selector retains
the same kernel, launch arguments and device allocations. Projected mode adds two
compact tables per adjacent pair; recursion, collision, BFL, restriction, buffer
ownership and synchronized publication are unchanged. `remap_finest` preserves it.
Runtime eligibility is periodic-root, fully blocked D2Q9/SRT/FP32, body/Jet-free,
without LES; the fixed-patch backend has no forcing interface. Shifted and unshifted
storage use the same reconstruction. One generic builder handles scalar windows,
unique union rows and all adjacent hierarchy pairs without depth/rectangle caps.
Donors are original explosion donors intersected with owned, uncovered ACTIVE
cells. An axis uses centered differences only if both neighbors are legal;
otherwise both addresses repeat the center. Nested layout periodicity is explicit.
No covered cells, holes, inactive cells or stale halo copies enter the stencil.
The 33-line kernel is unchanged from our `519473a` experiment and retains its
mathematical attribution. Host tests cover union decomposition, concavity, narrow
gaps, grazing packet paths, offsets, sparse/nested owner addresses, three/four-level
source timing, unchanged constant dispatch, remapping and shifted/unshifted actual
kernel arithmetic compiled as host C++. These are not CUDA or flow validation.
## Reviewed bounded evidence
The `1aa8110` CUDA gate, with production exactly `a135252`, passed exact-dev
omitted/default and explicit-constant equivalence, per-population reconstruction,
scalar/union/four-level scheduling, alternate layouts, and changed remapping in
both storage modes. Operator maximum error was 2.98023224e-8; the eight-step
concave replay maximum was 1.58037614e-7. Whole-step mass/momentum remain
finite diagnostics, not an FP32 conservation certificate. Reviewed evidence:
`7aa66ff0542175092753ea099f40b903ece0527e`.
The fresh four-case `2cf9853` N128/256 Taylor–Green repeat retained all `11901a7`
settings and gates. All 20 main scalar rows and 260 early-pressure samples matched
the older receipts exactly; this is not field-array equality. Projected worst
velocity was 0.001995299260844 versus 0.002 (only 0.235% margin), pressure
0.012157174904932 and energy 0.001184362645162. Constant velocity
0.004418076615223 failed its gate, so the aggregate verdict remains false.
The projected early sampled pressure peak was 0.021621453573334; it remains a
descriptive sampled maximum. Reviewed evidence: `90849a55cad4a0da9b72747da7bfe6eb75962a44`.
The old N64 projected failures remain relevant. These results support only this
bounded plumbing gate and short periodic Re100 smooth-decay experiment. They do
not establish union/multilevel physics accuracy, convergence, long-time stability,
body/Jet, boundary, forcing, MRT or LES suitability. Constant remains the default.
## Separate LES source profile
The two older LES source manifests and the historical default are unchanged and
correctly reject this different source tree. The existing LES runner now offers
`--experiment a135252`, pinning all 101 actual committed corrected source files.
Its explicit baseline-header override describes only a synthetic isolated reverse
control, never the committed a135252 header. The unchanged corrected-only uniform
LES regression (Cs=0/.16/1) is the remaining integration gate; no LES result is
inherited from the projected evidence. The separate historical jet-CV `channel.py`
pin mismatch predates this port and is not addressed here.
+1 -1
View File
@@ -25,7 +25,7 @@ under the alternate compile branch.
- No LES, no external forcing (`d_params` force fields stay zero).
- Shifted Jets stay SRT-only: `body/jets.py` keeps its existing guard. This extension does not
provide shifted MRT Jets.
- `PeriodicBlockStepper` (uniform blocked stepper) remained SRT-only; it was removed in 8e1feff and its single-level capability, still SRT-only, is `FixedPatchStepper(refinements=[])`.
- `PeriodicBlockStepper` (uniform blocked stepper) remains SRT-only.
## Evidence status
CPU: `tests/unit/test_refinement_mrt.py` (host guards, source contract). GPU:
+1 -1
View File
@@ -1,6 +1,6 @@
# Blocked AMR root channel increment
Opt in with `FixedPatchStepper(..., coarse_boundary="channel", refinements=[bounds], level_blocks=[(by,bx), (fy,fx)])`. Every level must be blocked. The supported boundary is the existing uniform D2Q9/SRT/FP32 double-buffer channel: local Zou-He west inlet, pressure-NEQ east outlet, stationary halfway bounce-back y walls. Uniform and parabolic inlet profiles and shifted/unshifted storage are supported; the existing configuration guards still reject MRT/LES and incompatible storage. This does not change the external DFG MRT reference.
Opt in with `FixedPatchStepper(..., coarse_boundary="channel", base_block=(by,bx), fine_block=(fy,fx))`, or the existing level-list form. Every level must be blocked. The supported boundary is the existing uniform D2Q9/SRT/FP32 double-buffer channel: local Zou-He west inlet, pressure-NEQ east outlet, stationary halfway bounce-back y walls. Uniform and parabolic inlet profiles and shifted/unshifted storage are supported; the existing configuration guards still reject MRT/LES and incompatible storage. This does not change the external DFG MRT reference.
`step/block_channel.cu` is a small root-only kernel. Fine kernels, transfer kernels and recursive hop scheduling are unchanged. Global coordinates and the global profile height/velocity are separate from block-local storage strides. Corner ownership and collision phases match `OneStep`:
-116
View File
@@ -1,116 +0,0 @@
# src coherence stage (from main 914906e)
Owner direction 2026-10-28: unified style over historical compatibility, inside `src`.
No numerical change is intended. Stages after 5f69eba: 65339ab (one boundary/Jet record,
host preparation helpers, pair accessors), c5a3cdb (`block_collide` families), 442979b
(exchange metadata on the boundary record). All three received parent/candidate
generated-config and host/GPU equality checks. Only the collision slice ran a new PTX
matrix (a4fc46d, whose src is identical to c5a3cdb); 65339ab and 442979b reused prior
PTX evidence after unchanged CUDA/compiler and generated-input checks. In the collision
matrix, only the two channel-root SRT modules changed PTX (omega load/clamp moved);
their parent/candidate outputs matched exactly in the exercised cases.
## Conventions (defined once, in `lbm.hierarchy.Hierarchy`)
- Level `l = 0 .. depth-1` (0 = root). Pair `hierarchy.pairs[l-1]` builds level `l`.
- Array shapes are `(ny, nx)`; rectangles, origins and centers are `(x, y)`.
- Coarse/fine name the two members (parent/child) of ANY pair, not absolute L0/L1:
`fine_shape`, `fine_origin`, `prepare_fine_boundary`, `blocked_fine_tables`,
`blocked_coarse_tables`.
- Refined levels (l >= 1) of FixedPatchStepper place bodies/Jets in the root EDGE frame
(root cell `i` = `[i, i+1)`). Uniform `Simulation` objects AND the static circles of a
single-level (depth 1) FixedPatchStepper use the integer-NODE frame. Nothing converts
between them. This concerns input placement only: within the EDGE frame, refined
levels still project root-edge coordinates to fine-local cell indices with the
half-cell shift (`prepare_fine_boundary`: `(v - o)/dx - .5`; hits back via `fine_hits_to_edge_frame`
`origin + dx*(hit + .5)`), using `edge_frame`/`fine_origin` below.
- `Hierarchy.edge_frame(l)` -> `((ox, oy), h)`: cell `(i, j)` of level `l`'s allocated
array (ghost band and holes included) covers `[o + h*i, o + h*(i+1))`. A scalar
FixedPatch2D window carries the half-coarse-cell ghost shift `bounds - HALO/2` in
`fine_origin`; a set's level-wide grid has origin 0. Level 1 boundaries are built from
the root config (the wall-aware fields live there); deeper ones from the parent frame.
- `FixedPatch2D.fine_level_offset` (was `fine_level_origin`) is an array INDEX offset
`(y, x)`, not an `(x, y)` origin.
## FixedPatchStepper migration map
Historical: left column = names at or after 914906e that no longer exist; right column =
the current name.
| removed / changed | replacement |
|---|---|
| `patch`, `child_patch` | `hierarchy.pairs[0]`, `hierarchy.pairs[1]` |
| `base_layout`, `fine_layout`, `child_layout` (stepper properties) | `layouts[0]`, `layouts[1]`, `layouts[2]` (`FixedPatchSet2D.fine_layout(block)` is unchanged) |
| `fine_boundary`, `child_boundary` | `boundaries[1]`, `boundaries[2]` (host geometry; `None` = no bodies/Jets) |
| `_jets` | `jets[1]` |
| `_boundaries[l]` (dict from L1, level keys), `_level_boundaries` (b4eacf7), `_device_boundaries` (65339ab) | `_boundaries[l]`: the one combined per-level `_BlockedBoundary` record (list from L0, `None` at L0 unless single-level static circles): host geometry `.host`, `.tables`, `.jets`, `.jet_cache`, exchange `.spacing`/`.centers`, device handles |
| `_fine_tables`, `_actuated`, `_jet_cache` | `_boundaries[1].tables` / `.actuated` / `.jet_cache` |
| `_level_jets` (private) | `_boundaries[l].jets` (public `jets[l]` is derived) |
| `_exchange_spacing[l]`, `_exchange_centers[l]` (private) | `_boundaries[l].spacing` (h_l, root units) / `.centers` (root edge frame); only levels with a boundary report exchange |
| `_level_blocks` (private) | `_layouts[l].block` |
| `_remap_cfg` (private) | `_root_cfg` |
| `refinement_tables.blocked_base_tables` (also via `refinement_stepper`) | `refinement_tables.blocked_coarse_tables` (import from `refinement_tables`; the stepper no longer re-exports) |
| `_fine_leaf`, `_child_leaf` | `leaf[1]`, `leaf[2]` (copies) |
| `_middle_kind` | `hierarchy.kinds[1]` |
| `_boundary_hosts` (private) | public `boundaries[l]` = `_boundaries[l].host` (host geometry only; the private record is the combined one) |
| `remap_child(rects, child_objects=...)` | `remap_finest(rects, objects=...)` |
| `set_jet_amplitude(i, a)` (implicit level 1) | `set_jet_amplitude(i, a, level=l)` (required) |
| `read_raw_exchange(b)` / `read_exchange_window(b)` (implicit level 1) | `read_raw_exchange(b, level=l)` / `read_exchange_window(b, level=l)` (required) |
| `_level_name(l)` "base/fine/child" messages | `"level l"` |
| `refinement.MIN_PATCH_GAP` | deleted (no gap policy exists) |
| `params.cuh HaloPlan`, `collide_trt_no_force` | deleted (zero references) |
Kept: `step_count`, constructor keywords (`refinements`, `level_blocks`, `level_objects`,
`level_jets`, `coarse_boundary`, `explosion`), `Simulation.set_jet_amplitude(i, a)` (one level),
`RemapTopology.from_config(..., child_objects=, child_jets=)` (internal remap helper; "child" =
the replaced level relative to its parent).
## Responsibilities
- Construction: `_check_levels` (inputs); host preparation, numpy only, before any device
work: `_level_layouts`, `_kernel_configs` (per-level kernel configs), `_host_boundaries`,
`_prepare_blocked_boundary`, `_fluid_leaves`; `Hierarchy.edge_frame` (geometry frames);
then `_planned_bytes()` (memory preflight) and `_build_level` / `_build_pair` /
`_upload_boundary` (device). A construction failure closes the partial instance.
Observation reads share `_observed_boundary` + `_summarize`.
- Pair accessors: `rectangles` (a scalar FixedPatch2D gives `(bounds,)`) and
`fine_level_offset` (`(0, 0)` for a set) on both pair types; the types and their
storage frames stay distinct.
- Per-level state, by lifecycle:
- Fixed plan structure (built once in `__init__`, read by `_planned_bytes()` before any
allocation, readable after `close()` through the public views): `_layouts`,
`_level_cfgs`, `_leaf`, `_state`, and in each `_boundaries[l]` record the host geometry
`.host`, link tables, `.jet_cache`, `.spacing`, `.centers`.
- Mutable boundary data in the same record: `.jets` and `.tables["surface_velocity"]`,
changed only by `set_jet_amplitude` (host prepare -> device write -> commit; an uncertain
write fails the instance); device handles (`.flags`, `.device`, `.kernel`, ledgers) are
set by `_upload_boundary` and cleared by `close()`.
- Runtime: `_levels` (`_Level`) holds compiled modules, kernels, the two population
buffers (swapped per step) and transfer/kind handles. It is created only after the
memory preflight and cleared on `close()`; `.layout`/`.boundary` reference the plan for
`_hop`. Folding the plan into `_Level` would mix both lifecycles in one record.
- Blocked kernels: `operators/block_collision.cuh block_collide` is the one collision extension
point of `BlockStep` and `BlockChannelStep`. It computes rho/u once and reads no runtime
force (`d_params.fx/fy`). Two compile-time families: uniform-equivalent (MRT on any level,
SRT on the channel root `BLOCK_ROOT_BC == 2`): clamped omega, `collide_mrt_no_force` or
`collide_srt` with its zero Guo term; and level SRT (`BLOCK_ROOT_BC` 0/1): unclamped omega,
`collide_srt_no_force`, with the shifted direct-momentum feq under `USE_DDF_SHIFTING`.
The uniform path keeps `collide_dispatch` (runtime force, LES, TRT).
- `explosion="projected_linear"` stays an optional capability; `"constant"` stays default.
## Not done / open
- Two per-level config derivations stay distinct on purpose: `Hierarchy.configs`
(`_middle_config`, wall fields reset on every refined level, geometry validation) vs.
`FixedPatchStepper._kernel_configs` (reset only on level 1 of a slot_reservoir root;
generates each level's config.h). Folding them needs a config.h/PTX diff gate.
- No single-`Simulation` facade over FixedPatchStepper: the two own different state models
(uniform node frame, body sync, forcing, checkpoint vs. fixed blocked hierarchy, edge
frame, remap-as-new-instance). A facade would only forward calls; no concrete need found.
- Deferred, each needing its own evidence first: sharing the channel-root cell/donor selector
with OneStep (the closure functions are already shared; a shared selector changes uniform
signatures and needs a uniform PTX gate); merging FixedPatch2D into FixedPatchSet2D
(different origin, ghost band, layout size and addressing; needs a geometry/parity table and
would need tiles that are not grid-aligned); one `clamp_omega` helper for uniform and
blocked paths (it lives in the hash-pinned LES header `turbulence_smag.cuh`).
- Tests, examples and validation callers are migrated separately (pinned runners stay frozen).
-393
View File
@@ -1,393 +0,0 @@
# Evidence index (draft, R0)
Active-tree evidence at candidate 0f8fdbc30ea640c764b38ee598d8f4d135c81c85, classified for the release cleanup
(thread C0C78UFKGRM 1793021833.593279). Draft for review: nothing is removed by this file.
Recovery of any listed file: `git show 0f8fdbc3:<path>` (blob id given; an immutable tag is requested as anchor).
Classes: K = conclusion kept in the active tree; K-code = tested research code kept; D-snapshot = pre-Gitea
patch/diff transport (code landed in git from ed1a6ee on; not yet verified hunk by hunk); P-pin = generated hash inventories, git-status dumps and manifests;
R-raw = run outputs, logs, signals, frames, one-shot run scripts and handoff notes. D, P, R leave the active tree in R1.
No file under src/ or a test reads any of these paths (git grep at 0f8fdbc3); runners under tests/validation
that read research/j* are handled in R2.
| Class | Files | Bytes |
|---|---:|---:|
| K | 31 | 196,506 |
| K-code | 3 | 24,324 |
| D-snapshot | 18 | 338,948 |
| P-pin | 50 | 979,177 |
| R-raw | 142 | 3,790,554 |
## (root)
| File | Class | Bytes | Blob |
|---|---|---:|---|
| BASELINE_JET_AMR.txt | R-raw | 5,244 | 62aa2a66dc05 |
## output
| File | Class | Bytes | Blob |
|---|---|---:|---|
| round3_compatibility_summary.md | R-raw | 4,366 | f243318f5591 |
## research/fine_surface_20261014
| File | Class | Bytes | Blob |
|---|---|---:|---|
| baseline.json | P-pin | 54,507 | 71a4543ead37 |
| cpu_device_guard_cmd.txt | R-raw | 241 | 06e0f35d92b3 |
| cpu_device_guard_exit.txt | R-raw | 2 | 573541ac9702 |
| cpu_device_guard_stdout.txt | R-raw | 98 | 424d584a031c |
| cpu_existing_runs.json | R-raw | 650 | 3179536407b7 |
| cpu_final_run.json | R-raw | 386 | 523fadb3fec9 |
| cpu_final_stdout.txt | R-raw | 99 | 9cfe73da3858 |
| cpu_lifecycle_run.json | R-raw | 387 | 5b403b9e2f30 |
| cpu_lifecycle_stdout.txt | R-raw | 112 | 3c5c49b285fa |
| cpu_maps_stdout.txt | R-raw | 95 | 71dd87f9faf9 |
| cpu_preallocation_run.json | R-raw | 337 | fa6e96d98cdd |
| cpu_preallocation_stdout.txt | R-raw | 98 | 5d643576f251 |
| cpu_shared_kernel_stdout.txt | R-raw | 98 | 8a0c046bb9b1 |
| cpu_surface_command.txt | R-raw | 113 | 2a5ff31e8d06 |
| cpu_surface_exit.txt | R-raw | 2 | 573541ac9702 |
| cpu_surface_projection_command.txt | R-raw | 171 | 70b7f5c62c1a |
| cpu_surface_projection_exit.txt | R-raw | 2 | 573541ac9702 |
| cpu_surface_projection_stdout.txt | R-raw | 113 | efd37a149cc1 |
| cpu_surface_stdout.txt | R-raw | 99 | 9cfe73da3858 |
| gpu_preparation_run1.py | R-raw | 14,043 | ee586d035b7a |
| gpu_run.json | R-raw | 2,943 | 15b8ba70d528 |
| gpu_stdout.txt | R-raw | 5,424 | c5216e0267f3 |
| gpu_test_run1.py | R-raw | 15,479 | f30376abc97f |
| incremental.diff | D-snapshot | 53,853 | 318d92e7d177 |
| manifest.json | P-pin | 8,411 | 8281338416af |
| report.txt | K | 8,373 | 6b9e5123e414 |
## research/fine_surface_joint_20261014
| File | Class | Bytes | Blob |
|---|---|---:|---|
| baseline_00.json | P-pin | 21,302 | 4ae101ebd02b |
| baseline_01.json | P-pin | 26,191 | 548cf7b3b494 |
| baseline_02.json | P-pin | 22,440 | e0421e7a4052 |
| baseline_03.json | P-pin | 8,584 | da99b45f32a4 |
| blow_boundary.csv | R-raw | 12,536 | e13363b69457 |
| blow_collisions.csv | R-raw | 19,145 | 5d5b395486bb |
| blow_ledger.csv | R-raw | 15,977 | b43bbfd0b8d2 |
| blow_propagation.csv | R-raw | 9,714 | f06d80d296ca |
| blow_signals.npz | R-raw | 221,114 | 528adf75baa2 |
| blow_summary.json | R-raw | 4,113 | 32285ac15be3 |
| command.txt | R-raw | 232 | 510ecb2925dd |
| criteria.txt | K | 7,480 | dd36e76a3be1 |
| frozen.json | R-raw | 11,972 | b5a3324e8686 |
| gpu_run.json | R-raw | 1,955 | 67ac4785f7e4 |
| incremental.diff | D-snapshot | 625 | e03c132d22df |
| integrity.json | P-pin | 826 | a8b6e4be88d2 |
| manifest.json | P-pin | 4,388 | d2af50260c39 |
| regions.json | R-raw | 27,525 | 7bba26904221 |
| report.txt | K | 8,207 | d7571e068ad3 |
| result.json | R-raw | 11,442 | 9580c2686e70 |
| review.txt | K | 2,117 | bbe1d8a83d47 |
| runner.py | R-raw | 5,847 | 812973acc000 |
| stdout.txt | R-raw | 19,940 | c9218e2e48fc |
| suction_boundary.csv | R-raw | 12,505 | b870be05e6c0 |
| suction_collisions.csv | R-raw | 19,173 | 2de48c0a84ab |
| suction_ledger.csv | R-raw | 16,208 | 92cf1f44911e |
| suction_propagation.csv | R-raw | 9,720 | f017bcc3ca6f |
| suction_signals.npz | R-raw | 220,058 | 66d06cf2d909 |
| suction_summary.json | R-raw | 4,126 | d70a1d371950 |
| validate.py | R-raw | 19,576 | 37441febd168 |
| zero_boundary.csv | R-raw | 11,491 | 65c92e48c974 |
| zero_collisions.csv | R-raw | 18,915 | 027cb8ff6b23 |
| zero_ledger.csv | R-raw | 16,051 | 34a99bb89dd7 |
| zero_signals.npz | R-raw | 127,958 | a966f093a9cd |
| zero_summary.json | R-raw | 1,508 | 6e033f991438 |
## research/fixed_patch_jet_20261013
| File | Class | Bytes | Blob |
|---|---|---:|---|
| baseline.json | P-pin | 69,721 | 91ad9c4d1e88 |
| before.json | R-raw | 690 | 06e1b6278a29 |
| fixed_patch_jet.py | R-raw | 9,646 | 6fbdcd9a076b |
| incremental.patch | D-snapshot | 9,891 | b43129d82c69 |
| manifest.json | P-pin | 1,967 | 08daa0248f60 |
| notes.txt | K | 2,214 | 50070fe54efb |
| results.json | R-raw | 1,100 | d959a6a1cc2c |
| stdout.txt | R-raw | 1,272 | eccdc0f6b16a |
## research/fixed_patch_jet_closure_20261013
| File | Class | Bytes | Blob |
|---|---|---:|---|
| baseline.json | P-pin | 72,692 | 8a157af82ecf |
| cpu_results.json | R-raw | 628 | 8e4a1c090c93 |
| cpu_stdout.txt | R-raw | 590 | d800b783fe47 |
| incremental.patch | D-snapshot | 3,490 | a9a486c68afd |
| layout_uncropped.png | R-raw | 51,785 | 0cf1cfa5e99d |
| manifest.json | P-pin | 1,849 | 31cf0c739241 |
| notes.txt | K | 3,023 | 0f7e7bdfd0e6 |
## research/fixed_patch_jet_records_20261013
| File | Class | Bytes | Blob |
|---|---|---:|---|
| baseline.json | P-pin | 71,228 | a8e967f76e08 |
| before.json | R-raw | 690 | ebd6f4251fc2 |
| fixed_patch_jet.py | R-raw | 10,650 | f6b299885da0 |
| incremental.patch | D-snapshot | 5,539 | c7f4d0c02ccc |
| manifest.json | P-pin | 2,057 | 1d616c2541df |
| notes.txt | K | 2,449 | b58cec14d359 |
| results.json | R-raw | 1,136 | 788af64a6ebd |
| stdout.txt | R-raw | 20,750 | a765b06deefa |
## research/j1-2026-10-12
| File | Class | Bytes | Blob |
|---|---|---:|---|
| REVIEW.md | K | 7,667 | 8f469daef054 |
| baseline-status.txt | P-pin | 2,601 | 64ca05ab952e |
| baseline.json | P-pin | 23,052 | ab5852bcb0ef |
| incremental.diff | D-snapshot | 29,912 | dd9c4e688888 |
| results.json | R-raw | 68,294 | 56547aa8a678 |
## research/j2-slot-2026-10-12
| File | Class | Bytes | Blob |
|---|---|---:|---|
| REVIEW.md | K | 12,930 | 69fa4d3bccd3 |
| baseline-status.txt | P-pin | 2,825 | e711afb0016c |
| baseline.json | P-pin | 24,660 | 286e023da94f |
| incremental.diff | D-snapshot | 25,231 | 649f3b5d168d |
| results.json | R-raw | 477,249 | b4272e25179d |
## research/j3-coverage-2026-10-12
| File | Class | Bytes | Blob |
|---|---|---:|---|
| REVIEW.md | K | 11,409 | 5887cf73dce2 |
| baseline-status.txt | P-pin | 3,055 | 78e88356caa3 |
| baseline.json | P-pin | 30,407 | 095972547a7f |
| incremental.diff | D-snapshot | 20,114 | f0d502dc5c5b |
| negative-budget-20744.h5 | R-raw | 88,017 | 693b133be84d |
| negative-end-20760.h5 | R-raw | 88,174 | 429270d263d4 |
| restart-21280.h5 | R-raw | 88,062 | 74900affae38 |
| restart-reference-21296.h5 | R-raw | 88,154 | 7510ee97885b |
| results.json | R-raw | 296,314 | 7806b10e65cb |
| verification.json | K | 1,468 | 78a07c47aea4 |
## research/j3-lag-2026-10-12
| File | Class | Bytes | Blob |
|---|---|---:|---|
| REVIEW.md | K | 11,732 | 34fe5dbb2cae |
| baseline-status.txt | P-pin | 2,968 | 6ae6094ea9d7 |
| baseline.json | P-pin | 26,505 | 5c289d14ff30 |
| end-21280.h5 | R-raw | 92,366 | f30b209179ab |
| incremental.diff | D-snapshot | 15,790 | 5c6c485e1f75 |
| results.json | R-raw | 21,718 | 2bf58939129c |
| start-20760.h5 | R-raw | 92,247 | ae0abd05190c |
## research/j3-offline-2026-10-12
| File | Class | Bytes | Blob |
|---|---|---:|---|
| REVIEW.md | K | 6,271 | a76e1b486bd2 |
| lags.csv | R-raw | 1,104 | dd3136dd2dc2 |
| localization.png | R-raw | 136,013 | 74b56f3e814c |
| regions.csv | R-raw | 2,126 | 5d361e6872af |
| results.json | R-raw | 8,852 | bf77b0f534d4 |
## research/j3-reservoir-2026-10-12
| File | Class | Bytes | Blob |
|---|---|---:|---|
| REVIEW.md | K | 13,563 | a74b67a64e0d |
| baseline-status.txt | P-pin | 2,896 | a318e077dfae |
| baseline.json | P-pin | 25,571 | 060502ea7dd8 |
| incremental.diff | D-snapshot | 22,898 | 8e2c79a86c98 |
| results.json | R-raw | 182,747 | 7fd5e51a1785 |
## research/refinement_example_20261013
| File | Class | Bytes | Blob |
|---|---|---:|---|
| baseline.json | P-pin | 8,286 | 591478100da8 |
| before.json | R-raw | 818 | 7ab0962775b1 |
| incremental.patch | D-snapshot | 8,682 | 7ad8719d0167 |
| manifest.json | P-pin | 1,798 | bf1eee8a7cf8 |
| notes.txt | K | 1,185 | 7ff019ad023d |
| results.json | R-raw | 1,118 | 94697b0988b9 |
| stdout.txt | R-raw | 325 | 501446f557f4 |
## research/refinement_followup_20261013
| File | Class | Bytes | Blob |
|---|---|---:|---|
| README.txt | K | 4,887 | af65e889f20e |
| baseline.json | P-pin | 5,343 | 7c60ad8ad692 |
| cleanup.json | R-raw | 1,253 | 15cf342fba2e |
| cpu_results.json | R-raw | 1,056 | 1166dffd2a09 |
| cpu_results_attempt1.json | R-raw | 642 | d139d5508105 |
| cpu_stdout.txt | R-raw | 499 | d1b33ea20563 |
| cpu_stdout_attempt1.txt | R-raw | 879 | 6a9fe7fce39a |
| gpu_before.json | R-raw | 589 | 46762a0ab207 |
| gpu_before_no_launch.json | R-raw | 589 | 074982d1b2d4 |
| gpu_rectangle_before.json | R-raw | 589 | 831e3793339f |
| gpu_rectangle_before_no_launch_1791886104981638567.json | R-raw | 589 | c33885d50bfd |
| gpu_rectangle_results.json | R-raw | 1,003 | 9c6da219ac6c |
| gpu_rectangle_stdout.txt | R-raw | 332 | 9185bf303954 |
| gpu_results.json | R-raw | 1,189 | 53e4f1c44de1 |
| gpu_seed1_before.json | R-raw | 589 | c77ea3485f84 |
| gpu_seed1_results.json | R-raw | 996 | b718e8af84e4 |
| gpu_seed1_stdout.txt | R-raw | 306 | 4d7de0897137 |
| gpu_stdout.txt | R-raw | 68 | b0956fe3cdb0 |
| incremental.patch | D-snapshot | 16,836 | 9df25d8c177a |
| manifest.json | P-pin | 4,410 | 177704d6f1ae |
| run_gpu.py | R-raw | 5,237 | b5af3f0d4f79 |
## research/refinement_prepare_20261013
| File | Class | Bytes | Blob |
|---|---|---:|---|
| README.txt | K | 8,939 | 8e210c28c7c5 |
| baseline.json | P-pin | 46,348 | d5f257216b8d |
| incremental.patch | D-snapshot | 15,751 | 0ddb32550584 |
| manifest.json | P-pin | 2,113 | 9fd94ccf2c3e |
| validation.json | R-raw | 3,567 | f057e7cbcd35 |
| validation.txt | R-raw | 873 | 594daa45c58c |
## research/refinement_slot_20261013
| File | Class | Bytes | Blob |
|---|---|---:|---|
| baseline.json | P-pin | 67,717 | d498081e7c50 |
| before.json | R-raw | 690 | 430e76b3aa8a |
| cpu_results.json | R-raw | 1,421 | 4f6bc4d9428f |
| incremental.patch | D-snapshot | 18,676 | 67a3e8cc9e68 |
| manifest.json | P-pin | 2,270 | 6515458290f4 |
| notes.txt | K | 2,612 | 08a713ba4b37 |
| results.json | R-raw | 1,160 | 03e683027ba8 |
| stdout.txt | R-raw | 643 | d57b91769826 |
## research/refinement_slot_followup_20261013
| File | Class | Bytes | Blob |
|---|---|---:|---|
| baseline.json | P-pin | 68,536 | 89a26ce79438 |
| before.json | R-raw | 690 | e707df192ff2 |
| incremental.patch | D-snapshot | 495 | c6f6fa11237d |
| manifest.json | P-pin | 1,920 | 4840243a33aa |
| notes.txt | K | 1,549 | 4b77583ca116 |
| planar_velocity.cuh | R-raw | 1,911 | 87db7466c843 |
| planar_velocity.sha256 | P-pin | 251 | 6694c4a1c1f0 |
| results.json | R-raw | 1,050 | 6ef6eda854c6 |
| stdout.txt | R-raw | 1,160 | 461860351c0c |
## research/refinement_step_20261013
| File | Class | Bytes | Blob |
|---|---|---:|---|
| README.txt | K | 10,671 | 88d6f24743f5 |
| baseline.json | P-pin | 40,620 | 20f61559dd38 |
| cpu_validation.json | R-raw | 1,231 | 7999fd30018c |
| gpu_failure_validation.json | R-raw | 868 | 5f6db65ee4f9 |
| gpu_failure_validation.txt | R-raw | 248 | d9a570a1e9e9 |
| gpu_validation.json | R-raw | 672 | 2028f59e1055 |
| gpu_validation.txt | R-raw | 632 | 9699918ee127 |
| incremental.patch | D-snapshot | 26,516 | d95f9347633d |
| manifest.json | P-pin | 2,075 | 32a7f1a7309e |
## research/repair-2026-10-10
| File | Class | Bytes | Blob |
|---|---|---:|---|
| TRON_HANDOFF.md | R-raw | 25,208 | a339786c7944 |
| baseline-hashes.json | P-pin | 16,020 | 9b4e24cb1d10 |
| baseline-status.txt | P-pin | 429 | 515be249b638 |
| checkpoint-domain-2026-10-12.diff | D-snapshot | 14,443 | 035ed630dda1 |
| classic-results.json | R-raw | 244,729 | 7297aa0d5bca |
| classic-signals.npz | R-raw | 630,332 | 8cf6d1218aa0 |
| final-gpu.log | R-raw | 191 | a0eec1a8ea31 |
| final-unit.log | R-raw | 188 | 94efba2f1d42 |
| provenance.json | P-pin | 1,020 | 25e22b4f0696 |
| repair-architecture.md | K | 9,498 | 12051c3e8571 |
| repair-numerical.md | K | 10,769 | 0c5757fe5f36 |
| repair-state.md | K | 8,481 | 3c7f2d5b687a |
| simplification-checks.log | R-raw | 1,045 | fa9d464be740 |
## research/shear_refinement
| File | Class | Bytes | Blob |
|---|---|---:|---|
| README.md | K-code | 4,142 | 67bdd8ed622b |
| boundary_shear_tagging.py | K-code | 13,859 | b5415a1c4ea1 |
| example.py | K-code | 6,323 | 66295d3f0273 |
## research/surface_actuation_20261014
| File | Class | Bytes | Blob |
|---|---|---:|---|
| baseline.json | P-pin | 54,139 | 0ad06888dbeb |
| cpu_checkpoint_run.json | R-raw | 400 | 39681b60ff15 |
| cpu_checkpoint_stdout.txt | R-raw | 114 | 929e4b8d4f46 |
| cpu_initial.txt | R-raw | 620 | 29213c58a679 |
| cpu_surface_command.txt | R-raw | 118 | 6d8c615a6e03 |
| cpu_surface_exit.txt | R-raw | 2 | 573541ac9702 |
| cpu_surface_stdout.txt | R-raw | 99 | ee9237fc26e5 |
| gpu_run.json | R-raw | 1,698 | dc8f87eafaa9 |
| gpu_stdout.txt | R-raw | 159 | b262891e52c3 |
| incremental.diff | D-snapshot | 50,206 | 43090a1a8b11 |
| manifest.json | P-pin | 7,527 | 35daa01271cb |
| report.txt | K | 5,078 | 1162a6479f9f |
## research/wall_seam_pulse_20261014
| File | Class | Bytes | Blob |
|---|---|---:|---|
| analyze.py | R-raw | 10,343 | ed7cf6999a49 |
| interpretation.txt | K | 3,514 | 90935db2aa99 |
| results.json | R-raw | 15,137 | 678a0759871a |
| run.json | R-raw | 1,170 | 53e303fd0d5d |
| sources.csv | R-raw | 2,313 | 40d3736b8ef1 |
| stdout.txt | R-raw | 1,179 | 756d8c38e213 |
## tests/validation/multigrid/cpuref_0_3_evidence
| File | Class | Bytes | Blob |
|---|---|---:|---|
| README.txt | K | 4,133 | 62897bfd9812 |
| README_grok.md | K | 1,751 | 2d36e05661a8 |
| baseline.json | P-pin | 39,291 | 188491096547 |
| manifest.json | P-pin | 3,495 | 10c19f5fe5ca |
| results_horsea.json | R-raw | 2,281 | 3b163384e376 |
| stdout_grok.txt | R-raw | 7,143 | 5a0c270ea4ae |
| stdout_horsea.txt | R-raw | 9,013 | adff8bb8d295 |
## tests/validation/multigrid/local_stress_v1_evidence
| File | Class | Bytes | Blob |
|---|---|---:|---|
| README.txt | K | 8,239 | 3513fd294223 |
| TABLE_local_stress_observable_v1.md | K | 2,967 | 6115dcde0f46 |
| baseline.json | P-pin | 43,991 | ab631ea4f2ad |
| comparison.json | R-raw | 13,248 | d7d1b36eb247 |
| manifest.json | P-pin | 4,132 | c6c44d8125cd |
| results_horsea.json | R-raw | 2,671 | 79bdf001f948 |
| run_once.py | R-raw | 4,313 | fa9c5239e993 |
| source_manifest.json | P-pin | 622 | 0516f1860407 |
| stdout_horsea.txt | R-raw | 17,091 | a20c554693df |
| stdout_local_stress_observable_v1.txt | R-raw | 12,238 | 692ffe1a05da |
## tests/validation/multigrid/shear_v0_2_evidence
| File | Class | Bytes | Blob |
|---|---|---:|---|
| README.txt | K | 7,220 | 772f74ad40ff |
| baseline.json | P-pin | 41,751 | 70d9c9fa71c0 |
| comparison.json | R-raw | 7,679 | 0af8736f9a01 |
| joint_report.md | K | 6,110 | eed871221298 |
| manifest.json | P-pin | 3,796 | b22d03a1ba9e |
| results_horsea.json | R-raw | 1,957 | b2cd48a07a08 |
| run_once.py | R-raw | 3,245 | 0b8d5e21f326 |
| source_manifest.json | P-pin | 574 | 4d1c742690c7 |
| stdout_grok.txt | R-raw | 7,950 | 0e80b1aba7f6 |
| stdout_horsea.txt | R-raw | 11,421 | 3c867cdac197 |
+22 -34
View File
@@ -1,26 +1,23 @@
# Fixed three-level rectangle unions
# Fixed three-level separated patches
This page describes three-level `FixedPatchStepper` setups built from the
`refinements` level list, with rectangle unions on either adjacent pair. The
same constructor supports arbitrary depth; three or more levels require
all-blocked storage and bodies/Jets only on the finest level. This is not
general AMR: there is no per-region timestep, moving geometry, automatic
dynamic refinement or L1 body support in the three-level form.
`FixedPatchStepper` supports exactly three static, blocked levels with separated
rectangle sets on either adjacent pair. This is not general AMR: there is no
per-region timestep, moving geometry, dynamic refinement or L1 body support.
All regions share one current/spare buffer pair per level. The existing schedule
advances L0 once, L1 twice and L2 four times per base step, with aggregate transfers.
No CUDA kernel changes are involved.
## Explicit coordinate frames
For a single scalar parent patch, pass a scalar rectangle as `refinements[1]`:
`(x0,y0,x1,y1)` in half-open integer indices of the allocated L1 window,
including its ghost band.
Keep the existing scalar `child=(x0,y0,x1,y1)` for a single scalar parent patch.
Its coordinates retain their original meaning: half-open integer indices in the
allocated L1 window, including its ghost band.
A rectangle list as `refinements[1]` accepts a scalar or a set parent and
interprets every rectangle in the actual allocated L1 array, including ghost
bands and holes. It never infers region-local coordinates. The physical root
cell edge corresponding to an L1 index `(x,y)` is
`parent.fine_origin + (x,y)/2`:
The new keyword `child_rectangles_in_parent=[...]` is mutually exclusive with
`child`. It accepts a scalar or a set parent and interprets every rectangle in
the actual allocated L1 array, including ghost bands and holes. It never infers
region-local coordinates. The physical root cell edge corresponding to an L1
index `(x,y)` is `parent.fine_origin + (x,y)/2`:
- A scalar L1 parent has a shifted window origin.
- A set L1 parent has origin `(0,0)` and public shape `(2*ny,2*nx)`.
@@ -33,23 +30,20 @@ are physically equivalent to parent set `[(4,4,12,12)]` with child set
`[(8,8,16,16)]`. Reusing the same numbers after changing the parent frame can
move the physical region.
Both adjacent pairs are built from their unions, without a fixed rectangle-gap
rule. Adjacent, overlapping and concave unions are accepted only when their
actual transfer dependencies close. The whole child union must preserve
inherited ACTIVE coverage, uncovered ACTIVE donors/receivers, surviving pulls, and the upstream
Both adjacent pairs require a Chebyshev gap of at least four in their own
parent-cell units. The whole child union must preserve inherited ACTIVE
coverage, uncovered ACTIVE donors/receivers, surviving pulls, and the upstream
restriction footprint. Sparse holes remain unowned; inherited INNER remains
stream-only and OUTER remains invalid. Block sizes must divide their allocated
level grids. Sparse storage owns every grid-aligned tile intersecting the union
plus its four-cell ghost band; rectangle and support edges need not align with
tile edges.
stream-only and OUTER remains invalid. Block sizes must align with every
support edge, including its four-cell ghost band.
## Example
```python
run = FixedPatchStepper(
cfg, context=ctx,
refinements=[[(4, 4, 20, 28), (24, 4, 44, 28)], [(16, 16, 32, 40), (56, 16, 80, 40)]],
level_blocks=[(4, 4)] * 3,
cfg, [(4, 4, 20, 28), (24, 4, 44, 28)], context=ctx,
base_block=(4, 4), fine_block=(4, 4), child_block=(4, 4),
child_rectangles_in_parent=[(16, 16, 32, 40), (56, 16, 80, 40)],
)
```
@@ -57,11 +51,11 @@ This example uses a 48x32 root. `initialize` and `download` use three Q-major
dense arrays. Public arrays and masks still scale with the full allocated
extent; sparse device buffers do not remove that host-memory limit.
Stationary whole-circle L2 bodies and Jets (`level_objects[2]`, `level_jets[2]`) use root edge
Stationary whole-circle `child_objects` and `child_jets` use root edge
coordinates and a single L2 boundary object. Body IDs are physical object-list
positions, not region IDs or caller `obj_id`. Complete boundary stencils must
avoid the transfer footprint. Change a child Jet with
`set_jet_amplitude(index, value, level=2)` (`level` is required); L1 bodies/Jets remain unsupported.
`set_jet_amplitude(index, value, level=2)`; L1 bodies/Jets remain unsupported.
## Verification
@@ -71,12 +65,6 @@ CPU geometry, table, pre-CUDA rejection, schedule and cleanup tests:
PYTHONPATH=src python -m unittest discover -s tests/unit -p 'test_refinement*.py' -v
```
`tests/unit/test_union_refinement.py` covers union dependencies and tile
ownership; `tests/unit/test_hierarchy.py` covers composed geometry through four
levels. `tests/unit/test_raw_exchange.py` checks the four-level host schedule
with CUDA mocks. These establish topology/control-flow coverage, not numerical
qualification at every depth or interface shape.
Explicit GPU gates are in
`tests/validation/multigrid/test_refinement_three_level_multipatch_gpu.py`.
They cover physical-frame equivalence, twins, asymmetric refinement, multiple
-26
View File
@@ -1,26 +0,0 @@
# Device state lifecycle (uniform Simulation, FixedPatchStepper)
State is grouped by what a change invalidates. This file describes the code as
it is; it adds no framework. Line references are to candidate 0f8fdbc3.
| Class | Contents | How it changes | Owner rule |
|---|---|---|---|
| 1. Module and its globals | Compile-time (visible to the compiler, fixed per module): `config.h` macros incl. `NX`/`NY`, `COLLISION_MODEL`, `BLOCK_ROOT_BC`, `WALL_*`. Runtime per-module globals (not visible to the compiler; written by host code): `__constant__` `d_params`, `d_w`, `d_cx`, `d_cy`, `d_block_channel`, `d_block_wall`; plus bound kernel handles | Globals are written once by the owner right after `load_module`. Uniform grid: `Simulation._recompile` (construction, body-topology sync) loads a new module and re-uploads; `update_runtime_params` rewrites `d_params` (omega, force, n_objects) between runs. Steppers: never rewritten after construction, so they are immutable for the instance's lifetime although they are runtime values. The blocked slot/reservoir root reads its slot from the `d_block_wall` constant (the former dense route used `WALL_*` macros; both were bit-identical in the parity gates). | Every owner loads its own module (`compiler.load_module` = `module_from_file`, no cache). A loaded module is never shared between live owners: globals are per module, so sharing would share mutable `d_params`. |
| 2. Geometry, layout, tables | flags, curved-link tables, block layouts and halo pairs, transfer (explode/coalesce) tables, block kinds | Immutable for the life of an instance, except translating circles on the uniform grid: their swept-band flags and cut links are rewritten on the device every advancing step (`MovingCircleCells`/`MovingCircleLinks`, docs/moving_bodies.md); the host flag mirror is then stale for those cells. Uniform grid otherwise: rebuilt by body sync. `FixedPatchStepper`: a geometry change is a new instance (`remap_finest`), committed only after a successful transfer. | No in-place table edits. |
| 3. Controls and dependent boundary columns | Jet amplitude -> per-link `surface_velocity`; body actions (uniform grid) | Between steps, without a table or module rebuild (`set_jet_amplitude`). | Input (index, level, amplitude) is validated before any device write; invalid input changes nothing and the instance stays usable. The host copy commits only after the upload returned. Any error after validation leaves the device column undefined and fails the instance: `FixedPatchStepper` status `failed`, `Simulation._failed`; every later device-touching public method (`Simulation`: `stream`, `set_jet_amplitude`, `initialize`, `sync_bodies`, `run`/`step`, `read_surface_exchange`, `read_body(s)`, `get_macroscopic`, `get_ddf`, `update_runtime_params`, `snapshot`, `restore`, save/load checkpoint) raises; cached host readers and host-side staging (e.g. `get_flags` without translating circles, `add_body`/`remove_body` before a sync) do not touch the device and stay callable; with translating circles `get_flags` reads the device flags back and refuses after a failure. Translating-circle targets follow the same rule: `set_body` validates before mutation, the applied offset commits only after a successful synchronized step, and a failed moving step fails the instance (docs/moving_bodies.md). `close()` still releases resources and stays retryable. |
| 4. Per-step state | population double buffers and their parity, raw-exchange ledgers, observations | Device-only during `step(n)` / `run(n)`; buffer swaps are host-handle swaps. | Valid on the host only after the single wait at the end of the call. |
Storage routes are separate concerns:
- Original uniform grid (`uniform.cu`, `Simulation`): whole-domain `NX`/`NY`.
- Blocked levels (`blocked.cu`; every `FixedPatchStepper` level, including the
single level of `refinements=[]`): `NX`/`NY` are one block window including halo.
- Dense refinement levels (`refinement.cu`) were removed: `level_blocks` is
required and every refinement level is blocked.
A single-rectangle refinement (`FixedPatch2D`) is stored in its own fine
window frame (`FixedPatchStepper.__init__`,
`BlockLayout2D.build(*pair.fine_shape, ...)`). Only a rectangle set
(`FixedPatchSet2D`) uses the level-wide frame `(2*ny, 2*nx)`, whose
`fine_layout` requires that full grid to be divisible by the tile
(`refinement.py:240-252`).
-451
View File
@@ -1,451 +0,0 @@
# Moving circles on the uniform dense grid
## Status and user guide (current)
Development-stage feature, integrated from the reviewed slices S1–S3a
(production code identical to `653fd997`). Physical evidence and its limits:
`docs/validation/moving_bodies_stage1.md`. The force used as a structural RHS
is a declared stage choice, not an established result.
*What it does.* Translating circles (with optional spin) on the dense
double-buffer D2Q9 path: per-step cut links from an FP64 geometry record
(M2 `MovingCircleLinks`), reclassification and refill of covered/uncovered
cells (M1 `MovingCircleCells`), moving-wall Bouzidi with wall velocity
`u + omega x r`, and separate per-body diagnostics.
*API.*
- `Simulation.add_body("circle", center=(x, y), radius=r,
translation=Translation(offset_box, max_speed))` before `initialize()`;
`offset_box = (x_min, x_max, y_min, y_max)` in lattice cells around the
base centre (must contain 0), `0 < max_speed ≤ .5`.
- `set_body(id, offset=(dx, dy), vx=..., vy=..., omega=...)` sets a pending
target (validated against the applied offset: box, Euclidean step ≤ .5
cell, speed, no sliding hold); `vx`/`vy` are the interval wall velocity.
- `run(1)` advances a pending target and commits it only after the step
synchronizes with `no_donor == 0`; `run(n > 1)` with a pending target
raises; without a target `run(n)` holds the geometry (spin allowed).
Direct `LBMStepper.step` refuses while a target is pending.
- `moving_diagnostics()` returns per body the columns of
`MovingTables.DIAG` (CME, Wen term, link mass, cover/uncover momentum and
mass, eq_only, no_donor, crossings) accumulated since obs were zeroed;
`obs`/`read_force` keep their contract (CME on the body).
- `get_flags()` reads device flags back when moving circles exist.
- `snapshot()` / `restore()` save and restore populations only, keeping the
current clock, controls, observations, geometry and flags. Use them only
with unchanged geometry; restoring across a translation is unsupported
and is not rejected at runtime. They do not provide FSI rollback.
*Supported:* dense double buffer, D2Q9, FP32 unshifted, SRT/MRT, circles,
spin. *Rejected before mutation:* EsoPull, ddf shifting, D3Q19, refinement /
blocked steppers, Jets or surface actuation on a moving circle,
`sync_bodies` and checkpoints while a moving circle exists, swept bands that
touch the domain edge, other bodies, sensors or force regions.
*Failure lifecycle.* Device-side failures (upload, step, sync, no_donor)
mark the Simulation failed; later run/step/sync/checkpoint calls and direct
stepper calls refuse until `close()`.
*Known limitations.* No mass correction at curved walls (static or moving);
cover/uncover and Wen terms are diagnostics, not a selected force law; the
host loop costs a fixed per-step synchronization (≈ 1 ms/step measured on a
7.2 M-cell grid, run(1) class); outer boundaries are the single dense
channel package (see `configs/CONFIG.md`).
## Design record (staged contract, kept as written)
The sections below are the contract the slices were reviewed against
(S0 → S3a). They are kept unchanged for provenance; where they say "draft",
"proposed" or "until S2/S3", the status above supersedes them.
Original scope (Frank via TRON, thread C0C78UFKGRM/1793335557.460509):
uniform-grid prescribed translation and single-cylinder free transverse VIV;
base main `b2034f87e03656c5f67ab88562a70d6c338b9918`.
## 1. Working standards
- Lean, readable kernels in the existing idiom (`kernels/step/*.cu`, shared
`apply_bouzidi_link`); Python manages state and validation. No framework,
no compatibility layer, no new dependency.
- The existing stationary/rotating path (`CurvedBoundaryKernel`, host link
packing, `sync_bodies`) stays byte-identical. Moving circles are a separate,
explicitly enabled path with its own numerical contract (this file).
- One authority per state (section 4). No per-step full-domain download,
rebuild or recompile; local swept-band device updates are in scope.
- Small slices (S1 host spec, S2 kernels without motion, S3 motion + refill,
S4 oscillator); each reviewed SHA is frozen before targeted runs.
- Tolerances, benchmark parameters and the force composition used in the
structural RHS are declared before the runs that judge them. Fresh vs carried
evidence and existing baseline failures are recorded; smoke, convergence and
physical validation are reported separately. No undeclared force filtering.
- External code (waLBerla GPLv3, FluidX3D source-available, VIVSIM) is a
concept reference only; nothing is copied.
## 2. Current update order (main b2034f87, dense double buffer)
`Simulation.run(n)`: upload host action (if `upload_act`) → zero obs (if
`zero_obs`) → `DoubleBufferStepper.step(n)` → download obs → synchronize.
Per step (`lbm/stepper.py` `step`):
1. `CurvedBoundaryKernel` over the packed cut links: reads post-collision
`G^{n-1}` at `k_f` (and `k_f - c` for q < 1/2), adds the moving-wall term
(`Uw = -omega*ry, Vw = omega*rx` + optional per-link `surface_velocity`),
writes the reflected value into slot `opp` of the solid node `k_s`, and
accumulates `c(f_toward + f_reflected)` and torque into `obs`.
2. `OneStep` pull/collide `ddf → temp` (collides FLUID cells; obstacle
interiors flagged `SOLID|OBSTACLE|BC_CURVED`), then buffer swap.
3. `ForceRegionKernel`, `SensorKernel`.
Raw `obs` force is the force **on the body** (link derivation in section 6;
DFG docs report `Cd > 0` from it). Translation `vx, vy` in the action slots is
currently stored but not read by any curved kernel.
## 3. Proposed update order (moving circles)
Per step n, before the existing step 1:
- **M1 `MovingCircleCells`** (one thread per swept-band cell): classify with the
analytic predicates `in_old = inside(x, c^{n-1})`, `in_new = inside(x, c^n)`;
write `flag = in_new ? SOLID|OBSTACLE|BC_CURVED : FLUID`; covered cells
(`!in_old && in_new`) accumulate `node_cover`; fresh cells
(`in_old && !in_new`) are refilled (section 5) and accumulate `node_uncover`.
- **M2 `MovingCircleLinks`** (one thread per band cell × direction): compute
q and hit point at `c^n` (FP64, same formula as `find_circle_intersection`,
same clamp [0.01, 1] and fallback classes as `CircleGeometry.build_cut_links`),
skip non-cut slots, call `apply_bouzidi_link` with
`u_w = (vx^n, vy^n) + omega × (hit - c^n)`, accumulate the link terms
(section 6).
Then the unchanged steps 1–3. M2 must follow M1 because a q < 1/2 link may
read `k_f - c` at a fresh cell.
Exclusivity: moving bodies are never packed into the `CurvedBoundaryKernel`
link arrays (`n_curved` covers stationary/rotating bodies only), so M2 is the
sole writer of their solid slots and the sole source of their link force; no
double write and no double `obs`. The stationary path's packing, launch and
arithmetic are unchanged.
Completeness: after M1, every (fluid cell, direction) pair in the swept band
whose neighbour is `in_new` is visited by M2. This holds because the band is
the circle AABB at the declared extreme offsets plus 2 cells, and the host
rejects per-step moves with `|c_new - c_old|_2 > 0.5` and bands touching the domain edge,
other bodies, sensors or force regions. Each M2 write lands at
`index_f(k_s, opp)`, the exact slot the adjacent fluid cell pulls, so every
fluid pull from a solid slot of a moving body is BFL-written.
## 4. State, masks and authority
- Host `ObjectState` is the single authority for one displacement
`offset = (dx, dy)` relative to the immutable base centre, plus
`velocity = (vx, vy)` and `omega`. Centres are always derived
(`c = base + offset`), never stored as independent state. Velocity is
supplied, never differenced from offsets.
- `c^{n-1}` is derived from the last *committed* offset, `c^n` from the
offset being applied. Per body a small device **motion record**
`{c^{n-1}, c^n, u^n}` is uploaded with the action buffer; action-slot meaning
is not overloaded. (CANDIDATE layout; OPEN.)
- Fail-closed commit: an enqueued upload is not a completed one. The new
offset becomes the committed offset only after the stream synchronization
that follows the upload and the step group succeeds. Any upload or later
synchronization failure leaves host and device motion state unknown: the
simulation is marked unusable (existing `_require_usable` lifecycle), and
no API may report a host/device match afterwards.
### 4a. Applied geometry vs pending target (CANDIDATE, narrow API)
Two distinct facts per moving body, one authority each:
`applied` = offset of the geometry last applied on the device, changed only
by a successful commit; `pending` = an optional requested target
`(offset, velocity)`, changed only by `set_body`. A setter returning proves
nothing about the device. The uploaded motion record is derived each run:
`{c_old = base + applied, c_new = base + (pending.offset if pending is not
None else applied), u = pending.velocity if pending is not None else 0}`
(explicit `is not None`, so a zero offset or zero velocity is a valid target). Translational wall velocity exists only on a step
that advances to a pending target; a hold step (`c_old == c_new`) has
translational `u_w = 0` (spin unchanged), so a non-advancing circle never
acts as a sliding wall. One moving step consumes one target.
| situation | contract |
|---|---|
| `set_body` target | validated against `applied` (box, `|Δc|_2 ≤ 0.5` cell, speed bound) before mutation; on success replaces `pending` |
| target with `offset == applied` and nonzero translational velocity | `ValueError` before mutation (would be a hold step with sliding wall velocity) |
| two setters before a run | last wins; each validated against `applied`, not against the earlier pending target |
| `run(1)` with pending | upload record, step, sync; on success `applied := pending.offset` (applied stores only the offset), `pending := None` |
| `run(1)` without pending | hold: `c_old == c_new`, no cover/uncover, links at `applied`, translational `u_w = 0` |
| `run(n > 1)` with pending | `ValueError` before mutation (single-step-only moving API) |
| `run(n > 1)`, no pending | allowed: hold for n steps, translational `u_w = 0`, spin only |
| `run(0)` | no motion upload, no commit; `pending` kept |
| validation failure | `ValueError`, nothing mutated, retry allowed |
| upload/launch/sync failure after enqueue | simulation unusable; `applied` and `pending` are not reported as device truth; no re-entry |
Displacement norm: Euclidean per-step `|c_new - c_old|_2 ≤ 0.5` lattice
cells (declared bound; CANDIDATE value).
- Membership is never read from a flag that M1 is overwriting. The host
verifies at `add_body` that every swept-band cell has base flag FLUID, so the
band's base state is known without reading `flag_gpu`.
- `flag_gpu` is the device authority for band cells while a moving body
exists; the host flag mirror is stale for those cells and is not used.
## 5. Refill of fresh cells (CANDIDATE)
- Stored time level: everything in `ddf` at M1 is post-collision `G^{n-1}`; the
refill writes all 9 slots at that same level, so every slot of a fresh cell
that the following pull or a q < 1/2 link may read is defined. Donor values
are read at the same level `G^{n-1}` (old-fluid cells, untouched by M1).
- `G = f_eq(rho_bar, u_w(x)) + G_neq(x_d)` where `rho_bar` = mean density of
valid neighbours and `x_d` = the valid neighbour best aligned with the outward
normal `(x - c^n)/|x - c^n|`; `G_neq(x_d) = G(x_d) - f_eq(rho(x_d), u(x_d))`.
- Valid donor: inside the domain, base FLUID, `!in_old && !in_new`.
- Sets: `V(x)` = the 8 lattice neighbours that are in the domain, base FLUID
and `!in_old && !in_new`. `rho_bar` = mean density over `V(x)`. NEQ donor =
the member of `V(x)` with the largest `c·n` among those with `c·n > 0`
(`n` = outward normal at `c^n`); none → NEQ part 0 (counted as `eq_only`).
- `V(x)` empty → no defined density: the cell is refilled with
`f_eq(RHO, u_w(x))` so all 9 slots stay defined, the `no_donor` counter is
incremented, and the host treats `no_donor > 0` after the run as a hard
failure (simulation unusable). It is not a silent fallback.
- Non-emptiness is NOT assumed from the displacement bound. Geometry
assumptions: circle radius `R ≥ R_min` (CANDIDATE 4 cells), `|Δc|_2 ≤ 0.5`.
S1 includes a host enumeration over sub-cell centre positions and
displacement directions on a fine grid within these bounds that checks every
fresh cell has nonempty `V(x)`; it is evidence for those bounds, not a
proof, and runtime keeps the counter.
- Fresh cells only write, old-fluid cells only read: no race.
## 6. Discrete momentum ledger (to review; not a selection)
Notation per step n: `F^n` = fluid set after M1; links L = (k_f, dir) at
`c^n`; `f_t = G_dir(k_f)`; `f_r` = value M2 writes into `(k_s, opp)`.
Fluid momentum before streaming:
P_pre = P(G^{n-1} on F^{n-1}) - sum_cover sum_q c_q G_q + sum_fresh sum_q c_q G_q^refill
During the pull, every population entering a fluid cell comes from a fluid
cell except the BFL-written slots; `f_t` leaves into solid and is never pulled
by a fluid cell. Collision conserves momentum for SRT/MRT without forcing.
Hence, in exact arithmetic,
P_fluid^n - P_fluid^{n-1} = - sum_L c (f_t + f_r) - I_cover + I_uncover + B^n + S^n
with `B^n` external-boundary momentum (inlet/outlet/walls) and `S^n` forcing.
So **`F_CME + node_cover - node_uncover`** is, by construction, the body
momentum that balances the fluid ledger of *this* storage/update order.
Branch details: halfway fallback links (`f_r = f_t` plus the moving-wall
addend) and q < 1/2 links enter the link accumulators exactly like Bouzidi
links, with `c(f_t + f_r)`. In the q < 1/2 branch `G_dir(k_f - c)` is an
interpolation input only, not a fluid-momentum sink. Pull-slot completeness
is the section 3 contract. The scope excludes `ddf_shifting` (rejected), so
the unshifted force form applies. The balance is a fluid-side identity; it
does not close body+fluid globally in a domain with inlet/outlet/forcing
(`B^n`, `S^n`), and the solid-slot writes are boundary plumbing, not physical
streaming packets.
The Wen 2014 relative-velocity link sum adds `-u_w (f_t - f_r) = u_w dm` per
link (`dm = f_r - f_t`, the BFL mass defect). That term has no counterpart in
the fluid ledger above. Peng et al. (PRE 2016 submitted, §II.C, Eqs. 20–21)
show for a straight wall that the bounce-back error accumulated before a node
switch cancels with the switching momentum. Whether `sum u_w dm` approximates
`I_cover - I_uncover` here (implying double counting if both are used) is
**NOT demonstrated** for this implementation: it needs a bounded experiment
(prescribed slow translation; cumulative `sum u_w dm` vs cumulative node
impulse, per crossing cycle). Momentum conservation is also distinct from
Galilean invariance, which G4 tests.
External reference note: waLBerla's covered/refill momentum switches
(`conserveMomentumWhenMapping`, `conserveMomentumWhenRestoring`) default to
false (Astra), so their existence does not establish which combination its
validation cases use. A bookkeeping identity alone does not select the
physical force either.
Mass ledger (same proposition status). With `M^n` the active-fluid mass
summed over `F^n` at the sample point,
M^n - M^{n-1} = sum_L dm - m_cover + m_uncover + B_m^n
(`dm = f_r - f_t`, `m_cover`/`m_uncover` = summed `sum_q G` of covered and
refilled cells, `B_m` external-boundary mass flux; collision conserves mass).
Sample point (fixed): post-collision storage after the swap of step n, i.e.
the same level `G` that M1 reads at step n+1; both ledgers are evaluated
there. Ledger residual = left side minus every listed term. Zero mass flux
does not imply zero momentum exchange: closed walls (bounce-back, free-slip)
still exchange momentum, so "no inlet/outlet, no forcing" does NOT give
`B = S = 0`. The rounding-level momentum check therefore uses either a
momentum-isolated case (periodic in both directions, no forcing) or counts
the wall impulse explicitly as part of `B`; the mass check may use a closed
domain. Otherwise `B`, `S` are not separately measured, the residual is
reported as their estimate and is not a pass criterion.
Both ledger identities are **proposed, unreviewed**: they are not called
proven until review and a host replay agree.
Accumulators (separate, per body, per step window; all reported raw):
`link_cme`, `link_wen_correction` (= `sum u_w dm`), `node_cover`,
`node_uncover` (momentum), `link_dm`, `mass_cover`, `mass_uncover` (mass),
plus counters of crossings, `eq_only` and `no_donor`, so force spikes can be
correlated with crossing events.
**OPEN — structural RHS candidates (not selected):**
(a) `link_cme + node_cover - node_uncover`; (b) `link_cme + link_wen_correction`
(no node impulse). Selection follows the ledger review and the bounded
experiment above.
## 7. Rejection semantics
- Before any mutation (`ValueError`, no device write, host state unchanged):
moving body with EsoPull, `ddf_shifting`, refinement/blocked stepper, D3Q19,
Jets or surface actuation on that body, `sync_bodies`/checkpoint while a
moving body exists; swept band touching domain boundaries, other bodies,
sensors or force regions; `set_body` offset outside the declared box,
per-step move `|c_new - c_old|_2 > 0.5` cell (the single bound of 4a), speed
above the declared bound.
- Host motion state is committed only after the upload *and* the following
synchronization of the step group succeed (section 4); any failure in
between makes the simulation unusable and no host/device match is reported.
- The only soft device diagnostic is `eq_only` (NEQ part omitted, counted).
`no_donor > 0` is a hard failure: it is checked after the step group and
before the step is committed or any results are returned; on failure the
simulation is unusable (section 5).
## 8. Coupling and units (CANDIDATE)
Lattice units, rho0 = 1, per unit span. Step n uses `c^n`, `u^n` and yields
`F^n`. Weak staggered update
`v^{n+1} = v^n + (F^n - c v^n - k y^n)/m`, `y^{n+1} = y^n + v^{n+1}`
(Newmark is an alternative; OPEN). Mass, natural frequency, damping and `U_r`
follow the chosen benchmark's own definitions. Leontini, Thompson & Hourigan,
JFS 22:857–864 (2006), DOI 10.1016/j.jfluidstructs.2006.04.003, as read by
Grok from the full text: Re = 200, `m* = m/m_f = 10` (m_f displaced fluid
mass), `zeta = c/c_crit = 0.01`, `U* = U/(f_N D)` with `f_N` the natural
frequency in fluid assuming an inviscid added-mass term; the paper also
reports frequencies normalised by the in-vacuo value. Our runner derives `k`
from that in-fluid `f_N` (added-mass coefficient as stated in the paper) and
never from the vacuum formula; `c_crit` convention and the original
comparison data are still OPEN. Stability/convergence is gated by fluid
time/space refinement of the coupled run, not assumed from the mass ratio;
structural sub-stepping does not substitute for fluid-coupling time
refinement.
## 9. Gates (map; tolerances OPEN, declared before runs)
G0 zero-motion regression, split: G0a populations of the existing stationary
and spin-only path bitwise unchanged vs main; G0b moving path at rest (with
and without spin) reproduces the stationary link set and populations; force
estimator outputs are compared separately from populations, since the moving
path adds estimator terms · G1 raw force sign (`Cd > 0`) ·
G2/G3 prescribed translation incl. ledger accumulators · G4 matched
relative-flow (moving body in quiescent fluid vs fixed body in uniform
stream), optional boost, co-moving sanity · G5 Dütsch et al. 1998 Re 100,
KC 5 in-line (needs 2-component offset) · G6 Leontini free transverse VIV
(m* = 10, ζ = 0.01, Re = 200, definitions pending) · G7 time-level contract
frozen before the oscillator.
Candidate pre-run engineering tolerances (Astra; for discussion, not
literature limits or measured outcomes; norms, averaging window, convergence
protocol and physical reference uncertainty must be fixed before adoption, and
none may be weakened after seeing runs):
normalised momentum-ledger residual FP32 ≤ 5e-6 (FP64 oracle ≤ 2e-13);
10-cycle relative mass drift ≤ 1e-4; prescribed-motion grid study D = 40/80/160
with change in the force-response amplitude (observed quantity and norm to be
named before adoption) ≤ 2 % and phase ≤ 2°; enlarged domain
amplitude change ≤ 1 %; matched-frame force RMS ≤ 2 % and improving with
refinement; VIV refinement amplitude ≤ 2 %, frequency ≤ 1 %, five consecutive
cycle amplitudes within 1 %.
Conditions stated before any moving kernel (S2/S3), not only at the
oscillator stage: (i) fluid refinement protocol for the prescribed-motion
gates — diffusive scaling (dx halved, dt quartered, physical Re, KC and
amplitude fixed), the observed quantities and norms named in advance;
(ii) Galilean constant-boost G4-boost requires boundaries compatible with a
uniform boost (periodic in the boost direction or boundaries that move with
it); if the current dense configuration cannot represent that, G4-boost is
recorded as not runnable rather than replaced by another test.
## 10. Proposed minimal S1 scope (host only; held until cross-review)
- `add_body` moving-circle declaration: base centre, declared offset box
(x/y), speed bound; swept-band computation and band-placement validation.
- `set_body(offset=..., velocity=..., omega=...)` validation before mutation
(section 7), applied/pending state and the run-transition table of 4a,
and the fail-closed commit of section 4.
- Host enumeration check of refill-donor non-emptiness (section 5) within
the declared `R_min` and displacement bound.
- Motion-record host layout and upload path; exclusion of moving bodies from
the packed `CurvedBoundaryKernel` links (stationary packing unchanged).
- Explicit rejections for unsupported modes.
- S1 execution guard: until the S2 moving kernels exist, any `run(n)` with
`n > 0` (and `step(n)`) while a moving body is declared raises before any
device mutation (no action/motion upload, no launch), including the
nominal at-rest case, because the moving body is excluded from the static
packed links and nothing else would apply its boundary. `run(0)` keeps its
existing behaviour. Simulations without moving bodies are unaffected.
Preparation/commit helpers are exercised by host tests with mocks; no
operational unsupported stepping path is exposed.
- Host tests (Astra runs them): rejection-before-mutation, band geometry,
derived centres, fail-closed behaviour under an injected upload/sync
failure, stationary packing byte-identical, execution guard (positive-step
run with a moving body raises before upload/launch; stationary unaffected). No kernels, no GPU runs, no
y-only VIV oscillator (S4), no force RHS.
## 10a. S2a — fixed-centre moving links (implemented on `claude/moving-bodies-s2a`)
- `MovingCircleLinks` (`kernels/step/moving_circle.cu`, dense double buffer)
runs before the static curved kernel when moving tables are attached: one
thread per (band cell, direction) slot, FP64 `{cx, cy, r}` at the applied
offset, q/hit/clamp/fallback in the order of `build_cut_links` with explicit
`_rn` operations (host/device bit parity is a test target, not a claim),
then the unchanged `apply_bouzidi_link` with spin wall velocity only.
- `obs` keeps its contract (CME and torque from `apply_bouzidi_link`). A
separate per-body diagnostics buffer receives `cme`, the Wen term
`u_w * dm`, `link_dm` and the link count from the same link outputs; it is
zeroed with obs and only exists when moving circles do. Optional
`slot_debug` (q, fallback, rx, ry per slot) is null in production.
- Displacement is disabled: `set_body(offset=..., vx=..., vy=...)` on a
translating circle raises before mutation, before and after initialize, so
no pending target can exist and `run`/`step`/direct `LBMStepper.step` all
hold at the applied geometry. `initialize()` never reads or consumes
`pending`. `stepper.blocked` is cleared only when the moving tables are
attached (initialize, stepper recreation).
- S3 rule (not implemented): once targets are re-enabled, any pending target
must raise in `run`/`step` and in `LBMStepper.step` alike before any launch,
swap or step-count change; zero-step calls never consume it.
- Not validation: a pass of the zero-motion gates says nothing about moving
geometry. GPU parity (G0a/G0b) is unverified until run on a free device.
## 10b. S3a — translation with refill (implemented on `claude/moving-bodies-s3a`)
- Targets are enabled again (4a). `set_body(offset, vx, vy)` validates against
`applied`; a pending target blocks direct `LBMStepper.step(n > 0)` before any
launch; `run(n > 1)` with a target raises before mutation; `run(0)` never
consumes it; `run(1)` advances it.
- Per advancing step, on one stream: upload the immutable FP64 records
`{c_old, c_new, r, u}` (only when changed) → M1 `MovingCircleCells` → M2
`MovingCircleLinks` (`u_w = u + omega x r` at the hit point) → static curved →
`OneStep` → swap → force region → sensors → synchronize → read `no_donor` →
commit (`applied := pending.offset`) → upload the hold record
(`c_old = c_new = applied`, `u = 0`) → synchronize. Hold steps launch no M1
and are the S2a path. Commit point: the advancing step-group sync plus a
zero `no_donor` count; a failure before it commits nothing. The hold-record
upload stages the next step after the commit: if it (or its sync) fails,
`applied` already matches the moved device geometry, but the Simulation is
still marked failed and refuses reuse. Any failure from the first upload on
marks the circles and the Simulation failed.
- M1 classifies with the old/new predicates only (no flag reads). Covered
cells add raw `sum c G` / `sum G` of `G^{n-1}` to `cover_*` and become
`SOLID|OBSTACLE|BC_CURVED`. Uncovered cells: donors are fluid in both
geometries; `rho_bar` over all of them; NEQ from the donor with the largest
unnormalized `c·n > 0` (first direction wins ties, `n` from `c_new`);
`u_w` (translation + spin) at the cell position; all nine slots written;
raw `sum c G`, `sum G` of the refill go to `uncover_*`; no donor →
`f_eq(RHO, u_w)` plus `no_donor` (hard failure before commit); no NEQ donor
→ `eq_only`.
- `get_flags()` reads the device flags back (synchronized) when moving tables
exist; otherwise it returns the host array as before.
- Not selected: the structural force. CME+node vs CME+Wen stay OPEN until X1
and G4 have run.
## 11. Remaining decisions
Structural RHS (section 6); motion-record layout; refill details; coupling
scheme; benchmark definitions and digitized reference data; tolerances;
execution/resource feasibility (Astra, pending); whether a quiescent domain
(zero inlet velocity) runs cleanly in the current config.
-150
View File
@@ -1,150 +0,0 @@
# Uncontrolled steady pinball: development protocol
Frozen before results: 1280 x 512 D2Q9/FP32/MRT, unshifted double buffer;
rho=1, mean U=.01, nu=.004, D=20, Re_D=50. Centres are (600,255.5),
(626,270.5), (626,240.5), radius10. No upstream cylinder. Passive probes stay
at (800,295.5), (800,255.5), (800,215.5), radius5. Every omega is zero.
Parabolic inlet, regularized NEQ damping1; y bounce-back planes .5/510.5;
NEQ-extrapolated outlet with backflow clamp (the configured blend is inactive in
this MRT route). Initial fluid is
parabolic equilibrium. Fixed warmup is 4 NX/U=512000 steps.
Geometry and fluid parameters follow DynamisLab 609116ec26f19766f5e9ce2b0df427d4e24faae8,
`legacy_test/core/legacy_env_builder.py:build_steady_cloak` and the retained
`config_lbm_pinball_legacy_re50d_u001.json`. This is a modern Celeris reconstruction,
not a bitwise Legacy reproduction: Legacy uses shifted incompressible MRT, a
different curved interpolation and prescribed parabolic outlet velocity. Current
Celeris uses standard MRT, two-branch BFL and extrapolated outlet velocity.
The fixed-hold control uses the SAME modern settings as the free case.
## Shared translation and ownership
`Translation(..., shared=True)` is explicitly transverse only. Reusing one
envelope INSTANCE declares common motion; equal but separate envelopes are not
linked. Before initialization and every positive Simulation.run, group members
must have identical applied offsets, effective target offsets and interval wall
velocities. Stage each member with set_body, then call run(1) once. An incomplete
or inconsistent target group is rejected before device uploads and can be fixed.
Existing pending-target direct-stepper restrictions remain.
Only members of that group may have overlapping whole swept bands. Their fixed
surface clearance must exceed4 lattice cells. This conservative bound exceeds
2 sqrt(2)+MAX_STEP = 3.329: M1 changed cells lie in old/new disks and read one-link
neighbors; M2 reads at most two links outside its new disk. Old/new relative
displacement is at most .5. Thus reads cannot reach another member's old/new
solid or M1 write region. M1 writes are disjoint. M2 writes opposite population
slots inside its own new disk, also disjoint. Separate first_slot ranges still
allocate each body's logical band/debug storage. Shared band cells alone do not
write anything: M1 returns unless membership changes; M2 returns unless a cut
link exists. Unrelated moving/static body and probe overlaps still reject.
This is a sufficient restricted-group condition, not support for near-contact
or independent motion. The r4, distance8.3, dy=.5 counterexample remains rejected.
CPU tests enumerate candidate donors/write footprints over subcell phases;
GPU tests compare fixed vs hold and member-order permutations through crossings
at wall speed .01 over the full +/-D envelope (8000 steps). The initial .5-speed
fixture failed with NaNs; a single-circle control also failed at step69. That
Mach~.866 fixture was unsuitable as a low-Mach numerical gate. Its failure is
retained; the geometric MAX_STEP proof is unchanged, not a stability guarantee.
CUDA kernels and arithmetic are unchanged.
## New structural parameters (not inherited from the paper)
One transverse state y,v; total assembly M=10 rho (3 pi D^2/4)=9424.77796,
fn=U/(5D)=.0001, Tn=10000, zeta=.01, K=M(2 pi fn)^2,
C=2 zeta M(2 pi fn). Initial y=v=0, no seed or later kick. Force is the sum
of the three instantaneous CME_y+Wen_y values from the previous fluid step;
node impulses are logged separately and never added. The damped oscillator is
integrated exactly for that piecewise-constant force over dt=1. Wall velocity
is y[n+1]-y[n], not the endpoint oscillator velocity. No filtering or retuning.
Observe600000 steps=60Tn=300 D/U after warmup, in each of free and fixed-hold
cases. Final20Tn is a descriptive comparison window; ten-Tn blocks expose drift.
No settled/periodic claim follows merely from reaching the cap. Stop on |y|>D,
nonfinite values, no_donor, rejected displacement or speed>.05. Report any quiet
response honestly. No automatic extension. --smoke uses32 warmup+32 steps only.
Native-step rows retain y,v, each body's CME/Wen/node terms and refill counters;
each600000x15 FP64 file is72MB. No full-field output or policy training. Compile
and run on one device serially, keep original outputs and dirty trees untouched.
This stage makes no claim of physical grid convergence or vibration suppression.
Run from checkout with PYTHONPATH=src and the existing Python environment:
`python research/steady_pinball/run.py --smoke --out <new-external-directory>`
then separately `--out <new-free-directory>` and `--fixed --out <new-fixed-directory>`.
## Zero-damping development follow-up (predeclared 2026-10-31)
The original d0bcefa run and its zeta=.01 results remain historical evidence.
The current runner exposes three fixed `--case` choices, all with C=0 as requested:
| Case | M / (rho 3 pi D^2/4) | fn | M | K | Purpose |
|---|---:|---:|---:|---:|---|
| Z0 | 10 | .0001 | 9424.77796 | .00372075320 | Remove damping only |
| M2 | 2 | .0001 | 1884.95559 | .000744150640 | Reduce mass and stiffness together at fixed fn |
| R2 | 2 | .00006 | 1884.95559 | .000267894231 | Change stiffness only relative to M2 |
R2 deliberately places fn at the retained fixed-body lift peak, f=.00006
(St=.12 on mean U). This is an informed development choice, not independent
physical validation or a promise of lock-in. All geometries, fluid settings,
512000-step fixed warmup, y0=v0=0, force time level and unfiltered RHS are unchanged.
No run result may select a replacement parameter or an automatic extension.
Each case has a 600000-step cap, the same 300 D/U physical duration. This equals
60 natural periods for Z0/M2 and 36 for R2. Compare the common final200000 native
steps, and six100000-step blocks, retaining actual natural/response frequencies
and ongoing drift. Do not call those blocks ten R2 periods. No settled-VIV claim
follows from a larger displacement. Report all three outcomes, including quiet
responses or stopped cases, alongside the existing fixed and damped-free records.
Unchanged stops: |y|>D, invalid move/speed, nonfinite values or no_donor. A 1800s
per-case wall cap and GPU headroom check bound resource use. Preserve partial rows;
do not add damping, clip trajectories, filter forces, or relax any stop. Each
case adds72MB of scalar history, no full fields. The separate Legacy/modern
baseline diagnosis does not change this FSI protocol.
## Matched D40 resolution comparison (predeclared 2026-10-31)
The first finer case is M2, with exactly the same mass ratio2, reduced velocity5,
zero structural damping, initial rest state and physical geometry as the retained
D20 M2. Targeting a development response of0.5--1D does not imply this case will
reach it. Do not change the structure within this comparison or discard a smaller
response. D60 is a supported scaling choice, not an automatically scheduled run.
For s=D/20 use dx'=dx/s, dt'=dt/s^2, U'=.01/s, nu'=.004, rho'=1,
fn'=fn/s^2, M'=s^2 M and K'=K/s^2. The same physical force per unit span
has F'_lat=F_lat/s. For D40 this gives U=.005, fn=.000025,
M=7539.82237, K=.000186037660, Re_D=50, and Tn=40000 steps.
The y walls are at .5 and ny-1.5, while x inlet/outlet source nodes are at0
and nx-1. Therefore map x'=s*x and y'=.5+s*(y-.5), with
nx'=s*(1280-1)+1, ny'=s*(512-2)+2. D40 uses2559x1022 and centres
(1200,510.5),(1252,540.5),(1252,480.5); every radius, probe and envelope scales
with D. The parabolic profile retains the same normalized wall coordinate.
This preserves the existing ghost-source boundary convention, without asserting
that it is an exact continuum open-boundary location.
Warmup=2048000 and response=2400000 native steps at D40: the SAME256 and300
D/U durations as D20, including60 natural periods after release. Compare the
six50-D/U blocks and the common final100-D/U window. Report max absolute y/D,
half-range, RMS, frequency and drift; peak-to-peak is not amplitude. No settling
claim follows from completion. Keep raw individual CME/Wen/node terms and all
crossing/fallback counters. Normalize net force by .5*rho*U^2*D in each case.
Report native crossing/non-crossing force increments separately, plus D40
four-step block means aligned with D20 intervals (diagnostic only, never replace
the raw spikes). Report final-window high-frequency variance above f*D/U=1,
using demeaned FFT Parseval weights, with the same physical cutoff for both cases.
Different motion histories confound spike-size comparisons; this is simultaneous
space/time/Mach refinement, not proof of a unique coarse-grid cause.
Only one full D40 M2 is initially scheduled, after CPU checks and a32+32-step
smoke. Stop on the unchanged geometry/speed/nonfinite/no_donor checks or a7200s
whole-case wall cap; save completed rows and status. The velocity cap scales to
.025 so its physical bound is unchanged. No force filtering, trajectory clipping,
automatic parameter search or extension. Scalar history is288MB; no full fields
or DDF snapshots are written. Two FP32 population buffers require about188MiB,
before flags, moving tables and context overhead; measure actual peak usage in
the smoke and full run. Keep all D20 outcomes. No production source changes.
Commands (one GPU and serial compilation, existing environment):
`python research/steady_pinball/run.py --case M2 --D 40 --smoke --out <new-smoke-dir>`
then `python research/steady_pinball/run.py --case M2 --D 40 --wall-seconds 7200 --out <new-full-dir>`.
-96
View File
@@ -1,96 +0,0 @@
# Supported settings: 2D Jet + fixed multilevel refinement (candidate 888c8038)
Every entry names the guard that enforces it (file and function or kernel guard,
at source revision 888c80381c006e976be2af8a452288729fc17c1b) and, where one
exists, an existing test that exercises it. A setting not listed here is not
claimed. Observations at the end are tied to the exact case in which they were
measured; they are not general bounds.
## Lattice, storage, collision
| Setting | Supported | Guard |
|---|---|---|
| Dimension / lattice | 2D D2Q9 only (3D refinement is future work) | `lbm/refinement.py` `FixedPatch2D.from_config`; `#error` guards in `lbm/kernels/blocked.cu` |
| Precision / streaming | FP32 storage, double buffer | same guards |
| Collision on refinement levels | SRT or MRT; TRT and LES rejected | `lbm/refinement.py` `FixedPatch2D.from_config` (re-checked for fine boundaries in `prepare_fine_boundary`) |
| Shifted (ddf_shifting) storage | supported (every level is blocked); one representation on all levels | `lbm/refinement_stepper.py` `FixedPatchStepper._check_storage` |
| Fine relaxation | 1/tau_fine must lie inside the configured omega guards | `lbm/refinement.py` `FixedPatch2D.from_config` |
| Single blocked level `FixedPatchStepper(refinements=[], level_blocks=[(by, bx)], level_objects=[circles])` (replaces `PeriodicBlockStepper`, removed in 8e1feff) | doubly periodic, SRT only, no LES/Jets/projection/exchange observation, stationary passive circles; `download()` returns a 1-tuple | `lbm/refinement_stepper.py` `_check_levels`, `FixedPatchStepper.__init__`; `lbm/blocks.py` `static_circle_tables` |
| Refinement storage | blocked on every level: `level_blocks` is required, one tile `(by, bx)` per level dividing that level; a whole-level tile (`(ny, nx)` at the root, the fine window shape for one rectangle) always divides. Dense and mixed dense/blocked storage were removed (parity of D/D, D/B and B/B recorded at 1573942 and 2469af7) | `lbm/refinement_stepper.py` `_check_levels`, `FixedPatchStepper.__init__` |
Tests: `tests/unit/test_refinement_mrt.py`, `tests/validation/multigrid/test_refinement_mrt_gpu.py`,
`tests/unit/test_shifted_storage.py`, `tests/unit/test_shifted_block_operators.py`.
## Refinement hierarchy (`FixedPatchStepper(cfg, *, context, refinements, ...)`)
| Setting | Supported | Guard |
|---|---|---|
| Depth | at least one refinement level; one entry per level in `level_blocks`/`level_objects`/`level_jets` | `lbm/refinement_stepper.py` `_check_levels` |
| Single L1 rectangle | two-coarse-cell margin to the domain edge | `lbm/refinement.py` `FixedPatch2D.from_config` |
| Several L1 rectangles (union) | require `level_blocks[0]` and `level_blocks[1]`; transfers must close (dependency checks, no fixed gap rule) | `lbm/refinement_stepper.py` `_check_levels`; `lbm/refinement.py` `_check_parent_pull_closure` |
| Nested levels | depth >= 3 must be all blocked; a blocked level requires blocked levels below; a scalar child needs a scalar parent | `lbm/refinement_stepper.py` `_check_levels`; `lbm/hierarchy.py` `build_hierarchy` |
| Nested transfer support | child only over inherited ACTIVE cells, donors/receivers uncovered ACTIVE, no child over the parent restriction footprint | `lbm/refinement.py` `_nested_kind` |
| Tile shape | positive `(by, bx)`; the level allocation must be divisible by the tile (no partial tiles yet) | `lbm/refinement_stepper.py` `_check_levels`, `lbm/refinement.py` `FixedPatchSet2D.fine_layout` (shape); `lbm/blocks.py` `BlockLayout2D.build` (divisibility) |
| Coarse boundary | `periodic`, `slot_reservoir` or `channel` | `lbm/refinement_stepper.py` `FixedPatchStepper.__init__` |
| `slot_reservoir` | on-site velocity bottom slot, reservoir top, velocity 0, 2 <= slot width < nx, patch rows 4..ny-4 | `lbm/refinement_stepper.py` `FixedPatchStepper.__init__`; `lbm/refinement.py` `prepare_fine_boundary` |
| `channel` | all levels blocked; Zou-He inlet, pressure-NEQ outlet, stationary bounce-back walls; transfers away from the closure | `lbm/channel.py` `validate_channel_config`, `validate_channel_transfers` |
| Explosion | `constant` (default); `projected_linear` only periodic, all-blocked SRT, no bodies/Jets, no LES (experimental) | `lbm/refinement_stepper.py` `FixedPatchStepper.__init__` |
| L2 runtime remap (`remap_finest`) | periodic three-level L2 sets only, no L0/L1 bodies, identical pinned boundary | `lbm/refinement_stepper.py` `FixedPatchStepper.remap_finest` |
Tests: `tests/unit/test_refinement_three_level_multipatch.py`, `tests/validation/multigrid/test_refinement_multipatch.py`,
`test_refinement_three_level.py`, `test_refinement_three_level_multipatch_gpu.py`, `test_refinement_blocked_base.py`,
`test_refinement_blocked_fine.py`, `test_channel_root_gpu.py`, `tests/unit/test_channel_root.py`,
`tests/unit/test_projected_pair_stepper.py`, `test_runtime_child_remap_gpu.py`, `test_pinned_body_remap_gpu.py`.
## Bodies and Jets on refined levels
| Setting | Supported | Guard |
|---|---|---|
| Bodies | stationary circles without sensor/force-region roles, entirely inside the patch union, fully resolved, disjoint, stencil away from the two-hop transfer footprint | `lbm/refinement.py` `prepare_fine_boundary` |
| Level | not on L0; only on the finest level of a nested hierarchy | `lbm/refinement_stepper.py` `_check_levels` |
| Jet condition | velocity only; `pressure` is reserved and raises NotImplementedError | `body/jets.py` `Jet.__post_init__` |
| Jet geometry | each Jet selects resolved links of exactly one stationary body; no Jet/Jet or Jet/SurfaceActuation overlap; abs(amplitude) < 1 lattice | `body/jets.py` `Jet.__post_init__`, `jet_link_shapes`, `_surface_links` |
| Jets with shifted storage | only in the all-blocked stepper with SRT and no LES | `body/jets.py` `_surface_links` |
| SurfaceActuation | unshifted storage only | `body/objects.py` `SimObject.validate_surface_actuation` |
| Raw boundary exchange (force) | blocked body levels only; the window ledger requires `observe_raw_exchange` | `lbm/refinement_stepper.py` `FixedPatchStepper.__init__` |
| Checkpoint with Jets | refused | `simulation.py` `Simulation.save_checkpoint` |
Raw exchange is the cut-link momentum reaction (wall + aperture), not a validated
nozzle thrust or stress load. Jet amplitude is a lattice velocity, not a flow rate.
Tests: `tests/unit/test_jets.py`, `test_jets_review.py`, `test_shifted_jets.py`, `test_raw_exchange.py`,
`tests/validation/multigrid/test_refinement_jets.py`, `test_shifted_jets_gpu.py`, `test_raw_exchange_gpu.py`,
`test_exchange_window.py`, `test_fine_surface_gpu.py`.
## Observations from specific tested cases (not general bounds)
- SRT channel root vs uniform OneStep, strict-parity test
`tests/validation/multigrid/test_channel_root_gpu.py::ChannelRootGPU::test_uniform_matched_root`:
in one of its 18 sub-cases (2 storage modes x 3 profile/clamp/damp settings x 3 blocks; which
one is not recorded), 1 of the 4320 compared values differs, max abs 2.33e-10, max rel 1.77e-7
(about 2 ULP). The test remains failed; its cause and any general bound are unresolved.
- MRT root parity test
`tests/validation/multigrid/test_refinement_mrt_gpu.py::RefinementMRTGPU::test_uniform_matched_mrt_root`
(12 sub-cases: 2 storage modes x 2 profiles x 3 blocks, default outlet settings) passed bitwise
in the Horsea focused GPU run at K1 7813334 (2026-10-21).
- Periodic Taylor-Green Re 100, measured at 36ffc8b1 (V3 matched pair, N128 depth 2):
every per-sample screen passed; the constant explosion's aggregate worst velocity L2,
0.004418, exceeds its preregistered 0.002 budget, while projected_linear passed that budget.
- Curved-wall pressure (pilot harness, exact-q analytic annulus, tau 0.8): the coarse swirl r1
case exceeded its terminal density-gain cap (1.105e-4 vs 1e-4), so the frozen 9-case matrix
is NOT_ESTABLISHED. No validated fix exists. This is a pilot harness, not production code.
- DFG 2D-1 Re 20 (V4, SRT, t* 60, pressure-NEQ outlet and local Zou-He inlet): refined and uniform
runs were NOT_STEADY under the frozen steady rule, so no interval verdict was given.
## Moving circles (dense `Simulation`, uniform grid only)
Translating circles with optional spin are supported on the dense double
buffer D2Q9 FP32 path only (not on `FixedPatchStepper` or refined levels).
API, supported/rejected modes and limits: `docs/moving_bodies.md`; stage-1
evidence: `docs/validation/moving_bodies_stage1.md`.
## Not supported in this release
3D, dynamic/automatic AMR (tagging, time-varying hierarchies beyond `remap_finest`),
moving bodies on refined levels, pressure Jets, Jet checkpoints, LES or TRT on refined
levels, partial (non-dividing) tiles.
+29 -41
View File
@@ -1,15 +1,11 @@
# Synchronized leaf G remap and manual continuation
This is a CPU state-transfer component, **not a runtime adaptive solver**.
It changes only an L2 rectangle union in a three-level periodic hierarchy.
The body-free case is shown first; stationary L2 bodies/Jets follow the bounded
support contract below. L0/L1 topology, relaxation parameters, direction order,
It changes only a separated L2 rectangle set in a three-level, body-free,
periodic hierarchy. L0/L1 topology, relaxation parameters, direction order,
physical frame and completed time remain unchanged. Existing geometry checks
validate child support/nesting and actual transfer dependencies, without a fixed
rectangle-gap rule. The three-level remap limit is unchanged by the stepper's
arbitrary-depth level-list support. The CPU helper itself adds no CUDA,
scheduler, allocator, boundary handling, collision or ghost refill. A bounded
runtime adapter is described below.
validate child support/nesting and separation. The CPU helper itself adds no CUDA, scheduler, allocator, boundary handling,
collision or ghost refill. A bounded runtime adapter is described below.
```python
from CelerisLab.lbm.refinement import FixedPatchSet2D
@@ -33,13 +29,11 @@ origin is retained. Physical lower edges are `origin + spacing*(x,y)` on L0,
`origin + spacing*(parent.fine_origin + (x,y)/4)` on L2.
Factory options `coarse_boundary` and `objects` must declare `"periodic"` and
an empty collection; L2 bodies use `objects` as described below.
LBMConfig's channel settings do not select this operator's boundary mode.
Geometry metadata is factory-created;
an empty body collection (all levels). LBMConfig's channel settings do not
select this operator's boundary mode. Geometry metadata is factory-created;
manual mutation/forging of metadata is unsupported. Caller certifies the
actual state matches the declared boundary and has no forcing, that all device
work and old readers are quiescent, and that inputs are the **current owned-leaf
postcollision G**.
actual state has no bodies/forcing, that all device work and old readers are
quiescent, and that inputs are the **current owned-leaf postcollision G**.
Count/phase stamps check consistency, not freshness or active-buffer identity.
`current_g` is three NumPy Q-major arrays `(9,ny,nx)`. For blocked inputs,
@@ -77,12 +71,11 @@ PYTHONPATH=src python -m unittest discover -s tests/unit -p 'test_synchronized_r
## Explicit runtime continuation
`replacement = current.remap_finest(rectangles)` returns a new ready
`replacement = current.remap_child(rectangles)` returns a new ready
`FixedPatchStepper` at the **same absolute step_count**. It supports only
periodic three-level runs with no L0/L1 bodies and an existing L2
`FixedPatchSet2D`; stationary L2 bodies/Jets follow the support contract below.
Scalar or union L1 parents are supported; scalar `child=` is deliberately
rejected. L0/L1, relaxation and block sizes stay fixed.
body-free periodic three-level runs whose existing L2 uses
`child_rectangles_in_parent`. Scalar or union L1 parents are supported; scalar
`child=` is deliberately rejected. L0/L1, relaxation and block sizes stay fixed.
No tagger, scheduler, in-place replacement, or general rollback is provided.
The method holds the stepper lock, validates geometry, synchronizes its stream,
@@ -99,7 +92,7 @@ resources; an actual old-device synchronization/read failure marks the old
stepper failed, consistent with its existing failure semantics.
```python
replacement = current.remap_finest([(20, 16, 36, 40)])
replacement = current.remap_child([(20, 16, 36, 40)])
# Explicitly choose the returned continuation only after success.
current.close()
current = replacement
@@ -119,25 +112,24 @@ Per-population conservation is not an advancement target under collision.
### Bounded pinned-body extension
`replacement = current.remap_finest(rectangles, objects=original_objects)`
also accepts stationary Circles inside the L2 union. Each body's **physical
boundary, complete BFL support and current per-link velocity must remain
identical**. Rectangles are not pinned: they may be split, merged or resized,
and other fluid coverage may change, including within the same connected union,
provided that physical support is unchanged. Existing nesting, union transfer
dependencies, full-circle containment, reverse cut-link
coverage and two-hop BFL transfer-footprint checks still apply. This is manual
remapping, not adaptive body tracking or runtime body editing.
`replacement = current.remap_child(rectangles, child_objects=original_objects)`
also accepts stationary Circles in separated L2 rectangles. Every body’s
**complete containing rectangle must remain identical**. Several bodies may
share one pinned rectangle; there need not be any fluid-only rectangles.
Separated fluid-only rectangles may be added, removed or moved. Existing
nesting, four-cell gap, full-circle containment, reverse cut-link coverage and two-hop BFL
transfer-footprint checks still apply. This is manual remapping, not adaptive
body tracking or runtime body editing.
Supply all original root edge-frame definitions in their construction order.
Do not pass `current.boundaries[2].objects`: those are projected fine-local copies.
Do not pass `current.child_boundary.objects`: those are projected fine-local copies.
There is no retained mutable original-object registry. Before reading device
state, remapping reprojects the supplied definition onto the old topology and
compares exact ordered circle centers/radii, flags, physical cut links, body
IDs, q, lever arms, hits, BFL support,
legacy SurfaceActuation specification and current per-link velocity against
the existing boundary. Omission, changed geometry/actuation or changed physical
boundary/BFL support fail without device reads or allocations; reordered bodies fail
the existing boundary. Omission, changed geometry/actuation and changed body
rectangle fail without device reads or allocations; reordered bodies fail
too. Root object IDs are still assigned by list position, as in the constructor.
Jets are taken from the live L2 boundary, including the most recent successful
@@ -149,16 +141,12 @@ boundary and velocities must match exactly.
typed `FineBoundary2D`; it derives L2 fluid leaves as ACTIVE minus SOLID. No
caller mask is accepted. Solids, ghosts and holes are never donors and remain
NaN, physical-fluid overlap is exact, and exposed L1 still restricts all four
old fluid children. The helper checks old/new physical boundary and velocity
equality too. Its immutable `pinned_rectangles` is legacy containment metadata,
not a remap eligibility constraint; it may be empty for a seam-spanning body.
Scalar-parent window offsets remain part of the coordinate conversion.
old fluid children. The helper checks the old/new body pins and boundary too.
Its immutable `pinned_rectangles` is a canonical, deduplicated tuple, empty for body-free layouts; rectangle input
order does not affect it. Scalar-parent window offsets remain part of the
coordinate conversion.
Focused CPU mocks: `tests/unit/test_pinned_body_remap.py`.
`tests/unit/test_union_body_support.py` also checks seam-spanning circles,
rectangle repartition/resize, unchanged physical support and live-Jet
preservation. These host checks do not establish GPU evolution accuracy.
GPU declarations:
Focused CPU mocks: `tests/unit/test_pinned_body_remap.py`. GPU declarations:
`tests/validation/multigrid/test_pinned_body_remap_gpu.py`, covering no-op
next-step bitwise identity, moved-fluid A→B→A, current-amplitude preservation,
physical boundary/velocity equality, source independence and absolute counts.
-49
View File
@@ -1,49 +0,0 @@
# DFG 2D-1 (Re 20) against the official reference intervals
`tests/validation/run_dfg_re20.py` runs the steady DFG 2D-1 case on the refined
channel root (D40 root, D80 wide fine patch) and on the uniform D80 grid. Geometry,
inflow, initial state, layout and observation are those of the pinned Re100 runner
(`run_refined_dfg.py`, `re100_helpers.py`, both unchanged); only the lattice
viscosity is set for Re 20 (root tau 0.56, fine 0.62, uniform 0.62, lattice speed 0.01).
Label: "compared against the official reference bands", not a full benchmark
certification: the outlet is pressure-NEQ (`neq_extrap`), not do-nothing, and the
inlet is local Zou-He.
## Quantities
- Cd = 2 Fx / (rho U^2 D), Cl = 2 Fy / (rho U^2 D), interval means over each sample.
- p_diff = p(0.15, 0.2) - p(0.25, 0.2), linear extrapolation to the exact surface
points (`re100_helpers.pressure_probes`). The helper returns dp / (rho U^2) in
scaled units; p_diff = dp * 0.04 for U = 0.2 m/s, rho = 1. Both are reported.
- Official intervals (Schaefer & Turek 1996): Cd [5.57, 5.59], Cl [0.0104, 0.0110],
p_diff [0.1172, 0.1176].
## Bounds and steady rule (frozen before any run)
- End time t* = 60 (30 s physical): 240,000 refined root steps, 480,000 uniform steps.
- One sample every t* = 0.5 (0.25 s) at the end of a synchronized root step; Cd and
Cl are interval means. Trailing window t* 50..60 inclusive (21 samples).
- Steady rule (our engineering choice, not official): in the window, for each of Cd,
Cl, p_diff, max - min <= 10 % and |least-squares slope x window| <= 2 % of its
official interval width (range 2e-3 / 6e-5 / 4e-5; slope 4e-4 / 1.2e-5 / 8e-6).
Absolute limits, so the small Cl is never a divisor. Never relaxed after a run.
- The verdict uses the window mean; the distance to the nearest interval edge is
reported for each quantity (negative outside).
- Force sign: fluid on cylinder, x along the inflow, y up from the bottom wall; Cd > 0
and the official Cl > 0 (`cd_positive` is reported).
- Not steady at t* = 60: status `NOT_STEADY`, no interval verdict. Health limits
(Mach <= 0.2, rho in [0.5, 1.5]) stop the run with `UNHEALTHY`.
- Steady: `WITHIN_INTERVALS` or `OUTSIDE_INTERVALS`, with the per-quantity verdict.
## Run
```
python tests/validation/run_dfg_re20.py --output <new-dir> --plan
CUDA_VISIBLE_DEVICES=<n> python tests/validation/run_dfg_re20.py --case both \
--collision SRT --output <new-dir> --max-wall-seconds <cap>
```
The uniform D80 control separates method error from refinement error. `--plan` writes `plan.json` only. A GPU run writes `plan.json` and `result.json`
(per case: lattice parameters, every sample, window ranges, status, verdict).
Host contract tests: `tests/unit/test_dfg_re20.py`.
@@ -1,242 +0,0 @@
# Prescribed in-line oscillation: Dütsch Re 100 / KC 5 — PROTOCOL (review draft)
Production under test: `claude/moving-bodies-s3a` at `653fd997` (unchanged).
Primary source: Dütsch, Durst, Becker & Lienhart, J. Fluid Mech. 360, 249–271
(1998), doi:10.1017/S002211209800860X. Source facts below were verified from
the paper (Slack C0C78UFKGRM/1793335557.460509, TRON 1793356317–1793356724,
page numbers given). Everything marked ENGINEERING is our a-priori choice,
fixed before any benchmark result; it is not a paper uncertainty.
## 0. Source facts (with provenance)
- Motion (p.251): `x_c = -A sin(2 pi f t)`; paper phase phi = 0 is the mean
position moving left at `-U_max`. KC = 5 gives `A/D = 5/(2 pi) = .7957747`.
- Start (p.254): the computation starts a quarter cycle before phi = 0, at
`x = +A` with zero velocity (phi = -90°).
- Experiment (pp.252, 254–256): D = 10 mm, L = .65 m, tank 1.2 × .7 × .7 m,
rho = 998.2 kg/m³, mu = 1.00818 mPa·s, Re = 100, KC = 5; LDA with 1° phase
bins and 200 samples per node and degree; no quantified experimental error
recovered.
- Profiles (Fig. 8, p.260): LAB-FIXED stations x1 = -6, 0, 6, 12 mm, i.e.
x/D = -.6, 0, .6, 1.2, at phi = 180°, 210°, 330°; u1 and u2 versus x2.
- Original numerics (§2): cylinder-fixed accelerated frame (`x_i = x~_i +
x_i,s`, outer velocity `-u_c`, inertial source `-rho du_c/dt`), O-grid outer
diameter 120 D, illustrated 192 circumferential nodes, 720 steps/cycle; tank
influence neglected.
- Force fit (verified): force ON THE BODY per unit span
`F1 = -0.5 rho D c_d xdot|xdot| - (pi/4) rho D² c_i xddot` with `x = -A sin phi`;
normalized by `0.5 rho D U_max²`: `c_d cos phi |cos phi| - (pi² c_i / KC) sin phi`.
`c_i` is the ADDED-MASS coefficient; total inertia `c_m = c_i + 1` is a
different convention and is not used.
- Table 1 computed Morison coefficients (c_d, c_i) for 96 / 192 / 384 perimeter
nodes at dt/T = 1/720: (2.15, 1.42), (2.10, 1.45), (2.09, 1.45). These are
fitted computed coefficients, not a measured force and not an error bar. The
paper states the coarse direct force is closer to the finest direct force
than the Morison fit, but gives no waveform-error norm.
## 1. Kinematics, time levels and phase bookkeeping
- Our run is in the LAB frame: the circle moves, the fluid is initially at rest
(rho = 1, u = 0); no fictitious inertial source is added. Lab-fixed profile
stations are compared directly (no frame transform needed).
- Geometry reference: the circle's base centre is the INITIAL position, the
right turning point `x_mean + A`; the declared offset box is x in [-2A, 0],
y = 0. The body is never teleported: offset 0 at s = 0.
- Elapsed time s = n dt from the start. Analytic position relative to the mean
`x(s) = A cos(2 pi s / T)`, velocity `u(s) = -U_max sin(2 pi s / T)`; paper
phase `phi = (360 s / T - 90) mod 360`.
- Step n moves from s_n to s_{n+1}: target offset `x(s_{n+1}) - A`; wall
velocity = the single-step interval average `(x(s_{n+1}) - x(s_n)) / dt`
(analytic, supplied, not differenced from stored offsets). The per-step
force of `run(1)` / `read_force(normalize=True)` is attributed to that same
interval.
- Paper cycle k = 1, 2, ... spans `s in [T/4 + (k-1) T, T/4 + k T)`; the
initial quarter cycle (s < T/4) is cycle 0 and never scored. Every logged
sample carries (k, phi). Example: phi = 330° of cycle 1 is at s = 7T/6.
- T is chosen as a multiple of 360 native steps, so phi = 180°, 210°, 330° fall
exactly on step boundaries and 1° bins hold whole steps.
## 2. Resolution ladder (uniform grid, fixed Mach), ENGINEERING
| D (cells) | T (steps) | U_max = 5 D / T | nu_lat = U_max D / 100 | tau (3 nu + .5) |
|---|---|---|---|---|
| 20 | 2,520 | .039683 | .0079365 | .5238 |
| 40 | 5,040 | .039683 | .015873 | .5476 |
| 80 | 10,080 | .039683 | .031746 | .5952 |
- MRT collision. Mach fixed (Ma ≈ .0687); a separate Mach check at D = 40
with T = 10,080 (U_max halved, tau .5238) is labelled MACH and not part of the
ladder.
- Per-step displacement ≤ U_max ≈ .04 cells (bound .5).
## 3. Domain and boundaries (finite-domain reproduction), ENGINEERING
The paper's 120 D O-grid is not reproduced; our rectangular box is a
reproduction choice. The native dense channel (zero-velocity inlet,
pressure-NEQ outlet, bounce-back y walls) is a finite-domain approximation,
not infinite still fluid. The G4 x-periodic / y-slip override is a separately
labelled sensitivity family and never mixed into the primary ladder. The
runner owner confirms the zero-velocity inlet is supported as configured
before any run.
Order (closed):
1. Smoke + ledger at D = 40, native BC, 40 D × 40 D (finite box).
2. Domain sensitivity before any literature statement: same BC, D = 40,
L ∈ {20 D, 40 D, 60 D}; record the change in Fx waveform, c_d, c_i and the
scored profiles.
3. Optional labelled BC sensitivity: one x-periodic / y-slip case at D = 40,
40 D; reported as a delta against native only.
4. Only after step 2 passes: the D = 20 / 40 / 80 ladder on the chosen BC and
domain.
Domain criteria (a-priori, ENGINEERING; separate from the Table 1 bands): the
reference is 60 D at D = 40; 20 D and 40 D are compared with it on the common
window (same cycle indices) and the common valid point set:
- `RMS(Fx_L - Fx_60D) / F_ref ≤ .01` over the window (per-step waveforms);
- `|c_d,L - c_d,60D| ≤ .02` and `|c_i,L - c_i,60D| ≤ .02` (window fits);
- `max(|u_L - u_60D|, |v_L - v_60D|)` over common points `/ U_max ≤ .02`.
The chosen domain is the smallest of {20 D, 40 D} passing all three; if
neither passes, the domain verdict is FAIL and the ladder is not run.
- Memory/runtime: measured by a smoke run before each new size; measurements
inform scheduling only, never tolerances.
## 4. Observables (raw, per step, preserved)
- `obs` Fx, Fy (CME) and all `MovingTables.DIAG` columns per step: CME,
CME + Wen and CME + cover − uncover reported separately; crossings, eq_only,
no_donor (no_donor must stay 0).
- Velocity u1, u2 on lab-fixed lines x/D = -.6, 0, .6, 1.2 (relative to the
mean cylinder position) versus x2, at the scored phases. Lab frame
throughout; if any body-frame intermediate is used, position and velocity
are both transformed.
- Sampling cost: raw force and diagnostics counters are read every step
(small). Flags and velocity fields are read only at the steps inside the
three prescribed 1° phase bins (φ ∈ [180, 181), [210, 211), [330, 331),
T/360 steps each) of the cycles needed for settling and scoring, plus
sparse declared audits; never every step.
- Point set: fixed x2/D sample points per station, identical on every grid
and declared before running; per point the interpolation weights are
recorded; the scored set is the common subset valid on all compared grids
at that phase, with its coverage recorded; point sets never change silently.
- Interpolation: bilinear on cell centres using only cells that are fluid at
that step (flags read back via `get_flags()`); weights renormalized over the
fluid corners; a sample with no fluid corner, or inside the circle at that
step, is MISSING and logged, never set to zero. One-degree bins keep valid
fluid weights. The numerical average need not mimic the 200 LDA samples.
## 5. Periodic-regime gate (every case, before any statistic), ENGINEERING
Ledger rows, same schema for every case: dx, dt (in D and T units); U_max, nu,
Re, KC (lattice and nondimensional); T (native steps, nondimensional);
duration (native steps, s/T, paper cycles); discard; accepted window; cycle
ledger; verdict PASS or FAIL-CLOSED.
Scales: `F_ref = 0.5 rho U_max² D`, `U_ref = U_max`; never a near-zero mean
force as denominator.
- Settling metrics, cycle k−1 → k, phase-aligned by step within the cycle
(error budget: one fifth of the tightest comparison band of the same
observable, so residual drift cannot obscure a domain or resolution pass):
- waveform `RMS(dFx) / F_ref ≤ .002`;
- `|d mean|`, `|d RMS|`, `|d peak|` of Fx, each `/ F_ref ≤ .002`;
- scored profiles (fluid-only) `|du| / U_max`, `|dv| / U_max ≤ .004` at the
three phases × four stations;
- Morison `|dc_d| ≤ .004`, `|dc_i| ≤ .004`.
- Conservative comparison: every domain and cross-resolution criterion is
applied to `|difference| + drift_a + drift_b`, where drift is the largest
pair metric of that case inside the scored window.
- These constants (protocol.py SETTLE, DOMAIN, CROSS) are frozen before any
physical result; no post-result loosening.
- Rule: all metrics for N = 3 consecutive whole paper cycles → settled.
Cycle indexing (worked example, binding). Paper cycle k spans
`s/T in [0.25 + (k-1), 0.25 + k)`, i.e. φ0 → φ0; cycle 0 is the startup
quarter `s/T < 0.25`. Pair p_k compares cycle k−1 with cycle k.
- Discarded: cycles 0, 1, 2 (s/T < 2.25). Pairs are formed only between
cycles ≥ 3, so the first pair is p_4 (3 → 4).
- Settling proof for a case: three consecutive pairs all pass; K_case = the
first cycle AFTER the last of those pairs. Earliest possible: pairs 3→4,
4→5, 5→6 pass ⇒ K_case = 7.
- Common window: K = max over compared cases of K_case; every case scores the
same three complete cycles K, K+1, K+2 and must stay settled continuously:
every pair p_j for j = its own K_case .. K+2 must pass (including the wait
between its local settle and the common K), otherwise that case is
FAIL-CLOSED.
- Cap: cycle k ends at s/T = k + 0.25, so the last admissible scored cycle is
11 (ends 11.25 T) and K ≤ 9. Examples: K = 7 → cycles 7–9 (ends 9.25 T);
K = 9 → cycles 9–11 (ends 11.25 T); any K_case > 9 → FAIL-CLOSED.
- Run length: cases run sequentially; each runs to the end of cycle 11
(11.25 T: 28,350 / 56,700 / 113,400 steps for D = 20 / 40 / 80; the 12 T
cap is 30,240 / 60,480 / 120,960) and keeps its scalar and profile data, then
the common K is chosen. An unscorable partial cycle 12 is not run.
- Profiles: an empty common valid point set is FAIL-CLOSED; coverage, missing
points, bin time weights (equal per step) and the original fluid corner
weight sums are reported.
- The earlier G4 timing check (100 coarse vs 400 diffusive-fine steps) was a
matched short transient, not periodic evidence.
## 6. Metrics and per-observable criteria (ENGINEERING, not paper uncertainty)
Three criteria stay separate; the G4 same-grid 2% target is not reused.
- Morison fit: least squares of the normalized per-step force over the
accepted window against the two basis functions, each averaged over its
single step interval, fixed paper phase, no free phase shift; c_d and
added-mass c_i per force candidate.
- Cross-resolution: every scored observable's error against its reference must
decrease monotonically D20 → D40 → D80 (necessary, not sufficient).
- Against Table 1 (computed fit, not measured force): at D ≥ 40,
`|c_d - 2.09| ≤ .15` and `|c_i - 1.45| ≤ .15`, and monotone.
- Force waveform against its own Morison reconstruction: residual reported
only, no pass/fail (the paper reports fit error above its coarse numerical
error and gives no percentage).
- Fig. 8 LDA profiles: QUALITATIVE ONLY. The original figure could not be
accessed or rendered; no digitized ordinates and no measurement uncertainty
exist. After a digitization with figure number, tool and uncertainty note:
`RMS(|du|, |dv|) / U_max ≤ .15` at D ≥ 40 and monotone; LDA error stays
UNQUANTIFIED and is stated with every comparison.
- Fy sanity (not literature): `|mean Fy| / F_ref ≤ .05` in the accepted window.
- No force candidate is selected for an RHS by these results; each is
reported.
Superseded (earlier drafts): the velocity-jump start, guessed stations and
phases, "C_M" for c_i, and the uncertainty wording; cycles 4–6, the 30-cycle
cap and the earlier tolerance numbers.
## 6b. Amendment: duration-only diagnostic (declared before the run)
- Background: the D40 domain stage (20 D / 40 D / 60 D, 11.25 T each) failed
settling in every box (no window). That result stands.
- One declared diagnostic: D = 40, 60 D box, from rest, through cycle 31
(157,500 steps = 31.25 T). Physics, settling tolerances, bands and
production unchanged; three proof pairs plus a continuously settled
K..K+2 with K ≤ 29.
- Invocation: `run.py --D 40 --box 60 --cycles 31` then
`analyze.py --horizon 31`; the JSON records `horizon` and `horizon_mode`.
Without `--horizon` the analyzer is the original cap-11 scoring.
- Failure at 31.25 T: report and stop; no automatic extension, matrix, D80
or VIV. A pass shows only that this box settles with time; it is not a
domain or literature pass.
- The phase-locked high-frequency force component (residual of the mean
waveform after its Morison reconstruction, above 20/T) is reported per case
as unqualified; it needs separate qualification before any Morison or
literature statement.
## 6a. Driver unit tests (CPU, before any run)
- Phase/cycle arithmetic: s ↔ (k, φ), cycle boundaries, bin membership,
window/cap selection for K = 7, 8, 9, 10.
- Solid-aware interpolation: all-fluid, partial-solid (renormalized weights),
no-fluid (MISSING) and inside-circle cases.
- Interval-averaged Morison bases and the two-parameter fit on a synthetic
waveform with known c_d, c_i.
## 7. Order and scope
Protocol review → one-shot Dütsch driver (analytic `set_body` + `run(1)`,
cycle ledger, lab-fixed fluid-only station sampler, two-basis Morison fit; no
reusable runner until the first smoke shows shared helpers pay off; no
production change) → smoke (memory/runtime) → domain sensitivity →
ladder → physical-validation verdict → only then single-cylinder VIV. No AMR,
no pinball, no manuscript work.
-46
View File
@@ -1,46 +0,0 @@
# Moving circles — stage-1 evidence (development level)
Owner criterion (Yanqi via TRON): prescribed motion and one free VIV case both
"reasonably close" to a predeclared reference = development-stage success.
This is practical closeness, not a formal validation claim. Every number
below is from Astra's receipts as posted in Slack C0C78UFKGRM thread
1793335557.460509; raw outputs stay on Horsea under `/tmp/celeris-results-*`
and the Mac evidence directory (not in git).
## Code
- Production: `src/` identical to `653fd997` (slices S1 `983020b6`,
S2a `b920a303`, S3a `653fd997`). CPU suites and GPU hold/crossing gates
passed on those slices (G0a/G0b population-bitwise; X1 crossing ledger).
- Research drivers: `research/duetsch_re100_kc5/` (`dd37883d`, capture
`a0aea3b9`) and `research/viv_re150/` (`fb507aa1`), run as committed.
## Prescribed in-line oscillation (Dütsch et al. 1998, Re 100, KC 5)
- Protocol: `docs/validation/duetsch_re100_kc5_protocol.md`.
- Frozen settling gate (.002 F_ref waveform): **FAILED** and kept on record:
D40 domain stage (20/40/60 D, 11.25 T) and the 60 D duration diagnostic
(31.25 T, 0/28 pairs; last waveform drift .00613 F_ref).
- Unscored late-cycle CME fits c_d ≈ 2.02–2.05, c_i ≈ 1.46–1.48 vs the
paper's computed (2.09, 1.45); fields clean and symmetric (owner view).
The owner accepts ~0.61 % cycle drift at D40 for this stage; the gate is
not re-scored.
- Fig. 8 LDA profiles: qualitative only (no digitized ordinates).
## Free transverse VIV (Kolahdouz §4.1.1 setup, Qin §4.5 target)
- Predeclaration: `docs/validation/viv_re150_predeclare.md` (agreed at
`1abfe954` under its former name `leontini_viv_predeclare.md`); run at `fb507aa1`, single case, D = 40, 75 D × 60 D.
- Result: status `scored`, 336,384 steps (t U/D = 336.4); A_max = .5464 D vs
.520 D (+5.1 %, inside the declared ±10 % band); mean half peak-to-peak
.5448 D; f/f_n = .955; block drifts .78/.29/.18 %, last-five spreads
.15/.11/.10 %; finite, no_donor = 0, no seed; 337.7 s, ~1.0 ms/step.
## Limitations (binding)
- One resolution (D40) per case; no grid or time convergence.
- Structural RHS = CME + Wen is a theory-motivated stage choice; CME-only
and node-impulse variants were not run as RHS; no force law is validated.
- Boundary mismatches: NEQ outlet with backflow clamp vs traction outlet;
finite box; the strict Dütsch settling failure is unexplained.
- No mass correction at curved walls; ~1 ms/step host-coupled overhead.
-59
View File
@@ -56,62 +56,3 @@ Root must independently review and admit any execution. An admitted paired pilot
the existing watchdog, device selection, `--gpu-gates-approved`, positive
`--max-wall-seconds` and a new output directory, adding only `--exchange-window` to the
selected collision's pilot command. No GPU or remote action was performed for this patch.
## Public Jet diagnostic option
`--collision MRT --exchange-window --jet-scenario zero|fixed|reverse` selects
unshifted FP32, no LES, constant transfer on both existing public backends. Default
`none` and the historical manifests remain unchanged. `zero` registers both Jets at
zero amplitude and retains every existing DFG verdict, including pressure. `fixed`
uses outward radial parabolic sectors at pi/2 and 3pi/2, half-width pi/12, peaks
(+.001,-.001). Both use the existing D40/D80 geometry and U=.01. Sector origins are
(80.5,81) in root edge coordinates and (160,160.5) in uniform node coordinates;
radial extent is [0,1.005R]. `reverse` keeps those peaks through t*=300, updates both
public setters at that completed boundary, and continues to 305 with opposite signs.
Fixed diagnostic windows are 250–260, 260–270, 270–280, 280–290 and 290–300; reversal
adds 300–305. Histories retain exact interval endpoints and root/uniform lattice
impulses. The refined window uses total exchange; uniform uses public `read_force`
batch averages. Driven windows report mean/min/max/RMS Cd/Cl without applying DFG
reference or periodicity gates. Exit zero means healthy execution, not qualification;
`matched_case_pass` remains false. No independent CV or stress-load claim is made.
This opt-in records current source/helper hashes with the existing manifest format,
checkout revision/dirty state and runner hash. Pilot/full must match that snapshot
and the exact scenario. It does not relabel current source as historical evidence.
Use the existing bounded plan/pilot/full commands with the three option flags above;
full requires the matching completed t*=5 paired pilot. `step_seconds` includes force
readback; observation/overhead and total wall time are separate, not kernel throughput.
Add `--observe-cv` to record independent scalar control-volume samples from the
already downloaded public macros. The pilot exercises this path at t*=.02..5;
full records 501 endpoints at 290..300. Each `cv_history.jsonl` row contains actual
root time, preceding interval start/duration/impulse, and two inventories/outward
fluxes ordered [mass,Px,Py]. Exclude the first row's preceding impulse: that row
supplies the initial inventory. Uniform native impulse converts by .5² to root
units; refined ledger impulse is already in root units. Explicit native-aligned
contours differ by .25 root cells in x and are recorded in the plan. Cropping
retains the two-cell face-gradient stencils. No fields or per-link arrays are saved.
`cv_observation_seconds` is the CV portion of observation/overhead time. Helper hash
and the observation flag must match pilot/full. Time integration, temporal
coarsening and any physical assessment remain separate offline work.
## Bounded resolution and placement controls
Defaults remain `--case both --uniform-diameter 80 --placement wide`. Single-case
runs use `--case uniform` or `--case refined` and never report a matched-case pass.
Their own reference/periodicity or driven-diagnostic results remain available.
A single zero-Jet full run exits successfully only when its periodicity and the
unchanged complete DFG reference verdict pass; driven completion is not qualification.
The D160 option requires `--case uniform --uniform-diameter 160` and an explicit
public Jet scenario. U and Jet speeds stay .01 and ±.001; viscosity is .016,
grid 3521×658, center (320,320.5), radius80. CV uses root spacing/time factor .25,
origin (.375,.75), impulse factor .0625, root viscosity .004, and x-face shift .125.
`--case refined --placement compact` selects root bounds (48,48,128,128), fine
168×168 and origin (46,46), with the same D40/fineD80 body/Jet resolution. Its CV
contours are (52,52,112,112) and (50,50,120,120); complete face-gradient stencils
are clear of both solids and the transfer footprint. Compact is refined-only.
CV metadata gives each grid's spacing/origin/contours explicitly. Case, uniform
diameter and placement must match the newly completed pilot in every receipt.
-155
View File
@@ -1,155 +0,0 @@
# Single-cylinder free transverse VIV, Re 150 (Kolahdouz/Qin) — PREDECLARATION (agreed at 1abfe954)
(File renamed from `leontini_viv_predeclare.md`; the Leontini Re 200 case is only the queued second case below.)
Stage goal (Yanqi via TRON): prescribed Dütsch case + one VIV case both
"reasonably close" to a predeclared reference = development-stage success, then
cleanup. The frozen Dütsch settling failure (0/28 pairs at 31.25 T) stays on
record. Practical closeness is not a formal validation claim. Agreed by
Claude, Grok and Astra at 1abfe954; implemented in research/viv_re150/.
## Primary case (one case)
- Reference: Qin et al., arXiv:2003.12186, §4.5 (maximum amplitudes at
U_red = 3…8: .075, .551, .520, .459, .370, .084 D in the text, as quoted by
Grok/Astra) for the target; setup from Kolahdouz et al., arXiv:2003.12046,
§4.1.1 (read 2026-10-30 via ar5iv): U* sweep 3–8 with amplitudes only in
Fig. 3(b) (no text values), domain [−30, 45] D × [−30, 30] D with the
cylinder "initially at rest and centered at the origin", uniform inflow on
the left, zero normal traction and tangential velocity at the outlet, zero
normal velocity and tangential traction on y = ±30 D; no initial
perturbation and no total simulated time stated.
- Re = 150, U* = U/(f_n D) = 5, m/(rho D²) = 2 (m* = rho_s/rho_f = 8/pi),
zeta = 0, f_n = sqrt(K/M)/(2 pi) in vacuum. Transverse DOF only.
- Target: A_max = .520 D (peak amplitude, Qin §4.5). Multi-code reference
(Kolahdouz ILE-NS, Qin II-LBM). Reference uncertainty: not stated; the
spread between codes is not quantified here.
## Our reproduction (declared differences)
- D = 40, MRT, dense double buffer; U_lat = .04, nu = U D / 150 = .010667,
tau = .532.
- Nominal 75 D × 60 D lattice allocation (3000 × 2400), based on the
Kolahdouz §4.1.1 layout. Coordinate clarification of the existing run:
the driver uses `(30 D, (ny - 1)/2)` = `(1200, 1199.5)` in lattice
coordinates, or `(30 D, 29.9875 D)`. The earlier `(30 D, 30 D)` wording
described the nominal box, not the exact executed centre.
Free-slip reflection planes are at `y = .5` and `y = ny - 1.5 = 2398.5`;
their separation is 2398 cells = 59.95 D, with the centre exactly midway,
29.975 D from each wall. Node counts are not physical wall-to-wall lengths.
This corrects the description, without changing the historical geometry
or results. Qin's own box ([0, 40 D] × [0, 20 D], centre (10 D, 10 D)) is
the target paper's, not ours.
- Boundaries: uniform Zou–He inlet, NEQ outlet (rho = 1, backflow clamp),
free-slip y walls. Kolahdouz uses a traction outlet: declared mismatch.
- Qin's own VIV box (40 D × 20 D) is not used; a smaller box would be a
declared cost cut with more blockage, not "the same case".
- Allowed y offset ±1.5 D; peak transverse speed ≈ .026 cells/step (bound .5).
## Structure, coupling and time levels
- M = 2 rho D² (lattice rho = 1), f_n = U/(5 D) = 2e-4 per step, K = M (2 pi f_n)²,
zeta = 0. Equation of motion uses the structural mass only.
- Per-step sequence (binding): known (y_n, v_n) and the last completed force
F_last → candidate (y_{n+1}, v_{n+1}) by the update below → `set_body` with
offset y_{n+1} and wall velocity (y_{n+1} − y_n)/dt with lattice dt = 1 (interval average; a
different quantity from the endpoint v_{n+1}) → `run(1)` → only on success:
commit y_n ← y_{n+1}, v_n ← v_{n+1}, F_last ← force read for that step (failure
semantics: see the window section). Whether the
measured force represents the interval's start, midpoint or end is not
asserted; the one-step lag is declared, not corrected.
- Initial state: fluid initialized by `Simulation.initialize()` to the uniform
inlet velocity U everywhere (the code's actual initial condition; the
source gives no fluid initial condition), body at rest at the declared
centre, y_0 = v_0 = 0, F_last = 0.
- Structural update over one step with constant force F = F_last (exact
harmonic solution, zeta = 0): y_e = F/K, w = 2 pi f_n,
y^{n+1} = y_e + (y^n − y_e) cos w + (v^n / w) sin w,
v^{n+1} = −(y^n − y_e) w sin w + v^n cos w.
- Primary RHS (single coupled run): CME + Wen (theory-motivated stage choice:
Galilean-invariant link correction; G4 could not separate it from CME).
Raw CME and the node terms are logged as diagnostics of that trajectory;
the node impulse is not added. A run with CME-only as RHS is a separate,
labelled sensitivity case, decided separately. Neither RHS is claimed proven.
## Window, failure semantics, acceptance (engineering, development stage)
Clocks: lattice dt = 1; T_n = 1/f_n = 5,000 steps. Response cycles are
counted from the measured signal; T_n is used only for the seed check.
- Start: body at rest at the declared centre (the only initial condition the
source states), y_0 = v_0 = 0, F_last = 0; fluid uniform at U (as
initialized by the code; our choice, not the source's).
- Seed (conditional, one time, declared): at step n_seed = 200,000
(t = 40 T_n), if max |y_t| over states t in [175,000, 200,000) is below
.01 D, then
in that step, after the check and before the candidate update, set
v_n += 2 pi f_n (.01 D) (F_last unchanged) and proceed normally; the
geometry moves continuously through the update. `seed_applied`, step and
size are logged; never repeated or changed.
- Cycle boundary (discrete, fixed threshold): step n closes a cycle iff
y[n−1] < 0 and y[n] ≥ 0 (upward crossing of the equilibrium y = 0);
y_0 = 0 is not a crossing; no boundary fewer than 2 samples after the
previous one.
- Per cycle: positive peak max(y), negative peak min(y), mean offset
(time-mean of y), amplitude a = (max − min)/2 (the only amplitude used for
settling).
- Eligibility: a completed cycle is counted (toward the settling proof and
the 150 budget) only if a ≥ .01 D; ineligible cycles are logged, never
counted, so near-zero motion cannot settle or pass.
- Seed reset: if the seed is applied, the incomplete cycle at n_seed is
discarded and the settling proof starts empty; the 150 budget and the
native cap keep running from t = 0.
- Windows: adjacent non-overlapping blocks of 10 counted cycles;
W_k = mean amplitude of block k (W_k ≥ .01 D by eligibility, so no zero
denominator).
- Block rule R(k), k ≥ 2: |W_k − W_{k−1}| / W_{k−1} ≤ .02 and, for the last 5
cycles of block k, max_i |a_i − ā_5| / ā_5 ≤ .01 with ā_5 their mean.
- Settled at the end of block k when R(k) holds. Scoring = the next two
blocks k+1, k+2 (20 counted cycles); both must satisfy R. If one fails,
that block becomes the new reference: the settling proof restarts from it
and continues within the caps (deterministic; no other retry). A pass
requires one complete stable 20-cycle scoring window before a cap; success
stops after its 20th cycle.
- Caps (both always armed; the first hit wins and is reported): 150 counted
response cycles including the 20 scoring cycles (so settling must complete
by counted cycle 130) → `timeout_response_cycles`; 1,000,000 native steps
(200 T_n) → `timeout_native_steps`.
- Durations reported: total steps, t U/D, counted and uncounted cycles,
measured T_resp (mean boundary interval); N_resp / f_n only as a labelled
secondary clock.
- Growth is not a failure: initial VIV growth is physical. Checked every
step (FAIL-CLOSED): non-finite y, v or force; |y| > 1.0 D (the tighter
gate; about twice the largest reference peak, .551 D) or the ±1.5 D offset
box reached; no_donor > 0; a failed upload/step/sync.
- On any failure the driver stops and reports; it never retries,
sub-iterates, retunes the RHS or parameters, or starts a second case.
Device-side failures (upload/step/sync, no_donor) mark the Simulation
failed through the existing lifecycle; input rejection by `set_body`
(offset box, speed, step bound) and timeouts end the driver run without
that mark.
- Target metric over the 20 scoring cycles: A_max/D = max over those cycles
of max(|positive peak|, |negative peak|) about y = 0 (Qin reports "maximum
amplitude"); also reported: mean amplitude, positive and negative peaks,
mean offset, f/f_n (no target).
- Acceptance: |A_max − .520 D| ≤ .052 D (±10 %), stated together with the
boundary mismatch and the absence of a quoted reference uncertainty. A pass
is "reasonably close" for the stage: not grid convergence, not force-RHS
validation, and it does not touch the Dütsch settling record.
- Driver reads force and the small diagnostics per step only; field reads
only at a few predeclared snapshots. No runtime claim: there is no measured
timing on this grid yet (the D40/60D receipt is a different, sampled case).
## Plumbing smoke (not the benchmark)
`run.py --smoke --D 20 --steps 32` runs exactly 32 steps of the same BC
package and coupling loop (no seed, settle or score; never scored);
status `smoke_ok` iff every step is finite, no_donor stays 0 and the body
stays inside its box. The physical case is only the default invocation
(`--D 40`, no `--smoke`).
## Queued, not in this stage
Leontini et al. JFS 22 (2006) Re = 200, m* = 10, zeta = 0.01, U* = 5.0 (needs
the owners' c_crit convention and a Fig. 1 digitization). D80, literature
claims, pinball, AMR, BC redesign, threshold changes.
+13 -15
View File
@@ -1,9 +1,9 @@
"""Public API demo: fixed 2:1 patch with a stationary, optionally actuated circle.
From this checkout, after checking the selected physical GPU is idle:
PYTHONPATH="$PWD/src" CUDA_VISIBLE_DEVICES=0 python examples/fixed_patch_circle.py
... --speed -0.002 --mode slot_reservoir
Both levels use blocked storage (--base-block, --fine-block tiles).
PYTHONPATH="$PWD/src" CUDA_VISIBLE_DEVICES=0 python examples/fixed_patch_circle.py --fine-block 16 20
... --speed -0.002 --fine-block 16 20 --mode slot_reservoir
Omit --fine-block for the (equivalent) dense fine storage.
A short smoke run (default 128 coarse steps): it shows setup, stepping and
fluid-only diagnostics, not a developed flow, accuracy or long-time stability.
@@ -40,13 +40,13 @@ def config(mode):
def leaf_masks(run, mode):
"""Known fluid leaves: uncovered coarse cells (minus wall rows in slot mode)
and fine ACTIVE cells outside the body."""
p = run.hierarchy.pairs[0]
p = run.patch
x0, y0, x1, y1 = p.bounds
coarse = np.ones(p.coarse_shape, bool)
coarse[y0:y1, x0:x1] = False
if mode == "slot_reservoir":
coarse[[0, -1]] = False
solid = (run.boundaries[1].flags.reshape(p.fine_shape) & SOLID) != 0
solid = (run.fine_boundary.flags.reshape(p.fine_shape) & SOLID) != 0
return coarse, (p.fine_kind == ACTIVE) & ~solid
@@ -71,10 +71,8 @@ def main():
parser.add_argument("--mode", choices=("periodic", "slot_reservoir"), default="periodic")
parser.add_argument("--speed", type=float, default=.002,
help="normal surface velocity, coarse lattice units (+ blows, - sucks, 0 passive)")
parser.add_argument("--base-block", type=int, nargs=2, metavar=("BY", "BX"), default=(12, 8),
help="root tile; must divide the 24 x 32 root")
parser.add_argument("--fine-block", type=int, nargs=2, metavar=("BY", "BX"), default=(16, 20),
help="fine tile; must divide the whole fine allocation (32 x 40 here)")
parser.add_argument("--fine-block", type=int, nargs=2, metavar=("BY", "BX"),
help="blocked fine storage; must divide the whole fine allocation (32 x 40 here)")
parser.add_argument("--steps", type=int, default=128)
args = parser.parse_args()
if args.steps < 0:
@@ -88,20 +86,20 @@ def main():
body = SimObject(0, geometry=CircleGeometry(*CENTER, RADIUS), center=CENTER, radius=RADIUS,
surface_actuation=spec)
cfg = config(args.mode)
fine_block = tuple(args.fine_block) if args.fine_block else None
# The caller owns the CUDA context; the stepper must be closed first (the
# nested with-statement does that).
with CudaContext(0) as context, FixedPatchStepper(
cfg, context=context, coarse_boundary=args.mode, refinements=[BOUNDS],
level_blocks=[tuple(args.base_block), tuple(args.fine_block)],
level_objects=[(), (body,)]) as run:
p = run.hierarchy.pairs[0]
cfg, BOUNDS, context=context, coarse_boundary=args.mode,
fine_objects=[body], fine_block=fine_block) as run:
p = run.patch
# Rest equilibrium is already postcollision G, so no initial collision is
# needed. Non-leaf entries (covered, ghost, solid) are discarded by initialize.
rest = np.asarray(compute_feq_d2q9(1.0, 0.0, 0.0), np.float32)[:, None, None]
run.initialize(np.broadcast_to(rest, (9, *p.coarse_shape)),
np.broadcast_to(rest, (9, *p.fine_shape)))
storage = f"blocked root {tuple(args.base_block)} fine {tuple(args.fine_block)}"
print(f"mode={args.mode} storage={storage} speed={args.speed:+g} "
storage = f"blocked {fine_block}" if fine_block else "dense"
print(f"mode={args.mode} fine storage={storage} speed={args.speed:+g} "
f"(fine allocation {p.fine_shape[0]}x{p.fine_shape[1]}, fine origin {p.fine_origin})")
masks = leaf_masks(run, args.mode)
start = leaf_diagnostics(*run.download(), masks)
+4 -7
View File
@@ -31,7 +31,6 @@ from CelerisLab.lbm.refinement import ACTIVE
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
BOUNDS, AMPLITUDE, WIDTH = (16, 4, 32, 28), .02, 12
LEVEL_BLOCKS = [(12, 8), (8, 8)] # blocked storage: 48x48 root, 56x40 fine window
TIMES, MAX_MACH, MAX_SECONDS = (0, 300, 1200), .1, 120
@@ -133,8 +132,7 @@ def main():
lattice_model='D2Q9', collision='SRT', streaming='double_buffer',
store_precision='FP32', ddf_shifting=False, les_enabled=False)
report = dict(config=dict(nx=48, ny=48, bounds=BOUNDS, W=WIDTH, A=AMPLITUDE, nu=.1,
rho=1, tau_coarse=.8, tau_fine=1.1, coarse_boundary='slot_reservoir',
level_blocks=LEVEL_BLOCKS),
rho=1, tau_coarse=.8, tau_fine=1.1, coarse_boundary='slot_reservoir'),
guards=dict(check_every=50, positive_density=True, finite=True,
max_Mach=MAX_MACH, max_seconds=MAX_SECONDS,
timing='cooperative checks, not an exact interruption deadline'),
@@ -150,10 +148,9 @@ def main():
samples=[], status='running', last_synchronized_coarse_step=0)
start, run = time.monotonic(), None
try:
with CudaContext(0) as context, FixedPatchStepper(cfg, context=context,
coarse_boundary='slot_reservoir', refinements=[BOUNDS],
level_blocks=LEVEL_BLOCKS) as run:
p = run.hierarchy.pairs[0]
with CudaContext(0) as context, FixedPatchStepper(cfg, BOUNDS, context=context,
coarse_boundary='slot_reservoir') as run:
p = run.patch
masks = [np.ones(p.coarse_shape, bool), p.fine_kind == ACTIVE]
masks[0][[0, -1]] = False
masks[0][4:28, 16:32] = False
+3 -4
View File
@@ -16,7 +16,7 @@ from CelerisLab.config import load_lbm_config
from CelerisLab.cuda.context import CudaContext
from CelerisLab.lbm.equilibrium import compute_feq_d2q9, compute_macro_from_ddf
from CelerisLab.lbm.refinement import ACTIVE
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
from CelerisLab.lbm.refinement_stepper import PeriodicPatchStepper
def shear_equilibrium(shape, origin, dx, period):
@@ -48,9 +48,8 @@ def main():
nx=16, ny=16, viscosity=.1, velocity=1e-3, rho=1.0,
lattice_model="D2Q9", collision="SRT", streaming="double_buffer",
store_precision="FP32", ddf_shifting=False, les_enabled=False)
with CudaContext(0) as context, FixedPatchStepper(cfg, context=context, refinements=[(4, 4, 12, 12)],
level_blocks=[(16, 16), (24, 24)]) as run:
p = run.hierarchy.pairs[0]
with CudaContext(0) as context, PeriodicPatchStepper(cfg, (4, 4, 12, 12), context=context) as run:
p = run.patch
coarse = shear_equilibrium(p.coarse_shape, (0, 0), 1.0, cfg.ny)
fine = shear_equilibrium(p.fine_shape, p.fine_origin, .5, cfg.ny)
coarse_leaf = np.ones(p.coarse_shape, dtype=bool)
-224
View File
@@ -1,224 +0,0 @@
"""Ledger, settling, common window and Morison fits for Dütsch cases (numpy only).
python analyze.py CASE_DIR [CASE_DIR ...] # cases of one comparison
"""
from __future__ import annotations
import json
import math
import os
import sys
import numpy as np
import protocol as p
def load(path):
with open(os.path.join(path, "case.json")) as fh:
case = json.load(fh)
raw = np.load(os.path.join(path, "raw.npz"))
case.update(force=raw["force"], diag=raw["diag"], samples=raw["samples"], path=path)
return case
def cycle_slices(case):
"""{k: slice of step indices m whose interval [m, m+1] lies in paper cycle k}."""
T, q = case["T"], case["T"] // 4
return {k: slice(q + (k - 1) * T, q + k * T)
for k in range(1, case["cycles"] + 1) if q + k * T <= len(case["force"])}
def coarse_wave(case, k, r=1):
"""Normalized per-step Fx of cycle k, averaged over r native steps per sample."""
f = case["force"][cycle_slices(case)[k], 0] / case["F_ref"]
return f.reshape(-1, r).mean(axis=1)
def profiles(case, k):
"""{(phase, station, x2): (u, v)} of cycle k, time-averaged over the bin steps
(equal weights per step) from valid samples only."""
s = case["samples"]
acc = {}
for _, _, ph, xs, x2, u, v, _ in s[s[:, 1] == k]:
if not (math.isnan(u) or math.isnan(v)):
acc.setdefault((ph, xs, x2), []).append((u, v))
return {key: tuple(np.mean(vals, axis=0)) for key, vals in acc.items()}
def coverage(case, k):
s = case["samples"]
rows = s[s[:, 1] == k]
missing = int(np.isnan(rows[:, 5]).sum())
return dict(samples=len(rows), missing=missing, bin_steps=len(set(rows[:, 0])),
min_fluid_weight=float(np.nanmin(np.where(rows[:, 7] > 0, rows[:, 7], np.nan)))
if len(rows) else math.nan)
def common_points(dicts):
keys = set(dicts[0])
for d in dicts[1:]:
keys &= set(d)
return sorted(keys)
def candidates(case):
"""Per-step Fx of each reported force candidate (none is selected as an RHS)."""
d = case["diag"]
return {"cme": d[:, 0], "cme_wen": d[:, 0] + d[:, 2], "cme_node": d[:, 0] + d[:, 6] - d[:, 9]}
def cycle_morison(case, ks, fx=None):
T = case["T"]
fx = case["force"][:, 0] if fx is None else fx
idx = np.concatenate([np.arange(len(fx))[cycle_slices(case)[k]] for k in ks])
b = np.array([p.interval_bases(int(m), T) for m in idx])
return p.morison_fit(fx[idx] / case["F_ref"], b[:, 0], b[:, 1])
def pair_metrics(case, k):
a, b = coarse_wave(case, k - 1), coarse_wave(case, k)
pa, pb = profiles(case, k - 1), profiles(case, k)
common = common_points([pa, pb]) # empty set -> inf -> pair fails (FAIL-CLOSED)
prof = max((max(abs(pa[c][0] - pb[c][0]), abs(pa[c][1] - pb[c][1])) for c in common),
default=math.inf) / case["u_max"]
(cd0, ci0), (cd1, ci1) = cycle_morison(case, [k - 1]), cycle_morison(case, [k])
m = dict(wave=float(np.sqrt(np.mean((b - a) ** 2))),
scalar=float(max(abs(b.mean() - a.mean()), abs(np.sqrt(np.mean(b * b)) - np.sqrt(np.mean(a * a))),
abs(np.abs(b).max() - np.abs(a).max()))),
profile=float(prof), morison=float(max(abs(cd1 - cd0), abs(ci1 - ci0))),
profile_points=len(common))
m["pass"] = all(m[key] <= tol for key, tol in p.SETTLE.items())
return m
def hf_component(case, ks):
"""Unqualified phase-locked high-frequency force component (reported, not checked):
mean CME waveform over cycles ks minus its two-basis Morison reconstruction,
split at 20/T; cycle differencing does not remove it."""
T = case["T"]
if not ks:
return dict(label="unqualified phase-locked HF force component, not checked", cycles=[])
mean = np.mean([coarse_wave(case, k) for k in ks], axis=0)
c_d, c_i = cycle_morison(case, ks)
q = T // 4
b = np.array([p.interval_bases(q + m, T) for m in range(T)])
resid = mean - (c_d * b[:, 0] - (np.pi ** 2 * c_i / p.KC) * b[:, 1])
spec = np.fft.rfft(resid)
high = spec.copy()
high[:21] = 0.0
energy = one_sided_energy(spec, T)
return dict(label="unqualified phase-locked HF force component, not checked",
cycles=list(ks), hf_rms_over_F_ref=float(np.sqrt(np.mean(np.fft.irfft(high, T) ** 2))),
hf_energy_fraction=float(energy[21:].sum() / max(energy.sum(), 1e-300)),
top_harmonics=[int(j) for j in np.argsort(energy[21:])[::-1][:5] + 21])
def one_sided_energy(spec, n):
"""Parseval-consistent energy per rfft bin: interior bins count twice, DC and
(for even n) Nyquist once."""
w = np.full(len(spec), 2.0)
w[0] = 1.0
if n % 2 == 0:
w[-1] = 1.0
return w * np.abs(spec) ** 2
def ledger(case, horizon=p.LAST_SCORED):
ks = [k for k in sorted(cycle_slices(case)) if k <= horizon]
pairs = {k: pair_metrics(case, k) for k in ks if k - 1 > p.DISCARD}
return pairs, p.settled_start({k: v["pass"] for k, v in pairs.items()})
def main(argv):
"""argv: CASE_DIR ... [--reference CASE_DIR]; the reference defaults to the
largest D (resolution ladder); domain runs name the 60 D case explicitly."""
ref_path = None
if "--reference" in argv:
i = argv.index("--reference")
ref_path, argv = argv[i + 1], argv[:i] + argv[i + 2:]
horizon, explicit = p.LAST_SCORED, "--horizon" in argv # default: original scoring
if explicit:
i = argv.index("--horizon")
horizon, argv = int(argv[i + 1]), argv[:i] + argv[i + 2:]
paths = argv if ref_path is None or ref_path in argv else argv + [ref_path]
cases = [load(path) for path in paths]
for c in cases if explicit else (): # complete cycles 1..N; never pad or shift
if c["cycles"] < horizon or len(c["force"]) < round((0.25 + horizon) * c["T"]):
raise SystemExit(f"{c['path']}: run does not contain complete cycles 1..{horizon}")
ledgers = [ledger(c, horizon) for c in cases]
window = p.common_window([k for _, k in ledgers],
[{k: v["pass"] for k, v in pairs.items()} for pairs, _ in ledgers],
last_scored=horizon)
report = dict(window=window, horizon=horizon, cases=[],
horizon_mode="default-cap" if horizon == p.LAST_SCORED else
"extended duration diagnostic (declared amendment, D40/60D only)")
for case, (pairs, k_case) in zip(cases, ledgers):
limit = round((0.25 + horizon) * case["T"]) # events inside the horizon only
entry = {key: case[key] for key in ("path", "D", "T", "box", "n", "u_max", "nu", "tau", "A",
"steps", "cycles", "F_ref")}
entry.update(duration_T=case["steps"] / case["T"], K_case=k_case, pairs=pairs,
coverage={k: coverage(case, k) for k in sorted(cycle_slices(case))
if p.DISCARD < k <= horizon},
no_donor=float(case["diag"][:limit, 13].sum()),
eq_only=float(case["diag"][:limit, 12].sum()),
hf_component=hf_component(case, window or tuple(
[k for k in sorted(cycle_slices(case)) if p.DISCARD < k <= horizon][-3:])))
if window: # residual drift inside the scored window (pairs K..K+2)
entry["window_drift"] = {key: max(pairs[k][key] for k in window)
for key in ("wave", "profile", "morison")}
entry["morison_obs"] = cycle_morison(case, window)
entry["morison"] = {name: cycle_morison(case, window, fx)
for name, fx in candidates(case).items()}
entry["fy_mean"] = float(np.mean(np.concatenate(
[case["force"][cycle_slices(case)[k], 1] for k in window])) / case["F_ref"])
report["cases"].append(entry)
if window and len(cases) > 1: # cross-resolution waveform against the finest case
fine = (next(c for c in cases if c["path"] == ref_path) if ref_path
else max(cases, key=lambda c: c["D"]))
report["reference"] = fine["path"]
ref = np.concatenate([coarse_wave(fine, k) for k in window])
for case, entry in zip(cases, report["cases"]):
r = fine["T"] // case["T"] # 1 for equal-D domain runs
ref_c = ref.reshape(-1, r).mean(axis=1)
own = np.concatenate([coarse_wave(case, k) for k in window])
entry["wave_rms_vs_finest"] = float(np.sqrt(np.mean((own - ref_c) ** 2)))
for k in window: # profiles on the common valid point set of all compared cases
per_case = [profiles(c, k) for c in cases]
keys = common_points(per_case)
report.setdefault("common_profile_points", {})[k] = len(keys)
if not keys:
report["verdict"] = "FAIL-CLOSED: empty common profile set"
continue
fine_p = per_case[cases.index(fine)]
for case, entry, prof in zip(cases, report["cases"], per_case):
d = max(max(abs(prof[key][0] - fine_p[key][0]), abs(prof[key][1] - fine_p[key][1]))
for key in keys) / case["u_max"]
entry.setdefault("profile_max_vs_finest", {})[k] = float(d)
ref_entry = report["cases"][cases.index(fine)]
for entry in report["cases"]: # conservative: difference + both drifts
if entry is ref_entry:
continue
drift = {key: entry["window_drift"][key] + ref_entry["window_drift"][key]
for key in ("wave", "profile", "morison")}
dm = max(abs(entry["morison"]["cme"][0] - ref_entry["morison"]["cme"][0]),
abs(entry["morison"]["cme"][1] - ref_entry["morison"]["cme"][1]))
entry["conservative"] = dict(
wave=entry["wave_rms_vs_finest"] + drift["wave"],
profile=max(entry.get("profile_max_vs_finest", {}).values(), default=math.inf)
+ drift["profile"],
morison_cme=dm + drift["morison"])
bands = p.DOMAIN if ref_path else p.CROSS # domain runs name the 60 D reference
c = entry["conservative"]
entry["pass_scope"] = (
("wave+profile+morison_cme" if "morison" in bands else "wave+profile")
+ ": conservative (|diff| + both window drifts) on the obs/CME Fx waveform and "
"simulation-to-simulation profiles; CME+Wen and CME+node reported, not checked; "
"no Fig. 8 / literature verdict")
entry["pass"] = (c["wave"] <= bands["wave"] and c["profile"] <= bands["profile"]
and ("morison" not in bands or c["morison_cme"] <= bands["morison"]))
print(json.dumps(report, indent=1, default=float))
if __name__ == "__main__":
main(sys.argv[1:])
-52
View File
@@ -1,52 +0,0 @@
"""Research-only field capture helpers for the Dütsch driver (numpy only).
Snapshots are taken at the first step of the scored phase bins in declared
cycles: a near-body crop at full resolution and a fluid-weighted block-mean
downsample of the whole domain; plus scalar trends at every bin read.
"""
from __future__ import annotations
import numpy as np
CROP_X, CROP_Y, DOWN = 8.0, 6.0, 8 # crop half-widths in D; downsample factor
STRIP = 2.0 # inlet/outlet strip width in D
def crop_box(xm, ym, D, n, body_extent):
"""Inclusive integer crop (x0, x1, y0, y1); fail closed if it leaves the domain
interior or does not contain the body's swept extent (x_lo, x_hi, y_lo, y_hi)."""
x0, x1 = int(round(xm - CROP_X * D)), int(round(xm + CROP_X * D))
y0, y1 = int(round(ym - CROP_Y * D)), int(round(ym + CROP_Y * D))
if x0 < 1 or y0 < 1 or x1 > n - 2 or y1 > n - 2:
raise ValueError("capture crop leaves the domain interior")
bx0, bx1, by0, by1 = body_extent
if not (x0 <= bx0 and bx1 <= x1 and y0 <= by0 and by1 <= y1):
raise ValueError("capture crop clips the body's swept extent")
return x0, x1, y0, y1
def downsample(field, fluid, f=DOWN):
"""Fluid-weighted f x f block means; NaN where a block has no fluid cell."""
ny, nx = field.shape
h, w = ny // f, nx // f
v = np.where(fluid, field, 0.0)[:h * f, :w * f].reshape(h, f, w, f).sum(axis=(1, 3))
c = fluid[:h * f, :w * f].reshape(h, f, w, f).sum(axis=(1, 3))
with np.errstate(invalid="ignore", divide="ignore"):
return np.where(c > 0, v / np.maximum(c, 1), np.nan).astype(np.float32)
def fluid_mean(field, fluid, xs=slice(None), ys=slice(None)):
m = fluid[ys, xs]
return float(field[ys, xs][m].mean()) if m.any() else float("nan")
def trend_row(s, k, ph, rho, ux, uy, fluid, D, n, crop):
"""(s, cycle, phase, <rho>, <ux>, <uy>, <rho>_crop, inlet <rho>, <ux>, outlet <rho>, <ux>)."""
w = max(1, int(round(STRIP * D)))
x0, x1, y0, y1 = crop
cx, cy = slice(x0, x1 + 1), slice(y0, y1 + 1)
inlet, outlet, inner = slice(1, 1 + w), slice(n - 1 - w, n - 1), slice(1, n - 1)
return (s, k, ph, fluid_mean(rho, fluid), fluid_mean(ux, fluid), fluid_mean(uy, fluid),
fluid_mean(rho, fluid, cx, cy),
fluid_mean(rho, fluid, inlet, inner), fluid_mean(ux, fluid, inlet, inner),
fluid_mean(rho, fluid, outlet, inner), fluid_mean(ux, fluid, outlet, inner))
-136
View File
@@ -1,136 +0,0 @@
"""Pure arithmetic of the Dütsch Re 100 / KC 5 protocol (numpy only, no CUDA).
docs/validation/duetsch_re100_kc5_protocol.md is the contract: elapsed time s
in native steps from the right turning point, paper phase
phi = (360 s / T - 90) mod 360, paper cycle k >= 1 spans
s/T in [0.25 + (k-1), 0.25 + k); cycle 0 is the startup quarter.
"""
from __future__ import annotations
import math
from functools import lru_cache
import numpy as np
KC, RE = 5.0, 100.0
STATIONS = (-0.6, 0.0, 0.6, 1.2) # lab-fixed x/D from the mean position
PHASES = (180.0, 210.0, 330.0) # degrees; bins [p, p + 1)
X2 = tuple(np.round(np.arange(-1.5, 1.5001, 0.05), 10)) # declared x2/D points
DISCARD, LAST_SCORED, N_PAIRS = 2, 11, 3 # cycles 0..2 discarded, cap 12 T
# A-priori bands. Settling is one fifth of the tightest comparison band of the
# same observable, and comparisons add both cases' residual window drift
# (conservative: |difference| + drift_a + drift_b <= band).
DOMAIN = dict(wave=.01, morison=.02, profile=.02) # vs the 60 D reference at D = 40
CROSS = dict(wave=.02, profile=.02) # D40 vs D80
SETTLE = dict(wave=.002, scalar=.002, profile=.004, morison=.004)
def amplitude(D):
return KC * D / (2.0 * math.pi)
def position(s, T, A):
"""Centre position relative to the mean: x = A cos(2 pi s / T), evaluated from the
native step modulo T so every cycle repeats the same geometry bit for bit."""
return A * math.cos(2.0 * math.pi * (int(s) % T) / T)
def phase(s, T):
"""Paper phase in degrees from the native step modulo T (the unwrapped cycle
index is kept separately by ``cycle``)."""
return (360.0 * (int(s) % T) / T - 90.0) % 360.0
def cycle(s, T):
return 0 if s < 0.25 * T else int(math.floor(s / T - 0.25)) + 1
def bin_phase(s, T):
"""Scored phase whose 1-degree bin [p, p + 1) contains time s, else None."""
phi = phase(s, T)
for p in PHASES:
if p <= phi < p + 1.0:
return p
return None
@lru_cache(maxsize=8)
def _quadrature_rule(points):
"""Reuse the nodes and weights, which depend only on quadrature order."""
return np.polynomial.legendre.leggauss(points)
def interval_bases(n, T, points=16):
"""Single-step interval averages over [n, n + 1] of cos phi |cos phi| and sin phi
(phi in radians = 2 pi s / T - pi / 2), Gauss-Legendre quadrature."""
x, w = _quadrature_rule(points)
s = int(n) % T + 0.5 * (x + 1.0) # periodic: identical bases in every cycle
phi = 2.0 * np.pi * s / T - 0.5 * np.pi
c = np.cos(phi)
return 0.5 * float(np.sum(w * c * np.abs(c))), 0.5 * float(np.sum(w * np.sin(phi)))
def morison_fit(f_norm, b1, b2):
"""Least squares of f_norm = c_d b1 - (pi^2 c_i / KC) b2 (paper phase, no shift)."""
a = np.column_stack([b1, -(math.pi ** 2 / KC) * np.asarray(b2)])
(c_d, c_i), *_ = np.linalg.lstsq(a, np.asarray(f_norm), rcond=None)
return float(c_d), float(c_i)
def inside_circle(x, y, centre, radius):
return (x - centre[0]) ** 2 + (y - centre[1]) ** 2 < radius * radius
def sample(field, fluid, x, y, centre, radius):
"""Profile sample: MISSING if the exact point is inside the body (checked before
any corner interpolation), else ``bilinear_fluid``."""
if inside_circle(x, y, centre, radius):
return None, (), 0.0
return bilinear_fluid(field, fluid, x, y)
def bilinear_fluid(field, fluid, x, y):
"""Bilinear value at (x, y) in cell coordinates from fluid cells only.
Returns (value, ((ix, iy, weight), ...), fluid_weight_sum) with weights
renormalized over the fluid corners and the original sum of their bilinear
weights, or (None, (), 0.0) when no corner is fluid (MISSING, never zero).
"""
x0, y0 = int(math.floor(x)), int(math.floor(y))
fx, fy = x - x0, y - y0
corners = ((x0, y0, (1 - fx) * (1 - fy)), (x0 + 1, y0, fx * (1 - fy)),
(x0, y0 + 1, (1 - fx) * fy), (x0 + 1, y0 + 1, fx * fy))
used = [(i, j, w) for i, j, w in corners if w > 0.0 and fluid[j, i]]
total = sum(w for _, _, w in used)
if total == 0.0:
return None, (), 0.0
used = tuple((i, j, w / total) for i, j, w in used)
return float(sum(w * field[j, i] for i, j, w in used)), used, float(total)
def settled_start(pairs_pass):
"""K_case from {k: pass of pair (k-1 -> k)}: first cycle after three consecutive
passing pairs among cycles > DISCARD, or None."""
run = 0
for k in sorted(pairs_pass):
if k - 1 <= DISCARD:
continue
run = run + 1 if pairs_pass[k] else 0
if run == N_PAIRS:
return k + 1
return None
def common_window(k_cases, pairs_pass_by_case, last_scored=LAST_SCORED):
"""Common scored cycles K..K+2 with K = max K_case, or None (FAIL-CLOSED) when a
case never settled, K + 2 exceeds the cap, or a case fails any pair p_j for
j = its own K_case .. K + 2 (continuous settling, including the wait for K)."""
if any(k is None for k in k_cases):
return None
K = max(k_cases)
if K + 2 > last_scored:
return None
for k_case, pairs in zip(k_cases, pairs_pass_by_case):
if not all(pairs.get(j, False) for j in range(k_case, K + 3)):
return None
return K, K + 1, K + 2
-133
View File
@@ -1,133 +0,0 @@
"""One-shot Dütsch Re 100 / KC 5 prescribed in-line oscillation driver.
Protocol: docs/validation/duetsch_re100_kc5_protocol.md. Native dense channel
(zero-velocity inlet, pressure-NEQ outlet, bounce-back y walls), MRT, lab frame.
Writes raw per-step force/diagnostics and phase-bin profile samples to --out
(outside the checkout); analysis is in analyze.py. No production change.
python run.py --D 40 --box 40 --cycles 11 --out /path/outside/checkout
"""
from __future__ import annotations
import argparse
import json
import math
import os
import tempfile
import numpy as np
import protocol as p
TEMPLATE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "src",
"CelerisLab", "configs", "config_lbm.json")
def config(D, box, T):
"""Shipped config_lbm.json (full parser schema) with only the case fields
overridden: grid, still fluid (velocity 0), nu from Re, MRT, uniform inlet
profile, bounce-back y walls. Inlet scheme and outlet mode stay the
template's (zou_he_local, neq_extrap) and are recorded in case.json."""
u = p.KC * D / T
n = int(round(box * D))
with open(TEMPLATE) as fh:
cfg = json.load(fh)
cfg["grid"].update(lattice_model="D2Q9", nx=n, ny=n, nz=1)
cfg["physics"].update(data_type="FP32", viscosity=u * D / p.RE, velocity=0.0, rho=1.0)
cfg["method"].update(collision="MRT", streaming="double_buffer", store_precision="FP32",
ddf_shifting=False, y_wall_bc="bounce_back")
cfg["method"]["les"]["enabled"] = False
cfg["method"]["inlet"]["profile"] = "uniform"
return cfg, u, n
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--D", type=int, required=True)
ap.add_argument("--box", type=float, default=40.0, help="box side in D")
ap.add_argument("--T", type=int, default=None, help="period in steps (multiple of 360)")
ap.add_argument("--cycles", type=int, default=p.LAST_SCORED, help="run to the end of this cycle")
ap.add_argument("--out", required=True)
ap.add_argument("--dump-fields", default="",
help="research capture: comma-separated paper cycles, e.g. 11,21,30,31")
args = ap.parse_args()
D, T = args.D, args.T or 126 * args.D
if T % 360:
raise SystemExit("T must be a multiple of 360 steps")
if os.path.realpath(args.out).startswith(os.path.realpath(os.path.join(os.path.dirname(__file__), "..", ".."))):
raise SystemExit("--out must be outside the checkout")
cfg, u_max, n = config(D, args.box, T)
A, R = p.amplitude(D), 0.5 * D
xm = ym = 0.5 * (n - 1)
os.makedirs(args.out, exist_ok=True)
from CelerisLab.body.motion import Translation
from CelerisLab.simulation import Simulation
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fh:
json.dump(cfg, fh)
sim = Simulation(fh.name)
try:
body = sim.add_body("circle", (xm + A, ym), R,
translation=Translation((-2.0 * A, 0.0, 0.0, 0.0), 1.01 * u_max))
sim.initialize()
dump = {int(c) for c in args.dump_fields.split(",") if c}
if dump:
import capture
if max(dump) > args.cycles or min(dump) <= p.DISCARD:
raise SystemExit("--dump-fields cycles must lie in the scored range of this run")
crop = capture.crop_box(xm, ym, D, n, (xm - A - R, xm + A + R, ym - R, ym + R))
trend = []
steps = int(round((0.25 + args.cycles) * T))
force = np.zeros((steps, 2)); diag = []; samples = []
for m in range(steps):
x0, x1 = p.position(m, T, A), p.position(m + 1, T, A)
sim.set_body(body, offset=(max(x1 - A, -2.0 * A), 0.0), vx=x1 - x0)
sim.run(1)
force[m] = sim.read_force(body)[:2]
diag.append(sim.moving_diagnostics()[0])
s = m + 1 # state after the step
ph = p.bin_phase(s, T)
if ph is not None and p.cycle(s, T) > p.DISCARD:
fields = sim.get_macroscopic()
fluid = (sim.get_flags().reshape(n, n) & 1) != 0
ux, uy = (np.asarray(fields[k]).reshape(n, n) for k in ("ux", "uy"))
if dump: # research capture only; the default path is unchanged
rho = np.asarray(fields["rho"]).reshape(n, n)
trend.append(capture.trend_row(s, p.cycle(s, T), ph, rho, ux, uy, fluid, D, n, crop))
if p.cycle(s, T) in dump and p.phase(s, T) == ph: # first step of the bin
x0c, x1c, y0c, y1c = crop
cy_, cx_ = slice(y0c, y1c + 1), slice(x0c, x1c + 1)
np.savez_compressed(
os.path.join(args.out, f"fields_c{p.cycle(s, T)}_p{int(ph)}.npz"),
s=s, cycle=p.cycle(s, T), phase=ph, crop=np.asarray(crop),
force=force[m], centre=np.asarray((xm + p.position(s, T, A), ym)),
ux=ux[cy_, cx_].astype(np.float32), uy=uy[cy_, cx_].astype(np.float32),
rho=rho[cy_, cx_].astype(np.float32), fluid=fluid[cy_, cx_].astype(np.uint8),
down_ux=capture.downsample(ux, fluid), down_uy=capture.downsample(uy, fluid),
down_rho=capture.downsample(rho, fluid))
centre = (xm + p.position(s, T, A), ym) # analytic centre after the step
for xs in p.STATIONS:
for x2 in p.X2:
x, y = xm + xs * D, ym + x2 * D
u, wu, wsum = p.sample(ux, fluid, x, y, centre, R)
v, _, _ = p.sample(uy, fluid, x, y, centre, R)
samples.append((s, p.cycle(s, T), ph, xs, x2,
math.nan if u is None else u, math.nan if v is None else v,
wsum, json.dumps(wu)))
if dump:
np.save(os.path.join(args.out, "trend.npy"), np.asarray(trend, float))
np.savez(os.path.join(args.out, "raw.npz"), force=force, diag=np.asarray(diag),
samples=np.asarray([r[:8] for r in samples], float))
with open(os.path.join(args.out, "case.json"), "w") as fh:
json.dump(dict(D=D, T=T, box=args.box, n=n, u_max=u_max, nu=cfg["physics"]["viscosity"],
tau=3 * cfg["physics"]["viscosity"] + 0.5, A=A, steps=steps,
cycles=args.cycles, F_ref=0.5 * u_max ** 2 * D,
inlet=cfg["method"]["inlet"], outlet=cfg["method"]["outlet"],
weights=[r[8] for r in samples]), fh)
finally:
sim.close()
if __name__ == "__main__":
main()
-49
View File
@@ -1,49 +0,0 @@
{
"_doc": "Legacy geometry Re_D=50, U0=0.01, SI=800 baseline (kar_legacy_re50d_u001)",
"grid": {
"lattice_model": "D2Q9",
"nx": 1280,
"ny": 512,
"nz": 1
},
"physics": {
"data_type": "FP32",
"viscosity": 0.004,
"velocity": 0.01,
"rho": 1.0
},
"method": {
"collision": "MRT",
"streaming": "double_buffer",
"store_precision": "FP32",
"ddf_shifting": false,
"les": {
"enabled": false,
"cs": 0.16,
"closed_form": true
},
"trt": {
"magic_param": 0.1875
},
"inlet": {
"profile": "parabolic",
"scheme": "regularized",
"regularized_neq_damp": 1.0
},
"outlet": {
"mode": "neq_extrap",
"backflow_clamp": true,
"blend_alpha": 0.7,
"srt_neq_damp": 0.5
},
"y_wall_bc": "bounce_back",
"omega_guard": {
"min": 0.01,
"max": 1.99
}
},
"cuda": {
"threads_per_block": 256,
"compute_capability": "auto"
}
}
-196
View File
@@ -1,196 +0,0 @@
"""Uncontrolled paper-geometry steady Re_D=50, one transverse assembly DOF.
Three predeclared zero-damping structures; --smoke uses only 32 + 32 steps.
See docs/steady_pinball_fiv.md for historical solver differences and new structure.
"""
from __future__ import annotations
import argparse
import json
import math
from pathlib import Path
import subprocess
import time
import numpy as np
D, U, NU = 20., .01, .004
CENTRES = ((600., 255.5), (626., 270.5), (626., 240.5))
PROBES = ((800., 295.5), (800., 255.5), (800., 215.5))
WARMUP, STEPS, PERIOD = 512000, 600000, 10000
STRUCTURES = {'Z0': (10., 1.e-4), 'M2': (2., 1.e-4), 'R2': (2., 6.e-5)}
COLUMNS = ('step', 'y', 'v', 'cme_F', 'cme_U', 'cme_L', 'wen_F', 'wen_U', 'wen_L',
'node_F', 'node_U', 'node_L', 'eq_only', 'no_donor', 'crossings')
ROOT = Path(__file__).resolve().parents[2]
def config(diameter=D):
"""Diffusive refinement preserves the inlet/outlet and half-grid y walls."""
scale = diameter / D
if diameter not in (20, 40, 60):
raise ValueError('diameter must be 20, 40 or 60')
cfg = json.loads(Path(__file__).with_name('config.json').read_text())
cfg['grid']['nx'] = round((cfg['grid']['nx'] - 1) * scale + 1)
cfg['grid']['ny'] = round((cfg['grid']['ny'] - 2) * scale + 2)
cfg['physics']['velocity'] = U / scale
return cfg
def scaled_points(points, scale):
return tuple((scale*x, .5 + scale*(y-.5)) for x, y in points)
class Structure:
"""Exact damped oscillator update for a force held over dt=1; no force filter."""
def __init__(self, mass_ratio=10., frequency=1.e-4, zeta=0., diameter=D):
if not (math.isfinite(mass_ratio) and mass_ratio > 0. and
math.isfinite(frequency) and frequency > 0. and
math.isfinite(zeta) and 0. <= zeta < 1.):
raise ValueError('positive finite mass ratio/frequency and 0 <= zeta < 1 required')
self.mass_ratio, self.frequency = mass_ratio, frequency
self.mass = mass_ratio * 3. * math.pi * diameter * diameter / 4.
self.omega = 2. * math.pi * frequency
self.zeta = zeta
self.stiffness = self.mass * self.omega**2
self.damping = 2. * self.zeta * self.mass * self.omega
self.y = self.v = 0.
a = self.zeta * self.omega
wd = self.omega * math.sqrt(1. - self.zeta**2)
self.ec = math.exp(-a) * math.cos(wd)
self.es = math.exp(-a) * math.sin(wd) / wd
self.a = a
def candidate(self, force):
equilibrium = force / self.stiffness
z = self.y - equilibrium
return (equilibrium + (self.ec + self.a*self.es)*z + self.es*self.v,
-self.omega**2*self.es*z + (self.ec - self.a*self.es)*self.v)
def summarize(rows, diameter=D, block_steps=10*PERIOD):
windows = []
for start in range(0, len(rows), block_steps):
block = rows[start:start + block_steps]
fy = block[:, 3:9].sum(axis=1)
windows.append(dict(start_step=int(block[0, 0]), end_step=int(block[-1, 0]),
y_mean_D=float(block[:, 1].mean()/diameter),
y_rms_D=float(np.sqrt(np.mean(block[:, 1]**2))/diameter),
y_peak_D=float(np.abs(block[:, 1]).max()/diameter),
fy_mean=float(fy.mean()), fy_rms=float(np.sqrt(np.mean(fy**2))),
mean_power=float(np.mean(fy*block[:, 2]))))
return windows
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument('--out', required=True, type=Path)
ap.add_argument('--fixed', action='store_true', help='same modern moving-path geometry held fixed')
ap.add_argument('--smoke', action='store_true', help='32+32 steps, no physical result')
ap.add_argument('--case', choices=STRUCTURES, default='Z0',
help='predeclared structure; all cases have zero structural damping')
ap.add_argument('--D', type=int, choices=(20, 40, 60), default=20,
help='diameter in cells; diffusive scaling, matched geometry and physical duration')
ap.add_argument('--wall-seconds', type=float, default=7200., help='whole-case wall-clock cap')
args = ap.parse_args()
if not math.isfinite(args.wall_seconds) or args.wall_seconds <= 0:
ap.error('--wall-seconds must be finite and positive')
scale, time_scale = args.D / D, (args.D // int(D))**2
velocity = U / scale
centres, probes = scaled_points(CENTRES, scale), scaled_points(PROBES, scale)
period = PERIOD*time_scale
out = args.out.resolve()
if out == ROOT or ROOT in out.parents:
ap.error('output must be outside the checkout')
out.mkdir(parents=True, exist_ok=False)
cfg = config(args.D)
(out/'config.json').write_text(json.dumps(cfg, indent=2)+'\n')
source = subprocess.check_output(['git', '-C', str(ROOT), 'rev-parse', 'HEAD'], text=True).strip()
mass_ratio, frequency = STRUCTURES[args.case]
s = Structure(mass_ratio, frequency/time_scale, zeta=0., diameter=args.D)
warmup, count = (32, 32) if args.smoke else (WARMUP*time_scale, STEPS*time_scale)
rows = np.lib.format.open_memmap(out/'steps.npy', mode='w+', dtype=np.float64,
shape=(count, len(COLUMNS)))
rows[:] = np.nan
from CelerisLab import Simulation
from CelerisLab.body.motion import MovingTables, Translation
names = MovingTables.DIAG
col = {k: names.index(k) for k in names}
status, completed, f_last = 'running', 0, 0.
started = time.monotonic()
report = dict(source=source, case=args.case, fixed=args.fixed, smoke=args.smoke, columns=COLUMNS,
structure=dict(mass=s.mass, stiffness=s.stiffness, damping=s.damping,
mass_ratio_three_disks=s.mass_ratio,
zeta=s.zeta, fn=s.frequency, Tn=1./s.frequency, y0=0., v0=0.),
warmup_steps=warmup, requested_steps=count, dt=1., D=args.D, U=velocity, nu=NU,
relative_to_D20=dict(dx=1/scale, dt=1/time_scale),
centres=centres, probes=probes, wall_planes=(.5, cfg['grid']['ny']-1.5),
wall_cap_seconds=args.wall_seconds,
duration_Tn=count*s.frequency, duration_tU_D=count*velocity/args.D,
summary_block_steps=10*period,
rhs='sum of three instantaneous CME_y + Wen_y, one-step lag, no filtering')
try:
with Simulation(str(out/'config.json')) as sim:
# Match Legacy object order; probes remain passive and fixed in the lab.
for c in probes:
sim.add_body('sensor', c, 5.*scale)
envelope = Translation((0., 0., -args.D, args.D), .05/scale, shared=True)
bodies = [sim.add_body('circle', c, args.D/2, translation=envelope) for c in centres]
sim.initialize()
# At release obtain the last instantaneous force, not a warmup average.
remaining = warmup - 1
while remaining:
if time.monotonic()-started > args.wall_seconds:
raise TimeoutError('whole-case wall cap during warmup')
chunk = min(10000, remaining)
sim.run(chunk)
remaining -= chunk
sim.run(1)
d = sim.moving_diagnostics()
f_last = float(d[:, col['cme_y']].sum(dtype=np.float64) + d[:, col['wen_y']].sum(dtype=np.float64))
report['warmup_elapsed_s'] = time.monotonic()-started
print(json.dumps(dict(event='release', **report)), flush=True)
for n in range(count):
if n % 1000 == 0 and time.monotonic()-started > args.wall_seconds:
raise TimeoutError('whole-case wall cap during response')
y1, v1 = (0., 0.) if args.fixed else s.candidate(f_last)
if not all(map(math.isfinite, (y1, v1))) or abs(y1) > args.D:
status = 'stopped_nonfinite_or_travel_bound'
break
if not args.fixed:
for bid in bodies:
sim.set_body(bid, offset=(0., y1), vy=y1-s.y)
sim.run(1)
d = sim.moving_diagnostics()
cme, wen = d[:, col['cme_y']], d[:, col['wen_y']]
node = d[:, col['cover_jy']] - d[:, col['uncover_jy']]
row = (n+1, y1, v1, *cme, *wen, *node,
d[:, col['eq_only']].sum(), d[:, col['no_donor']].sum(), d[:, col['crossings']].sum())
if not np.isfinite(row).all() or row[-2] != 0:
status = 'stopped_nonfinite_or_no_donor'
break
rows[n] = row
completed = n+1
s.y, s.v = y1, v1
f_last = float(cme.sum(dtype=np.float64)+wen.sum(dtype=np.float64))
if completed % period == 0:
rows.flush()
print(json.dumps(dict(event='progress', step=completed, t_Tn=completed*s.frequency,
y_D=s.y/args.D, elapsed_s=time.monotonic()-started)), flush=True)
else:
status = 'smoke_completed' if args.smoke else 'duration_completed_not_qualified'
except BaseException as exc:
status = f'error: {type(exc).__name__}: {exc}'
raise
finally:
rows.flush()
report.update(status=status, completed_steps=completed, elapsed_s=time.monotonic()-started,
windows=summarize(rows[:completed], args.D, 10*period),
totals={key: float(rows[:completed, i].sum()) for key, i in
[('eq_only', 12), ('no_donor', 13), ('crossings', 14)]},
limitation='Uncontrolled development response only. No automatic settling, grid convergence, control or historical-solver equivalence claim.')
(out/'report.json').write_text(json.dumps(report, indent=2)+'\n')
print(json.dumps(report), flush=True)
if __name__ == '__main__':
main()
-23
View File
@@ -1,23 +0,0 @@
# Research VIV driver — coupling contract (not production)
`run.py` couples a 1-DOF undamped spring–mass (`viv.py`) to the production
moving-circle API. Everything specific to fluid–structure coupling lives here,
not in `src/`.
- Per step: known (y_n, v_n) and the last completed force F_last → exact
harmonic candidate (y_{n+1}, v_{n+1}) → `set_body(offset=(0, y_{n+1}),
vy=y_{n+1} − y_n)` (interval wall velocity, lattice dt = 1) → `run(1)` →
commit y, v and F_last only after the step succeeded. One step of explicit
force lag (weak coupling); no sub-iteration.
- Structural RHS = CME + Wen (y), read from `moving_diagnostics()` by the
names in `MovingTables.DIAG`; raw CME, the `obs` force and node terms are
logged as diagnostics. This is a stage choice, not a validated force law.
- Production guarantees used: fail-closed target commit (synchronized step,
`no_donor == 0`), input rejection before mutation, a failed device step
marks the Simulation failed.
- Not provided, by design: device-resident structure state or batched
(multi-step) coupling, strong/sub-iterated coupling (would need state
rollback), more than one DOF. These are future work and would be a separate
execution path, not an abstraction over this driver.
- Recorded result and limits: `docs/validation/moving_bodies_stage1.md`;
predeclaration: `docs/validation/viv_re150_predeclare.md`.
-136
View File
@@ -1,136 +0,0 @@
"""One-shot predeclared VIV run (docs/validation/viv_re150_predeclare.md).
Re 150, U* = 5, m/(rho D^2) = 2, zeta = 0, D = 40 on a nominal 75 D x 60 D
lattice (uniform inlet, NEQ outlet, free-slip y walls). The cylinder is at
(30 D, (ny - 1)/2); y-wall planes are .5 and ny - 1.5 in lattice coordinates.
Structural RHS = CME + Wen (diagnostics: raw CME, node terms).
Research driver only: Simulation API, no production change.
PYTHONPATH=src python research/viv_re150/run.py --out /path/outside/checkout
"""
from __future__ import annotations
import argparse
import json
import math
import os
import tempfile
import numpy as np
import viv
D, U = 40, 0.04
TEMPLATE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "src",
"CelerisLab", "configs", "config_lbm.json")
def config(D=D, U=U):
nx, ny = 75 * D, 60 * D
with open(TEMPLATE) as fh:
cfg = json.load(fh)
cfg["grid"].update(lattice_model="D2Q9", nx=nx, ny=ny, nz=1)
cfg["physics"].update(data_type="FP32", viscosity=U * D / viv.RE, velocity=U, rho=1.0)
cfg["method"].update(collision="MRT", streaming="double_buffer", store_precision="FP32",
ddf_shifting=False, y_wall_bc="free_slip")
cfg["method"]["les"]["enabled"] = False
cfg["method"]["inlet"]["profile"] = "uniform"
return cfg, nx, ny
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--out", required=True)
ap.add_argument("--D", type=int, default=D)
ap.add_argument("--smoke", action="store_true",
help="plumbing smoke only: exactly --steps steps (<= 64), never scored")
ap.add_argument("--steps", type=int, default=0)
args = ap.parse_args()
if args.smoke != bool(args.steps) or args.steps > 64 or args.steps < 0:
raise SystemExit("--smoke requires --steps N with 1 <= N <= 64 (and only with --smoke)")
if args.D != D and not args.smoke:
raise SystemExit("the physical case is predeclared at D = 40")
if os.path.realpath(args.out).startswith(os.path.realpath(os.path.join(os.path.dirname(__file__), "..", ".."))):
raise SystemExit("--out must be outside the checkout")
os.makedirs(args.out, exist_ok=True)
for name in ("report.json", "steps.npy"): # never overwrite earlier evidence
if os.path.exists(os.path.join(args.out, name)):
raise SystemExit(f"{args.out} already holds {name}; use a fresh output directory")
Dn = args.D
cfg, nx, ny = config(Dn)
s, cyc = viv.Structure(Dn, U), viv.Cycles(Dn)
T_n = 1.0 / s.f_n
step_cap, n_seed = int(round(viv.STEP_CAP_TN * T_n)), int(round(viv.SEED_TN * T_n))
quiet = int(round(5 * T_n))
if args.smoke:
step_cap = args.steps
centre = (30.0 * Dn, 0.5 * (ny - 1))
from CelerisLab.body.motion import MovingTables, Translation
col = {k: MovingTables.DIAG.index(k) for k in ("cme_y", "wen_y", "cover_jy", "uncover_jy", "no_donor")}
from CelerisLab.simulation import Simulation
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fh:
json.dump(cfg, fh)
sim = Simulation(fh.name)
rows, status, seed_step, f_last = [], "running", None, 0.0
try:
body = sim.add_body("circle", centre, 0.5 * Dn,
translation=Translation((0.0, 0.0, -1.5 * Dn, 1.5 * Dn), 0.5))
sim.initialize()
for n in range(step_cap):
# rows[k] holds the state y_{k+1}; the declared window is y_t, t in [n_seed - 5 T_n, n_seed)
if not args.smoke and n == n_seed and \
max(abs(r[1]) for r in rows[n_seed - quiet - 1:n_seed - 1]) < viv.ELIGIBLE * Dn:
s.v += s.seed_velocity(Dn) # after the quiet check, before the update
seed_step = n
cyc.discard_current()
cyc.reset_proof()
y1, v1 = s.candidate(f_last)
if not (math.isfinite(y1) and math.isfinite(v1)) or abs(y1) > viv.Y_LIMIT * Dn:
status = "fail_bound_or_nonfinite"
break
try:
sim.set_body(body, offset=(0.0, y1), vy=y1 - s.y)
except ValueError as exc: # input rejection: stop, no retry
status = f"rejected: {exc}"
break
sim.run(1) # device failures raise and mark the Simulation failed
d = sim.moving_diagnostics()[0]
f_obs = sim.read_force(body)[1]
f_rhs = float(d[col["cme_y"]] + d[col["wen_y"]]) # CME + Wen (y)
row = (n, y1, v1, f_rhs, float(d[col["cme_y"]]), float(d[col["wen_y"]]), float(f_obs),
float(d[col["cover_jy"]] - d[col["uncover_jy"]]), float(d[col["no_donor"]]))
if not viv.finite(row): # every recorded output, not only the RHS
status = "fail_nonfinite_output"
break
s.y, s.v, f_last = y1, v1, f_rhs # commit only after a successful step
rows.append(row)
if args.smoke:
continue # no settle / score in the plumbing smoke
if cyc.update(s.y) == "scored":
status = "scored"
break
if cyc.counted >= viv.CYCLE_CAP:
status = "timeout_response_cycles"
break
else:
status = ("smoke_ok" if all(r[8] == 0.0 for r in rows) else "smoke_no_donor") \
if args.smoke else "timeout_native_steps"
except BaseException as exc:
status = f"error: {exc!r}"
raise
finally:
np.save(os.path.join(args.out, "steps.npy"), np.asarray(rows, float))
report = dict(status=status, smoke=args.smoke, D=Dn, steps=len(rows), t_U_over_D=len(rows) * U / Dn, T_n=T_n,
seed_step=seed_step, counted_cycles=cyc.counted, cycles=cyc.log,
columns=["n", "y", "v", "F_rhs_cme_wen", "F_cme", "F_wen", "F_obs",
"node_cover_minus_uncover_y", "no_donor"])
if status == "scored" and not args.smoke:
report["score"] = viv.score(cyc.window)
with open(os.path.join(args.out, "report.json"), "w") as fh:
json.dump(report, fh, indent=1)
sim.close()
if __name__ == "__main__":
main()
-126
View File
@@ -1,126 +0,0 @@
"""Pure logic of the predeclared Re 150 free transverse VIV case (stdlib only).
Contract: docs/validation/viv_re150_predeclare.md. Oscillator update with a
constant force over one lattice step, response-cycle tracking with fixed y = 0
crossings, eligibility, 10-cycle blocks, settling/scoring with deterministic
restart, and the conditional seed.
"""
from __future__ import annotations
import math
RE, U_STAR, MASS_NUMBER = 150.0, 5.0, 2.0 # m/(rho D^2) = 2 -> m* = 8/pi
TARGET_A, BAND = 0.520, 0.10 # Qin §4.5 maximum amplitude, ±10 %
ELIGIBLE, BLOCK, LAST5 = 0.01, 10, 5 # in D; cycles; cycles
WINDOW_TOL, LAST5_TOL = 0.02, 0.01
CYCLE_CAP, STEP_CAP_TN, SEED_TN, Y_LIMIT = 150, 200, 40, 1.0
def finite(values):
"""True iff every recorded value is finite (fail-closed output check)."""
return all(math.isfinite(float(v)) for v in values)
class Structure:
"""Undamped spring-mass, exact harmonic update over dt = 1 with constant force."""
def __init__(self, D, u):
self.m = MASS_NUMBER * D * D # lattice rho = 1
self.f_n = u / (U_STAR * D)
self.w = 2.0 * math.pi * self.f_n
self.k = self.m * self.w * self.w
self.y = self.v = 0.0
def candidate(self, force):
ye = force / self.k
c, s = math.cos(self.w), math.sin(self.w)
return (ye + (self.y - ye) * c + self.v / self.w * s,
-(self.y - ye) * self.w * s + self.v * c)
def seed_velocity(self, D):
return self.w * ELIGIBLE * D # amplitude ~ .01 D
class Cycles:
"""Upward y = 0 crossings, eligibility, blocks, settle/score with restart."""
def __init__(self, D):
self.D = D
self.reset_proof()
self.counted = 0
self.log = [] # one dict per completed cycle
self._cur = None
self._prev = 0.0
self._since = 0
def reset_proof(self):
self.amps = [] # eligible amplitudes since proof start
self.blocks = [] # block means
self.settled_at = None # block index where R held
self.window = None # completed scoring cycles (list of dicts)
def discard_current(self):
self._cur, self._since = None, 0
def rule(self, k):
"""R(k): block-mean change ≤ 2 % and last-five spread ≤ 1 %."""
if k < 1:
return False
w0, w1 = self.blocks[k - 1], self.blocks[k]
last = self.amps[(k + 1) * BLOCK - LAST5:(k + 1) * BLOCK]
a5 = sum(last) / LAST5
return abs(w1 - w0) / w0 <= WINDOW_TOL and max(abs(a - a5) for a in last) / a5 <= LAST5_TOL
def update(self, y):
"""Feed y after a committed step; returns 'scored' when a stable window completes."""
out = None
crossing = self._prev < 0.0 <= y and self._since >= 2
if crossing:
if self._cur is not None:
out = self._close()
self._cur, self._since = dict(ymax=y, ymin=y, ysum=0.0, n=0), 0
if self._cur is not None:
c = self._cur
c["ymax"], c["ymin"] = max(c["ymax"], y), min(c["ymin"], y)
c["ysum"] += y
c["n"] += 1
self._since += 1
self._prev = y
return out
def _close(self):
c = self._cur
a = 0.5 * (c["ymax"] - c["ymin"]) / self.D
cyc = dict(ymax=c["ymax"] / self.D, ymin=c["ymin"] / self.D,
offset=c["ysum"] / c["n"] / self.D, amp=a, steps=c["n"], eligible=a >= ELIGIBLE)
self.log.append(cyc)
if not cyc["eligible"]:
return None
self.counted += 1
self.amps.append(a)
if self.settled_at is not None:
self.window.append(cyc)
if len(self.amps) % BLOCK:
return None
self.blocks.append(sum(self.amps[-BLOCK:]) / BLOCK)
k = len(self.blocks) - 1
if self.settled_at is None:
if self.rule(k):
self.settled_at, self.window = k, []
return None
if not self.rule(k): # scoring block failed: restart from it
amps, blocks = self.amps[-BLOCK:], self.blocks[-1:]
self.reset_proof()
self.amps, self.blocks = amps, blocks
return None
return "scored" if k == self.settled_at + 2 else None
def score(window):
"""Target metric and reported quantities over the 20 scoring cycles."""
a_max = max(max(abs(c["ymax"]), abs(c["ymin"])) for c in window)
return dict(A_max=a_max, mean_amp=sum(c["amp"] for c in window) / len(window),
ymax=max(c["ymax"] for c in window), ymin=min(c["ymin"] for c in window),
offset=sum(c["offset"] for c in window) / len(window),
T_resp=sum(c["steps"] for c in window) / len(window),
within_band=abs(a_max - TARGET_A) <= BAND * TARGET_A)
+8 -16
View File
@@ -207,30 +207,22 @@ def surface_link_velocity(objects, cfg, flags, fluid_idx, dir, body_id, rx, ry,
jets=(), hits=None, *, _shifted_blocked=False):
"""Per-link prescribed surface velocity (n, 2) FP32 and aperture mask.
See _surface_links, which also returns the unit-amplitude jet shapes.
"""
return _surface_links(objects, cfg, flags, fluid_idx, dir, body_id, rx, ry, jets, hits,
_shifted_blocked=_shifted_blocked)[:2]
def _surface_links(objects, cfg, flags, fluid_idx, dir, body_id, rx, ry,
jets=(), hits=None, *, _shifted_blocked=False):
"""(velocity, aperture, jet shape, jet id); the last two are None without jets.
Host-only and storage-neutral: rows follow the given physical cut-link
order. Returns all None when neither an object carries surface
order. Returns (None, None) when neither an object carries surface
actuation nor a jet is given. Jets need true FP64 ``hits`` (n, 2) and may
not share a link with each other or with a legacy aperture.
Shifted storage is privately enabled only by the all-blocked stepper.
"""
enabled = [obj for obj in objects if obj.surface_actuation is not None]
if not enabled and not jets:
return None, None, None, None
from .preprocess.flag_overlay import validate_cut_link_ownership
validate_cut_link_ownership(flags, fluid_idx, dir, cfg.nq)
return None, None
from ..lbm.descriptors import FLUID, SOLID
if np.any((flags[fluid_idx] & FLUID) == 0) or np.any(flags[fluid_idx] & SOLID):
raise ValueError("Surface cut links must have active fluid receivers")
if len(np.unique(fluid_idx.astype(np.uint64)*cfg.nq+dir)) != len(dir):
raise ValueError("Surface boundary requires unique cut-link writers")
surface_velocity = np.zeros((len(dir), 2), np.float32)
surface_aperture = np.zeros(len(dir), bool)
shape = jet_id = None
for obj in enabled:
obj.validate_surface_actuation(cfg)
selected = body_id == obj.obj_id
@@ -263,4 +255,4 @@ def _surface_links(objects, cfg, flags, fluid_idx, dir, body_id, rx, ry,
raise ValueError("Jet overlaps a SurfaceActuation aperture")
surface_velocity[driven] = jet_link_velocity(jets, shape, jet_id)[driven]
surface_aperture |= driven
return surface_velocity, surface_aperture, shape, jet_id
return surface_velocity, surface_aperture
+15 -22
View File
@@ -11,7 +11,7 @@ Responsibilities:
Design::
Packed ``obs`` layout matches ``generate_config(..., n_objects=count)`` and
``config_obs.h`` macros. Host sizing uses :func:`~CelerisLab.cuda.compiler.obs_layout`.
``config_obs.h`` macros. Host sizing uses :func:`~CelerisLab.cuda.compiler_v2.obs_layout`.
Pending edits are lightweight: only the "intent" (add / remove) is stored.
All derived quantities (flags, compact lists, GPU buffers) are built at
@@ -27,8 +27,8 @@ import pycuda.driver as cuda
from .objects import SimObject
from .registry import BodyRegistry
from .preprocess.flag_overlay import merge_flag_masks, validate_cut_link_ownership
from ..cuda.compiler import obs_layout
from .preprocess.flag_overlay import merge_flag_masks
from ..cuda.compiler_v2 import obs_layout
from ..cuda.obs_layout import ObsLayout
from ..lbm.descriptors import (
FLUID, SOLID,
@@ -369,9 +369,9 @@ class ObjectManager:
Args:
objects: List of SimObject instances to process.
domain_flags: Optional uint16 flags for receiver/donor classification.
domain_flags: Optional uint16 flags for donor-cell classification.
sensor_cell_counts_out: Int array to fill with per-object sensor
cell counts after the full geometry has passed validation.
cell counts. If provided, ``fill(0)`` is called first.
hits_out: List that receives one FP64 (n, 2) array of true hit
points row-aligned with the cut-link columns (for Jets).
@@ -394,8 +394,8 @@ class ObjectManager:
active_fluid = ((domain_flags & FLUID) != 0) & ((domain_flags & SOLID) == 0)
force_owners: set[int] = set()
sensor_counts = (np.zeros_like(sensor_cell_counts_out)
if sensor_cell_counts_out is not None else None)
if sensor_cell_counts_out is not None:
sensor_cell_counts_out.fill(0)
ez = None
if self.cfg and self.cfg.is_d3q19:
@@ -405,9 +405,7 @@ class ObjectManager:
for obj in objects:
obj.validate_surface_actuation(self.cfg)
# Moving circles never enter the packed static links (docs/moving_bodies.md, section 3).
if (not obj.is_sensor) and (not obj.is_force_region) and obj.motion is None \
and hasattr(obj, 'get_curved_list'):
if (not obj.is_sensor) and (not obj.is_force_region) and hasattr(obj, 'get_curved_list'):
(
fluid_idx, dirs, q_vals, body_ids,
rx_vals, ry_vals, rz_vals, fallback_vals
@@ -432,9 +430,9 @@ class ObjectManager:
if len(cells) > 0:
s_cells.append(cells)
s_ids.append(ids)
if (sensor_counts is not None
and 0 <= obj.obj_id < sensor_counts.size):
sensor_counts[obj.obj_id] = int(len(cells))
if (sensor_cell_counts_out is not None
and 0 <= obj.obj_id < sensor_cell_counts_out.size):
sensor_cell_counts_out[obj.obj_id] = int(len(cells))
if obj.is_force_region and hasattr(obj, 'get_sensor_list'):
cells, ids = obj.get_sensor_list(self.nx, self.ny, self.nz)
keep = active_fluid[cells]
@@ -463,9 +461,8 @@ class ObjectManager:
r_fr_ids = np.concatenate(fr_ids) if fr_ids else np.zeros(0, dtype=np.int32)
r_hit = np.concatenate(cl_hit) if cl_hit else np.zeros((0, 2))
# Passive walls need the same ownership guarantees as driven walls.
# Overlapping solids are not a supported union-boundary operation.
validate_cut_link_ownership(domain_flags, r_idx, r_dir, self.nq)
if hits_out is not None:
hits_out.append(r_hit)
if self.jets or any(obj.surface_actuation is not None for obj in objects):
# Reject unresolved apertures/ownership before sync or restart mutates state.
from ..lbm.curved_links import CurvedLinkSoA
@@ -473,11 +470,6 @@ class ObjectManager:
links.assign_host(r_idx, r_dir, r_q, r_bid, r_rx, r_ry, r_rz, r_fallback)
links.assign_surface(objects, self.cfg, domain_flags, jets=self.jets, hits=r_hit)
if sensor_cell_counts_out is not None:
sensor_cell_counts_out[:] = sensor_counts
if hits_out is not None:
hits_out.append(r_hit)
return (
r_idx, r_dir, r_q, r_bid,
r_rx, r_ry, r_rz, r_fallback,
@@ -495,6 +487,8 @@ class ObjectManager:
the caller has already prepared and uploaded the final flag field
for this geometry, such as during initialization.
"""
self._telemetry_field = field
next_flags = self.build_flags(field.build_channel_flags()) if rebuild_flags else field.flag
hits = []
@@ -505,7 +499,6 @@ class ObjectManager:
fr_cells, fr_obj_id,
) = self.build_compact_lists(domain_flags=next_flags, hits_out=hits)
self._telemetry_field = field
if rebuild_flags:
field.flag = next_flags
field.upload_flags()
-224
View File
@@ -1,224 +0,0 @@
# CelerisLab/body/motion.py
"""Translation targets and device tables (dense double buffer, D2Q9 only).
Contract: docs/moving_bodies.md, sections 4a, 5 and 7. The applied geometry
(last successfully committed offset) and an optional pending target are two
separate facts; centres are always ``base + offset``. MovingCircle owns host
targets, MovingTables owns device records, and Simulation commits completed steps.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Callable, Optional, Tuple
MAX_STEP = 0.5 # Euclidean centre displacement per step (lattice cells)
BAND_MARGIN = 2 # cells added around the swept circle AABB
SHARED_CLEARANCE = 4.0 # > two diagonal links + MAX_STEP; docs/steady_pinball_fiv.md
R_MIN = 4.0 # smallest radius covered by the donor enumeration
Vec = Tuple[float, float]
def _vec(value, name: str) -> Vec:
x, y = (float(v) for v in value)
if not (math.isfinite(x) and math.isfinite(y)):
raise ValueError(f"{name} must be finite, got {value!r}")
return x, y
@dataclass(frozen=True)
class Translation:
"""Declared motion envelope: offset box (x_min, x_max, y_min, y_max) and speed bound."""
offset_box: Tuple[float, float, float, float]
max_speed: float
shared: bool = False # same envelope instance: synchronous transverse motion
def __post_init__(self):
box = tuple(float(v) for v in self.offset_box)
speed = float(self.max_speed)
if len(box) != 4 or not all(math.isfinite(v) for v in box + (speed,)):
raise ValueError("offset_box needs 4 finite values and max_speed must be finite")
if not (box[0] <= 0.0 <= box[1] and box[2] <= 0.0 <= box[3]):
raise ValueError("offset_box must contain the zero offset")
if not 0.0 < speed <= MAX_STEP:
raise ValueError(f"max_speed must satisfy 0 < max_speed <= {MAX_STEP}")
if self.shared and box[:2] != (0.0, 0.0):
raise ValueError("shared translation is transverse only (x offsets must be zero)")
object.__setattr__(self, "offset_box", box)
object.__setattr__(self, "max_speed", speed)
def validate_shared_translations(circles):
"""Check common old/target offsets and wall velocities before device work.
Per-body setters may stage members in any order. Envelope identity (not
value equality) defines a group; a missing target means hold at applied.
"""
groups = {}
for motion in circles:
if not motion.translation.shared:
continue
target = motion.pending if motion.pending is not None else (motion.applied, (0.0, 0.0))
state = (motion.applied, target)
key = id(motion.translation)
if groups.setdefault(key, state) != state:
raise ValueError("shared translation needs identical applied/target offsets and velocities")
class MovingCircle:
"""Applied offset, pending target and the fail-closed commit of one circle."""
def __init__(self, base, radius: float, translation: Translation,
on_failure: Optional[Callable[[BaseException], None]] = None):
self.base = _vec(base, "base")
self.radius = float(radius)
if not (math.isfinite(self.radius) and self.radius >= R_MIN):
raise ValueError(f"moving circle radius must be >= {R_MIN}")
if not isinstance(translation, Translation):
raise TypeError("translation must be a Translation")
self.translation = translation
self.applied: Vec = (0.0, 0.0)
self.pending: Optional[Tuple[Vec, Vec]] = None
self.failed = False
self._on_failure = on_failure # owner lifecycle hook (Simulation marks itself failed)
def centre(self, offset: Vec) -> Vec:
return self.base[0] + offset[0], self.base[1] + offset[1]
def check_target(self, offset, velocity) -> Tuple[Vec, Vec]:
"""Validate a target against ``applied``; returns it, mutates nothing."""
self._require_usable()
offset, velocity = _vec(offset, "offset"), _vec(velocity, "velocity")
x0, x1, y0, y1 = self.translation.offset_box
if not (x0 <= offset[0] <= x1 and y0 <= offset[1] <= y1):
raise ValueError(f"offset {offset} outside declared box {self.translation.offset_box}")
if self.translation.shared and velocity[0] != 0.0:
raise ValueError("shared translation needs zero x velocity")
step = math.hypot(offset[0] - self.applied[0], offset[1] - self.applied[1])
if step > MAX_STEP:
raise ValueError(f"per-step displacement {step} exceeds {MAX_STEP}")
if math.hypot(*velocity) > self.translation.max_speed:
raise ValueError(f"velocity {velocity} exceeds max_speed {self.translation.max_speed}")
if offset == self.applied and velocity != (0.0, 0.0):
raise ValueError("a hold target (offset == applied) needs zero translational velocity")
return offset, velocity
def set_target(self, offset, velocity) -> None:
"""Replace the pending target (last wins); invalid input keeps the old one."""
self.pending = self.check_target(offset, velocity)
def record(self) -> Tuple[Vec, Vec, Vec]:
"""Motion record (c_old, c_new, u) for the next single moving step."""
self._require_usable()
if self.pending is not None:
offset, velocity = self.pending
else:
offset, velocity = self.applied, (0.0, 0.0)
return self.centre(self.applied), self.centre(offset), velocity
def commit(self) -> None:
"""Applied := pending offset (caller has synchronized a successful step)."""
if self.pending is not None:
self.applied = self.pending[0]
self.pending = None
def fail(self, exc: BaseException) -> None:
self.failed = True
if self._on_failure is not None:
self._on_failure(exc)
def band(self) -> Tuple[int, int, int, int]:
"""Inclusive cell AABB (x0, x1, y0, y1) swept over the whole offset box."""
x0, x1, y0, y1 = self.translation.offset_box
r, m = self.radius, BAND_MARGIN
return (math.floor(self.base[0] + x0 - r) - m, math.ceil(self.base[0] + x1 + r) + m,
math.floor(self.base[1] + y0 - r) - m, math.ceil(self.base[1] + y1 + r) + m)
def _require_usable(self) -> None:
if self.failed:
raise RuntimeError("moving circle state is unknown after a failed step")
class MovingTables:
"""Device tables of the translating circles for ``MovingCircleCells``/``Links``.
Band table (x0, y0, width, height, first_slot, body_id), immutable per run;
FP64 motion records (cx_old, cy_old, cx_new, cy_new, r, ux, uy), immutable
during a step; separate per-body diagnostics (see DIAG), never part of obs.
"""
BAND_INTS, GEOM_DOUBLES = 6, 7
DIAG = ("cme_x", "cme_y", "wen_x", "wen_y", "link_dm", "n_links",
"cover_jx", "cover_jy", "cover_mass", "uncover_jx", "uncover_jy", "uncover_mass",
"eq_only", "no_donor", "crossings")
DIAG_FLOATS = len(DIAG)
def __init__(self, band, geom):
import numpy as np
import pycuda.driver as cuda
self.n_bodies = len(geom) // self.GEOM_DOUBLES
self.n_slots = int(band[-2] + band[-4] * band[-3] * 8)
self.n_cells = self.n_slots // 8
self.geom = list(geom) # host copy of the uploaded records
self.band_gpu = cuda.to_device(np.asarray(band, np.int32))
self.geom_gpu = cuda.to_device(np.asarray(geom, np.float64))
# Owned page-locked staging for async record uploads (allocated once).
self._geom_pinned = cuda.pagelocked_empty(len(geom), np.float64)
self.diag_gpu = cuda.to_device(np.zeros(self.n_bodies * self.DIAG_FLOATS, np.float32))
self.slot_debug_gpu = None # optional (q, fallback, rx, ry) per slot for parity tests
@classmethod
def upload(cls, circles):
"""``circles``: [(MovingCircle, body_id)] in body order."""
return cls(*cls.layout(circles))
@staticmethod
def layout(circles):
"""Host band and geometry tables (no device work)."""
band, geom, first = [], [], 0
for motion, body_id in circles:
x0, x1, y0, y1 = motion.band()
width, height = x1 - x0 + 1, y1 - y0 + 1
band += [x0, y0, width, height, first, int(body_id)]
first += width * height * 8
geom += MovingTables.record(motion)
return band, geom
@staticmethod
def record(motion):
"""FP64 motion record of one circle for its next step (hold if nothing pending)."""
c_old, c_new, u = motion.record()
return [*c_old, *c_new, motion.radius, *u]
def upload_records(self, circles, stream) -> bool:
"""Enqueue the records when they differ from the device copy; True if advancing.
The pinned staging buffer is reused: callers synchronize ``stream`` after every
upload before the next one (``Simulation._run_moving``), so no copy is in flight
when it is rewritten. ``geom`` is the enqueued state; after a failed copy the
owner is failed and the cache is never trusted again.
"""
import pycuda.driver as cuda
geom = [v for motion, _ in circles for v in self.record(motion)]
if geom != self.geom:
self._geom_pinned[:] = geom
cuda.memcpy_htod_async(self.geom_gpu, self._geom_pinned, stream)
self.geom = geom
g = self.GEOM_DOUBLES
return any(geom[i:i + 2] != geom[i + 2:i + 4] for i in range(0, len(geom), g))
def enable_slot_debug(self):
import numpy as np
import pycuda.driver as cuda
self.slot_debug_gpu = cuda.to_device(np.zeros(self.n_slots * 4, np.float32))
def zero_diagnostics(self, stream):
import pycuda.driver as cuda
cuda.memset_d32_async(self.diag_gpu, 0, self.n_bodies * self.DIAG_FLOATS, stream)
def download_diagnostics(self):
import numpy as np
import pycuda.driver as cuda
out = np.empty((self.n_bodies, self.DIAG_FLOATS), np.float32)
cuda.memcpy_dtoh(out, self.diag_gpu)
return out
-1
View File
@@ -140,7 +140,6 @@ class SimObject:
x=float(c[0]), y=float(c[1]), z=z0,
)
self.control = ObjectControl()
self.motion = None # MovingCircle for a translating circle (body/motion.py)
@property
def surface_actuation(self):
+1 -10
View File
@@ -1,6 +1,6 @@
# CelerisLab/body/preprocess/flag_overlay.py
"""
Flag overlay and cut-link ownership checks against the merged domain.
Flag overlay: merge per-object flag masks onto a clean domain base.
"""
from __future__ import annotations
@@ -12,15 +12,6 @@ import numpy as np
from ...lbm.descriptors import FLUID, SOLID, SENSOR_FLAG, FRC_REGION
def validate_cut_link_ownership(flags, fluid_idx, directions, nq):
"""Reject solid receivers and multiple writers to one population."""
if np.any((flags[fluid_idx] & FLUID) == 0) or np.any(flags[fluid_idx] & SOLID):
raise ValueError("Cut links must have active fluid receivers")
writers = fluid_idx.astype(np.uint64) * nq + directions
if len(np.unique(writers)) != len(directions):
raise ValueError("Boundary requires unique cut-link writers")
def merge_flag_masks(
base: np.ndarray,
masks: List[np.ndarray],
+1 -1
View File
@@ -7,7 +7,7 @@ Two JSON files:
config_body.json – object list
LBMConfig / BodyConfig are plain dataclasses; they translate to
macro dicts consumed by `cuda/compiler.py` for header generation.
macro dicts consumed by `cuda/compiler_v2.py` for header generation.
"""
import json
+2 -16
View File
@@ -60,14 +60,14 @@ Python `config.py` 只负责读取和校验,不是配置位置。显式配置
**`lbm/kernels/config/*.h` 由编译器根据 `LBMConfig` 自动生成,请勿手改。**
仓库中检入的 `config/*.h` 可能是历史版本。`compiler.generate_config` 会复制 kernel 源码到独立临时构建目录,并在副本中生成配置;不会改写包内头文件或 PTX。返回目录须显式传给 `compile_kernel(build_dir=...)`,编译结果与配置摘要均保存在该目录。`Simulation` 负责目录清理;直接使用编译器的调用者须调用 `cleanup_build_dir()`。跳过生成配置直接编译仓库中的 `uniform.cu` 属不受支持用法。
仓库中检入的 `config/*.h` 可能是历史版本。`compiler_v2.generate_config` 会复制 kernel 源码到独立临时构建目录,并在副本中生成配置;不会改写包内头文件或 PTX。返回目录须显式传给 `compile_kernel(build_dir=...)`,编译结果与配置摘要均保存在该目录。`Simulation` 负责目录清理;直接使用编译器的调用者须调用 `cleanup_build_dir()`。跳过生成配置直接编译仓库中的 `kernel_v2.cu` 属不受支持用法。
```
config_lbm.json
↓ load_lbm_config()
config.py:LBMConfig (读取 + 校验)
↓ to_macros()
cuda/compiler.py (独立构建目录中生成 config/*.h)
cuda/compiler_v2.py (独立构建目录中生成 config/*.h)
↓
config_grid.h → NX, NY, NZ, NT, LATTICE_MODEL;
DIM / NQ 由 LATTICE_MODEL 推导写入(与 grid.lattice_model 一致,非 JSON 顶层字段)
@@ -86,20 +86,6 @@ step/one_step_*.cu → kernel 编排
- 转速 `omega` 属于运行时状态,可在步进期间更新;该更新不改变 `N_OBJS` 或 `OBS_*` 内存布局,因此**不需要**因 `omega` 变化触发重新编译。
- 力矩观测从 `obs` 的 torque segment 读取;force / torque / sensor 段统一由 `config_obs.h` 宏描述。
### 外边界能力(Outer boundaries, dense `Simulation`)
The dense path supports one channel package, not four independently
selectable sides:
- left x = 0: velocity inlet (`method.inlet.scheme`, `profile`, `physics.velocity`);
- right x = nx−1: outlet (`method.outlet.mode`; `neq_extrap` holds rho = 1 with
a `max(u, 0)` backflow clamp when `backflow_clamp` is set);
- bottom and top: one shared `y_wall_bc` (`bounce_back` halfway or
`free_slip`); corners are walls.
`y_wall_bc = "on_site_velocity"` switches to x-periodic with on-site y planes
and requires an object-free domain. There is no x/y-periodic or symmetry
option for domains with bodies; adding outer BC types is a separate design
question, not covered by these keys.
### 稳定性默认窗口(Stability defaults)
- 默认 `method.omega_guard = {min: 0.01, max: 1.99}`,用于约束碰撞频率与 LES 有效粘度路径。
+1 -1
View File
@@ -1,4 +1,4 @@
"""CUDA build helpers (compilation itself does not need a live context)."""
from . import compiler
from . import compiler_v2 as compiler
__all__ = ["compiler"]
@@ -1,13 +1,11 @@
# CelerisLab/cuda/compiler.py
# CelerisLab/cuda/compiler_v2.py
"""
Kernel configuration, compilation, and module loading.
Owners call build_module(cfg, entry, arch=...), then set_params(module, omega, ...).
build_module runs, in a private directory it removes afterwards:
Workflow:
1. build_dir = generate_config(lbm_cfg, n_objects) — isolated source snapshot
2. compile_kernel(arch, source=entry, build_dir=build_dir) — nvcc -ptx
2. compile_kernel(arch, build_dir=build_dir) — nvcc -ptx kernel_v2.cu
3. load_module(ptx_path) — PyCUDA module from PTX
Entries: uniform.cu (uniform grid), blocked.cu (every blocked level, single or refined).
"""
from __future__ import annotations
@@ -18,6 +16,7 @@ import shutil
import tempfile
import hashlib
import json
from contextvars import ContextVar
from dataclasses import asdict
from pathlib import Path
from typing import Optional
@@ -34,6 +33,9 @@ _KERNEL_DIR = os.path.join(
)
_last_build_dir = ContextVar("celeris_last_build_dir", default=None)
_last_ptx_path = ContextVar("celeris_last_ptx_path", default=None)
def kernel_path(filename: str) -> str:
return os.path.join(_KERNEL_DIR, filename)
@@ -52,16 +54,14 @@ def _write(path: str, content: str):
def generate_config(cfg: LBMConfig, n_objects: int = 0,
*, build_root: Optional[str] = None, block_root_bc: int = 0) -> str:
*, build_root: Optional[str] = None) -> str:
"""Snapshot kernels and render headers in a fresh private directory.
Returns its path; pass it to compile_kernel(build_dir=...). The caller owns cleanup_build_dir() once the loaded module is no longer
Returns its path. Pass it explicitly to compile_kernel for interleaved
builds. Legacy generate_config(); compile_kernel() remains context-local.
The caller owns cleanup_build_dir() once the loaded module is no longer
needed for recompilation/debugging. No package files are changed.
block_root_bc specializes a blocked root module; fine/dense builds use 0.
"""
if (isinstance(block_root_bc, bool) or not isinstance(block_root_bc, int)
or block_root_bc not in (0, 1, 2)):
raise ValueError("block_root_bc must be an integer mode 0, 1 or 2")
macros = cfg.to_macros()
if isinstance(n_objects, bool) or not isinstance(n_objects, int) or n_objects < 0:
raise ValueError("n_objects must be a nonnegative integer")
@@ -71,19 +71,20 @@ def generate_config(cfg: LBMConfig, n_objects: int = 0,
try:
shutil.copytree(_KERNEL_DIR, build_dir, dirs_exist_ok=True,
ignore=shutil.ignore_patterns("*.ptx", "*.cubin", "*.tmp"))
_render_config(macros, n_objects, os.path.join(build_dir, "config"),
block_root_bc=block_root_bc)
_render_config(macros, n_objects, os.path.join(build_dir, "config"))
digest = hashlib.sha256()
for path in sorted(Path(build_dir).rglob("*")):
if path.is_file():
digest.update(str(path.relative_to(build_dir)).encode())
digest.update(path.read_bytes())
metadata = {"source_sha256": digest.hexdigest(), "config": asdict(cfg),
"n_objects": n_objects, "block_root_bc": block_root_bc}
"n_objects": n_objects}
_write(os.path.join(build_dir, ".celeris-build"), json.dumps(metadata, sort_keys=True))
except BaseException:
shutil.rmtree(build_dir)
raise
_last_build_dir.set(build_dir)
_last_ptx_path.set(None)
return build_dir
@@ -95,9 +96,12 @@ def cleanup_build_dir(build_dir: Optional[str]) -> None:
if path == Path(_KERNEL_DIR).resolve() or not (path / ".celeris-build").is_file():
raise ValueError("Refusing to remove a directory not owned by the compiler")
shutil.rmtree(path)
if _last_build_dir.get() == str(build_dir):
_last_build_dir.set(None)
_last_ptx_path.set(None)
def _render_config(m: dict, n_objects: int, cdir: str, *, block_root_bc: int = 0):
def _render_config(m: dict, n_objects: int, cdir: str):
_write(os.path.join(cdir, "config_grid.h"), f"""{_HEADER}\
// Layer: Global / Grid
#ifndef CELERIS_CONFIG_GRID_H
@@ -161,9 +165,6 @@ def _render_config(m: dict, n_objects: int, cdir: str, *, block_root_bc: int = 0
#define STORE_PRECISION {m['STORE_PRECISION']}
#define USE_DDF_SHIFTING {m['USE_DDF_SHIFTING']}
// Internal blocked root mode: 0=periodic/fine, 1=slot/reservoir, 2=channel.
#define BLOCK_ROOT_BC {block_root_bc}
#define USE_LES {m['USE_LES']}
#define LES_CS {m['LES_CS']}
#define LES_CLOSED_FORM {m['LES_CLOSED_FORM']}
@@ -240,9 +241,13 @@ def _render_config(m: dict, n_objects: int, cdir: str, *, block_root_bc: int = 0
# ---------------------------------------------------------------------------
# Compilation
# ---------------------------------------------------------------------------
def compile_kernel(arch: str = "sm_70", source: str = "uniform.cu",
output: Optional[str] = None, *, build_dir: str) -> str:
"""Compile the snapshot in build_dir (from generate_config); never package headers."""
def compile_kernel(arch: str = "sm_70", source: str = "kernel_v2.cu",
output: Optional[str] = None, *,
build_dir: Optional[str] = None) -> str:
"""Compile a generated private source snapshot; never use package headers."""
# A failed attempt must not make implicit load_module() reuse older code.
_last_ptx_path.set(None)
build_dir = build_dir or _last_build_dir.get()
if not build_dir or not (Path(build_dir) / ".celeris-build").is_file():
raise RuntimeError("Call generate_config() and pass its build_dir before compiling")
root = Path(build_dir).resolve()
@@ -267,36 +272,23 @@ def compile_kernel(arch: str = "sm_70", source: str = "uniform.cu",
finally:
if os.path.exists(tmp):
os.unlink(tmp)
_last_ptx_path.set(str(dst))
return str(dst)
def load_module(ptx_path: str):
"""Load one compiled PTX file as a new PyCUDA module."""
def load_module(ptx_path: Optional[str] = None):
"""Load explicit PTX or the last PTX compiled in this Python context."""
ptx_path = ptx_path or _last_ptx_path.get()
if not ptx_path:
raise RuntimeError("No compiled PTX; call compile_kernel() first")
import pycuda.driver as cuda
return cuda.module_from_file(ptx_path)
def build_module(cfg: LBMConfig, source: str, *, arch: str, n_objects: int = 0,
block_root_bc: int = 0):
"""Generate, compile and load one private module; the build directory is removed.
# ---------------------------------------------------------------------------
# Back-compat helpers (used by test scripts)
# ---------------------------------------------------------------------------
The single build path of every owner (Simulation, FixedPatchStepper levels).
Each call returns a NEW module: callers must not share it, because its
globals (d_params, ...) are mutable per module.
"""
build_dir = generate_config(cfg, n_objects=n_objects, block_root_bc=block_root_bc)
try:
return load_module(compile_kernel(arch, source=source, build_dir=build_dir))
finally:
cleanup_build_dir(build_dir)
def set_params(module, omega: float, fx: float = 0.0, fy: float = 0.0, fz: float = 0.0,
n_objects: int = 0) -> None:
"""Write the module's __constant__ LBMParams (core/params.cuh layout)."""
import struct
import pycuda.driver as cuda
ptr, size = module.get_global("d_params")
if size != struct.calcsize("=ffffI"):
raise RuntimeError("LBMParams host/device layout mismatch")
cuda.memcpy_htod(ptr, struct.pack("=ffffI", omega, fx, fy, fz, n_objects))
def compile_kernel_v2(arch: str = "sm_70") -> str:
"""Alias for compile_kernel() kept for test-script compatibility."""
return compile_kernel(arch=arch)
+1 -1
View File
@@ -2,7 +2,7 @@
"""Packed ``obs`` buffer layout for force / torque / sensor telemetry.
Usage::
Host code (e.g. ``ObjectManager``) and ``compiler.generate_config`` must use
Host code (e.g. ``ObjectManager``) and ``compiler_v2.generate_config`` must use
the same ``dim`` and ``n_objects`` so allocation sizes match device macros.
Design::
+286
View File
@@ -0,0 +1,286 @@
# CelerisLab/lbm/block_stepper.py
"""Opt-in periodic backend for one BlockLayout2D level, optional static circles.
D2Q9/SRT/FP32 unforced double buffers. Each step refreshes every halo from
its owner core, then pulls/collides core cells into the spare buffer, then
swaps. Only halo pair tables are uploaded; dense ownership stays on the host.
The caller owns the CudaContext and must close this backend before it.
Optional ``objects`` are stationary passive circles (static linear BFL via
receiver-local staging: halo refresh -> cut links -> pull, no copy between).
No force/actuation readout, motion, walls, refinement, checkpoint or runtime
parameter mutation; wall/slot/inlet fields in cfg are ignored.
"""
from dataclasses import replace
from numbers import Integral
import struct
from threading import RLock
import numpy as np
import pycuda.driver as cuda
from ..cuda import compiler_v2 as compiler
from .blocks import NQ, BlockLayout2D, blocked_link_tables
from .descriptors import D2Q9_EX, D2Q9_EY, FLUID, SOLID
from .population_storage import _StoredPopulations, _encode_physical, _decode_stored
MAX_GRID_DIM = 65535 # CUDA gridDim.y/z limit (rows, blocks)
def _static_circles(layout, objects):
"""Host tables for stationary circles; rejects before any CUDA work.
Every periodic neighbor of every fluid core is inspected, including domain
edges that CircleGeometry does not enumerate, so clipped or incomplete
geometry cannot pass. Each physical link has one receiver-owning block.
"""
from ..body.geometry.circle import CircleGeometry
ny, nx = layout.shape
flags = np.full(ny * nx, FLUID, np.uint16)
for obj in objects:
if (type(obj.geometry) is not CircleGeometry or obj.is_sensor or obj.is_force_region
or obj.surface_actuation is not None
or any(getattr(obj.state, name) != 0 for name in ("vx", "vy", "omega"))
or any(vars(obj.control).values())):
raise ValueError("Block bodies must be stationary passive circles")
cx, cy = obj.geometry.center
radius = obj.geometry._radius
if not np.isfinite([cx, cy, radius]).all() or radius <= 0:
raise ValueError("Block circle center/radius must be finite with radius > 0")
mask = obj.geometry.build_flag_mask(nx, ny)
solid = (mask & SOLID) != 0
if not solid.any() or np.any(flags[solid] & SOLID):
raise ValueError("Block circles must be resolved and disjoint")
flags[solid] = mask[solid]
solid = ((flags & SOLID) != 0).reshape(ny, nx)
if solid[[0, -1]].any() or solid[:, [0, -1]].any():
raise ValueError("Block circles must not touch the domain edge")
links = [link for obj in objects
for link in obj.geometry.build_cut_links(nx, ny, domain_flags=flags)]
def source(r, q):
y, x = divmod(r, nx)
return (y + D2Q9_EY[q]) % ny, (x + D2Q9_EX[q]) % nx
receivers = {(int(link.fluid_idx), int(link.dir)) for link in links}
required = {(int(r), q) for r in np.flatnonzero(~solid.ravel())
for q in range(1, NQ) if solid[source(r, q)]}
if len(receivers) != len(links) or receivers != required:
raise ValueError("Cut links must cover every fluid pull from a solid exactly once")
tables, _ = blocked_link_tables(
layout, flags, np.array([link.fluid_idx for link in links], np.uint32),
np.array([link.dir for link in links], np.uint8),
np.array([link.q for link in links], np.float32),
np.array([link.fallback for link in links], np.uint8))
return dict(solid=solid, **tables)
class PeriodicBlockStepper:
"""G->G stepping of Q-major (9, ny, nx) postcollision populations.
On a device/launch failure the instance becomes unusable: close and
recreate it rather than assuming a partially advanced buffer rolled back.
"""
def __init__(self, cfg, layout, *, context, objects=()):
if not isinstance(layout, BlockLayout2D) or layout.periodic != (True, True):
raise ValueError("PeriodicBlockStepper requires a doubly periodic BlockLayout2D")
checked = replace(cfg)
checked.validate()
if (not checked.is_d2q9 or checked.collision != "SRT"
or checked.streaming != "double_buffer" or checked.store_precision != "FP32"
or checked.les_enabled
or (checked.ny, checked.nx) != layout.shape):
raise ValueError("PeriodicBlockStepper requires matching D2Q9/SRT/FP32/"
"double_buffer config without LES")
ly, lx = layout.local_shape
pairs = len(layout.halo_dst)
tpb = checked.threads_per_block
if layout.nblocks > MAX_GRID_DIM or ly > MAX_GRID_DIM or not 0 < pairs < 2**31:
raise ValueError("block count, block rows or halo pairs exceed launch limits")
objects = tuple(objects) # an empty iterable takes the body-free path
self.boundary = _static_circles(layout, objects) if objects else None
links = 0 if self.boundary is None else len(self.boundary["cell"])
if links >= 2**31:
raise ValueError("cut links exceed launch limits")
self._ddf_shifting = bool(checked.ddf_shifting)
self.layout = layout
self.ctx = context
self._links = links
self._link_grid = ((links + tpb - 1) // tpb, 1)
# Wall/slot fields are not applied; neutralize them like the fine patch level.
self._local_cfg = replace(checked, nx=lx, ny=ly, y_wall_bc="bounce_back", top_wall_bc="velocity",
wall_slot_width=0, wall_normal_velocity=0.0, velocity=0.0)
self._block = (tpb, 1, 1)
self._grid = ((lx + tpb - 1) // tpb, ly, layout.nblocks)
self._copy_grid = ((pairs + tpb - 1) // tpb, 1)
self._bytes = int(np.prod(layout.blocked_shape)) * 4
self._lock = RLock()
self._status = "new"
self._step_count = 0
self._allocations = []
self._stream = self._module = self._copy = self._step = self._curved = None
self._tables = {}
with self.ctx.activate():
try:
free, _ = cuda.mem_get_info()
tables = sum(a.nbytes for k, a in (self.boundary or {}).items() if k != "solid")
if 2 * self._bytes + layout.halo_dst.nbytes + layout.halo_src.nbytes + tables > free:
raise MemoryError("blocked buffers exceed free device memory")
self._stream = cuda.Stream()
config = self._local_cfg
config.validate()
if not np.isclose(config.omega, 1 / (3 * checked.viscosity + .5), rtol=1e-14, atol=0):
raise RuntimeError("block omega/tau mismatch")
build = compiler.generate_config(config)
try:
ptx = compiler.compile_kernel(self.ctx.sm_arch, source="blocks.cu", build_dir=build)
module = compiler.load_module(ptx)
finally:
compiler.cleanup_build_dir(build)
ptr, size = module.get_global("d_params")
if size != struct.calcsize("=ffffI"):
raise RuntimeError("LBMParams host/device layout mismatch")
cuda.memcpy_htod(ptr, struct.pack("=ffffI", config.omega, 0, 0, 0, 0))
self._module = module
self._current = self._allocate(self._bytes)
self._spare = self._allocate(self._bytes)
self._dst = self._upload(layout.halo_dst)
self._src = self._upload(layout.halo_src)
self._copy = module.get_function("BlockHaloCopy")
self._step = module.get_function("BlockStep")
if self.boundary is not None:
self._tables = {k: self._upload(a) for k, a in self.boundary.items() if k != "solid"}
self._curved = module.get_function("BlockCurvedBoundary")
except BaseException:
try:
self.close()
except Exception:
pass # Preserve the original construction failure.
raise
def _allocate(self, size):
pointer = cuda.mem_alloc(size)
self._allocations.append(pointer)
return pointer
def _upload(self, array):
array = np.ascontiguousarray(array)
pointer = self._allocate(array.nbytes)
cuda.memcpy_htod(pointer, array)
return pointer
def _require(self, *states):
if self._status not in states:
raise RuntimeError(f"Block stepper is {self._status}; expected {' or '.join(states)}")
@property
def step_count(self):
return self._step_count
def initialize(self, g):
"""Upload dense G_0; the spare buffer starts NaN so stale halos cannot hide."""
with self._lock, self.ctx.activate():
self._require("new", "ready")
dense = np.array(g, dtype=np.float64 if self._ddf_shifting else np.float32,
order="C", copy=True)
if dense.shape != (NQ, *self.layout.shape):
raise ValueError("Expected a Q-major (9, ny, nx) array")
fluid = np.ones(self.layout.shape, bool) if self.boundary is None else ~self.boundary["solid"]
if not np.isfinite(dense[:, fluid]).all() or (dense[:, fluid].sum(axis=0) <= 0).any():
raise ValueError("Fluid populations must be finite with positive density")
dense[:, ~fluid] = np.nan # Solid cores are deliberately invalid.
encoded = _encode_physical(dense, self._ddf_shifting)
values = encoded[:, fluid]
if (not np.isfinite(values).all()
or (values.sum(axis=0, dtype=np.float64) + int(self._ddf_shifting) <= 0).any()):
raise ValueError("Stored fluid populations must be finite with positive density")
blocked = self.layout.to_blocked(encoded)
spare = np.full_like(blocked, np.nan)
try:
cuda.memcpy_htod(self._current, blocked)
cuda.memcpy_htod(self._spare, spare)
self._step_count = 0
self._status = "ready"
except BaseException:
self._status = "failed"
raise
def step(self, n=1):
"""Advance n steps: halo refresh, then core pull/collide, per step."""
if isinstance(n, bool) or not isinstance(n, Integral) or n < 0:
raise ValueError("n must be a nonnegative integer")
with self._lock, self.ctx.activate():
self._require("ready")
kw = dict(block=self._block, stream=self._stream)
try:
self._status = "advancing"
for _ in range(n):
self._copy(self._current, self._dst, self._src,
np.uint32(len(self.layout.halo_dst)), grid=self._copy_grid, **kw)
if self._curved is not None:
t = self._tables
self._curved(self._current, t["block"], t["cell"], t["dir"], t["q"],
t["fallback"], np.uint32(self._links), np.uintp(0), # no actuation
np.uintp(0), np.uintp(0), np.uintp(0), # no observation
grid=self._link_grid, **kw)
self._step(self._current, self._spare, np.uint32(self.layout.halo),
self._tables.get("flags", np.uintp(0)), np.uintp(0), # no kind
grid=self._grid, **kw)
self._stream.synchronize()
self._current, self._spare = self._spare, self._current
self._step_count += 1
self._status = "ready"
except BaseException:
self._status = "failed"
raise
def download(self):
"""Return physical core populations (FP64 when shifted); no host cache."""
snapshot = self._download_raw()
return _decode_stored(snapshot.values[0], snapshot.shifted)
def _download_raw(self):
"""Return one tagged dense FP32 population array without conversion."""
with self._lock, self.ctx.activate():
self._require("ready")
blocked = np.empty(self.layout.blocked_shape, np.float32)
try:
cuda.memcpy_dtoh(blocked, self._current)
except BaseException:
self._status = "failed"
raise
return _StoredPopulations((self.layout.to_dense(blocked),), self._ddf_shifting)
def close(self):
"""Release owned resources; retain them for retry if context activation fails."""
with self._lock:
if self._status == "closed":
return
self._status = "failed"
with self.ctx.activate():
self._status = "closed"
error = None
try:
if self._stream is not None:
self._stream.synchronize()
except Exception as exc:
error = exc
self._copy = self._step = self._curved = self._module = None
self._tables = {}
for pointer in self._allocations:
try:
pointer.free()
except Exception as exc:
error = error or exc
self._allocations.clear()
self._stream = None
if error is not None:
raise error
def __enter__(self):
return self
def __exit__(self, *exc):
try:
self.close()
except Exception:
if exc[0] is None:
raise
+2 -85
View File
@@ -18,9 +18,7 @@ all NQ populations from an owner core cell into another block's halo cell.
Periodicity is explicit per axis. Across a non-periodic exterior, halo cells
have no pair and stay invalid (NaN after packing): no wrap, no implied BC.
Host preparation plus thin launch wrappers: no CUDA import or allocation
(launch_* only call kernels and device tables the caller owns), no refinement
levels; level_config and static_circle_tables prepare one blocked level.
Preparation only: no CUDA, no boundary conditions, no refinement levels.
The one-cell same-level halo here is unrelated to the four-cell fine halo of
the fixed refinement patch (refinement.py), whose semantics are unchanged.
@@ -33,7 +31,7 @@ receiver-local boundary values: the solid's owner-core copy of that slot may
be stale, which is distinct from the unique core ownership of stored state.
No moving geometry is implied.
"""
from dataclasses import dataclass, replace
from dataclasses import dataclass
from numbers import Integral
from types import SimpleNamespace
@@ -375,84 +373,3 @@ def blocked_link_tables(layout, flags, fluid_idx, dir, q, fallback):
dir=columns[1].astype(np.uint8), q=columns[2].astype(np.float32),
fallback=columns[3].astype(np.uint8))
return tables, mapped
# One blocked level as FixedPatchStepper builds and launches it (any depth). Raw argument orders of the blocked.cu kernels live only here.
def level_config(cfg, layout, tau):
"""Kernel config of one blocked level: NX/NY = block window incl. halo.
Physical wall/slot/inlet fields are neutralized; a blocked root gets them
from its module globals (d_block_wall/d_block_channel) instead.
"""
ly, lx = layout.local_shape
local = replace(cfg, nx=lx, ny=ly, y_wall_bc="bounce_back", top_wall_bc="velocity",
wall_slot_width=0, wall_normal_velocity=0.0, velocity=0.0)
local.validate()
if not np.isclose(local.omega, 1 / tau, rtol=1e-14, atol=0):
raise RuntimeError("block level omega/tau mismatch")
return local
def launch_halo_copy(kernel, buffer, dst, src, count, *, block, stream):
"""BlockHaloCopy over ``count`` uploaded (dst, src) block-cell pairs."""
kernel(buffer, dst, src, np.uint32(count),
grid=((count + block[0] - 1) // block[0], 1), block=block, stream=stream)
def launch_curved_boundary(kernel, buffer, tables, count, *, block, stream,
velocity=None, rx=None, ry=None, raw=None):
"""BlockCurvedBoundary on uploaded link tables; None = no actuation/observation."""
none = np.uintp(0)
kernel(buffer, tables["block"], tables["cell"], tables["dir"], tables["q"], tables["fallback"],
np.uint32(count), none if velocity is None else velocity,
none if rx is None else rx, none if ry is None else ry, none if raw is None else raw,
grid=((count + block[0] - 1) // block[0], 1), block=block, stream=stream)
def static_circle_tables(layout, objects):
"""Host tables for stationary circles; rejects before any CUDA work.
Every periodic neighbor of every fluid core is inspected, including domain
edges that CircleGeometry does not enumerate, so clipped or incomplete
geometry cannot pass. Each physical link has one receiver-owning block.
"""
from ..body.geometry.circle import CircleGeometry
from .descriptors import FLUID, SOLID
ny, nx = layout.shape
flags = np.full(ny * nx, FLUID, np.uint16)
for obj in objects:
if (type(obj.geometry) is not CircleGeometry or obj.is_sensor or obj.is_force_region
or obj.surface_actuation is not None
or any(getattr(obj.state, name) != 0 for name in ("vx", "vy", "omega"))
or any(vars(obj.control).values())):
raise ValueError("Block bodies must be stationary passive circles")
cx, cy = obj.geometry.center
radius = obj.geometry._radius
if not np.isfinite([cx, cy, radius]).all() or radius <= 0:
raise ValueError("Block circle center/radius must be finite with radius > 0")
mask = obj.geometry.build_flag_mask(nx, ny)
solid = (mask & SOLID) != 0
if not solid.any() or np.any(flags[solid] & SOLID):
raise ValueError("Block circles must be resolved and disjoint")
flags[solid] = mask[solid]
solid = ((flags & SOLID) != 0).reshape(ny, nx)
if solid[[0, -1]].any() or solid[:, [0, -1]].any():
raise ValueError("Block circles must not touch the domain edge")
links = [link for obj in objects
for link in obj.geometry.build_cut_links(nx, ny, domain_flags=flags)]
def source(r, q):
y, x = divmod(r, nx)
return (y + D2Q9_EY[q]) % ny, (x + D2Q9_EX[q]) % nx
receivers = {(int(link.fluid_idx), int(link.dir)) for link in links}
required = {(int(r), q) for r in np.flatnonzero(~solid.ravel())
for q in range(1, NQ) if solid[source(r, q)]}
if len(receivers) != len(links) or receivers != required:
raise ValueError("Cut links must cover every fluid pull from a solid exactly once")
tables, _ = blocked_link_tables(
layout, flags, np.array([link.fluid_idx for link in links], np.uint32),
np.array([link.dir for link in links], np.uint8),
np.array([link.q for link in links], np.float32),
np.array([link.fallback for link in links], np.uint8))
return dict(solid=solid, **tables)
+4 -2
View File
@@ -94,10 +94,12 @@ class CurvedLinkSoA:
"""
self.surface_velocity = self.aperture = None
self.jets, self.jet_shape, self.jet_id = tuple(jets), None, None
from ..body.jets import _surface_links
self.surface_velocity, self.aperture, self.jet_shape, self.jet_id = _surface_links(
self.surface_velocity, self.aperture = surface_link_velocity(
objects, cfg, flags, self.fluid_idx, self.dir, self.body_id, self.rx, self.ry,
jets=self.jets, hits=hits)
if self.jets:
from ..body.jets import jet_link_shapes
self.jet_shape, self.jet_id = jet_link_shapes(self.jets, hits)
def set_jet_amplitude(self, index, amplitude, stream=None):
"""Rewrite jet rows from cached shapes; re-upload only the velocity column.
+3 -32
View File
@@ -21,24 +21,7 @@ from .refinement import (ACTIVE, OUTER_GHOST, FixedPatch2D, FixedPatchSet2D,
@dataclass(frozen=True, eq=False)
class Hierarchy:
"""configs[l]: validated LBMConfig of level l; pairs[l]: tables of (l, l+1);
kinds[l]: composed per-cell kind (child coverage OUTER on every non-finest level).
Index conventions (the single place they are defined):
* level l = 0 (root) .. depth-1; pair index p = l-1 builds level l from level p;
* array shapes are (ny, nx); rectangles, origins and centers are (x, y);
* a pair's two members are its COARSE (parent) and FINE (child) level, for every
pair, not absolute L0/L1 (fine_shape, fine_origin, blocked_coarse_tables, ...);
* refined levels (l >= 1) of FixedPatchStepper place bodies and Jets in the root
EDGE frame: root cell i covers [i, i+1), center i+.5. Uniform Simulation objects
and the static circles of a single-level (depth 1) FixedPatchStepper use the
integer-NODE frame (CircleGeometry.build_flag_mask at integer (x, y)). No
NODE<->EDGE input conversion is applied anywhere; inside the EDGE frame, refined
boundaries still project root-edge coordinates to fine-local cell indices with
the half-cell shift ((v - o)/h - .5, see prepare_fine_boundary);
* cell (i, j) of level l's allocated array (ghost band and holes included) covers
[o + h*i, o + h*(i+1)) with (o, h) = edge_frame(l); level 1 is built from the root
config directly, deeper levels from their parent frame (see edge_frame).
"""
kinds[l]: composed per-cell kind (child coverage OUTER on every non-finest level)."""
configs: tuple
pairs: tuple
kinds: tuple
@@ -47,18 +30,6 @@ class Hierarchy:
def depth(self):
return len(self.kinds)
def edge_frame(self, level):
"""(origin (x, y), spacing) of level ``level``'s array in the root edge frame.
Each pair's fine_origin is in parent cells: zero for a set (level-wide grid),
the shifted window origin for a scalar FixedPatch2D.
"""
origin, spacing = (0.0, 0.0), 1.0
for pair in self.pairs[:level]:
origin = tuple(o + spacing * f for o, f in zip(origin, pair.fine_origin))
spacing /= 2
return origin, spacing
def is_rectangle(value):
value = tuple(value)
@@ -115,14 +86,14 @@ def _check_chain(coarse, fine, pair):
def leaf_partition(h):
"""Base-cell fraction published as ACTIVE over all levels (exact: powers of two).
Window (scalar) levels are offset by their fine_level_offset first.
Window (scalar) levels are offset by their fine_level_origin first.
"""
base = np.zeros(h.kinds[0].shape)
oy = ox = 0
for level, kind in enumerate(h.kinds):
if level:
pair = h.pairs[level - 1]
py, px = pair.fine_level_offset
py, px = (pair.fine_level_origin if isinstance(pair, FixedPatch2D) else (0, 0))
oy, ox = 2 * oy + py, 2 * ox + px
y, x = np.nonzero(kind == ACTIVE)
np.add.at(base, ((y + oy) >> level, (x + ox) >> level), 0.25 ** level)
-47
View File
@@ -1,47 +0,0 @@
// CelerisLab/lbm/kernels/blocked.cu
// One fixed-size blocked level (lbm/blocks.py layout), specialized at compile
// time for periodic/fine, slot/reservoir or channel stepping. Separate
// compilation entry: NX, NY are the block window including halo, so index_f
// and linear_index address one block; blockIdx.z selects the block, whose
// populations start at b*NQ*TOTAL_CELLS (block-contiguous, Q-major inside).
#include <stdint.h>
#include <math.h>
#include "config.h"
#include "core/precision.cuh"
#include "core/descriptors.cuh"
#include "core/layout.cuh"
#include "core/params.cuh"
#include "core/flags.cuh"
#include "core/obs.cuh"
#include "core/cell_kind.cuh"
#include "operators/macro.cuh"
#include "operators/collision_srt.cuh"
#if COLLISION_MODEL == 2
#include "operators/collision_mrt.cuh"
#endif
#include "streaming/pull_double_buffer.cuh"
#include "boundary/curved_boundary.cuh"
#if DIM != 2 || NQ != 9 || (COLLISION_MODEL != 0 && COLLISION_MODEL != 2) || STORE_PRECISION != 0 || USE_LES || STREAMING_MODEL != 0
#error "Blocks require FP32 D2Q9 SRT or MRT double buffers without LES"
#endif
#ifndef BLOCK_ROOT_BC
#error "Blocks require a generated BLOCK_ROOT_BC mode"
#elif BLOCK_ROOT_BC < 0 || BLOCK_ROOT_BC > 2
#error "BLOCK_ROOT_BC must be 0, 1 or 2"
#endif
#if BLOCK_ROOT_BC == 1
#include "boundary/block_wall.cuh"
#endif
#include "operators/block_collision.cuh"
// Kernels, in definition order: transfers, boundary staging, then the one full
// step selected for this module (BlockStep, or BlockChannelStep for a channel root).
#include "step/block_transfer.cu"
#include "step/block_boundary.cu"
#if BLOCK_ROOT_BC != 2
#include "step/block_step.cu"
#else
#include "step/block_channel.cu"
#endif
+173
View File
@@ -0,0 +1,173 @@
// CelerisLab/lbm/kernels/blocks.cu
// One periodic level of fixed-size blocks (lbm/blocks.py layout). Separate
// compilation entry: NX, NY are the block window including halo, so index_f
// and linear_index address one block; blockIdx.z selects the block, whose
// populations start at b*NQ*TOTAL_CELLS (block-contiguous, Q-major inside).
#include <stdint.h>
#include <math.h>
#include "config.h"
#include "core/precision.cuh"
#include "core/descriptors.cuh"
#include "core/layout.cuh"
#include "core/params.cuh"
#include "core/flags.cuh"
#include "core/obs.cuh"
#include "operators/macro.cuh"
#include "operators/collision_srt.cuh"
#if COLLISION_MODEL == 2
#include "operators/collision_mrt.cuh"
#endif
#include "streaming/pull_double_buffer.cuh"
#include "boundary/curved_boundary.cuh"
#include "boundary/planar_velocity.cuh"
#if DIM != 2 || NQ != 9 || (COLLISION_MODEL != 0 && COLLISION_MODEL != 2) || STORE_PRECISION != 0 || USE_LES || STREAMING_MODEL != 0
#error "Blocks require FP32 D2Q9 SRT or MRT double buffers without LES"
#endif
// Fixed-patch cell kinds, matching refinement.py ACTIVE/INNER_GHOST/OUTER_GHOST.
constexpr unsigned char KIND_ACTIVE = 0u, KIND_INNER = 1u, KIND_OUTER = 2u;
// Zero-initialized for ordinary blocks/fine levels. Set only for parent walls;
// NX/NY remain local storage strides, never physical wall coordinates.
struct BlockWall { unsigned int nx, ny, slot_width; float amplitude; };
extern "C" { __constant__ BlockWall d_block_wall; }
// Halo refresh: block cell ids b*TOTAL_CELLS+c, sources are always core cells.
extern "C" __global__ void BlockHaloCopy(
fpxx* f, const uint64_t* dst, const uint64_t* src, unsigned int count)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
const uint64_t d = dst[row], s = src[row];
fpxx* to = f + (d / TOTAL_CELLS) * NQ * TOTAL_CELLS;
const fpxx* from = f + (s / TOTAL_CELLS) * NQ * TOTAL_CELLS;
for (unsigned int q = 0; q < NQ; ++q)
to[index_f(d % TOTAL_CELLS, q)] = from[index_f(s % TOTAL_CELLS, q)];
}
// Fixed-patch explosion into blocked fine storage: target = owner block cell
// id of a patch-ghost cell; donors are parent block cell ids. Dense parent
// storage is the one-window case with coarse_cells = full domain size.
extern "C" __global__ void BlockExplode(
const fpxx* coarse, fpxx* fine, const uint64_t* target, const uint64_t* donor,
unsigned int count, unsigned long coarse_cells)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
const uint64_t t = target[row];
fpxx* to = fine + (t / TOTAL_CELLS) * NQ * TOTAL_CELLS;
const uint64_t d = donor[row];
const fpxx* from = coarse + (d / coarse_cells) * NQ * coarse_cells;
for (unsigned int q = 0; q < NQ; ++q)
to[index_f(t % TOTAL_CELLS, q)] = from[q * coarse_cells + d % coarse_cells];
}
// Targets are receiver-local SOURCE slots for the immediately following pull.
// Keep child summation order identical to PatchCoarseStep; no collision here.
extern "C" __global__ void BlockCoalesce(
const fpxx* fine, fpxx* coarse, const uint64_t* target, const uint64_t* children,
unsigned int count)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
const uint64_t* s = children + 4ul * row;
coarse[target[row]] = 0.25f * (fine[s[0]] + fine[s[1]] + fine[s[2]] + fine[s[3]]);
}
// Static cut links, receiver-local staging: cell is the receiver's local core
// cell, so the existing BFL writes the solid-source window image that this
// block's next pull reads (no halo copy between this kernel and BlockStep).
// surface_velocity (nullable, 2 floats per link): prescribed fluid velocity on
// fixed geometry, same convention as CurvedBoundaryKernel. Optional raw rows
// (jx,jy,torque,dm) are unique physical links, never body-observation slots.
extern "C" __global__ void BlockCurvedBoundary(
fpxx* f, const uint32_t* block, const uint32_t* cell, const unsigned char* dir,
const float* q, const unsigned char* fallback, unsigned int count,
const float* surface_velocity, const float* rx, const float* ry, float* raw)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
float Uw = 0.0f, Vw = 0.0f;
if (surface_velocity != nullptr) {
const float u = surface_velocity[2 * row], v = surface_velocity[2 * row + 1];
if (u != 0.0f || v != 0.0f) { Uw = u; Vw = v; }
}
apply_bouzidi_link(dir[row], q[row], cell[row], f + (unsigned long)block[row] * NQ * TOTAL_CELLS,
Uw, Vw, rx ? rx[row] : 0.0f, ry ? ry[row] : 0.0f,
fallback[row], nullptr, -1, raw ? raw + 4ul * row : nullptr,
raw ? raw + 4ul * row + 3 : nullptr);
}
// Same-stream BFL -> this row-owned accumulation -> pull step. No atomics,
// no cross-row reduction and no history allocation proportional to substeps.
extern "C" __global__ void BlockAccumulateExchange(
const float* raw, double* integral, unsigned int count, double h, double dt)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
const unsigned long k = 4ul * row;
const double impulse = h * dt;
integral[k] += impulse * (double)raw[k];
integral[k + 1] += impulse * (double)raw[k + 1];
integral[k + 2] += h * impulse * (double)raw[k + 2];
integral[k + 3] += h * h * (double)raw[k + 3];
}
// Pull/collide/store core cells only; halo cells of out are left untouched.
// flags (block cell ids, nullable): solid cores store NaN and never collide.
// kind (block cell ids, nullable = all ACTIVE): OUTER skips the pull and
// stores NaN, INNER pulls without collision, ACTIVE pulls and collides.
extern "C" __global__ void BlockStep(const fpxx* in, fpxx* out, unsigned int halo,
const uint16_t* flags, const unsigned char* kind) {
unsigned int x, y; unsigned long k;
index_from_thread(x, y, k);
if (x < halo || y < halo || x >= NX - halo || y >= NY - halo) return;
const unsigned long base = (unsigned long)blockIdx.z * NQ * TOTAL_CELLS;
const unsigned long cell = (unsigned long)blockIdx.z * TOTAL_CELLS + k;
const unsigned char cell_kind = kind != nullptr ? kind[cell] : KIND_ACTIVE;
float f[NQ];
if (cell_kind == KIND_OUTER || (flags != nullptr && is_solid(flags[cell]))) {
for (int q = 0; q < NQ; ++q) f[q] = NAN;
} else {
unsigned long j[NQ];
compute_neighbors(x, y, j); // Core neighbors stay inside the window: no wrap.
stream_pull_load(k, f, in + base, j);
if (cell_kind == KIND_ACTIVE) {
if (d_block_wall.nx != 0) {
const unsigned int bx = NX - 2 * halo, by = NY - 2 * halo;
const unsigned int nbx = d_block_wall.nx / bx;
const unsigned int gx = (blockIdx.z % nbx) * bx + x - halo;
const unsigned int gy = (blockIdx.z / nbx) * by + y - halo;
if (gy == 1)
reconstruct_bottom_velocity(f, 0.0f, bottom_wall_normal_velocity(
gx, d_block_wall.nx, d_block_wall.slot_width, d_block_wall.amplitude));
else if (gy == d_block_wall.ny - 2) reconstruct_top_reservoir(f, RHO);
}
#if COLLISION_MODEL == 2
// Uniform MRT sequence (collide_dispatch, zero forcing); SRT path below unchanged.
float rho, ux, uy;
compute_rho_u(f, rho, ux, uy);
collide_mrt_no_force(f, rho, ux, uy,
fminf(OMEGA_COLLISION_MAX, fmaxf(OMEGA_COLLISION_MIN, d_params.omega)));
#else
float rho, ux, uy, feq[NQ];
compute_rho_u(f, rho, ux, uy);
#if USE_DDF_SHIFTING
// Preserve the density perturbation before adding the unit reference.
const float drho = f[0]+f[1]+f[2]+f[3]+f[4]+f[5]+f[6]+f[7]+f[8];
const float jx = f[1]-f[2]+f[5]-f[6]+f[7]-f[8];
const float jy = f[3]-f[4]+f[5]-f[6]-f[7]+f[8];
compute_feq_from_momentum(drho, jx, jy, ux, uy, feq);
#else
compute_feq(rho, ux, uy, feq);
#endif
collide_srt_no_force(f, feq, d_params.omega);
#endif
}
}
stream_pull_store(k, f, out + base);
}
// Root channel closure; fine and periodic levels keep BlockStep.
#include "step/block_channel.cu"
@@ -1,20 +0,0 @@
// Physical parent walls; NX/NY remain local storage strides.
#ifndef CELERIS_BOUNDARY_BLOCK_WALL_CUH
#define CELERIS_BOUNDARY_BLOCK_WALL_CUH
#include "planar_velocity.cuh"
struct BlockWall { unsigned int nx, ny, slot_width; float amplitude; };
extern "C" { __constant__ BlockWall d_block_wall; }
__device__ __forceinline__ void block_wall(float* f, unsigned int x, unsigned int y,
unsigned int halo) {
unsigned int gx, gy;
block_global_xy(x, y, halo, d_block_wall.nx, gx, gy);
if (gy == 1)
reconstruct_bottom_velocity(f, 0.0f, bottom_wall_normal_velocity(
gx, d_block_wall.nx, d_block_wall.slot_width, d_block_wall.amplitude));
else if (gy == d_block_wall.ny - 2) reconstruct_top_reservoir(f, RHO);
}
#endif
@@ -15,13 +15,10 @@
// D2Q9 y-wall half-way bounce-back for pull double-buffer streaming.
// ---------------------------------------------------------------------------
#if LATTICE_MODEL == LATTICE_D2Q9
// Rows 1 and top_row (= physical ny - 2) next to walls at rows 0 and ny - 1.
// y is the physical row; k addresses the node in fi_in (a block window may
// have NX/NY strides different from the physical extent).
__device__ inline void apply_wall_bb_d2q9_rows(unsigned int y, unsigned int top_row,
float* __restrict__ f,
const fpxx* __restrict__ fi_in,
unsigned long k)
__device__ inline void apply_wall_bb_d2q9_y_pull(unsigned int y,
float* __restrict__ f,
const fpxx* __restrict__ fi_in,
unsigned long k)
{
if (y == 1) {
// Directions sourced from y=0 wall in pull step: +y, +x+y, -x+y.
@@ -29,22 +26,14 @@ __device__ inline void apply_wall_bb_d2q9_rows(unsigned int y, unsigned int top_
f[5] = load_ddf(fi_in, index_f(k, 6u));
f[8] = load_ddf(fi_in, index_f(k, 7u));
}
else if (y == top_row) {
// Directions sourced from the y = ny - 1 wall in pull step: -y, -x-y, +x-y.
else if (y == (unsigned int)(NY - 2)) {
// Directions sourced from y=NY-1 wall in pull step: -y, -x-y, +x-y.
f[4] = load_ddf(fi_in, index_f(k, 3u));
f[6] = load_ddf(fi_in, index_f(k, 5u));
f[7] = load_ddf(fi_in, index_f(k, 8u));
}
}
__device__ inline void apply_wall_bb_d2q9_y_pull(unsigned int y,
float* __restrict__ f,
const fpxx* __restrict__ fi_in,
unsigned long k)
{
apply_wall_bb_d2q9_rows(y, (unsigned int)(NY - 2), f, fi_in, k);
}
// ---------------------------------------------------------------------------
// D2Q9 y-wall free-slip (specular reflection) for pull double-buffer streaming.
//
@@ -8,9 +8,6 @@
#define STORE_PRECISION 0
#define USE_DDF_SHIFTING 0
// Internal blocked root mode: 0=periodic/fine, 1=slot/reservoir, 2=channel.
#define BLOCK_ROOT_BC 0
#define USE_LES 0
#define LES_CS 0.160000f
#define LES_CLOSED_FORM 1
@@ -1,16 +0,0 @@
// CelerisLab – core/cell_kind.cuh
// Fixed-patch cell kinds stored per block cell, matching lbm/refinement.py
// ACTIVE/INNER_GHOST/OUTER_GHOST: OUTER is invalid (stores NaN, no pull),
// INNER streams without collision, ACTIVE pulls and collides.
#ifndef CELERIS_CORE_CELL_KIND_CUH
#define CELERIS_CORE_CELL_KIND_CUH
constexpr unsigned char KIND_ACTIVE = 0u, KIND_INNER = 1u, KIND_OUTER = 2u;
// NaN populations mark a cell without valid state (OUTER ghost or solid core).
__device__ __forceinline__ void fill_invalid(float* f)
{
for (int q = 0; q < NQ; ++q) f[q] = NAN;
}
#endif
@@ -53,18 +53,6 @@ __device__ __forceinline__ void index_from_thread(unsigned int& x,
k = (unsigned long)y * NX + x;
}
// Global cell of window cell (x, y) in block blockIdx.z of a row-major,
// full-domain block layout domain_nx cells wide. NX/NY are the local strides.
__device__ __forceinline__ void block_global_xy(unsigned int x, unsigned int y,
unsigned int halo, unsigned int domain_nx,
unsigned int& gx, unsigned int& gy)
{
const unsigned int bx = NX - 2 * halo, by = NY - 2 * halo;
const unsigned int nbx = domain_nx / bx;
gx = (blockIdx.z % nbx) * bx + x - halo;
gy = (blockIdx.z / nbx) * by + y - halo;
}
// ---------------------------------------------------------------------------
// Neighbor list (periodic BCs via modular arithmetic)
// ---------------------------------------------------------------------------
@@ -44,4 +44,16 @@ struct RigidBodyControl2D {
int control_mode; // 0=force, 1=velocity, 2=displacement
};
// ============================================================================
// Halo plan (multi-GPU / AMR 预留)
// ============================================================================
struct HaloPlan {
int face_id;
int peer_gpu;
int level;
size_t count;
int* send_idx;
int* recv_idx;
};
#endif // CELERIS_CORE_PARAMS_CUH
@@ -1,4 +1,4 @@
// CelerisLab – uniform.cu
// CelerisLab – kernel_v2.cu
// ============================================================================
// Compilation entry point (Stage 2 Architecture).
//
@@ -7,7 +7,7 @@
//
// File layout (each <300 lines):
// operators/helpers.cuh — bounce_back_swap, collide_dispatch
// step/init_flow.cu — InitTubeFlow, InitEsoPull
// step/init_flow.cu — InitTubeFlow_v2, InitEsoPull
// step/one_step_double.cu — OneStep (pull double-buffer)
// step/one_step_esopull.cu — EsoPullStep (single-buffer)
// step/aux_kernels.cu — CurvedBoundaryKernel, SensorKernel
@@ -71,7 +71,6 @@ extern "C"
#include "step/one_step_double.cu"
#include "step/one_step_esopull.cu"
#include "step/aux_kernels.cu"
#include "step/moving_circle.cu"
#include "step/esopull_macro.cu"
#include "step/esopull_sync.cu"
@@ -1,47 +0,0 @@
// CelerisLab – operators/block_collision.cuh
// The one collision extension point of the blocked steps (BlockStep, BlockChannelStep).
// Unforced by construction: FixedPatchStepper supplies no forcing, so no runtime
// d_params.fx/fy branch is read. Two compile-time arithmetic families:
// uniform-equivalent (MRT on every level; SRT on the channel root, BLOCK_ROOT_BC 2):
// collide_dispatch arithmetic with zero force and no LES: clamped omega;
// SRT keeps the zero Guo forcing term of collide_srt;
// level SRT (BLOCK_ROOT_BC 0/1): unclamped omega, no forcing term; shifted storage
// builds feq from the retained drho and direct momentum (numerical variant).
#ifndef CELERIS_OPERATORS_BLOCK_COLLISION_CUH
#define CELERIS_OPERATORS_BLOCK_COLLISION_CUH
#include "../core/params.cuh"
#include "macro.cuh"
#include "collision_srt.cuh"
#if COLLISION_MODEL == 2
#include "collision_mrt.cuh"
#endif
__device__ __forceinline__ void block_collide(float* f) {
float rho, ux, uy;
compute_rho_u(f, rho, ux, uy);
#if COLLISION_MODEL == 2 || BLOCK_ROOT_BC == 2
const float omega = fminf(OMEGA_COLLISION_MAX, fmaxf(OMEGA_COLLISION_MIN, d_params.omega));
#if COLLISION_MODEL == 2
collide_mrt_no_force(f, rho, ux, uy, omega);
#else
float feq[NQ], forcing[NQ] = {};
compute_feq(rho, ux, uy, feq);
collide_srt(f, feq, forcing, omega);
#endif
#else
float feq[NQ];
#if USE_DDF_SHIFTING
// Preserve the density perturbation before adding the unit reference.
const float drho = f[0]+f[1]+f[2]+f[3]+f[4]+f[5]+f[6]+f[7]+f[8];
const float jx = f[1]-f[2]+f[5]-f[6]+f[7]-f[8];
const float jy = f[3]-f[4]+f[5]-f[6]-f[7]+f[8];
compute_feq_from_momentum(drho, jx, jy, ux, uy, feq);
#else
compute_feq(rho, ux, uy, feq);
#endif
collide_srt_no_force(f, feq, d_params.omega);
#endif
}
#endif
@@ -1,14 +0,0 @@
// CelerisLab – operators/bounce_back_swap.cuh
// Full-way bounce-back at a wall node: swap every opposite pair (paired
// order q, q+1). Needs only NQ; shared by the uniform and blocked steps.
#ifndef CELERIS_OPERATORS_BOUNCE_BACK_SWAP_CUH
#define CELERIS_OPERATORS_BOUNCE_BACK_SWAP_CUH
__device__ __forceinline__ void bounce_back_swap(float* f) {
#pragma unroll
for (int i = 1; i < NQ; i += 2) {
float t = f[i]; f[i] = f[i+1]; f[i+1] = t;
}
}
#endif
@@ -57,4 +57,25 @@ __device__ __forceinline__ void collide_trt(float* __restrict__ f,
}
}
__device__ __forceinline__ void collide_trt_no_force(float* __restrict__ f,
const float* __restrict__ feq,
float omega)
{
const float wp = omega;
const float wm = compute_omega_minus(wp);
f[0] = f[0] - wp * (f[0] - feq[0]);
#pragma unroll
for (int i = 1; i < NQ; i += 2) {
const int ib = i + 1;
const float fi = f[i], fb = f[ib];
const float feqi = feq[i], feqb = feq[ib];
const float delta_s = 0.5f * ((fi - feqi) + (fb - feqb));
const float delta_a = 0.5f * ((fi - feqi) - (fb - feqb));
f[i] = fi - wp * delta_s - wm * delta_a;
f[ib] = fb - wp * delta_s + wm * delta_a;
}
}
#endif // CELERIS_OPERATORS_COLLISION_TRT_CUH
@@ -5,7 +5,12 @@
#ifndef CELERIS_OPERATORS_HELPERS_CUH
#define CELERIS_OPERATORS_HELPERS_CUH
#include "bounce_back_swap.cuh"
__device__ __forceinline__ void bounce_back_swap(float* f) {
#pragma unroll
for (int i = 1; i < NQ; i += 2) {
float t = f[i]; f[i] = f[i+1]; f[i+1] = t;
}
}
__device__ __forceinline__ void collide_dispatch(
float* f, float rho_n,
+130
View File
@@ -0,0 +1,130 @@
// Fixed, unforced internal 2:1 patch. Separate compilation entry;
// default OneStep/channel kernels are deliberately not included.
#include <stdint.h>
#include <math.h>
#include "config.h"
#include "core/precision.cuh"
#include "core/descriptors.cuh"
#include "core/layout.cuh"
#include "core/params.cuh"
#include "core/flags.cuh"
#include "core/obs.cuh"
#include "operators/macro.cuh"
#include "operators/collision_srt.cuh"
#if COLLISION_MODEL == 2
#include "operators/collision_mrt.cuh"
#endif
#include "streaming/pull_double_buffer.cuh"
#include "boundary/planar_velocity.cuh"
#include "boundary/curved_boundary.cuh"
extern "C" {
#include "step/curved_double.cu"
}
#if DIM != 2 || NQ != 9 || (COLLISION_MODEL != 0 && COLLISION_MODEL != 2) || STORE_PRECISION != 0 || USE_DDF_SHIFTING || USE_LES || STREAMING_MODEL != 0
#error "Refinement requires unshifted FP32 D2Q9 SRT or MRT double buffers without LES"
#endif
// Enabled only in the validation build; no diagnostic arrays in normal builds.
#ifdef CELERIS_REFINEMENT_AUDIT
extern "C" {
__device__ unsigned int collision_counts[TOTAL_CELLS];
__device__ unsigned int explosion_counts[TOTAL_CELLS];
__device__ unsigned int incoming_counts[NQ * TOTAL_CELLS];
}
#endif
__device__ __forceinline__ void patch_collide(float* f, unsigned long k) {
#if COLLISION_MODEL == 2
float rho, ux, uy;
compute_rho_u(f, rho, ux, uy);
collide_mrt_no_force(f, rho, ux, uy,
fminf(OMEGA_COLLISION_MAX, fmaxf(OMEGA_COLLISION_MIN, d_params.omega)));
#else
float rho, ux, uy, feq[NQ];
compute_rho_u(f, rho, ux, uy);
compute_feq(rho, ux, uy, feq);
collide_srt_no_force(f, feq, d_params.omega);
#endif
#ifdef CELERIS_REFINEMENT_AUDIT
++collision_counts[k];
#endif
}
extern "C" __global__ void PatchExplode(
const float* coarse, float* fine, const uint32_t* targets,
const uint32_t* donors, unsigned int count, unsigned long coarse_cells)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
for (unsigned int q = 0; q < NQ; ++q)
fine[index_f(targets[row], q)] = coarse[q * coarse_cells + donors[row]];
#ifdef CELERIS_REFINEMENT_AUDIT
++explosion_counts[targets[row]];
#endif
}
extern "C" __global__ void PatchFineStep(const float* in, float* out, const uint16_t* flags) {
unsigned int x, y; unsigned long k;
index_from_thread(x, y, k);
if (x >= NX || y >= NY) return;
float f[NQ];
// Outer halo is invalid after EACH hop, including when reusing the buffer
// that held explosion data. Inner halo only streams; it never collides.
if (x < 2 || y < 2 || x >= NX - 2 || y >= NY - 2
|| (flags != nullptr && is_solid(flags[k]))) {
for (int q = 0; q < NQ; ++q) f[q] = NAN;
} else {
unsigned long j[NQ];
compute_neighbors(x, y, j); // These interior targets never wrap.
stream_pull_load(k, f, in, j);
if (x >= 4 && y >= 4 && x < NX - 4 && y < NY - 4)
patch_collide(f, k);
}
stream_pull_store(k, f, out);
}
extern "C" __global__ void PatchCoarseStep(
const float* in, float* out, const float* fine,
const uint64_t* targets, const uint64_t* children, unsigned int count,
unsigned int x0, unsigned int y0, unsigned int x1, unsigned int y1,
unsigned int wall_aware)
{
unsigned int x, y; unsigned long k;
index_from_thread(x, y, k);
if (x >= NX || y >= NY) return;
// Both buffers were poisoned at initialization; covered cells stay invalid.
if (x >= x0 && x < x1 && y >= y0 && y < y1) return;
if (wall_aware && (y == 0 || y == NY - 1)) return;
unsigned long j[NQ];
compute_neighbors(x, y, j);
float f[NQ];
stream_pull_load(k, f, in, j);
if (x >= x0 - 1 && x <= x1 && y >= y0 - 1 && y <= y1) {
for (unsigned int q = 1; q < NQ; ++q) {
uint64_t slot = index_f(k, q);
unsigned int lo = 0, hi = count;
while (lo < hi) {
unsigned int mid = lo + (hi - lo) / 2;
if (targets[mid] < slot) lo = mid + 1;
else hi = mid;
}
if (lo < count && targets[lo] == slot) {
const uint64_t* s = children + 4ul * lo;
f[q] = 0.25f * (fine[s[0]] + fine[s[1]] + fine[s[2]] + fine[s[3]]);
#ifdef CELERIS_REFINEMENT_AUDIT
++incoming_counts[slot];
#endif
}
}
}
// Includes the eight grazing-corner links with UNCOVERED coarse donors.
// All replacement arrivals must be in place before computing rho/u.
if (wall_aware) {
if (y == 1) reconstruct_bottom_velocity(f, 0.0f, bottom_wall_normal_velocity(x));
else if (y == NY - 2) reconstruct_top_reservoir(f, RHO);
}
patch_collide(f, k);
stream_pull_store(k, f, out);
}
@@ -3,7 +3,7 @@
// ApplyEsoPullCurvedKernel, SensorKernel, ForceRegionKernel.
//
// Launched on compact lists (n_curved / n_sensor / n_cells threads).
// Included by uniform.cu. No standalone compilation.
// Included by kernel_v2.cu. No standalone compilation.
//
// Double-buffer mode:
// CurvedBoundaryKernel runs BEFORE OneStep.
@@ -1,42 +0,0 @@
// CelerisLab/lbm/kernels/step/block_boundary.cu
// Receiver-local curved-boundary staging and its raw-exchange accumulation.
// Part of the blocked.cu compilation entry; relies on its includes and guards.
// Static cut links, receiver-local staging: cell is the receiver's local core
// cell, so the existing BFL writes the solid-source window image that this
// block's next pull reads (no halo copy between this kernel and BlockStep).
// surface_velocity (nullable, 2 floats per link): prescribed fluid velocity on
// fixed geometry, same convention as CurvedBoundaryKernel. Optional raw rows
// (jx,jy,torque,dm) are unique physical links, never body-observation slots.
extern "C" __global__ void BlockCurvedBoundary(
fpxx* f, const uint32_t* block, const uint32_t* cell, const unsigned char* dir,
const float* q, const unsigned char* fallback, unsigned int count,
const float* surface_velocity, const float* rx, const float* ry, float* raw)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
float Uw = 0.0f, Vw = 0.0f;
if (surface_velocity != nullptr) {
const float u = surface_velocity[2 * row], v = surface_velocity[2 * row + 1];
if (u != 0.0f || v != 0.0f) { Uw = u; Vw = v; }
}
apply_bouzidi_link(dir[row], q[row], cell[row], f + (unsigned long)block[row] * NQ * TOTAL_CELLS,
Uw, Vw, rx ? rx[row] : 0.0f, ry ? ry[row] : 0.0f,
fallback[row], nullptr, -1, raw ? raw + 4ul * row : nullptr,
raw ? raw + 4ul * row + 3 : nullptr);
}
// Same-stream BFL -> this row-owned accumulation -> pull step. No atomics,
// no cross-row reduction and no history allocation proportional to substeps.
extern "C" __global__ void BlockAccumulateExchange(
const float* raw, double* integral, unsigned int count, double h, double dt)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
const unsigned long k = 4ul * row;
const double impulse = h * dt;
integral[k] += impulse * (double)raw[k];
integral[k + 1] += impulse * (double)raw[k + 1];
integral[k + 2] += h * impulse * (double)raw[k + 2];
integral[k + 3] += h * h * (double)raw[k + 3];
}
@@ -1,12 +1,5 @@
#if BLOCK_ROOT_BC != 2
#error "BlockChannelStep requires BLOCK_ROOT_BC == 2"
#endif
// Root-only blocked counterpart of OneStep's D2Q9/SRT channel closure.
// NX/NY are LOCAL strides; all physical boundary decisions use global coordinates.
#include "../core/cell_kind.cuh"
#include "../operators/bounce_back_swap.cuh"
#include "../boundary/bounce_back.cuh"
#include "../boundary/inlet/common.cuh"
#include "../boundary/inlet/zou_he_local.cuh"
#include "../boundary/outlet/pressure_neq.cuh"
@@ -22,13 +15,15 @@ extern "C" __global__ void BlockChannelStep(
unsigned int x, y; unsigned long k;
index_from_thread(x, y, k);
if (x < halo || y < halo || x >= NX - halo || y >= NY - halo) return;
unsigned int gx, gy;
block_global_xy(x, y, halo, d_block_channel.nx, gx, gy);
const unsigned int bx = NX - 2 * halo, by = NY - 2 * halo;
const unsigned int nbx = d_block_channel.nx / bx;
const unsigned int gx = (blockIdx.z % nbx) * bx + x - halo;
const unsigned int gy = (blockIdx.z / nbx) * by + y - halo;
const unsigned long base = (unsigned long)blockIdx.z * NQ * TOTAL_CELLS;
const unsigned long cell = (unsigned long)blockIdx.z * TOTAL_CELLS + k;
float f[NQ];
if (kind != nullptr && kind[cell] == KIND_OUTER) {
fill_invalid(f);
for (int q = 0; q < NQ; ++q) f[q] = NAN;
} else {
unsigned long j[NQ];
compute_neighbors(x, y, j);
@@ -36,19 +31,44 @@ extern "C" __global__ void BlockChannelStep(
const bool wall = gy == 0 || gy == d_block_channel.ny - 1;
const bool outlet = gx == d_block_channel.nx - 1;
if (wall) {
bounce_back_swap(f); // Wall owns all four corners; ghosts never collide.
// Wall owns all four corners. Ghosts pull and swap, never collide.
for (int q = 1; q < NQ; q += 2) {
const float t = f[q]; f[q] = f[q + 1]; f[q + 1] = t;
}
} else if (gx == 0) {
apply_zou_he_left_velocity_inlet_d2q9(
f, inlet_target_u((float)gy, d_block_channel.ny, d_block_channel.velocity), 0.0f);
} else if (outlet) {
// Immutable input donor, receiver-local (also valid across a block seam).
float donor[NQ];
load_node(in + base, k - 1, donor);
for (int q = 0; q < NQ; ++q)
donor[q] = load_ddf(in + base, index_f(k - 1, q));
apply_pressure_outlet_d2q9(f, donor);
} else {
apply_wall_bb_d2q9_rows(gy, d_block_channel.ny - 2, f, in + base, k);
} else if (gy == 1) {
f[3] = load_ddf(in + base, index_f(k, 4));
f[5] = load_ddf(in + base, index_f(k, 6));
f[8] = load_ddf(in + base, index_f(k, 7));
} else if (gy == d_block_channel.ny - 2) {
f[4] = load_ddf(in + base, index_f(k, 3));
f[6] = load_ddf(in + base, index_f(k, 5));
f[7] = load_ddf(in + base, index_f(k, 8));
}
if (!wall && !outlet) {
#if COLLISION_MODEL == 2
// Match uniform MRT arithmetic (collide_dispatch, zero forcing).
float rho, ux, uy;
compute_rho_u(f, rho, ux, uy);
collide_mrt_no_force(f, rho, ux, uy,
fminf(OMEGA_COLLISION_MAX, fmaxf(OMEGA_COLLISION_MIN, d_params.omega)));
#else
// Match uniform SRT arithmetic, including its shifted equilibrium path.
float rho, ux, uy, feq[NQ], forcing[NQ] = {};
compute_rho_u(f, rho, ux, uy);
compute_feq(rho, ux, uy, feq);
const float omega = fminf(OMEGA_COLLISION_MAX, fmaxf(OMEGA_COLLISION_MIN, d_params.omega));
collide_srt(f, feq, forcing, omega);
#endif
}
if (!wall && !outlet) block_collide(f);
}
stream_pull_store(k, f, out + base);
}
@@ -1,35 +0,0 @@
// CelerisLab/lbm/kernels/step/block_step.cu
// Generic blocked pull/collide/store for periodic/fine and slot/reservoir levels.
// Part of the blocked.cu compilation entry; relies on its includes and guards.
#if BLOCK_ROOT_BC == 2
#error "BlockStep is not built for the channel root (BLOCK_ROOT_BC == 2)"
#endif
// Pull/collide/store core cells only; halo cells of out are left untouched.
// flags (block cell ids, nullable): solid cores store NaN and never collide.
// kind (block cell ids, nullable = all ACTIVE): OUTER skips the pull and
// stores NaN, INNER pulls without collision, ACTIVE pulls and collides.
extern "C" __global__ void BlockStep(const fpxx* in, fpxx* out, unsigned int halo,
const uint16_t* flags, const unsigned char* kind) {
unsigned int x, y; unsigned long k;
index_from_thread(x, y, k);
if (x < halo || y < halo || x >= NX - halo || y >= NY - halo) return;
const unsigned long base = (unsigned long)blockIdx.z * NQ * TOTAL_CELLS;
const unsigned long cell = (unsigned long)blockIdx.z * TOTAL_CELLS + k;
const unsigned char cell_kind = kind != nullptr ? kind[cell] : KIND_ACTIVE;
float f[NQ];
if (cell_kind == KIND_OUTER || (flags != nullptr && is_solid(flags[cell]))) {
fill_invalid(f);
} else {
unsigned long j[NQ];
compute_neighbors(x, y, j); // Core neighbors stay inside the window: no wrap.
stream_pull_load(k, f, in + base, j);
if (cell_kind == KIND_ACTIVE) {
#if BLOCK_ROOT_BC == 1
block_wall(f, x, y, halo);
#endif
block_collide(f);
}
}
stream_pull_store(k, f, out + base);
}
@@ -1,78 +0,0 @@
// CelerisLab/lbm/kernels/step/block_transfer.cu
// Same-level halo refresh and 2:1 explosion/coalescence between blocked levels.
// Part of the blocked.cu compilation entry; relies on its includes and guards.
// Halo refresh: block cell ids b*TOTAL_CELLS+c, sources are always core cells.
extern "C" __global__ void BlockHaloCopy(
fpxx* f, const uint64_t* dst, const uint64_t* src, unsigned int count)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
const uint64_t d = dst[row], s = src[row];
fpxx* to = f + (d / TOTAL_CELLS) * NQ * TOTAL_CELLS;
const fpxx* from = f + (s / TOTAL_CELLS) * NQ * TOTAL_CELLS;
for (unsigned int q = 0; q < NQ; ++q)
to[index_f(d % TOTAL_CELLS, q)] = from[index_f(s % TOTAL_CELLS, q)];
}
// Fixed-patch explosion into blocked fine storage: target = owner block cell
// id of a patch-ghost cell; donors are parent block cell ids and coarse_cells
// is the parent's block window size.
extern "C" __global__ void BlockExplode(
const fpxx* coarse, fpxx* fine, const uint64_t* target, const uint64_t* donor,
unsigned int count, unsigned long coarse_cells)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
const uint64_t t = target[row];
fpxx* to = fine + (t / TOTAL_CELLS) * NQ * TOTAL_CELLS;
const uint64_t d = donor[row];
const fpxx* from = coarse + (d / coarse_cells) * NQ * coarse_cells;
for (unsigned int q = 0; q < NQ; ++q)
to[index_f(t % TOTAL_CELLS, q)] = from[q * coarse_cells + d % coarse_cells];
}
// Experimental Schornbaum--Ruede (arXiv:1508.07982, Algorithm 2)
// and Chen et al. (doi:10.1016/j.physa.2005.09.036) population interpolation.
// Independent implementation of the mathematical stencil; no upstream source copied.
// Stencil cells are owner cores; a disabled axis repeats its center twice.
extern "C" __global__ void BlockExplodeProjected(
const fpxx* coarse, fpxx* fine, const uint64_t* target, const uint64_t* donor,
unsigned int count, unsigned long coarse_cells,
const uint64_t* neighbors, const float* offsets)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
const uint64_t t = target[row], d = donor[row];
fpxx* to = fine + (t / TOTAL_CELLS) * NQ * TOTAL_CELLS;
for (unsigned int q = 0; q < NQ; ++q) {
float v[4];
for (unsigned int a = 0; a < 4; ++a) {
const uint64_t n = neighbors[4ul * row + a];
v[a] = coarse[(n / coarse_cells) * NQ * coarse_cells
+ q * coarse_cells + n % coarse_cells];
}
float gx = .5f * (v[1] - v[0]), gy = .5f * (v[3] - v[2]);
const float ex = d_cx[q], ey = d_cy[q];
if (q != 0) {
const float along = (ex * gx + ey * gy) / (ex * ex + ey * ey);
gx -= ex * along;
gy -= ey * along;
}
to[index_f(t % TOTAL_CELLS, q)] =
coarse[(d / coarse_cells) * NQ * coarse_cells + q * coarse_cells + d % coarse_cells]
+ offsets[2ul * row] * gx + offsets[2ul * row + 1] * gy;
}
}
// Targets are receiver-local SOURCE slots for the immediately following pull.
// Child summation order ((c0 + c1) + c2) + c3 is part of the numerics; no collision here.
extern "C" __global__ void BlockCoalesce(
const fpxx* fine, fpxx* coarse, const uint64_t* target, const uint64_t* children,
unsigned int count)
{
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= count) return;
const uint64_t* s = children + 4ul * row;
coarse[target[row]] = 0.25f * (fine[s[0]] + fine[s[1]] + fine[s[2]] + fine[s[3]]);
}
@@ -3,7 +3,7 @@
//
// Decodes the EsoPull backing layout into physical distributions, then
// computes rho/ux/uy. Output is SoA: rho_out[N], ux_out[N], uy_out[N].
// Included by uniform.cu. No standalone compilation.
// Included by kernel_v2.cu. No standalone compilation.
//
// This kernel is the sole path for macroscopic diagnostics in EsoPull mode.
// It uses the shared semantic helper layer (esopull_semantic_helpers.cuh).
@@ -5,7 +5,7 @@
// or encodes physical DDF back to EsoPull backing layout.
// Each thread handles one cell from a compact index list.
//
// Included by uniform.cu. No standalone compilation.
// Included by kernel_v2.cu. No standalone compilation.
// ============================================================================
#ifndef CELERIS_STEP_ESOPULL_SYNC_CU
+3 -3
View File
@@ -1,6 +1,6 @@
// CelerisLab – step/init_flow.cu
// Channel-flow initialization kernels (double-buffer and EsoPull).
// Included by uniform.cu after all headers. No standalone compilation.
// Included by kernel_v2.cu after all headers. No standalone compilation.
// ============================================================================
#ifndef CELERIS_STEP_INIT_FLOW_CU
@@ -73,9 +73,9 @@ __device__ __forceinline__ uint16_t finalize_domain_flag(
}
// ============================================================================
// InitTubeFlow — double-buffer channel initialization
// InitTubeFlow_v2 — double-buffer channel initialization
// ============================================================================
__global__ void InitTubeFlow(uint16_t* flag, fpxx* fi)
__global__ void InitTubeFlow_v2(uint16_t* flag, fpxx* fi)
{
#if DIM == 2
unsigned int x, y; unsigned long k;
@@ -1,183 +0,0 @@
// Translating circles (docs/moving_bodies.md): M1 MovingCircleCells reclassifies
// band cells and refills uncovered ones; M2 MovingCircleLinks applies the cut links.
// Geometry comes from an immutable FP64 record per body
// {cx_old, cy_old, cx_new, cy_new, r, ux, uy}; the M2 operation order mirrors
// CircleGeometry.build_cut_links / find_circle_intersection with explicit _rn
// operations (bit parity with the host builder is a test target, not a claim).
// Moving circles are never in the packed static links, so this kernel is the
// only writer of their solid slots.
#ifndef CELERIS_MOVING_CIRCLE_CU
#define CELERIS_MOVING_CIRCLE_CU
#if DIM == 2
constexpr int MOVING_BAND_INTS = 6; // x0, y0, width, height, first_slot, body_id
constexpr int MOVING_GEOM_DOUBLES = 7;
// cme_x, cme_y, wen_x, wen_y, link_dm, n_links, cover_jx, cover_jy, cover_mass,
// uncover_jx, uncover_jy, uncover_mass, eq_only, no_donor, crossings
constexpr int MOVING_DIAG_FLOATS = 15;
__device__ __forceinline__ double mc_sq(double v) { return __dmul_rn(v, v); }
__device__ __forceinline__ bool mc_inside(int x, int y, double cx, double cy, double r)
{
return __dadd_rn(mc_sq(__dsub_rn((double)x, cx)), mc_sq(__dsub_rn((double)y, cy))) < mc_sq(r);
}
// Smallest root t in [0, 1] of |p + t c - centre| = r (find_circle_intersection order).
__device__ __forceinline__ bool mc_hit(int x, int y, int ex, int ey,
double cx, double cy, double r, double& t)
{
const double dx = (double)ex, dy = (double)ey;
const double px = __dsub_rn((double)x, cx), py = __dsub_rn((double)y, cy);
const double a = __dadd_rn(mc_sq(dx), mc_sq(dy));
const double b = __dmul_rn(2.0, __dadd_rn(__dmul_rn(dx, px), __dmul_rn(dy, py)));
const double c = __dsub_rn(__dadd_rn(mc_sq(px), mc_sq(py)), mc_sq(r));
const double det = __dsub_rn(mc_sq(b), __dmul_rn(__dmul_rn(4.0, a), c));
if (det < 0.0) return false;
const double s = __dsqrt_rn(det), a2 = __dmul_rn(2.0, a);
const double t1 = __ddiv_rn(__dsub_rn(-b, s), a2);
const double t2 = __ddiv_rn(__dadd_rn(-b, s), a2);
if (0.0 <= t1 && t1 <= 1.0) { t = t1; return true; }
if (0.0 <= t2 && t2 <= 1.0) { t = t2; return true; }
return false;
}
__global__ void MovingCircleLinks(
fpxx* fi, const uint16_t* flag,
const int* band, const double* geom,
unsigned int n_bodies, unsigned int n_slots,
const float* action, float* obs, float* diag, float* slot_debug)
{
const unsigned int tid = threadIdx.x + blockIdx.x * blockDim.x;
if (tid >= n_slots) return;
unsigned int m = 0;
while (m + 1 < n_bodies && tid >= (unsigned int)band[(m + 1) * MOVING_BAND_INTS + 4]) ++m;
const int* b = band + m * MOVING_BAND_INTS;
const unsigned int local = tid - (unsigned int)b[4];
const unsigned int dir = local % 8u + 1u;
const int x = b[0] + (int)((local / 8u) % (unsigned int)b[2]);
const int y = b[1] + (int)((local / 8u) / (unsigned int)b[2]);
const double* g = geom + MOVING_GEOM_DOUBLES * m;
const double cx = g[2], cy = g[3], r = g[4];
const int ex = d_cx[dir], ey = d_cy[dir];
float* debug = slot_debug == nullptr ? nullptr : slot_debug + 4 * tid;
if (debug != nullptr) debug[0] = -1.0f;
double t;
if (mc_inside(x, y, cx, cy, r) || !mc_inside(x + ex, y + ey, cx, cy, r)
|| !mc_hit(x, y, ex, ey, cx, cy, r, t))
return;
const double hx = __dadd_rn((double)x, __dmul_rn(t, (double)ex));
const double hy = __dadd_rn((double)y, __dmul_rn(t, (double)ey));
const double hdx = __dsub_rn(hx, (double)x), hdy = __dsub_rn(hy, (double)y);
const double norm = __dsqrt_rn((double)(ex * ex + ey * ey));
double qd = __ddiv_rn(__dsqrt_rn(__dadd_rn(mc_sq(hdx), mc_sq(hdy))), norm);
qd = fmax(0.01, fmin(qd, 1.0));
const float q = (float)qd;
const float rx = (float)__dsub_rn(hx, cx), ry = (float)__dsub_rn(hy, cy);
// Same two-stage order as build_cut_links + pack_cut_links_to_soa: the fallback
// class is chosen from the clamped FP64 q (q < 1/2 needs a fluid donor at x - c);
// only then are q, rx, ry rounded to FP32 for the BFL branch and spin velocity.
unsigned char fallback = CURVED_FALLBACK_BOUZIDI;
if (qd < 0.5) {
const int xd = x - ex, yd = y - ey;
if (xd < 0 || xd >= (int)NX || yd < 0 || yd >= (int)NY || mc_inside(xd, yd, cx, cy, r)
|| (flag[linear_index((unsigned int)xd, (unsigned int)yd)] & FLAG_FLUID) == 0)
fallback = CURVED_FALLBACK_HALFWAY;
}
if (debug != nullptr) { debug[0] = q; debug[1] = (float)fallback; debug[2] = rx; debug[3] = ry; }
const int bid = b[5];
const float omega = action_omega(action, bid);
const float Uw = (float)g[5] - omega * ry, Vw = (float)g[6] + omega * rx; // u + omega x r
float exchange[3], dm;
apply_bouzidi_link(dir, q, linear_index((unsigned int)x, (unsigned int)y), fi,
Uw, Vw, rx, ry, fallback, obs, bid, exchange, &dm);
if (diag != nullptr) {
float* d = diag + m * MOVING_DIAG_FLOATS;
atomicAdd(&d[0], exchange[0]);
atomicAdd(&d[1], exchange[1]);
atomicAdd(&d[2], Uw * dm); // Wen 2014 correction -u_w (f_t - f_r)
atomicAdd(&d[3], Vw * dm);
atomicAdd(&d[4], dm);
atomicAdd(&d[5], 1.0f);
}
}
// M1: one thread per band cell, launched only on steps with c_old != c_new.
// Membership comes from the immutable old/new predicates, never from flags, so
// classification, refill and the flag write of one cell cannot race with another.
// ddf holds post-collision G^{n-1}; refilled cells get all nine slots at that level.
__global__ void MovingCircleCells(
fpxx* fi, uint16_t* flag, const int* band, const double* geom,
unsigned int n_bodies, unsigned int n_cells, const float* action, float* diag)
{
const unsigned int tid = threadIdx.x + blockIdx.x * blockDim.x;
if (tid >= n_cells) return;
unsigned int m = 0;
while (m + 1 < n_bodies && tid >= (unsigned int)band[(m + 1) * MOVING_BAND_INTS + 4] / 8u) ++m;
const int* b = band + m * MOVING_BAND_INTS;
const unsigned int local = tid - (unsigned int)b[4] / 8u;
const int x = b[0] + (int)(local % (unsigned int)b[2]);
const int y = b[1] + (int)(local / (unsigned int)b[2]);
const double* g = geom + MOVING_GEOM_DOUBLES * m;
const bool in_old = mc_inside(x, y, g[0], g[1], g[4]);
const bool in_new = mc_inside(x, y, g[2], g[3], g[4]);
if (in_old == in_new) return;
const unsigned long k = linear_index((unsigned int)x, (unsigned int)y);
float* d = diag + m * MOVING_DIAG_FLOATS;
float rho = 0.0f, jx = 0.0f, jy = 0.0f;
if (in_new) { // covered: its fluid content leaves the active set
for (unsigned int i = 0; i < NQ; ++i) {
const float f = load_ddf(fi, index_f(k, i));
rho += f; jx += (float)d_cx[i] * f; jy += (float)d_cy[i] * f;
}
flag[k] = FLAG_SOLID | FLAG_OBSTACLE | FLAG_BC_CURVED;
atomicAdd(&d[6], jx); atomicAdd(&d[7], jy); atomicAdd(&d[8], rho);
atomicAdd(&d[14], 1.0f);
return;
}
// Uncovered: donors are fluid in BOTH geometries; NEQ from the neighbour with the
// largest unnormalized c.n > 0 (first direction wins ties), n from c_new.
const float nx = (float)__dsub_rn((double)x, g[2]), ny = (float)__dsub_rn((double)y, g[3]);
float rho_sum = 0.0f, best_dot = 0.0f;
int n_valid = 0, best = -1;
for (unsigned int i = 1; i < NQ; ++i) {
const int xn = x + d_cx[i], yn = y + d_cy[i];
if (mc_inside(xn, yn, g[0], g[1], g[4]) || mc_inside(xn, yn, g[2], g[3], g[4])) continue;
const unsigned long kn = linear_index((unsigned int)xn, (unsigned int)yn);
for (unsigned int q = 0; q < NQ; ++q) rho_sum += load_ddf(fi, index_f(kn, q));
++n_valid;
const float dot = (float)d_cx[i] * nx + (float)d_cy[i] * ny;
if (dot > best_dot) { best_dot = dot; best = (int)i; }
}
const float omega = action_omega(action, b[5]);
const float uwx = (float)g[5] - omega * ny, uwy = (float)g[6] + omega * nx; // at the cell
float feq[NQ], neq[NQ];
compute_feq(n_valid > 0 ? rho_sum / (float)n_valid : (float)RHO, uwx, uwy, feq);
for (unsigned int i = 0; i < NQ; ++i) neq[i] = 0.0f;
if (best >= 0) {
const unsigned long kd = linear_index((unsigned int)(x + d_cx[best]), (unsigned int)(y + d_cy[best]));
float gd[NQ], rd = 0.0f, mx = 0.0f, my = 0.0f, fd[NQ];
for (unsigned int i = 0; i < NQ; ++i) {
gd[i] = load_ddf(fi, index_f(kd, i));
rd += gd[i]; mx += (float)d_cx[i] * gd[i]; my += (float)d_cy[i] * gd[i];
}
compute_feq(rd, mx / rd, my / rd, fd);
for (unsigned int i = 0; i < NQ; ++i) neq[i] = gd[i] - fd[i];
} else {
atomicAdd(&d[12], 1.0f);
}
if (n_valid == 0) atomicAdd(&d[13], 1.0f); // hard failure, checked before commit
for (unsigned int i = 0; i < NQ; ++i) {
const float f = feq[i] + neq[i];
store_ddf(fi, index_f(k, i), f);
rho += f; jx += (float)d_cx[i] * f; jy += (float)d_cy[i] * f;
}
flag[k] = FLAG_FLUID;
atomicAdd(&d[9], jx); atomicAdd(&d[10], jy); atomicAdd(&d[11], rho);
atomicAdd(&d[14], 1.0f);
}
#endif // DIM == 2
#endif // CELERIS_MOVING_CIRCLE_CU
@@ -1,6 +1,6 @@
// CelerisLab – step/one_step_double.cu
// Double-buffer PULL streaming step kernel.
// Included by uniform.cu. No standalone compilation.
// Included by kernel_v2.cu. No standalone compilation.
// ============================================================================
#ifndef CELERIS_STEP_ONE_STEP_DOUBLE_CU
@@ -1,6 +1,6 @@
// CelerisLab – step/one_step_esopull.cu
// Esoteric-Pull single-buffer step kernel.
// Included by uniform.cu. No standalone compilation.
// Included by kernel_v2.cu. No standalone compilation.
//
// CURVED BC: supported via PrepareEsoPullCurvedKernel + ApplyEsoPullCurvedKernel
// in aux_kernels.cu, launched by stepper.py before EsoPullStep.
@@ -21,14 +21,6 @@
// i.e. the population traveling in direction i at node n
// arrived from the node at n - c_i = neighbor in direction opp(i)
// ---------------------------------------------------------------------------
// load_node: all NQ populations of node n as stored (no streaming).
__device__ __forceinline__ void load_node(const fpxx* __restrict__ fi, unsigned long n,
float* __restrict__ f)
{
for (int q = 0; q < NQ; ++q)
f[q] = load_ddf(fi, index_f(n, q));
}
__device__ __forceinline__ void stream_pull_load(unsigned long n,
float* __restrict__ f,
const fpxx* __restrict__ fi,
+3 -15
View File
@@ -115,16 +115,10 @@ class FixedPatch2D:
return self.bounds[0] - HALO / 2, self.bounds[1] - HALO / 2
@property
def fine_level_offset(self) -> tuple[int, int]:
"""Array index (y, x) of this window's first cell on the level-wide 2:1 fine grid
(an index offset, not an (x, y) origin)."""
def fine_level_origin(self) -> tuple[int, int]:
"""(y, x) of this window's first cell on the level-wide 2:1 fine grid."""
return 2 * self.bounds[1] - HALO, 2 * self.bounds[0] - HALO
@property
def rectangles(self) -> tuple:
"""The one parent rectangle, as a sequence like FixedPatchSet2D.rectangles."""
return (self.bounds,)
@property
def covered(self) -> np.ndarray:
"""Dense coarse mask of cells replaced by the patch (not leaves)."""
@@ -157,6 +151,7 @@ class FixedPatch2D:
raise ValueError("levels are not synchronized: fine_steps must equal 2*coarse_steps")
MIN_PATCH_GAP = 4 # Legacy exported constant; union transfers impose no gap policy.
@dataclass(frozen=True, eq=False)
@@ -230,18 +225,11 @@ class FixedPatchSet2D:
def bounds(self) -> tuple:
return tuple(p.bounds for p in self.patches)
rectangles = bounds
@property
def fine_origin(self) -> tuple[float, float]:
"""Lower edge of the level-wide fine grid in coarse physical units."""
return 0.0, 0.0
@property
def fine_level_offset(self) -> tuple[int, int]:
"""The level-wide grid itself: zero (y, x) index offset."""
return 0, 0
@property
def covered(self) -> np.ndarray:
mask = np.zeros(self.coarse_shape, bool)
File diff suppressed because it is too large Load Diff
+5 -53
View File
@@ -8,7 +8,7 @@ from dataclasses import dataclass, replace
import numpy as np
from .blocks import blocked_link_tables
from ..body.jets import _surface_links
from ..body.jets import jet_link_shapes, surface_link_velocity
from .descriptors import D2Q9_EX, D2Q9_EY
from .refinement import ACTIVE, OUTER_GHOST
@@ -36,7 +36,7 @@ def blocked_kind(layout, dense_kind):
kind[owner[owned]] = np.asarray(dense_kind).ravel()[owned]
return kind
def blocked_coarse_tables(patch, layout, order, wall_aware=False, *, composed_kind=None):
def blocked_base_tables(patch, layout, order, wall_aware=False, *, composed_kind=None):
"""Parent donor cells and receiver-local arrival slots, in transfer row order.
Stage arrivals only after child reads and parent halo gather, immediately
@@ -66,50 +66,6 @@ def blocked_coarse_tables(patch, layout, order, wall_aware=False, *, composed_ki
blocked_kind(layout, dense_kind))
def projected_explosion_tables(pair, parent_layout, composed_parent_kind):
"""Experimental transverse linear explosion, in the pair's existing row order.
Schornbaum & Rüde (2016), arXiv:1508.07982, Algorithm 2, following
Chen et al., doi:10.1016/j.physa.2005.09.036. Independently implemented
from the mathematics: centered gradients, perpendicular projection and
child offsets +/-1/4. The kernel applies the projection per population.
Only original explosion donors that are owned, uncovered ACTIVE cells
may supply gradients. Both axis neighbors must be legal; otherwise repeat
the center twice for zero slope. Owner-core addresses avoid stale halos.
The same rule applies to scalar windows, unions and every adjacent pair;
nested layouts retain their own per-axis (usually nonperiodic) topology.
"""
ny, nx = pair.coarse_shape
kind = np.asarray(composed_parent_kind)
if parent_layout.shape != pair.coarse_shape or kind.shape != pair.coarse_shape:
raise ValueError("projected explosion parent layout/kind has the wrong shape")
donor = pair.ghost_coarse.astype(np.intp)
legal = np.zeros(ny * nx, bool)
legal[donor] = True
legal &= (parent_layout.owned.ravel() & ~pair.covered.ravel()
& (kind.ravel() == ACTIVE))
if not legal[donor].all():
raise ValueError("projected explosion donors need owned uncovered parent ACTIVE cells")
y, x = np.divmod(donor, nx)
neighbors = np.repeat(donor[:, None], 4, axis=1)
for axis, (dx, dy) in enumerate(((1, 0), (0, 1))):
minus = ((y - dy) % ny) * nx + (x - dx) % nx
plus = ((y + dy) % ny) * nx + (x + dx) % nx
valid = legal[minus] & legal[plus]
if not parent_layout.periodic[axis]:
coordinate, extent = (x, nx) if axis == 0 else (y, ny)
valid &= (coordinate > 0) & (coordinate < extent - 1)
neighbors[valid, 2 * axis] = minus[valid]
neighbors[valid, 2 * axis + 1] = plus[valid]
fy, fx = np.divmod(pair.ghost_fine, pair.fine_shape[1])
ox, oy = pair.fine_origin
offsets = np.column_stack((ox + (fx + .5) / 2 - (x + .5),
oy + (fy + .5) / 2 - (y + .5)))
return (np.ascontiguousarray(parent_layout.owner.ravel()[neighbors], dtype=np.uint64),
np.ascontiguousarray(offsets, dtype=np.float32))
# Required device columns plus optional actuation; observation arms are uploaded
# separately only when enabled. body_id/aperture remain host reduction metadata.
BLOCKED_BOUNDARY_DEVICE_COLUMNS = ("block", "cell", "dir", "q", "fallback", "surface_velocity")
@@ -130,8 +86,6 @@ class _BlockedBoundary:
interval_integral: object = None
accumulate: object = None
window_integral: object = None # optional observation-window FP64 ledger
spacing: float = None # level spacing h_l in root units (exchange scaling)
centers: tuple = () # body reference centers in the root edge frame
def _prepare_blocked_boundary(boundary, layout, cfg, jets, *, _shifted_blocked=False):
@@ -144,13 +98,11 @@ def _prepare_blocked_boundary(boundary, layout, cfg, jets, *, _shifted_blocked=F
tables.update(body_id=np.asarray(body_id, np.int32), rx=np.asarray(rx, np.float32),
ry=np.asarray(ry, np.float32), aperture=np.zeros(len(dirs), bool))
actuated = bool(jets) or any(o.surface_actuation is not None for o in boundary.objects)
jet_cache = None
if actuated:
cfg = replace(cfg)
cfg.validate()
tables["surface_velocity"], tables["aperture"], shape, jet_id = _surface_links(
tables["surface_velocity"], tables["aperture"] = surface_link_velocity(
boundary.objects, cfg, boundary.flags, fluid_idx, dirs, body_id, rx, ry,
jets=jets, hits=boundary.curved_hits, _shifted_blocked=_shifted_blocked)
if jets:
jet_cache = (shape, jet_id)
return _BlockedBoundary(boundary, tables, jets, actuated, jet_cache)
return _BlockedBoundary(boundary, tables, jets, actuated,
jet_link_shapes(jets, boundary.curved_hits) if jets else None)
+1 -33
View File
@@ -20,10 +20,6 @@ class LBMStepper:
self.cfg = cfg
self._esopull = (cfg.streaming == "esopull")
# Set by the owner when a step would be wrong (translating circles
# without attached moving tables, a pending target, or a failed step).
self.blocked: str | None = None
self.moving = None # MovingTables attached by the owner (translating circles)
if self._esopull:
self.step_fn = module.get_function("EsoPullStep")
@@ -32,17 +28,13 @@ class LBMStepper:
self.boundary_values_gpu = cuda.mem_alloc((6 * cfg.nx + 2 * cfg.ny * cfg.nq) * 4)
else:
self.step_fn = module.get_function("OneStep")
self.init_fn = module.get_function("InitTubeFlow")
self.init_fn = module.get_function("InitTubeFlow_v2")
self.curved_fn = module.get_function("CurvedBoundaryKernel")
self.prepare_esopull_curved_fn = module.get_function("PrepareEsoPullCurvedKernel")
self.apply_esopull_curved_fn = module.get_function("ApplyEsoPullCurvedKernel")
self.sensor_fn = module.get_function("SensorKernel")
self.force_region_fn = module.get_function("ForceRegionKernel")
# Moving-circle kernels exist only in D2Q9 double-buffer modules.
moving = not self._esopull and cfg.is_d2q9
self.moving_fn = module.get_function("MovingCircleLinks") if moving else None
self.moving_cells_fn = module.get_function("MovingCircleCells") if moving else None
tpb = cfg.threads_per_block
self.block = (tpb, 1, 1)
@@ -78,13 +70,6 @@ class LBMStepper:
stream: cuda.Stream | None = None,
):
"""Advance *n* time steps."""
if n > 0 and self.blocked is not None:
raise RuntimeError(self.blocked)
self._step(n, action_gpu=action_gpu, obs_gpu=obs_gpu, stream=stream)
def _step(self, n, *, action_gpu, obs_gpu, stream=None, advance=False):
"""Unguarded steps; ``advance`` runs one step that moves translating circles."""
assert not advance or n == 1
f = self.field
launch_kw = {}
if stream is not None:
@@ -96,8 +81,6 @@ class LBMStepper:
f.ddf_gpu, self.boundary_values_gpu, np.uint64(self._step_count),
block=self.block, grid=self.boundary_grid, **launch_kw,
)
if self.moving is not None:
self._launch_moving(action_gpu, obs_gpu, advance, **launch_kw)
self._launch_curved(action_gpu, obs_gpu, **launch_kw)
if self._esopull:
@@ -123,21 +106,6 @@ class LBMStepper:
# Any completed GPU step invalidates host DDF mirror.
f.invalidate_host_ddf_cache()
def _launch_moving(self, action_gpu, obs_gpu, advance, **launch_kw):
m, tpb = self.moving, self.cfg.threads_per_block
if advance: # M1 completes before M2 on the same stream
self.moving_cells_fn(
self.field.ddf_gpu, self.field.flag_gpu, m.band_gpu, m.geom_gpu,
np.uint32(m.n_bodies), np.uint32(m.n_cells), action_gpu, m.diag_gpu,
block=(tpb, 1, 1), grid=((m.n_cells + tpb - 1) // tpb, 1, 1), **launch_kw,
)
self.moving_fn(
self.field.ddf_gpu, self.field.flag_gpu, m.band_gpu, m.geom_gpu,
np.uint32(m.n_bodies), np.uint32(m.n_slots), action_gpu, obs_gpu,
m.diag_gpu, m.slot_debug_gpu if m.slot_debug_gpu is not None else np.uintp(0),
block=(tpb, 1, 1), grid=((m.n_slots + tpb - 1) // tpb, 1, 1), **launch_kw,
)
def _launch_curved(self, action_gpu, obs_gpu, **launch_kw):
f = self.field
if f.n_curved == 0:
+34 -242
View File
@@ -25,14 +25,7 @@ import pycuda.driver as cuda
from .config import LBMConfig, BodyConfig, load_lbm_config, load_body_config
from .cuda.context import CudaContext, context_bound
from .cuda import compiler
from .lbm.descriptors import FLUID
# Translating circles step only through their moving tables (attached at initialize).
PENDING_BLOCKED = "a pending translation target is advanced only by Simulation.run(1)"
MOVING_BLOCKED = ("translating circles need the moving kernels and their tables (attached "
"at initialize); "
"positive-step runs are rejected")
from .cuda import compiler_v2 as compiler
from .lbm.field import LBMField
from .lbm.stepper import LBMStepper
from .body.objects import SimObject
@@ -53,8 +46,8 @@ class Simulation:
device_id: int = 0):
# Internal stream for async DTOH coordination
self._stream: Optional[cuda.Stream] = None
self._build_dir = None
self._closed = False
self._failed = None # reason after an uncertain device write; only close() remains
# Load configs
self.lbm_cfg = load_lbm_config(lbm_config_path)
@@ -66,7 +59,9 @@ class Simulation:
try:
with self.ctx.activate():
arch = self._resolve_compile_arch()
self._module = compiler.build_module(self.lbm_cfg, "uniform.cu", arch=arch)
self._build_dir = compiler.generate_config(self.lbm_cfg, n_objects=0)
self._ptx_path = compiler.compile_kernel(arch=arch, build_dir=self._build_dir)
self._module = compiler.load_module(self._ptx_path)
self._compiled_n_objects = 0
self._allocation_signature = self.lbm_cfg.allocation_signature()
self._compiled_viscosity = self.lbm_cfg.viscosity
@@ -97,15 +92,13 @@ class Simulation:
@context_bound
def stream(self) -> cuda.Stream:
"""Internal CUDA stream for async DTOH coordination."""
self._require_usable()
if self._stream is None:
self._stream = cuda.Stream()
return self._stream
# -- Object management ---------------------------------------------------
def add_body(self, type: str = "circle", center=None,
radius: float = 0.0, *, surface_actuation=None, translation=None,
**kwargs) -> int:
radius: float = 0.0, *, surface_actuation=None, **kwargs) -> int:
"""Add a simulation body. Returns body_id.
Before ``initialize()`` the object is added to the formal registry
@@ -121,10 +114,6 @@ class Simulation:
angle, half_width (radians), normal_velocity (positive outward).
D2Q9/FP32/unshifted/DB only; set before initialization or stage
a new body. No runtime motion or amplitude setter.
translation: ``body.motion.Translation`` declaring a translating
circle (before initialize only; docs/moving_bodies.md).
Positive-step runs are rejected until ``initialize()`` has
attached its moving tables.
**kwargs: passed to the geometry constructor.
Returns:
@@ -135,10 +124,6 @@ class Simulation:
type_lower = str(type).strip().lower()
if surface_actuation is not None and type_lower != "circle":
raise ValueError("Surface actuation is only supported on circles")
if translation is not None and (type_lower != "circle" or surface_actuation is not None
or self._initialized):
raise ValueError("translation needs a circle without surface actuation, "
"added before initialize()")
if type_lower == "circle":
from .body.geometry.circle import CircleGeometry
@@ -147,11 +132,6 @@ class Simulation:
obj = SimObject(obj_id=-1, geometry=geom,
center=center, radius=radius,
is_sensor=False, surface_actuation=surface_actuation)
if translation is not None:
from .body.motion import MovingCircle
obj.motion = MovingCircle((cx, cy), radius, translation,
on_failure=self._fail_moving)
self.stepper.blocked = MOVING_BLOCKED
elif type_lower == "sensor":
from .body.geometry.circle import CircleGeometry
cx, cy = float(center[0]), float(center[1])
@@ -195,109 +175,15 @@ class Simulation:
def set_jet_amplitude(self, index: int, amplitude: float) -> None:
"""Change one jet's signed amplitude; re-uploads per-link velocity only.
Invalid input raises before any change. Any error after validation
(stream creation or upload) leaves the host amplitude unchanged but the
device column undefined, so the Simulation is marked failed: every later
device-touching public method (incl. ``stream``) raises; close() still
works. Close and recreate it.
Invalid input changes nothing. A device error leaves the host amplitude
unchanged but the device column undefined: treat it like any other CUDA
exception here and close/recreate the Simulation before stepping again.
"""
self._require_usable()
jet = self.bodies.jets[index].with_amplitude(amplitude)
if self._initialized:
try:
self.field.curved.set_jet_amplitude(index, amplitude, self.stream)
except BaseException as exc:
self._failed = f"jet amplitude upload failed: {exc!r}"
raise
self.field.curved.set_jet_amplitude(index, amplitude, self.stream)
self.bodies.jets[index] = jet # later geometry syncs keep the new amplitude
def _fail_moving(self, exc: BaseException) -> None:
# Device geometry is unknown after a failed moving step: same lifecycle as Jets.
self._failed = f"moving circle step failed: {exc!r}"
self.stepper.blocked = self._failed # direct stepper calls are refused as well
def _attach_moving(self) -> None:
# Direct stepper calls stay blocked until the moving tables exist.
tables = getattr(self, "_moving_tables", None)
self.stepper.moving = tables
moving = self._moving_bodies()
if moving and tables is None:
self.stepper.blocked = MOVING_BLOCKED
elif any(obj.motion.pending is not None for obj in moving):
self.stepper.blocked = PENDING_BLOCKED # direct steps never consume a target
else:
self.stepper.blocked = None
def _build_moving_tables(self):
moving = self._moving_bodies()
if not moving:
return None
from .body.motion import MovingTables
return MovingTables.upload([(obj.motion, obj.obj_id) for obj in moving])
@context_bound
def moving_diagnostics(self) -> np.ndarray:
"""Per translating circle (in body order), columns ``MovingTables.DIAG``,
accumulated since obs were last zeroed (diagnostics only, not a force RHS)."""
self._require_usable()
tables = self.stepper.moving
if tables is None:
from .body.motion import MovingTables
return np.zeros((0, MovingTables.DIAG_FLOATS), np.float32)
self.stream.synchronize()
return tables.download_diagnostics()
def _moving_bodies(self):
return [obj for obj in self.bodies.objects if obj.motion is not None]
def _reject_moving_bodies(self, operation: str) -> None:
if self._moving_bodies():
raise ValueError(f"{operation} is not supported with translating circles")
def _validate_moving_bodies(self) -> None:
"""Reject unsupported modes and swept-band placement before any mutation."""
moving = self._moving_bodies()
if not moving:
return
from .body.motion import SHARED_CLEARANCE, validate_shared_translations
validate_shared_translations([obj.motion for obj in moving])
cfg = self.lbm_cfg
if (not cfg.is_d2q9 or cfg.streaming != "double_buffer" or cfg.ddf_shifting
or cfg.store_precision != "FP32"):
raise ValueError("translating circles need D2Q9/FP32/unshifted/double_buffer")
if self.bodies.jets:
raise ValueError("Jets are not supported together with translating circles")
nx, ny = self.field.nx, self.field.ny
base = self.field.build_channel_flags().reshape(ny, nx)
for obj in moving:
x0, x1, y0, y1 = obj.motion.band()
if x0 < 1 or y0 < 1 or x1 > nx - 2 or y1 > ny - 2:
raise ValueError(f"swept band {(x0, x1, y0, y1)} touches the domain edge")
if not np.all((base[y0:y1 + 1, x0:x1 + 1] & FLUID) != 0):
raise ValueError(f"swept band {(x0, x1, y0, y1)} contains non-fluid base cells")
for other in self.bodies.objects:
if other is obj:
continue
(cx, cy), r = other.center[:2], float(other.radius)
if (other.motion is not None and obj.motion.translation.shared
and obj.motion.translation is other.motion.translation):
# Whole envelopes may overlap; simultaneous local stencils may not.
gap = np.hypot(obj.center[0] - cx, obj.center[1] - cy) - obj.radius - r
if gap <= SHARED_CLEARANCE:
raise ValueError("shared translation needs surface clearance > 4 cells")
continue
if other.motion is not None:
ox0, ox1, oy0, oy1 = other.motion.band()
else:
ox0, ox1, oy0, oy1 = cx - r - 2, cx + r + 2, cy - r - 2, cy + r + 2
if ox0 <= x1 and x0 <= ox1 and oy0 <= y1 and y0 <= oy1:
raise ValueError(f"swept band {(x0, x1, y0, y1)} overlaps body {other.obj_id}")
def _require_usable(self):
failed = getattr(self, "_failed", None)
if failed is not None:
raise RuntimeError(f"Simulation failed ({failed}); close() and recreate it")
def remove_body(self, id: int) -> None:
"""Remove a body from the simulation.
@@ -363,7 +249,7 @@ class Simulation:
# -- Runtime control -----------------------------------------------------
def set_body(self, id: int, omega: float = None,
vx: float = None, vy: float = None, offset=None) -> None:
vx: float = None, vy: float = None) -> None:
"""Set runtime body state (host action array only, no H2D).
The GPU action buffer is updated at the next ``run()`` call.
@@ -371,26 +257,10 @@ class Simulation:
Args:
id: body_id from add_body().
omega: rotation speed (lattice units). ``None`` = no change.
vx: translation velocity x. For a translating circle: the
interval wall velocity of the target, only together with
``offset``; otherwise stored in the body state (not applied
to stationary walls). ``None`` = no change.
vy: translation velocity y; same rules as ``vx``.
offset: target centre offset of a translating circle; with vx/vy
(default 0) it replaces the pending target after validation
against the applied offset (docs/moving_bodies.md, 4a). Only
``run(1)`` advances it; direct stepper calls are blocked meanwhile.
vx: translation velocity x (future). ``None`` = no change.
vy: translation velocity y (future). ``None`` = no change.
"""
obj = self.bodies.get(id)
motion = obj.motion
if motion is not None:
if offset is None and (vx is not None or vy is not None):
raise ValueError("a translating circle takes vx/vy only with an offset")
target = None if offset is None else motion.check_target(
offset, (0.0 if vx is None else vx, 0.0 if vy is None else vy))
vx = vy = None # the motion target, not ObjectState, owns translation
elif offset is not None:
raise ValueError(f"body {id} was not declared with a translation")
proposed = dict(vars(obj.state))
proposed.update({k: float(v) for k, v in (("omega", omega), ("vx", vx), ("vy", vy))
if v is not None})
@@ -406,9 +276,6 @@ class Simulation:
if vy is not None:
obj.state.vy = float(vy)
changed = True
if motion is not None and target is not None:
motion.pending = target
self._attach_moving()
if changed:
self.bodies._refresh_action_from_objects()
@@ -469,20 +336,13 @@ class Simulation:
def read_surface_exchange(self, id: int):
"""Latest step's wall/aperture (Fx,Fy,Tz) exchange, not total nozzle force.
Supports SurfaceActuation and Jet-driven bodies, including zero-amplitude
apertures. Passive bodies without an aperture are rejected.
Unlike read_body this is not accumulated/time-averaged. It resets after
geometry sync or checkpoint load; advance before interpreting it.
"""
self._require_usable()
obj = self.bodies.get(id)
curved = self.field.curved
has_aperture = (curved.aperture is not None
and np.any(curved.aperture & (curved.body_id == id)))
if obj.surface_actuation is None and not has_aperture:
if self.bodies.get(id).surface_actuation is None:
raise ValueError("Body has no surface actuation")
self.stream.synchronize()
return curved.surface_exchange(id)
return self.field.curved.surface_exchange(id)
@context_bound
def read_body(self, id: int, *,
@@ -502,7 +362,6 @@ class Simulation:
BodyTelemetry with fields ``force``, ``torque``, ``sensor``.
``sensor`` is a dim-length array of zeros for non-sensor bodies.
"""
self._require_usable()
if self.bodies.obs_pinned is None:
raise RuntimeError("No obs buffer. Call run() first.")
if stream is None:
@@ -522,7 +381,6 @@ class Simulation:
This is the most efficient readback for DRL control loops
(single contiguous D2H copy, no Python per-body overhead).
"""
self._require_usable()
if self.bodies.obs_pinned is None:
raise RuntimeError("No obs buffer. Call run() first.")
if stream is None:
@@ -548,8 +406,15 @@ class Simulation:
self.lbm_cfg.validate_runtime()
self._assert_allocation_contract()
arch = self._resolve_compile_arch()
self._module = compiler.build_module(self.lbm_cfg, "uniform.cu", arch=arch,
n_objects=expected_n_objects)
next_dir = compiler.generate_config(self.lbm_cfg, n_objects=expected_n_objects)
try:
next_ptx = compiler.compile_kernel(arch=arch, build_dir=next_dir)
next_module = compiler.load_module(next_ptx)
except Exception:
compiler.cleanup_build_dir(next_dir)
raise
old_dir = self._build_dir
self._build_dir, self._ptx_path, self._module = next_dir, next_ptx, next_module
self._compiled_n_objects = int(expected_n_objects)
# Reconnect field and stepper to new module
@@ -565,9 +430,9 @@ class Simulation:
self.field, self._module, self.lbm_cfg,
)
self.stepper._step_count = _prev_step_count
self._attach_moving()
self._assert_object_count_contract(expected_count=expected_n_objects)
compiler.cleanup_build_dir(old_dir)
def recompile(self):
"""Re-generate config headers and recompile kernel.
@@ -586,31 +451,23 @@ class Simulation:
@context_bound
def initialize(self):
"""Initialize flow field and sync objects to GPU."""
self._require_usable()
self._assert_allocation_contract()
# Validate the complete geometry before flags, populations or modules
# change. Compact-list construction publishes no outputs on failure.
next_flags = self.bodies.build_flags(self.field.build_channel_flags())
self._validate_moving_bodies()
self._attach_moving()
if self.bodies.jets and self.bodies.count == 0:
raise ValueError("Jets need bodies with cut links")
self.bodies.build_compact_lists_for(self.bodies.objects, next_flags)
# Recompile if objects were added after construction.
self._assert_allocation_contract()
if self._read_generated_n_objects() != self.bodies.count:
self.recompile()
self.field.flag = next_flags
self.field.flag = self.field.build_channel_flags()
if self.bodies.count > 0:
self.field.flag = self.bodies.build_flags(self.field.flag)
self.field.upload_flags()
if self.bodies.jets and self.bodies.count == 0:
raise ValueError("Jets need bodies with cut links")
self.stepper.initialize()
if self.bodies.count > 0:
self.bodies.sync_to_gpu(self.field, rebuild_flags=False)
else:
self.bodies.ensure_packed_buffers()
self._moving_tables = self._build_moving_tables()
self._attach_moving()
self._assert_runtime_contracts()
self._initialized = True
@@ -623,10 +480,8 @@ class Simulation:
In EsoPull mode, the backing layout is decoded to physical DDF on
GPU for the patched cells only, patched on host, then encoded back.
"""
self._require_usable()
if not self._initialized:
raise RuntimeError("Call initialize() before sync_bodies()")
self._reject_moving_bodies("sync_bodies()")
self._assert_allocation_contract()
# 1. Synchronize GPU
@@ -720,23 +575,12 @@ class Simulation:
sensor) on GPU and reset the step accumulator before stepping.
checkpoint_interval: Nonnegative integer; if >0, save every N steps.
"""
self._require_usable()
if not self._initialized:
raise RuntimeError("Call initialize() first")
steps = operator.index(steps)
checkpoint_interval = operator.index(checkpoint_interval)
if steps < 0 or checkpoint_interval < 0:
raise ValueError("steps and checkpoint_interval must be nonnegative")
moving = self._moving_bodies()
if steps > 0 and moving:
from .body.motion import validate_shared_translations
validate_shared_translations([obj.motion for obj in moving])
if steps > 0 and moving and self.stepper.moving is None:
raise RuntimeError(MOVING_BLOCKED)
if moving and checkpoint_interval > 0:
raise ValueError("checkpoints are not supported with translating circles")
if steps > 1 and any(obj.motion.pending is not None for obj in moving):
raise ValueError("a pending translation target needs run(1)")
self._assert_runtime_contracts()
# Discard any uncommitted body edits before stepping.
if self.bodies.has_pending_edit():
@@ -753,8 +597,6 @@ class Simulation:
# Zero obs segments before step group
if zero_obs:
self.bodies.zero_obs_async(stream)
if self.stepper.moving is not None:
self.stepper.moving.zero_diagnostics(stream)
if checkpoint_interval > 0:
done = 0
@@ -771,9 +613,6 @@ class Simulation:
if done < steps or done % checkpoint_interval == 0:
stream.synchronize()
self.save_checkpoint()
elif self.stepper.moving is not None and steps > 0:
self._run_moving(steps, stream, sync_obs)
return
else:
self.stepper.step(
steps,
@@ -792,37 +631,6 @@ class Simulation:
stream.synchronize()
def _run_moving(self, steps, stream, sync_obs):
"""Steps with translating circles: hold, or one advancing step that commits
each pending target only after synchronization and a zero ``no_donor`` count
(docs/moving_bodies.md 4a, 5). Any failure leaves the Simulation unusable."""
tables = self.stepper.moving
circles = [(obj.motion, obj.obj_id) for obj in self._moving_bodies()]
pending = any(motion.pending is not None for motion, _ in circles)
try:
advancing = tables.upload_records(circles, stream)
self.stepper._step(steps, action_gpu=self.bodies.action_gpu,
obs_gpu=self.bodies.obs_gpu, stream=stream, advance=advancing)
self.bodies.increment_obs_steps(steps)
if sync_obs:
self.bodies.download_obs_full_async(stream)
stream.synchronize()
if advancing:
no_donor = tables.download_diagnostics()[:, tables.DIAG.index("no_donor")]
if np.any(no_donor > 0):
raise RuntimeError("translating circle refill found no donor cell")
if pending: # a target equal to the applied offset is consumed as a hold step
for motion, _ in circles:
motion.commit()
if advancing:
tables.upload_records(circles, stream) # first hold: old = new = applied, u = 0
stream.synchronize()
self._attach_moving()
except BaseException as exc:
for motion, _ in circles: # device geometry/state unknown after any failure
motion.fail(exc)
raise
def step(self, n: int = 1):
"""Advance *n* steps (convenience for interactive use)."""
self.run(n)
@@ -831,25 +639,15 @@ class Simulation:
@context_bound
def get_macroscopic(self) -> Dict[str, np.ndarray]:
"""Download DDF and return rho, ux, uy [, uz]."""
self._require_usable()
return self.field.get_macroscopic(step_id=self.stepper.step_count)
@context_bound
def get_ddf(self) -> np.ndarray:
self._require_usable()
self.field.download_ddf(step_id=self.stepper.step_count)
return self.field.ddf.copy()
@context_bound
def get_flags(self) -> np.ndarray:
"""Flag array; read back from the device when translating circles may have moved."""
if getattr(self.stepper, "moving", None) is None:
return self.field.flag.copy()
self._require_usable()
self.stream.synchronize()
flags = np.empty_like(self.field.flag)
cuda.memcpy_dtoh(flags, self.field.flag_gpu)
return flags
return self.field.flag.copy()
# -- Runtime parameter updates -------------------------------------------
@context_bound
@@ -858,29 +656,24 @@ class Simulation:
Accepted: omega, fx, fy, fz, n_objects.
"""
self._require_usable()
self.field.update_params(**kwargs)
# -- Snapshots -----------------------------------------------------------
@context_bound
def snapshot(self):
"""Snapshot the flow field; restore keeps the current clock and controls."""
self._require_usable()
self.field.snapshot(step_id=self.stepper.step_count)
@context_bound
def restore(self):
"""Restore the saved flow field at the current streaming parity."""
self._require_usable()
self.field.restore(step_id=self.stepper.step_count)
# -- Checkpoint / Restore (HDF5) ----------------------------------------
@context_bound
def save_checkpoint(self, path: str = None):
"""Save full simulation state to an HDF5 file."""
self._require_usable()
from .common.checkpoint import save_checkpoint
self._reject_moving_bodies("save_checkpoint()")
if self.bodies.jets:
raise NotImplementedError("Jets are not checkpointed yet")
return save_checkpoint(self.field, self.stepper, self.lbm_cfg,
@@ -889,9 +682,7 @@ class Simulation:
@context_bound
def load_checkpoint(self, path: str):
"""Restore simulation state from an HDF5 checkpoint."""
self._require_usable()
from .common.checkpoint import load_checkpoint
self._reject_moving_bodies("load_checkpoint()")
if not self._initialized:
self.initialize()
load_checkpoint(path, self.field, self.stepper, self.lbm_cfg,
@@ -910,12 +701,13 @@ class Simulation:
cuda.Context.synchronize()
# Release owned GPU resources while their context is current.
self.stepper = None
self._moving_tables = None
self.bodies = None
self.field = None
self._module = None
self._stream = None
ctx.close()
compiler.cleanup_build_dir(getattr(self, "_build_dir", None))
self._build_dir = None
self._closed = True
def __enter__(self):
@@ -1,66 +0,0 @@
"""Checkpoint save -> load round trip for a scene with passive bodies. Requires GPU.
A fresh Simulation with the same scene, restored from the checkpoint must hold the same populations,
flags, body count and step count, and must stay bitwise identical to the original
after both continue for the same number of steps. Checkpoints with Jets are
refused (documented limitation) and are not covered here.
"""
import os
import tempfile
import unittest
import numpy as np
from ._cases import make_sim
# Three separated cylinders (pinball-like); cut-link receivers stay in bulk fluid.
CIRCLES = (((40, 32), 6), ((56, 22), 6), ((56, 42), 6))
def scene():
sim = make_sim()
sim.initialize()
for center, radius in CIRCLES:
sim.add_body("circle", center=center, radius=radius)
sim.sync_bodies()
return sim
class CheckpointBodiesGPU(unittest.TestCase):
def test_round_trip_is_bitwise_and_continues_identically(self):
sim = scene()
sim.run(60)
with tempfile.NamedTemporaryFile(suffix=".h5", delete=False) as f:
path = f.name
restored = None
try:
saved = sim.save_checkpoint(path)
# The loader requires the same body count and geometry: rebuild the scene.
restored = scene()
restored.load_checkpoint(saved)
self.assertEqual(restored.bodies.count, len(CIRCLES))
self.assertEqual(restored.stepper.step_count, sim.stepper.step_count)
np.testing.assert_array_equal(restored.get_flags(), sim.get_flags())
a, b = sim.get_ddf(), restored.get_ddf()
self.assertEqual(a.dtype, b.dtype)
self.assertEqual(a.tobytes(), b.tobytes())
sim.run(40)
restored.run(40)
self.assertEqual(restored.stepper.step_count, sim.stepper.step_count)
a, b = sim.get_ddf(), restored.get_ddf()
self.assertTrue(np.isfinite(a).all())
self.assertEqual(a.tobytes(), b.tobytes())
for i in range(len(CIRCLES)):
self.assertTrue(np.all(np.isfinite(restored.read_force(i))))
finally:
if restored is not None:
restored.close()
os.unlink(path)
sim.close()
if __name__ == "__main__":
unittest.main()
-53
View File
@@ -1,53 +0,0 @@
"""Direct Simulation Jet control upload; boundary-law accuracy is tested elsewhere."""
import numpy as np
import pycuda.driver as cuda
from CelerisLab.body.jets import Jet, Radial, Sector
from CelerisLab.lbm.descriptors import FLUID
from ._cases import make_sim
def test_simulation_jet_amplitude_upload_and_continued_steps():
# Same grid/circle/sector as the existing uniform surface-actuation fixture.
center = (16., 12.)
with make_sim(nx=32, ny=24, velocity=0) as sim:
sim.add_body("circle", center=center, radius=4.3)
index = sim.add_jet(Jet(Sector(center, .7, .85, 4.4),
Radial(center, inward=False), "quartic", .002))
sim.initialize()
curved = sim.field.curved
shape, jet_id = curved.jet_shape.copy(), curved.jet_id.copy()
flags = sim.get_flags()
driven = jet_id == index
assert driven.any() and (~driven).any()
velocity_pointer = int(curved.surface_velocity_gpu)
previous = None
for interval, amplitude in enumerate((.002, -.001)):
if interval:
count = sim.stepper.step_count
sim.set_jet_amplitude(index, amplitude)
assert sim.stepper.step_count == count # update does not step
expected = np.zeros_like(curved.surface_velocity)
expected[driven] = (amplitude * shape[driven]).astype(np.float32)
actual = np.empty_like(expected)
with sim.ctx.activate():
cuda.memcpy_dtoh(actual, curved.surface_velocity_gpu)
np.testing.assert_array_equal(actual, expected) # actual device column
np.testing.assert_array_equal(curved.surface_velocity, expected)
assert np.any(actual[driven] != 0)
if previous is not None:
assert not np.array_equal(actual[driven], previous[driven])
previous = actual.copy()
assert sim.bodies.jets[index].amplitude == amplitude
assert curved.jets[index].amplitude == amplitude
assert int(curved.surface_velocity_gpu) == velocity_pointer
np.testing.assert_array_equal(curved.jet_shape, shape)
np.testing.assert_array_equal(curved.jet_id, jet_id)
np.testing.assert_array_equal(sim.get_flags(), flags)
sim.run(2)
assert sim.stepper.step_count == 2 * (interval + 1)
assert sim._failed is None
macro = sim.get_macroscopic()
fluid = (flags.reshape(24, 32) & FLUID) != 0
assert all(np.isfinite(value[fluid]).all() for value in macro.values())
assert np.all(macro["rho"][fluid] > 0)
@@ -6,14 +6,14 @@ import pycuda.driver as cuda
import pytest
import CelerisLab
from CelerisLab.cuda import compiler
from CelerisLab.cuda import compiler_v2
from ._cases import make_sim
def test_source_provenance():
root = Path(__file__).resolve().parents[2] / "src/CelerisLab"
assert Path(CelerisLab.__file__).resolve().parent == root
assert Path(compiler.kernel_path("uniform.cu")).resolve() == root / "lbm/kernels/uniform.cu"
assert Path(compiler_v2.kernel_path("kernel_v2.cu")).resolve() == root / "lbm/kernels/kernel_v2.cu"
def test_two_contexts_interleaved_and_non_lifo_close():
@@ -21,7 +21,7 @@ def test_two_contexts_interleaved_and_non_lifo_close():
first = make_sim(nx=32, ny=16)
second = make_sim(nx=48, ny=24, streaming="esopull", shifted=True)
try:
assert first._module is not second._module
assert first._build_dir != second._build_dir
assert cuda.Context.get_current() == before
first.initialize()
second.initialize()
@@ -1,62 +0,0 @@
"""Small serial GPU gates for shared transverse circles; no physical benchmark."""
import json
from pathlib import Path
import numpy as np
from CelerisLab import Simulation
from CelerisLab.body.motion import MovingTables, Translation
CENTRES = ((70., 79.5), (96., 94.5), (96., 64.5))
def simulate(tmp_path, *, moving, offsets, reverse=False):
config = json.loads((Path(__file__).resolve().parents[2]/'research/steady_pinball/config.json').read_text())
config['grid'].update(nx=192, ny=160)
p = tmp_path/'config.json'
p.write_text(json.dumps(config))
with Simulation(str(p)) as sim:
envelope = Translation((0., 0., -20., 20.), .5, shared=True)
centres = list(reversed(CENTRES)) if reverse else list(CENTRES)
ids = [sim.add_body('circle', c, 10., translation=envelope if moving else None) for c in centres]
sim.initialize()
previous = 0.
crossings = 0.
for y in offsets:
if moving:
for bid in ids:
sim.set_body(bid, offset=(0., float(y)), vy=float(y-previous))
sim.run(1)
if moving:
diag = sim.moving_diagnostics()
assert np.isfinite(diag).all()
assert np.all(diag[:, MovingTables.DIAG.index('no_donor')] == 0)
crossings += diag[:, MovingTables.DIAG.index('crossings')].sum()
previous = y
forces = np.array([sim.read_force(bid) for bid in ids])
if reverse:
forces = forces[::-1]
return sim.get_ddf(), sim.get_flags(), forces, crossings
def test_fixed_vs_shared_hold(tmp_path):
fixed = simulate(tmp_path, moving=False, offsets=[0.]*32)
held = simulate(tmp_path, moving=True, offsets=[0.]*32)
assert np.array_equal(fixed[0], held[0], equal_nan=True)
assert np.array_equal(fixed[1], held[1])
# FP32 atomic force reduction order only; populations remain exactly equal.
np.testing.assert_allclose(fixed[2], held[2], rtol=0., atol=1e-4)
assert held[3] == 0
def test_common_motion_member_order_has_no_population_effect(tmp_path):
# MAX_STEP=.5 is a geometric bound, not a low-Mach stability guarantee.
# Keep the complete +/-D excursion at wall speed .01, equal to mean inflow.
offsets = np.concatenate((np.arange(1, 2001), np.arange(1999, -2001, -1),
np.arange(-1999, 1))) * .01
forward = simulate(tmp_path, moving=True, offsets=offsets)
reverse = simulate(tmp_path, moving=True, offsets=offsets, reverse=True)
assert forward[3] > 0 and forward[3] == reverse[3]
assert np.array_equal(forward[0], reverse[0], equal_nan=True)
assert np.array_equal(forward[1], reverse[1])
np.testing.assert_allclose(forward[2], reverse[2], rtol=0., atol=1e-4)
+1 -3
View File
@@ -16,7 +16,6 @@
#define NZ 1
#define NT 1
#define COLLISION_MODEL 0
#define BLOCK_ROOT_BC 2
#define USE_LES 0
#define RHO 1.f
#define U0 .025f
@@ -41,8 +40,7 @@ inline float __fdiv_rn(float a,float b){return a/b;}
#include "boundary/inlet_outlet.cuh"
#include "step/one_step_double.cu"
#else
#include "core/cell_kind.cuh"
#include "operators/block_collision.cuh"
constexpr unsigned char KIND_OUTER=2;
#include "step/block_channel.cu"
#endif
int main(){
-46
View File
@@ -1,46 +0,0 @@
"""Check historical source manifests against their own recorded commit, not today's checkout."""
import io
from pathlib import Path
import subprocess
import tarfile
ROOT = Path(__file__).resolve().parents[2]
def _require(commit):
"""Missing recorded history is an explicit failure, never a skip or a repin."""
found = subprocess.run(
["git", "-C", str(ROOT), "cat-file", "-e", commit + "^{commit}"],
capture_output=True,
)
if found.returncode:
raise AssertionError(
f"Required history missing: commit {commit} is not in this clone; "
"fetch it instead of repinning or substituting"
)
def tracked(commit, prefix="src"):
"""Tracked paths under prefix at the recorded commit."""
_require(commit)
return set(
subprocess.check_output(
["git", "-C", str(ROOT), "ls-tree", "-r", "--name-only", commit, prefix],
text=True,
).splitlines()
)
def historical_tree(commit, dest):
"""Extract the tracked tree of the recorded commit into dest."""
_require(commit)
archive = subprocess.check_output(
["git", "-C", str(ROOT), "archive", "--format=tar", commit]
)
dest = Path(dest)
dest.mkdir(parents=True, exist_ok=True)
extra = {"filter": "data"} if hasattr(tarfile, "data_filter") else {}
with tarfile.open(fileobj=io.BytesIO(archive)) as tar:
tar.extractall(dest, **extra)
return dest
+5 -29
View File
@@ -83,32 +83,6 @@ assert abs(sum(compute_feq_d2q9(1.,0.,0.)) - 1.) < 1e-6
class BuildContracts(unittest.TestCase):
def test_block_root_modes_are_rendered_and_recorded(self):
with tempfile.TemporaryDirectory() as root:
for mode in (0, 1, 2):
with self.subTest(mode=mode):
build = compiler.generate_config(LBMConfig(), build_root=root, block_root_bc=mode)
try:
method = Path(build, "config/config_method.h").read_text()
self.assertIn(f"#define BLOCK_ROOT_BC {mode}\n", method)
metadata = json.loads(Path(build, ".celeris-build").read_text())
self.assertEqual(metadata["block_root_bc"], mode)
self.assertNotIn("block_root_bc", metadata["config"])
finally:
compiler.cleanup_build_dir(build)
build = compiler.generate_config(LBMConfig(), build_root=root)
try:
self.assertEqual(json.loads(Path(build, ".celeris-build").read_text())["block_root_bc"], 0)
finally:
compiler.cleanup_build_dir(build)
def test_invalid_block_root_mode_fails_before_snapshot(self):
for mode in (-1, 3, True, False, 1.0, "1", None):
with self.subTest(mode=mode), patch.object(compiler.tempfile, "mkdtemp") as allocate:
with self.assertRaisesRegex(ValueError, "block_root_bc"):
compiler.generate_config(LBMConfig(), block_root_bc=mode)
allocate.assert_not_called()
@staticmethod
def fake_compile(command, check):
source = Path(command[-3])
@@ -126,7 +100,8 @@ class BuildContracts(unittest.TestCase):
def work(nx, count):
build = compiler.generate_config(LBMConfig(nx=nx), count, build_root=root)
barrier.wait()
output = compiler.compile_kernel(build_dir=build)
# Compatibility form is context-local even without explicit path.
output = compiler.compile_kernel()
data = json.loads(Path(output).read_text())
self.assertEqual(data, {"nx": nx, "objects": count})
metadata = json.loads(Path(output + ".json").read_text())
@@ -155,7 +130,7 @@ class BuildContracts(unittest.TestCase):
compiler.cleanup_build_dir(a)
compiler.cleanup_build_dir(b)
with self.assertRaises(RuntimeError):
compiler.compile_kernel(build_dir=a) # removed snapshot is never compiled
compiler.compile_kernel()
def test_failed_compile_cannot_implicitly_load_previous_artifact(self):
with tempfile.TemporaryDirectory() as root:
@@ -168,7 +143,8 @@ class BuildContracts(unittest.TestCase):
with self.assertRaises(subprocess.CalledProcessError):
compiler.compile_kernel(build_dir=build)
self.assertEqual(Path(previous).read_bytes(), old_bytes)
self.assertEqual(sorted(Path(build).glob("uniform-*.ptx")), []) # no partial output
with self.assertRaisesRegex(RuntimeError, "No compiled PTX"):
compiler.load_module()
compiler.cleanup_build_dir(build)
+7 -8
View File
@@ -7,7 +7,7 @@ from CelerisLab.config import LBMConfig
from CelerisLab.lbm.blocks import BlockLayout2D
from CelerisLab.lbm.channel import channel_masks, validate_channel_config, validate_channel_transfers
from CelerisLab.lbm.refinement import FixedPatch2D
from CelerisLab.lbm.refinement_tables import blocked_coarse_tables
from CelerisLab.lbm.refinement_tables import blocked_base_tables
from .test_numerical_operators import compile_run
@@ -31,7 +31,7 @@ def transfer(bounds, block=(4, 6)):
cfg = LBMConfig(nx=24, ny=20, viscosity=.1)
pair = FixedPatch2D.from_config(cfg, bounds)
layout = BlockLayout2D.build(20, 24, *block)
targets = blocked_coarse_tables(pair, layout, np.argsort(pair.coalesce_coarse))[1]
targets = blocked_base_tables(pair, layout, np.argsort(pair.coalesce_coarse))[1]
return pair, layout, targets
@@ -50,7 +50,7 @@ def test_masks_and_minimal_footprint():
def test_actual_closure_read_conflicts():
pair, layout, targets = transfer((6, 6, 18, 14))
validate_channel_transfers(pair, layout, targets)
for target in (layout.slot(int(layout.owner[5, -1]) - 1, 2), # integer under NumPy 1 and 2
for target in (layout.slot(layout.owner[5, -1]-1, 2),
layout.slot(layout.owner[1, 4], 4),
layout.slot(layout.owner[-2, 4], 3)):
with pytest.raises(ValueError, match="immutable"):
@@ -74,14 +74,14 @@ def test_evolved_ghost_snapshot_and_physical_download():
from .test_refinement_three_level_multipatch import host_module
host = host_module()
storage = {}
host.compiler.build_module.return_value.get_global.side_effect = lambda name: (name, 12 if name == "d_block_channel" else 20)
host.compiler.load_module.return_value.get_global.side_effect = lambda name: (name, 12 if name == "d_block_channel" else 20)
host.cuda.mem_alloc.side_effect = lambda size: Mock()
host.cuda.memcpy_htod.side_effect = lambda ptr, a: storage.__setitem__(id(ptr), np.array(a, copy=True))
host.cuda.memcpy_dtoh.side_effect = lambda a, ptr: np.copyto(a, storage[id(ptr)])
context = Mock(activate=nullcontext, sm_arch="sm_70")
cfg = LBMConfig(nx=24, ny=20, viscosity=.1, velocity=.025)
backend = host.FixedPatchStepper(cfg, context=context, coarse_boundary="channel",
refinements=[(6, 6, 18, 14)], level_blocks=[(4, 6), (4, 4)])
backend = host.FixedPatchStepper(cfg, (6, 6, 18, 14), context=context,
coarse_boundary="channel", base_block=(4, 6), fine_block=(4, 4))
try:
weights = np.array([4/9]+[1/9]*4+[1/36]*4)[:, None, None]
arrays = [np.broadcast_to(weights, level.shape).copy() for level in backend._levels]
@@ -96,7 +96,6 @@ def test_evolved_ghost_snapshot_and_physical_download():
arrays[0][0, 0, 0] = np.nan
with pytest.raises(ValueError, match="finite"):
backend.initialize(*arrays)
assert backend._levels[0].kernel == host.compiler.build_module.return_value.get_function("BlockChannelStep")
assert [call.kwargs["block_root_bc"] for call in host.compiler.build_module.call_args_list] == [2, 0]
assert backend._levels[0].kernel == host.compiler.load_module.return_value.get_function("BlockChannelStep")
finally:
backend.close()
-79
View File
@@ -1,79 +0,0 @@
"""CPU-only contract tests for run_dfg_re20. Not CUDA or DFG acceptance evidence."""
import sys
from pathlib import Path
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "validation"))
import run_dfg_re20 as v # noqa: E402
import run_refined_dfg as rr # noqa: E402
h, _ = rr.helpers(rr.DEFAULT_SOURCE)
def rows(values, tstar_end=v.TSTAR_MAX):
n = int(round(tstar_end / v.SAMPLE_TSTAR))
return [dict(tstar=(i + 1) * v.SAMPLE_TSTAR, **values(i)) for i in range(n)]
class Re20Contract(unittest.TestCase):
def test_lattice_parameters_and_bounds(self):
root, uniform = v.lattice_parameters(40), v.lattice_parameters(80)
self.assertAlmostEqual(root["tau"], 0.56, places=12)
self.assertAlmostEqual(uniform["tau"], 0.62, places=12)
self.assertEqual((root["steps"], root["interval"]), (240000, 2000))
self.assertEqual((uniform["steps"], uniform["interval"]), (480000, 4000))
self.assertAlmostEqual(v.plan()["refined_fine_tau"], 0.62, places=12)
def test_config_only_changes_viscosity(self):
base = v._re100_config(h, 40, "SRT", "none")
cfg, body, p = v.config(h, 40, "SRT")
self.assertAlmostEqual(cfg["physics"]["viscosity"], 0.01 * 40 / 20, places=15)
self.assertEqual((p["re"], p["viscosity"]), (20.0, cfg["physics"]["viscosity"]))
base[0]["physics"]["viscosity"] = cfg["physics"]["viscosity"]
self.assertEqual(base[0], cfg)
self.assertEqual(base[1], body)
with self.assertRaisesRegex(ValueError, "no Jets"):
v.config(h, 40, "MRT", "zero")
def test_units(self):
s = v.sample([2.0, -0.5], 4, 40, 3.0)
den = rr.SPEED**2 * 40 / 2
self.assertAlmostEqual(s["cd"], 2.0 / 4 / den)
self.assertAlmostEqual(s["cl"], -0.5 / 4 / den)
self.assertAlmostEqual(s["p_diff"], 3.0 * 0.04)
self.assertEqual(s["dp_scaled"], 3.0)
def test_steady_and_verdict(self):
inside = dict(cd=5.58, cl=0.0106, p_diff=0.1174)
out = v.summarize(rows(lambda i: inside))
self.assertEqual((out["status"], out["steady"], out["window_samples"]),
("WITHIN_INTERVALS", True, 21))
self.assertTrue(out["cd_positive"])
self.assertAlmostEqual(out["edge_distance"]["cl"], 0.0002)
self.assertTrue(all(out["verdict"].values()))
low = v.summarize(rows(lambda i: dict(inside, cl=0.0100)))
self.assertEqual(low["status"], "OUTSIDE_INTERVALS")
self.assertFalse(low["verdict"]["cl"])
def test_slow_drift_gives_no_verdict(self):
# Range 2e-5 passes (limit 6e-5); slope * window 2e-5 exceeds 1.2e-5.
out = v.summarize(rows(lambda i: dict(cd=5.58, cl=0.0106 + 1e-6 * i, p_diff=0.1174)))
self.assertEqual((out["status"], out["verdict"]), ("NOT_STEADY", None))
self.assertLessEqual(out["ranges"]["cl"], out["range_limits"]["cl"])
self.assertGreater(out["drifts"]["cl"], out["slope_limits"]["cl"])
def test_noise_range(self):
quiet = v.summarize(rows(lambda i: dict(cd=5.58, cl=0.0106 + 2e-5 * (i % 2), p_diff=0.1174)))
self.assertTrue(quiet["steady"]) # within the range limit, no trend
noisy = v.summarize(rows(lambda i: dict(cd=5.58, cl=0.0106 + 1e-4 * (i % 2), p_diff=0.1174)))
self.assertEqual(noisy["status"], "NOT_STEADY")
self.assertGreater(noisy["ranges"]["cl"], noisy["range_limits"]["cl"])
def test_incomplete_run_gives_no_verdict(self):
out = v.summarize(rows(lambda i: dict(cd=5.58, cl=0.0106, p_diff=0.1174), tstar_end=30))
self.assertEqual((out["status"], out["verdict"]), ("INCOMPLETE", None))
if __name__ == "__main__":
unittest.main()
-223
View File
@@ -1,223 +0,0 @@
"""CPU tests of the Dütsch protocol arithmetic (docs/validation/duetsch_re100_kc5_protocol.md §6a)."""
import importlib.util
import math
from pathlib import Path
import numpy as np
import pytest
_spec = importlib.util.spec_from_file_location(
"duetsch_protocol", Path(__file__).resolve().parents[2] / "research/duetsch_re100_kc5/protocol.py")
p = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(p)
def _load(rel, name):
"""Load a research driver module under a unique name (no bare ``import run``)."""
import sys
path = Path(__file__).resolve().parents[2] / rel
sys.path.insert(0, str(path.parent)) # for the driver's own sibling imports
try:
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
finally:
sys.path.remove(str(path.parent))
return mod
T = 2520
def test_phase_and_cycle_bookkeeping():
assert p.phase(0, T) == 270.0 and p.cycle(0, T) == 0 # +A, at rest, phi = -90
assert p.phase(T // 4, T) == 0.0 and p.cycle(T // 4, T) == 1 # mean position, u = -U_max
assert p.position(0, T, 1.0) == 1.0
assert p.cycle(T // 4 - 1, T) == 0 and p.cycle(T // 4 + T - 1, T) == 1
assert p.cycle(T // 4 + T, T) == 2
assert p.phase(T // 6, T) == pytest.approx(330.0) and p.cycle(T // 6, T) == 0 # startup quarter
assert p.phase(7 * T // 6, T) == pytest.approx(330.0) and p.cycle(7 * T // 6, T) == 1
for ph, frac in ((180.0, .75), (210.0, 5 / 6)):
s = round(frac * T) + T
assert p.phase(s, T) == pytest.approx(ph) and p.bin_phase(s, T) == ph
assert p.bin_phase(round(.75 * T) + T - 1, T) is None
assert sum(p.bin_phase(s, T) == 180.0 for s in range(T, 2 * T)) == T // 360
def test_settling_and_common_window():
passes = {k: True for k in range(3, 12)}
assert p.settled_start(passes) == 7 # pairs 3->4, 4->5, 5->6
late = {**passes, 5: False}
assert p.settled_start(late) == 9 # pairs 6->7, 7->8, 8->9
assert p.common_window([7, 9], [passes, passes]) == (9, 10, 11)
assert p.common_window([7, 10], [passes, passes]) is None # K + 2 = 12 beyond the cap
assert p.common_window([7, None], [passes, passes]) is None
assert p.common_window([7, 7], [passes, {**passes, 8: False}]) is None # must stay settled
# a case settled early must keep passing while waiting for the common K
assert p.common_window([7, 9], [{**passes, 8: False}, passes]) is None
long = {k: True for k in range(3, 32)}
assert p.common_window([10], [long]) is None # default cap 11
assert p.common_window([29], [long], last_scored=31) == (29, 30, 31)
assert p.common_window([30], [long], last_scored=31) is None
def test_solid_aware_bilinear():
field = np.arange(16.0).reshape(4, 4)
fluid = np.ones((4, 4), bool)
value, used, total = p.bilinear_fluid(field, fluid, 1.25, 1.5)
assert total == pytest.approx(1.0) and value == pytest.approx(0.75 * 0.5 * (5 + 9) + 0.25 * 0.5 * (6 + 10))
fluid[1, 1] = False # one solid corner
value, used, total = p.bilinear_fluid(field, fluid, 1.25, 1.5)
assert total == pytest.approx(1.0 - 0.75 * 0.5)
assert all(not (i == 1 and j == 1) for i, j, _ in used)
assert sum(w for *_, w in used) == pytest.approx(1.0)
fluid[:] = False
assert p.bilinear_fluid(field, fluid, 1.25, 1.5) == (None, (), 0.0)
def test_interval_bases_and_morison_fit_recover_known_coefficients():
b = np.array([p.interval_bases(n, T) for n in range(T // 4, T // 4 + 3 * T)])
phi = 2 * np.pi * (np.arange(T // 4, T // 4 + 3 * T) + .5) / T - np.pi / 2
assert np.allclose(b[:, 1], np.sin(phi), atol=1e-5) # midpoint ~ interval mean
f = 2.09 * b[:, 0] - (math.pi ** 2 * 1.45 / p.KC) * b[:, 1]
assert p.morison_fit(f, b[:, 0], b[:, 1]) == pytest.approx((2.09, 1.45), abs=1e-9)
def test_quadrature_rule_reused_across_steps_and_periods(monkeypatch):
original = np.polynomial.legendre.leggauss
orders = []
def counted(points):
orders.append(points)
return original(points)
p._quadrature_rule.cache_clear()
monkeypatch.setattr(np.polynomial.legendre, "leggauss", counted)
try:
for points in (16, 8, 16):
for period in (T, 2 * T):
for n in (0, 123, period + 123):
p.interval_bases(n, period, points)
assert orders == [16, 8]
finally:
p._quadrature_rule.cache_clear()
def test_interval_sine_basis_matches_exact_integral():
for period in (T, 2 * T):
half_width = math.pi / period
for n in (0, period // 4, period // 2, period - 1, 7 * period + 123):
midpoint = 2 * math.pi * ((n % period) + .5) / period - math.pi / 2
expected = math.sin(midpoint) * math.sin(half_width) / half_width
assert p.interval_bases(n, period)[1] == pytest.approx(expected, rel=0, abs=2e-15)
def test_geometry_repeats_exactly_across_cycles():
A = p.amplitude(20)
for m in (0, 1, T // 4, T // 2 - 1, 7 * T // 12):
for c in (1, 5, 11):
assert p.position(m + c * T, T, A) == p.position(m, T, A)
assert p.phase(m + c * T, T) == p.phase(m, T)
v0 = p.position(m + 1, T, A) - p.position(m, T, A)
assert p.position(m + 1 + c * T, T, A) - p.position(m + c * T, T, A) == v0
assert p.cycle(T // 4 + 5 * T, T) == 6 # unwrapped index stays separate
assert p.interval_bases(123 + 7 * T, T) == p.interval_bases(123, T)
def test_exact_point_inside_body_is_missing_even_with_fluid_corners():
field = np.zeros((6, 6))
fluid = np.ones((6, 6), bool)
fluid[3, 3] = False # only one corner solid
centre, radius = (3.0, 3.0), 0.3
value, used, total = p.sample(field, fluid, 3.2, 3.1, centre, radius) # inside the circle
assert value is None and used == () and total == 0.0
value, used, total = p.sample(field, fluid, 3.35, 3.0, centre, radius) # just outside
assert value is not None and total > 0.0
def test_generated_config_passes_the_real_loader(tmp_path):
import json
run = _load("research/duetsch_re100_kc5/run.py", "duetsch_run")
from CelerisLab.config import load_lbm_config
cfg_dict, u_max, n = run.config(20, 10.0, 2520)
path = tmp_path / "config_lbm.json"
path.write_text(json.dumps(cfg_dict))
cfg = load_lbm_config(str(path))
assert (cfg.nx, cfg.ny) == (n, n) == (200, 200)
assert cfg.collision == "MRT" and cfg.streaming == "double_buffer" and not cfg.ddf_shifting
assert cfg.velocity == 0.0 and cfg.viscosity == pytest.approx(u_max * 20 / 100)
assert cfg.y_wall_bc == "bounce_back"
def _analyze():
return _load("research/duetsch_re100_kc5/analyze.py", "duetsch_analyze")
def test_hf_energy_is_parseval_weighted():
a = _analyze()
n = 360
t = np.arange(n)
x = 1.0 + np.cos(2 * np.pi * 5 * t / n) + 2.0 * np.cos(2 * np.pi * 30 * t / n) + 0.5 * np.cos(np.pi * t)
e = a.one_sided_energy(np.fft.rfft(x), n) / n
assert e.sum() == pytest.approx(np.sum(x ** 2)) # Parseval
expected = (2.0 ** 2 / 2 + 0.5 ** 2) / np.mean(x ** 2) # bins >= 21 (30 and Nyquist)
assert e[21:].sum() / e.sum() == pytest.approx(expected)
def test_horizon_excludes_future_coverage_and_events(monkeypatch):
a = _analyze()
T, horizon = 360, 4
steps = round((0.25 + 6) * T) # cycles 1..6 recorded
diag = np.zeros((steps, 15))
diag[round((0.25 + horizon) * T):, 13] = 1.0 # synthetic future no_donor
rows = [(s, p.cycle(s, T), 180.0, 0.0, 0.0, 0.1, 0.0, 1.0) for s in range(steps)
if p.bin_phase(s, T) is not None and p.cycle(s, T) > p.DISCARD]
case = dict(path="c", D=20, T=T, box=10, n=200, u_max=.04, nu=.008, tau=.524, A=1.0,
steps=steps, cycles=6, F_ref=1.0, force=np.zeros((steps, 2)), diag=diag,
samples=np.asarray(rows, float))
monkeypatch.setattr(a, "load", lambda path: case)
out = []
monkeypatch.setattr("builtins.print", lambda text: out.append(text))
a.main(["c", "--horizon", str(horizon)])
import json
entry = json.loads(out[0])["cases"][0]
assert sorted(int(k) for k in entry["coverage"]) == [3, 4] # nothing from cycles 5, 6
assert entry["no_donor"] == 0.0
def _capture():
spec = importlib.util.spec_from_file_location(
"duetsch_capture", Path(__file__).resolve().parents[2] / "research/duetsch_re100_kc5/capture.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def test_capture_crop_downsample_and_trends():
c = _capture()
n, D = 2400, 40
xm = ym = 0.5 * (n - 1)
A, R = p.amplitude(D), 20.0
box = c.crop_box(xm, ym, D, n, (xm - A - R, xm + A + R, ym - R, ym + R))
assert box == (880, 1520, 960, 1440) and box[1] - box[0] + 1 == 641 and box[3] - box[2] + 1 == 481
with pytest.raises(ValueError):
c.crop_box(xm, ym, D, 600, (0, 1, 0, 1)) # leaves the domain
with pytest.raises(ValueError):
c.crop_box(xm, ym, D, n, (xm - 9 * D, xm, ym, ym)) # clips the swept body
field = np.arange(64.0).reshape(8, 8)
fluid = np.ones((8, 8), bool)
fluid[:4, :4] = False
down = c.downsample(field, fluid, 4)
assert np.isnan(down[0, 0]) and down[1, 1] == field[4:, 4:].mean()
rho = np.ones((n, n)); rho[:, 1:81] = 1.01
row = c.trend_row(10, 3, 180.0, rho, np.zeros((n, n)), np.zeros((n, n)),
np.ones((n, n), bool), D, n, box)
assert row[7] == pytest.approx(1.01) and row[9] == pytest.approx(1.0)
def test_dump_steps_are_the_first_step_of_each_bin():
T = 5040
for k in (11, 21, 30, 31):
starts = [s for s in range(T // 4 + (k - 1) * T, T // 4 + k * T)
if p.bin_phase(s, T) is not None and p.phase(s, T) == p.bin_phase(s, T)]
assert len(starts) == 3 and all(p.cycle(s, T) == k for s in starts)
-70
View File
@@ -1,70 +0,0 @@
"""The public examples run all-blocked: their level tiles must reach the device unchanged.
Host only: a context whose activation raises proves construction (layouts, tables,
boundary checks) succeeded up to the first CUDA call.
"""
import ast
from dataclasses import replace
from pathlib import Path
import unittest
from unittest.mock import Mock
from CelerisLab.body.geometry.circle import CircleGeometry
from CelerisLab.body.objects import SimObject
from CelerisLab.config import LBMConfig
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
EXAMPLES = Path(__file__).resolve().parents[2] / "examples"
def literal(name, constant):
"""Module-level literal from an example, read without importing it (no pycuda)."""
tree = ast.parse((EXAMPLES / name).read_text())
for node in tree.body:
if isinstance(node, ast.Assign):
for target in node.targets:
names = target.elts if isinstance(target, ast.Tuple) else [target]
for i, t in enumerate(names):
if isinstance(t, ast.Name) and t.id == constant:
value = node.value.elts[i] if isinstance(target, ast.Tuple) else node.value
return ast.literal_eval(value)
raise KeyError(constant)
def forbidden():
return Mock(activate=Mock(side_effect=AssertionError("CUDA reached")), sm_arch="sm_70")
class ExampleTiles(unittest.TestCase):
def test_fixed_patch_jet_tiles(self):
bounds, blocks = literal("fixed_patch_jet.py", "BOUNDS"), literal("fixed_patch_jet.py", "LEVEL_BLOCKS")
width, amplitude = literal("fixed_patch_jet.py", "WIDTH"), literal("fixed_patch_jet.py", "AMPLITUDE")
cfg = LBMConfig(nx=48, ny=48, viscosity=.1, velocity=0., wall_slot_width=width,
wall_normal_velocity=amplitude, y_wall_bc="on_site_velocity",
top_wall_bc="reservoir")
with self.assertRaisesRegex(AssertionError, "CUDA reached"):
FixedPatchStepper(cfg, context=forbidden(), coarse_boundary="slot_reservoir",
refinements=[bounds], level_blocks=[tuple(b) for b in blocks])
def test_fixed_periodic_patch_tiles(self):
with self.assertRaisesRegex(AssertionError, "CUDA reached"):
FixedPatchStepper(LBMConfig(nx=16, ny=16, viscosity=.1, velocity=1e-3), context=forbidden(),
refinements=[(4, 4, 12, 12)], level_blocks=[(16, 16), (24, 24)])
def test_fixed_patch_circle_default_tiles(self):
bounds = literal("fixed_patch_circle.py", "BOUNDS")
nx, ny = literal("fixed_patch_circle.py", "NX"), literal("fixed_patch_circle.py", "NY")
center, radius = literal("fixed_patch_circle.py", "CENTER"), literal("fixed_patch_circle.py", "RADIUS")
body = SimObject(0, geometry=CircleGeometry(*center, radius), center=center, radius=radius)
periodic = LBMConfig(nx=nx, ny=ny, viscosity=.1)
slot = replace(periodic, velocity=0, wall_normal_velocity=.001, wall_slot_width=8,
y_wall_bc="on_site_velocity", top_wall_bc="reservoir")
for mode, cfg in (("periodic", periodic), ("slot_reservoir", slot)):
with self.subTest(mode=mode), self.assertRaisesRegex(AssertionError, "CUDA reached"):
FixedPatchStepper(cfg, context=forbidden(), coarse_boundary=mode,
refinements=[bounds], level_blocks=[(12, 8), (16, 20)],
level_objects=[(), (body,)])
if __name__ == "__main__":
unittest.main()
+1 -43
View File
@@ -8,7 +8,6 @@ import numpy as np
ROOT = Path(__file__).resolve().parents[2]
REPO = ROOT
sys.path.insert(0, str(ROOT / "tests/validation"))
sys.path.insert(0, str(ROOT / "tests/unit"))
sys.path.insert(0, str(REPO / "src"))
from run_jet_circle_cv import (
CX,
@@ -17,10 +16,8 @@ from run_jet_circle_cv import (
case,
state,
cv,
cv_from_macros,
aperture_probe,
measured_aperture_flux,
PIN,
verify,
)
@@ -31,39 +28,6 @@ def eq(rho, u, v):
class Host(unittest.TestCase):
def test_public_macros_pressure_advection_and_cropped_frames(self):
for h, origin, shape, rect in (
(.5, (0., 0.), (48, 64), (5., 4., 25., 20.)),
(.5, (.25, .5), (48, 64), (5.25, 4., 25.25, 20.)),
(.25, (.375, .75), (96, 128), (5.125, 4., 25.125, 20.)),
):
y, x = np.indices(shape)
x = origin[0] + h * (x + .5)
k, u, v = 1e-5, .02, -.01
rho = 1 + 3 * k * x # p=(rho-1)/3=k*x
ux, uy, fluid = np.full(shape, u), np.full(shape, v), np.ones(shape, bool)
result = cv_from_macros(rho, ux, uy, fluid, origin, rect, .004, h)
expected = 320 * np.array([3*k*u, k + 3*k*u*u, 3*k*u*v])
np.testing.assert_allclose(result[1], expected, rtol=1e-11, atol=1e-13)
np.testing.assert_allclose(result, cv(eq(rho, u, v), fluid, origin, rect, .004, h), atol=1e-12)
x0, y0, x1, y1 = np.rint((np.array(rect)-np.tile(origin, 2))/h).astype(int)
selection = np.s_[y0-2:y1+2, x0-2:x1+2]
cropped_origin = np.asarray(origin) + h*np.array([x0-2, y0-2])
cropped = cv_from_macros(rho[selection], ux[selection], uy[selection],
fluid[selection], cropped_origin, rect, .004, h)
np.testing.assert_array_equal(cropped, result)
def test_public_macros_full_normal_viscous_stress(self):
y, x = np.indices((48, 64))
x = (x + .5) * .5
rho, ux, uy, fluid = np.ones_like(x), 1e-4*x*x, np.zeros_like(x), np.ones(x.shape, bool)
args = (rho, ux, uy, fluid, (0., 0.), (5., 4., 25., 20.))
_, viscous = cv_from_macros(*args, .004)
_, inviscid = cv_from_macros(*args, 0.)
np.testing.assert_allclose(viscous-inviscid, [0, -4*.004*1e-4*320, 0], atol=1e-13)
with self.assertRaisesRegex(ValueError, "matching 2D"):
cv_from_macros(rho, ux, uy[:-1], fluid, (0, 0), (5, 4, 25, 20), .004)
def test_descriptor_and_uniform_closed_flux(self):
from CelerisLab.lbm.descriptors import D2Q9_EX, D2Q9_EY
@@ -168,13 +132,7 @@ class Host(unittest.TestCase):
self.assertEqual(a["steps"] * 4, b["steps"])
def test_source_pin(self):
from tempfile import TemporaryDirectory
from historical_tree import historical_tree
with self.assertRaisesRegex(RuntimeError, "Source hash mismatch"):
verify(REPO)
with TemporaryDirectory() as directory:
verify(historical_tree(PIN, directory))
verify(REPO)
if __name__ == "__main__":
-149
View File
@@ -1,149 +0,0 @@
"""Jet amplitude update failure contract (host doubles only, no CUDA numerics).
Both owners validate before any device write, commit host state only after the
upload returns, and refuse every later call except close() after an uncertain
device write.
"""
from types import SimpleNamespace
import numpy as np
import pytest
pytest.importorskip("pycuda.driver")
from CelerisLab import simulation as module
from CelerisLab.body.jets import Jet, Radial, Sector
from tests.unit.test_pinned_body_remap import ready
from tests.unit.test_simulation_lifecycle import make_simulation
BAD = (float("nan"), float("inf"), 1.)
class Curved:
"""Records uploads; ``fail`` raises once the write was attempted."""
def __init__(self):
self.writes, self.fail = [], False
def set_jet_amplitude(self, index, amplitude, stream):
self.writes.append((index, amplitude))
if self.fail:
raise RuntimeError("upload failed")
def simulation(monkeypatch):
sim = make_simulation(monkeypatch)
jet = Jet(Sector((6., 5.), 0., .5, 4.), Radial((6., 5.)), "uniform", 1e-3)
sim._failed, sim._initialized = None, True
sim.bodies, sim.field = SimpleNamespace(jets=[jet]), SimpleNamespace(curved=Curved())
return sim, jet
def test_simulation_invalid_input_writes_nothing_and_stays_usable(monkeypatch):
sim, jet = simulation(monkeypatch)
for amplitude in BAD:
with pytest.raises(ValueError):
sim.set_jet_amplitude(0, amplitude)
with pytest.raises(IndexError):
sim.set_jet_amplitude(1, 2e-3)
assert sim.field.curved.writes == [] and sim.bodies.jets[0] is jet and sim._failed is None
sim.set_jet_amplitude(0, 2e-3) # retry after a rejected input
assert sim.field.curved.writes == [(0, 2e-3)] and sim.bodies.jets[0].amplitude == 2e-3
sim.close()
def test_simulation_upload_failure_keeps_host_and_blocks_reuse(monkeypatch):
sim, jet = simulation(monkeypatch)
sim.field.curved.fail = True
with pytest.raises(RuntimeError, match="upload failed"):
sim.set_jet_amplitude(0, 2e-3)
assert sim.bodies.jets[0] is jet and sim._failed is not None
sim.field.curved.fail = False
for call in (lambda: sim.set_jet_amplitude(0, 2e-3), lambda: sim.run(1), sim.step,
sim.sync_bodies, sim.initialize, sim.get_ddf, sim.get_macroscopic,
lambda: sim.read_surface_exchange(0), lambda: sim.read_body(0),
sim.read_bodies, sim.snapshot, sim.restore, sim.save_checkpoint,
lambda: sim.load_checkpoint("x.h5"), lambda: sim.update_runtime_params(omega=1.)):
with pytest.raises(RuntimeError, match="Simulation failed"):
call()
assert sim.field.curved.writes == [(0, 2e-3)]
sim.close() # cleanup still works
assert sim._closed and sim.ctx.is_closed
def test_simulation_stream_creation_failure_fails_closed(monkeypatch):
sim, jet = simulation(monkeypatch)
created = []
def stream():
created.append(1)
raise RuntimeError("stream creation failed")
monkeypatch.setattr(module.cuda, "Stream", stream, raising=False)
sim._stream = None
with pytest.raises(RuntimeError, match="stream creation failed"):
sim.set_jet_amplitude(0, 2e-3)
assert sim.bodies.jets[0] is jet and sim._failed is not None
assert sim.field.curved.writes == [] and created == [1]
for call in (lambda: sim.stream, lambda: sim.set_jet_amplitude(0, 2e-3)):
with pytest.raises(RuntimeError, match="Simulation failed"):
call()
assert created == [1] # the public stream property does not retry creation
sim.ctx.detach_failure = True
with pytest.raises(RuntimeError, match="detach failed"):
sim.close()
assert not sim._closed
sim.close() # close stays retryable after the failure
assert sim._closed and sim.ctx.is_closed
def test_simulation_before_initialize_commits_host_only(monkeypatch):
sim, _ = simulation(monkeypatch)
sim._initialized = False
sim.set_jet_amplitude(0, -2e-3)
assert sim.field.curved.writes == [] and sim.bodies.jets[0].amplitude == -2e-3
sim.close()
def stepper_state(run):
boundary = run._boundaries[2]
return boundary.tables["surface_velocity"].copy(), boundary.jets, run.jets[2]
def test_stepper_invalid_input_writes_nothing_and_stays_ready():
run, module, _, _, _ = ready()
try:
velocity, jets, level_jets = stepper_state(run)
module.cuda.memcpy_htod.reset_mock()
for args, kw, error in [((0, a), {"level": 2}, ValueError) for a in BAD] + [
((1, .01), {"level": 2}, IndexError), ((0, .01), {"level": 3}, ValueError)]:
with pytest.raises(error):
run.set_jet_amplitude(*args, **kw)
module.cuda.memcpy_htod.assert_not_called()
np.testing.assert_array_equal(run._boundaries[2].tables["surface_velocity"], velocity)
assert (run._boundaries[2].jets, run.jets[2]) == (jets, level_jets)
assert run._status == "ready"
run.set_jet_amplitude(0, .017, level=2)
assert run.jets[2][0].amplitude == .017
finally:
run.close()
@pytest.mark.parametrize("where", ["upload", "synchronize"])
def test_stepper_device_failure_keeps_host_and_blocks_reuse(where):
run, module, _, _, _ = ready()
try:
velocity, jets, level_jets = stepper_state(run)
if where == "upload":
module.cuda.memcpy_htod.side_effect = RuntimeError("upload failed")
else:
run._stream.synchronize.side_effect = RuntimeError("upload failed")
with pytest.raises(RuntimeError, match="upload failed"):
run.set_jet_amplitude(0, .017, level=2)
np.testing.assert_array_equal(run._boundaries[2].tables["surface_velocity"], velocity)
assert (run._boundaries[2].jets, run.jets[2]) == (jets, level_jets)
assert run._status == "failed"
module.cuda.memcpy_htod.side_effect = None
run._stream.synchronize.side_effect = None
for call in (lambda: run.set_jet_amplitude(0, .017, level=2), run.step, run.download):
with pytest.raises(RuntimeError, match="failed"):
call()
finally:
run.close()
assert run._status == "closed"
-51
View File
@@ -188,57 +188,6 @@ def jet_case(device, jets):
return field, bodies
def test_public_surface_exchange_for_jet_preserves_aperture_at_zero_and_reverse(device):
from CelerisLab.simulation import Simulation
jet = Jet(Sector(CENTER, 0., .6, 3.), Radial(CENTER, inward=False), "quartic", 2e-3)
field, bodies = jet_case(device, [jet])
sim = SimpleNamespace(bodies=bodies, field=field, _initialized=True,
stream=SimpleNamespace(synchronize=device.synchronize))
sim._require_usable = Simulation._require_usable.__get__(sim)
cl = field.curved
assert bodies.get(0).surface_actuation is None
aperture, velocity = cl.aperture, cl.surface_velocity.copy()
assert aperture.any() and (~aperture).any()
# Synthetic per-link exchanges test readback/splitting, not kernel physics.
values = np.arange(cl.count * 3, dtype=np.float32).reshape(-1, 3)
device.htod(cl.link_exchange_gpu, values)
for amplitude in (2e-3, 0., -1e-3):
Simulation.set_jet_amplitude(sim, 0, amplitude)
result = Simulation.read_surface_exchange(sim, 0)
assert cl.aperture is aperture
np.testing.assert_array_equal(aperture, cl.jet_id >= 0)
np.testing.assert_allclose(cl.surface_velocity, velocity * (amplitude / 2e-3))
for name, mask in (("wall", ~aperture), ("aperture", aperture)):
np.testing.assert_array_equal(result[name], values[mask].sum(axis=0, dtype=np.float64))
def test_public_surface_exchange_keeps_legacy_and_passive_guards(device):
from CelerisLab.simulation import Simulation
field = make_field(device)
bodies = manager(field, [circle(SurfaceActuation(0., .6, .002), radius=2.)])
bodies.sync_to_gpu(field)
sim = SimpleNamespace(bodies=bodies, field=field,
stream=SimpleNamespace(synchronize=device.synchronize))
sim._require_usable = Simulation._require_usable.__get__(sim)
for value in Simulation.read_surface_exchange(sim, 0).values():
np.testing.assert_array_equal(value, 0.)
field.curved.free_gpu_columns()
with pytest.raises(RuntimeError, match="diagnostic is allocated"):
Simulation.read_surface_exchange(sim, 0)
# An exchange buffer elsewhere in the scene must not admit a passive body.
for jets in ([], [Jet(Sector(CENTER, 0., .6, 3.), Radial(CENTER))]):
field, bodies = jet_case(device, jets)
passive = bodies.add(circle(center=(2., 3.), radius=.6))
bodies.sync_to_gpu(field)
sim.bodies, sim.field = bodies, field
with pytest.raises(ValueError, match="Body has no surface actuation"):
Simulation.read_surface_exchange(sim, passive)
if not jets:
with pytest.raises(ValueError, match="Body has no surface actuation"):
Simulation.read_surface_exchange(sim, 0)
def test_manager_passes_true_hits_and_reuploads_only_velocity(device):
# CPU memory double only: checks host/device bookkeeping, not GPU numerics.
jet = Jet(Sector(CENTER, 0., .6, 3.), Radial(CENTER, inward=False), "quartic", 2e-3)
+23 -55
View File
@@ -15,10 +15,6 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "validation"))
from run_uniform_les_flow import source_guard, decode_macros, HERE, HEADER
ROOT = HERE.parents[1]
sys.path.insert(0, str(ROOT / "tests/unit"))
from historical_tree import historical_tree, tracked
A135 = "a135252ae78b47b57d7e77d19001700b3bedac8b"
class GuardTests(unittest.TestCase):
@@ -46,80 +42,52 @@ class GuardTests(unittest.TestCase):
decode_macros(invalid, w, cx, cy)
def test_current_corrected_pins(self):
with TemporaryDirectory() as directory:
root = historical_tree(A135, directory)
with patch("run_uniform_les_flow.subprocess.check_output", return_value="historical-tree\n"):
identity = source_guard(root, "corrected", "a135252")
# base_commit comes from the pins file; the mock only feeds checkout_commit.
self.assertEqual(identity["base_commit"], A135)
self.assertEqual(identity["checkout_commit"], "historical-tree")
self.assertEqual(
source_guard(ROOT, "corrected", "dev8403956")["base_commit"],
"8403956a9afc0b0bb89265cec374392013d89016",
)
def test_corrected_and_tamper_guard(self):
pins = json.loads((HERE / "data/les_flow/source_pins_a135252.json").read_text())
pins = json.loads((HERE / "data/les_flow/source_pins_dev8403956.json").read_text())
with TemporaryDirectory() as directory:
origin = historical_tree(A135, Path(directory) / "origin")
root = Path(directory) / "fixture"
root = Path(directory)
for name in pins["sources"]:
dest = root / name
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(origin / name, dest)
shutil.copyfile(ROOT / name, dest)
with patch(
"run_uniform_les_flow.subprocess.check_output",
return_value="isolated-fixture\n",
):
source_guard(root, "corrected", "a135252")
source_guard(root, "corrected", "dev8403956")
with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"):
source_guard(root, "baseline", "a135252")
for name in ("kernels/step/one_step_double.cu", "kernels/blocks.cu",
"refinement_stepper.py", "refinement_tables.py"):
target = root / "src/CelerisLab/lbm" / name
original = target.read_bytes()
target.write_bytes(original + b"\n// tamper\n")
with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"):
source_guard(root, "corrected", "a135252")
target.write_bytes(original)
source_guard(root, "baseline", "dev8403956")
target = root / "src/CelerisLab/lbm/kernels/step/one_step_double.cu"
target.write_bytes(target.read_bytes() + b"\n// tamper\n")
with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"):
source_guard(root, "corrected", "dev8403956")
def test_historical_default_and_manifest_unchanged(self):
historical = {
"historical": ("source_pins.json", "87ca033619da1fc02bee822bfc5681b518a92a155c3c3d1dbbe5265c96066b39"),
"dev8403956": ("source_pins_dev8403956.json", "58187908485f2af7c78677d53de98015878372444265ce9e667683b8ef5d410f"),
}
for experiment, (name, expected) in historical.items():
manifest = HERE / "data/les_flow" / name
self.assertEqual(hashlib.sha256(manifest.read_bytes()).hexdigest(), expected)
for variant in ("baseline", "corrected"):
with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"):
source_guard(ROOT, variant, experiment)
with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"):
source_guard(ROOT, "corrected") # Omission still uses the historical profile.
def test_current_manifest_pins_actual_corrected_tree(self):
pins = json.loads((HERE / "data/les_flow/source_pins_a135252.json").read_text())
self.assertEqual(pins["base_commit"], A135)
self.assertEqual(set(pins["sources"]), tracked(A135))
with TemporaryDirectory() as directory:
root = historical_tree(A135, directory)
for name, expected in pins["sources"].items():
self.assertEqual(hashlib.sha256((root / name).read_bytes()).hexdigest(), expected)
self.assertEqual(pins["sources"][HEADER], pins["candidate_header_sha256"])
self.assertNotEqual(pins["sources"][HEADER], pins["baseline_header_sha256"])
manifest = HERE / "data/les_flow/source_pins.json"
self.assertEqual(hashlib.sha256(manifest.read_bytes()).hexdigest(),
"87ca033619da1fc02bee822bfc5681b518a92a155c3c3d1dbbe5265c96066b39")
for variant in ("baseline", "corrected"):
with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"):
source_guard(root, "baseline", "a135252")
source_guard(ROOT, variant)
def test_named_baseline_accepts_only_reversed_header(self):
pins = json.loads((HERE / "data/les_flow/source_pins_a135252.json").read_text())
pins = json.loads((HERE / "data/les_flow/source_pins_dev8403956.json").read_text())
with TemporaryDirectory() as directory:
origin = historical_tree(A135, Path(directory) / "origin")
root = Path(directory) / "fixture"
root = Path(directory)
for name in pins["sources"]:
dest = root / name
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(origin / name, dest)
shutil.copyfile(ROOT / name, dest)
subprocess.run(["git", "apply", "--reverse", str(HERE / "les/data/smagorinsky_normalization.patch")], cwd=root, check=True)
with patch("run_uniform_les_flow.subprocess.check_output", return_value="baseline-fixture\n"):
source_guard(root, "baseline", "a135252")
source_guard(root, "baseline", "dev8403956")
with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"):
source_guard(root, "corrected", "a135252")
source_guard(root, "corrected", "dev8403956")
def test_gpu_requires_opt_in_before_import(self):
with TemporaryDirectory() as directory:
-641
View File
@@ -1,641 +0,0 @@
"""Translating-circle host contract (docs/moving_bodies.md 4a, 5, 7).
Real Simulation entrypoints with device doubles; no CUDA context or GPU work.
"""
import math
from types import SimpleNamespace
import pytest
from CelerisLab.body.motion import MAX_STEP, R_MIN, MovingCircle, Translation
BAD = (float("nan"), float("inf"))
def circle(box=(-2., 2., -3., 3.), speed=.1):
return MovingCircle((50., 40.), 10., Translation(box, speed))
def test_translation_and_radius_validation():
for box in ((0., 1., -1., 1.)[:3], (.5, 1., -1., 1.), (-1., 1., BAD[0], 1.)):
with pytest.raises(ValueError):
Translation(box, .1)
for speed in (0., MAX_STEP + 1e-9) + BAD:
with pytest.raises(ValueError):
Translation((0., 0., -1., 1.), speed)
with pytest.raises(ValueError):
MovingCircle((0., 0.), R_MIN - .1, Translation((0., 0., -1., 1.), .1))
def test_targets_validate_against_applied_and_last_wins():
c = circle()
c.set_target((0., .3), (0., .05))
c.set_target((0., .5), (0., .05)) # second setter replaces, checked vs applied (0, 0)
assert c.pending == ((0., .5), (0., .05))
for offset, velocity in (((0., .51), (0., 0.)), # Euclidean bound
((.36, .36), (0., 0.)), # |dc|_2 = .509
((0., 3.1), (0., 0.)), # outside box
((0., .1), (0., .11)), # speed bound
((0., 0.), (0., .01)), # hold with sliding velocity
((BAD[0], 0.), (0., 0.)), ((0., 0.), (0., BAD[1]))):
with pytest.raises(ValueError):
c.set_target(offset, velocity)
assert c.pending == ((0., .5), (0., .05)) # invalid replacement keeps the old target
c.set_target((.35, .35), (0., 0.)) # |dc|_2 = .495 inside the bound
assert c.applied == (0., 0.)
def test_record_commit_and_hold(monkeypatch):
sim, writes, c = routed_advancing_simulation(monkeypatch)
assert c.record() == ((50., 40.), (50., 40.), (0., 0.))
sim.set_body(0, offset=(0., .25), vy=.05)
assert c.record() == ((50., 40.), (50., 40.25), (0., .05))
seen_at_sync = []
sim.run(1, stream=SimpleNamespace(synchronize=lambda: seen_at_sync.append(c.applied)))
assert seen_at_sync == [(0., 0.), (0., .25)] # commit between step sync and hold sync
uploads = [w[1][0] for w in writes if isinstance(w, tuple) and w[0] == "upload"]
assert uploads == [((50., 40.), (50., 40.25), (0., .05)),
((50., 40.25), (50., 40.25), (0., 0.))]
assert c.applied == (0., .25) and c.pending is None # offset-only commit
sim.run(1, stream=STREAM) # no new target: hold step
uploads = [w[1][0] for w in writes if isinstance(w, tuple) and w[0] == "upload"]
assert uploads[-1] == ((50., 40.25), (50., 40.25), (0., 0.)) and c.applied == (0., .25)
sim.set_body(0, offset=(0., 0.), vy=-.05) # zero offset is a real target
assert c.pending == ((0., 0.), (0., -.05))
sim.run(1, stream=STREAM)
assert c.applied == (0., 0.) and c.pending is None
@pytest.mark.parametrize("stage", ("upload", "step", "synchronize"))
def test_failure_after_upload_attempt_is_fail_closed(monkeypatch, stage):
sim, writes, c = routed_advancing_simulation(monkeypatch, fail="upload" if stage == "upload" else None)
sim.set_body(0, offset=(0., .25), vy=.05)
def fail(*a, **k):
raise RuntimeError(f"{stage} failed")
if stage == "step":
sim.stepper._step = fail
stream = SimpleNamespace(synchronize=fail) if stage == "synchronize" else STREAM
with pytest.raises(RuntimeError, match=f"{stage} failed"):
sim.run(1, stream=stream)
assert c.failed and c.applied == (0., 0.) and c.pending == ((0., .25), (0., .05))
assert sim._failed is not None and sim.stepper.blocked == sim._failed
for later in (c.record, lambda: sim.set_body(0, offset=(0., .1), vy=0.)):
with pytest.raises(RuntimeError, match="unknown"):
later()
with pytest.raises(RuntimeError, match="Simulation failed"):
sim.run(1, stream=STREAM)
with pytest.raises(RuntimeError, match="moving circle step failed"):
sim.stepper.step(1, action_gpu=None, obs_gpu=None)
def test_band_covers_offset_box_with_margin():
assert circle().band() == (50 - 2 - 10 - 2, 50 + 2 + 10 + 2, 40 - 3 - 10 - 2, 40 + 3 + 10 + 2)
# D2Q9 non-rest directions, paired order of lbm/descriptors.
_CX = (1, -1, 0, 0, 1, -1, 1, -1)
_CY = (0, 0, 1, -1, 1, -1, -1, 1)
def _inside(x: int, y: int, c, r: float) -> bool:
return (x - c[0]) ** 2 + (y - c[1]) ** 2 < r * r
def empty_donor_cells(c_old, c_new, radius: float):
"""Fresh cells (inside c_old, outside c_new) whose donor set V(x) is empty."""
r = radius
xs = range(math.floor(min(c_old[0], c_new[0]) - r) - 1, math.ceil(max(c_old[0], c_new[0]) + r) + 2)
ys = range(math.floor(min(c_old[1], c_new[1]) - r) - 1, math.ceil(max(c_old[1], c_new[1]) + r) + 2)
empty = []
for x in xs:
for y in ys:
if not _inside(x, y, c_old, r) or _inside(x, y, c_new, r):
continue
if not any(not _inside(x + cx, y + cy, c_old, r) and not _inside(x + cx, y + cy, c_new, r)
for cx, cy in zip(_CX, _CY)):
empty.append((x, y))
return empty
def enumerate_donor_coverage(radius: float, positions: int = 8, directions: int = 16) -> int:
"""Count empty-donor fresh cells over sub-cell centres and moves of MAX_STEP.
Evidence for the declared bounds (radius >= R_MIN, |dc|_2 <= MAX_STEP),
not a proof; the device keeps the ``no_donor`` counter.
"""
count = 0
for i in range(positions):
for j in range(positions):
c_old = (i / positions, j / positions)
for k in range(directions):
a = 2.0 * math.pi * k / directions
c_new = (c_old[0] + MAX_STEP * math.cos(a), c_old[1] + MAX_STEP * math.sin(a))
count += len(empty_donor_cells(c_old, c_new, radius))
return count
@pytest.mark.parametrize("radius", (R_MIN, 4.3, 7.5, 12.0))
def test_refill_donor_sets_nonempty_within_bounds(radius):
# Evidence for R >= R_MIN and |dc|_2 <= MAX_STEP on a fixed sample, not a proof.
assert enumerate_donor_coverage(radius) == 0
pytest.importorskip("pycuda.driver")
from CelerisLab import simulation as module # noqa: E402
from tests.unit.test_simulation_lifecycle import Owner, make_simulation # noqa: E402
class ClosableOwner(Owner):
"""Owner double that refuses activation after close, like CudaContext.activate."""
def activate(self):
if self.is_closed:
raise RuntimeError("CUDA context is closed")
return super().activate()
def moving_simulation(monkeypatch, *, moving=True):
sim = make_simulation(monkeypatch)
obj = SimpleNamespace(motion=circle() if moving else None, obj_id=0,
center=(50., 40.), radius=10.)
writes = []
sim._failed, sim._initialized = None, True
sim.bodies = SimpleNamespace(objects=[obj], count=1, jets=[], action_gpu=None, obs_gpu=None,
has_pending_edit=lambda: False,
_upload_action_async=lambda s: writes.append("action"),
zero_obs_async=lambda s: writes.append("obs"),
increment_obs_steps=lambda n: writes.append("count"),
download_obs_full_async=lambda s: writes.append("download"))
sim.stepper = SimpleNamespace(step=lambda n, **k: writes.append(("step", n)), blocked=None,
moving=None)
sim._assert_runtime_contracts = lambda: None
return sim, writes
STREAM = SimpleNamespace(synchronize=lambda: None)
def test_positive_steps_rejected_before_device_writes(monkeypatch):
sim, writes = moving_simulation(monkeypatch)
sim.bodies.objects[0].motion.set_target((0., .1), (0., .01))
sim.bodies.objects[0].motion.applied = (0., 0.)
for call in (lambda: sim.run(1, stream=STREAM), lambda: sim.run(5, stream=STREAM),
lambda: sim.step(), lambda: sim.run(3, stream=STREAM, checkpoint_interval=1)):
with pytest.raises(RuntimeError, match="moving kernels"):
call()
with pytest.raises(RuntimeError, match="moving kernels"): # nominal at rest as well
sim.bodies.objects[0].motion.pending = None
sim.run(1)
sim.bodies.objects[0].state = SimpleNamespace(omega=.02) # spin does not lift the guard
with pytest.raises(RuntimeError, match="moving kernels"):
sim.run(2, stream=STREAM)
assert writes == [] and sim.bodies.objects[0].motion.applied == (0., 0.)
def test_run_zero_keeps_pending_and_static_runs_proceed(monkeypatch):
sim, writes = moving_simulation(monkeypatch)
motion = sim.bodies.objects[0].motion
motion.set_target((0., .1), (0., .01))
sim.run(0, stream=STREAM) # existing action/obs work only; no motion upload or commit
assert motion.pending == ((0., .1), (0., .01)) and motion.applied == (0., 0.)
assert ("step", 0) in writes
static, writes = moving_simulation(monkeypatch, moving=False)
static.save_checkpoint = lambda *a: writes.append("checkpoint")
static.run(3, stream=STREAM)
static.run(4, stream=STREAM, checkpoint_interval=2)
assert writes.count(("step", 3)) == 1 and writes.count(("step", 2)) == 2
def test_direct_stepper_entry_is_guarded():
from CelerisLab.lbm.stepper import LBMStepper
launches = []
module = SimpleNamespace(get_function=lambda name: lambda *a, **k: launches.append(name))
cfg = SimpleNamespace(streaming="double_buffer", threads_per_block=128, is_d2q9=True,
nx=64, ny=32, nz=1)
field = SimpleNamespace(ddf_gpu="ddf", temp_gpu="temp", n_curved=0)
stepper = LBMStepper(field, module, cfg)
assert stepper.blocked is None
stepper.blocked = "translating circles need the moving kernels"
for n in (1, 3):
with pytest.raises(RuntimeError, match="moving kernels"):
stepper.step(n, action_gpu=None, obs_gpu=None)
assert launches == [] and (field.ddf_gpu, field.temp_gpu) == ("ddf", "temp")
assert stepper.step_count == 0
stepper.step(0, action_gpu=None, obs_gpu=None) # zero steps stay a no-op
assert launches == [] and stepper.step_count == 0
def test_moving_body_rejects_topology_and_checkpoint(monkeypatch):
sim, _ = moving_simulation(monkeypatch)
for call in (sim.sync_bodies, sim.save_checkpoint, lambda: sim.load_checkpoint("x.h5")):
with pytest.raises(ValueError, match="translating circles"):
call()
# -- Real-object routing, initialize rejections, packing, lifecycle -------------
import numpy as np # noqa: E402
from CelerisLab.body.geometry.circle import CircleGeometry # noqa: E402
from CelerisLab.body.manager import ObjectManager # noqa: E402
from CelerisLab.body.objects import SimObject # noqa: E402
from CelerisLab.lbm.descriptors import FLUID, SOLID # noqa: E402
class Cfg:
dim, is_d3q19, is_d2q9 = 2, False, True
streaming, ddf_shifting, store_precision, y_wall_bc = "double_buffer", False, "FP32", "bounce_back"
def body(x, y, r, translation=None, **kw):
obj = SimObject(obj_id=-1, geometry=CircleGeometry(x, y, r), center=(x, y), radius=r, **kw)
if translation is not None:
obj.motion = MovingCircle((x, y), r, translation)
return obj
def routed_simulation(monkeypatch):
sim = make_simulation(monkeypatch)
sim._failed, sim._initialized, sim.lbm_cfg = None, False, Cfg()
sim.stepper = SimpleNamespace(blocked=None)
sim.bodies = ObjectManager(nx=120, ny=80, nz=1, nq=9, cfg=Cfg())
return sim
def test_add_body_and_set_body_route_targets(monkeypatch):
sim = routed_simulation(monkeypatch)
bid = sim.add_body("circle", (50., 40.), 10., translation=Translation((-2., 2., -3., 3.), .1))
still = sim.add_body("circle", (100., 40.), 5.)
motion = sim.bodies.get(bid).motion
sim._moving_tables = "tables"
sim._attach_moving()
assert sim.stepper.blocked is None
sim.set_body(bid, offset=(0., .3), vy=.05)
assert sim.stepper.blocked == module.PENDING_BLOCKED # direct steps never consume a target
sim.set_body(bid, offset=(0., .4), vy=.05) # last wins
for kwargs in (dict(offset=(0., .6)), dict(vy=.05), dict(vx=.05),
dict(offset=(0., .1), vy=.2), dict(offset=(0., 0.), vy=.01)):
with pytest.raises(ValueError):
sim.set_body(bid, **kwargs)
assert motion.pending == ((0., .4), (0., .05)) # invalid input keeps the target
sim.set_body(bid, omega=.01) # omega-only keeps the pending target
obj = sim.bodies.get(bid)
assert obj.state.omega == .01 and (obj.state.vx, obj.state.vy) == (0., 0.)
assert motion.pending == ((0., .4), (0., .05)) and motion.applied == (0., 0.)
with pytest.raises(ValueError, match="not declared"):
sim.set_body(still, offset=(0., .1))
sim.set_body(still, vx=.01) # stationary API unchanged
assert sim.bodies.get(still).state.vx == .01 and sim.bodies.get(still).motion is None
for kwargs in (dict(type="sensor"), dict(surface_actuation=dict(angle=0., half_width=.2,
normal_velocity=.01))):
kind = kwargs.pop("type", "circle")
with pytest.raises(ValueError, match="translation"):
sim.add_body(kind, (20., 20.), 5., translation=Translation((0., 0., 0., 0.), .1),
**kwargs)
class Tables:
"""MovingTables double: records uploads, steps and diagnostics reads."""
DIAG = ("no_donor",)
def __init__(self, log, no_donor=0., fail=None):
self.log, self.no_donor, self.fail = log, no_donor, fail
def upload_records(self, circles, stream):
records = [m.record() for m, _ in circles]
self.log.append(("upload", records))
uploads = sum(1 for w in self.log if isinstance(w, tuple) and w[0] == "upload")
if self.fail == "upload" or (self.fail == "hold_upload" and uploads == 2):
raise RuntimeError("upload failed")
return any(r[0] != r[1] for r in records)
def zero_diagnostics(self, stream):
self.log.append("zero_diag")
def download_diagnostics(self):
return np.array([[self.no_donor]], np.float32)
def advancing_simulation(monkeypatch, **tables):
sim, writes = moving_simulation(monkeypatch)
sim.bodies.objects[0].motion = circle()
sim.stepper = SimpleNamespace(
moving=Tables(writes, **tables), blocked=None,
_step=lambda n, advance=False, **k: writes.append(("step", n, advance)))
from CelerisLab.lbm.stepper import LBMStepper
sim.stepper.step = LBMStepper.step.__get__(sim.stepper) # real public entry
sim._moving_tables = sim.stepper.moving
return sim, writes, sim.bodies.objects[0].motion
def routed_advancing_simulation(monkeypatch, **tables):
"""Real body registration, target routing, run and owner hook; double device I/O."""
sim = routed_simulation(monkeypatch)
bid = sim.add_body("circle", (50., 40.), 10., translation=Translation((-2., 2., -3., 3.), .1))
sim._initialized = True
sim._assert_runtime_contracts = lambda: None
writes = []
monkeypatch.setattr(sim.bodies, "_upload_action_async", lambda s: writes.append("action"))
monkeypatch.setattr(sim.bodies, "zero_obs_async", lambda s: writes.append("obs"))
monkeypatch.setattr(sim.bodies, "download_obs_full_async", lambda s: writes.append("download"))
sim.stepper = SimpleNamespace(
moving=Tables(writes, **tables), blocked=None,
_step=lambda n, advance=False, **k: writes.append(("step", n, advance)))
from CelerisLab.lbm.stepper import LBMStepper
sim.stepper.step = LBMStepper.step.__get__(sim.stepper)
sim._moving_tables = sim.stepper.moving
sim._attach_moving()
return sim, writes, sim.bodies.get(bid).motion
def test_run_one_advances_and_commits_after_sync(monkeypatch):
sim, writes, motion = advancing_simulation(monkeypatch)
motion.set_target((0., .25), (0., .05))
with pytest.raises(ValueError, match="run\\(1\\)"): # multi-step with a target
sim.run(2, stream=STREAM)
assert writes == [] and motion.pending is not None
sim.run(0, stream=STREAM) # zero steps never consume the target
assert motion.pending is not None
sim.run(1, stream=STREAM)
uploads = [w for w in writes if isinstance(w, tuple) and w[0] == "upload"]
assert uploads[0][1] == [((50., 40.), (50., 40.25), (0., .05))]
assert uploads[1][1] == [((50., 40.25), (50., 40.25), (0., 0.))] # first hold after move
assert ("step", 1, True) in writes
assert motion.applied == (0., .25) and motion.pending is None
writes.clear()
sim.run(3, stream=STREAM) # hold steps: no advance
assert ("step", 3, False) in writes and motion.applied == (0., .25)
def test_zero_motion_target_is_consumed_by_run_one(monkeypatch):
sim, writes, motion = advancing_simulation(monkeypatch)
motion.set_target((0., 0.), (0., 0.)) # equal to applied: a hold target
sim._attach_moving()
assert sim.stepper.blocked == module.PENDING_BLOCKED
sim.run(1, stream=STREAM)
assert motion.pending is None and motion.applied == (0., 0.)
assert sim.stepper.blocked is None and ("step", 1, False) in writes
writes.clear()
sim.run(3, stream=STREAM) # no stale latch
assert ("step", 3, False) in writes
@pytest.mark.parametrize("streaming,d2q9,bound", [("double_buffer", True, True),
("double_buffer", False, False),
("esopull", True, False)])
def test_moving_kernels_bound_only_for_d2q9_double_buffer(monkeypatch, streaming, d2q9, bound):
from CelerisLab.lbm import stepper as stepper_module
monkeypatch.setattr(stepper_module.cuda, "mem_alloc", lambda n: object())
moving = {"MovingCircleLinks", "MovingCircleCells"}
def get_function(name):
if name in moving and not (d2q9 and streaming == "double_buffer"):
raise KeyError(name) # absent from D3Q19 / EsoPull modules
return lambda *a, **k: None
cfg = SimpleNamespace(streaming=streaming, threads_per_block=128, is_d2q9=d2q9,
nx=16, ny=8, nz=4, nq=9 if d2q9 else 19)
stepper = stepper_module.LBMStepper(SimpleNamespace(), SimpleNamespace(get_function=get_function), cfg)
assert (stepper.moving_fn is not None) == bound and (stepper.moving_cells_fn is not None) == bound
def test_moving_diagnostics_empty_shape_matches_layout(monkeypatch):
from CelerisLab.body.motion import MovingTables
sim = make_simulation(monkeypatch)
sim._failed, sim.stepper = None, SimpleNamespace(moving=None)
assert sim.moving_diagnostics().shape == (0, MovingTables.DIAG_FLOATS)
@pytest.mark.parametrize("failure", ("upload", "first_sync", "no_donor", "hold_upload",
"hold_sync"))
def test_failed_advance_fails_simulation(monkeypatch, failure):
kwargs = dict(no_donor=1.) if failure == "no_donor" else dict(fail=failure)
sim, writes, motion = advancing_simulation(monkeypatch, **kwargs)
motion._on_failure = sim._fail_moving
motion.set_target((0., .25), (0., .05))
syncs = []
def synchronize(): # stands in for an asynchronous failure surfacing at sync
syncs.append(1)
if (failure, len(syncs)) in (("first_sync", 1), ("hold_sync", 2)):
raise RuntimeError("sync failed")
with pytest.raises(RuntimeError):
sim.run(1, stream=SimpleNamespace(synchronize=synchronize))
# Before the step-group sync and no_donor check nothing is committed; the hold
# record is staged after the commit (applied already matches the moved device).
committed = failure in ("hold_upload", "hold_sync")
assert motion.applied == ((0., .25) if committed else (0., 0.))
assert (motion.pending is None) == committed
assert motion.failed and sim._failed is not None
assert sim.stepper.blocked == sim._failed # direct stepper reuse refused too
with pytest.raises(RuntimeError, match="Simulation failed"):
sim.run(1, stream=STREAM)
def test_failed_hold_step_blocks_the_direct_stepper(monkeypatch):
# No target is pending, so only the failure latch can refuse direct steps.
from CelerisLab.lbm.stepper import LBMStepper
sim, writes, motion = advancing_simulation(monkeypatch, fail="upload")
motion._on_failure = sim._fail_moving
launches = []
kernels = SimpleNamespace(get_function=lambda name: lambda *a, **k: launches.append(name))
cfg = SimpleNamespace(streaming="double_buffer", threads_per_block=128, is_d2q9=True,
nx=64, ny=32, nz=1)
field = SimpleNamespace(ddf_gpu="ddf", temp_gpu="temp", flag_gpu="flag", n_curved=0)
stepper = LBMStepper(field, kernels, cfg)
stepper.moving = sim.stepper.moving
sim.stepper = stepper
assert motion.pending is None and stepper.blocked is None
with pytest.raises(RuntimeError, match="upload failed"):
sim.run(2, stream=STREAM) # hold step
assert sim._failed is not None and stepper.blocked == sim._failed
with pytest.raises(RuntimeError, match="moving circle step failed"):
stepper.step(1, action_gpu=None, obs_gpu=None)
assert launches == [] and stepper.step_count == 0
assert (field.ddf_gpu, field.temp_gpu) == ("ddf", "temp")
def test_moving_tables_layout_and_attachment(monkeypatch):
from CelerisLab.body.motion import MovingTables
a = MovingCircle((50., 40.), 10., Translation((-1., 1., -3., 3.), .1))
b = MovingCircle((90.25, 30.5), 5., Translation((0., 0., 0., 0.), .1))
band, geom = MovingTables.layout([(a, 2), (b, 5)])
wa, ha = 63 - 37 + 1, 55 - 25 + 1
assert band == [37, 25, wa, ha, 0, 2, 83, 23, 16, 16, wa * ha * 8, 5]
assert geom == [50., 40., 50., 40., 10., 0., 0., 90.25, 30.5, 90.25, 30.5, 5., 0., 0.] # hold
sim = routed_simulation(monkeypatch)
sim.add_body("circle", (50., 40.), 10., translation=Translation((0., 0., 0., 0.), .1))
sim._moving_tables = "tables"
sim._attach_moving()
assert sim.stepper.moving == "tables" and sim.stepper.blocked is None
sim._moving_tables = None
sim._attach_moving()
assert sim.stepper.moving is None and sim.stepper.blocked is not None
class CountingOwner(Owner):
"""Owner double that counts nested activations like CudaContext."""
def __init__(self):
super().__init__()
self.active = 0
def activate(self):
from contextlib import contextmanager
@contextmanager
def scope():
self.active += 1
try:
yield self
finally:
self.active -= 1
return scope()
def test_public_diagnostics_keep_owner_active_through_sync_and_download(monkeypatch):
sim = make_simulation(monkeypatch)
sim.ctx, sim._failed = CountingOwner(), None
seen = []
sim._stream = SimpleNamespace(synchronize=lambda: seen.append(("sync", sim.ctx.active)))
expected = np.arange(6, dtype=np.float32).reshape(1, 6)
def download():
seen.append(("download", sim.ctx.active))
return expected
sim.stepper = SimpleNamespace(moving=SimpleNamespace(download_diagnostics=download))
assert sim.moving_diagnostics() is expected
assert [name for name, _ in seen] == ["sync", "download"]
assert all(active > 0 for _, active in seen) and sim.ctx.active == 0
def test_stepper_launches_moving_links_before_static_curved():
from CelerisLab.lbm.stepper import LBMStepper
launches = []
module = SimpleNamespace(get_function=lambda name: lambda *a, **k: launches.append((name, a, k)))
cfg = SimpleNamespace(streaming="double_buffer", threads_per_block=128, is_d2q9=True,
nx=64, ny=32, nz=1)
curved = SimpleNamespace(**{f"{name}_gpu": name for name in (
"fluid_idx", "dir", "q", "rx", "ry", "rz", "fallback_class", "body_id")},
surface_velocity=None, surface_velocity_gpu=None, link_exchange_gpu=None)
field = SimpleNamespace(ddf_gpu="ddf", temp_gpu="temp", flag_gpu="flag", n_curved=5,
curved=curved, invalidate_host_ddf_cache=lambda: None)
stepper = LBMStepper(field, module, cfg)
stepper._launch_force_region = stepper._launch_sensor = lambda *a, **k: None
stepper.moving = SimpleNamespace(band_gpu="band", geom_gpu="geom", diag_gpu="diag",
slot_debug_gpu=None, n_bodies=1, n_slots=300)
stepper.step(2, action_gpu="act", obs_gpu="obs")
names = [name for name, _, _ in launches]
assert names == ["MovingCircleLinks", "CurvedBoundaryKernel", "OneStep"] * 2
assert stepper.step_count == 2
assert launches[1][1][0] == "ddf" and launches[3][1][0] == "temp" # swap between steps
args, kw = launches[0][1], launches[0][2]
assert args[:4] == ("ddf", "flag", "band", "geom") and int(args[5]) == 300
assert kw["grid"] == (3, 1, 1) and kw["block"] == (128, 1, 1)
def test_recompile_installs_a_guarded_stepper(monkeypatch):
from CelerisLab.lbm.stepper import LBMStepper
sim = routed_simulation(monkeypatch)
sim.add_body("circle", (50., 40.), 10., translation=Translation((0., 0., -1., 1.), .1))
launches = []
kernel_module = SimpleNamespace(get_function=lambda name: lambda *a, **k: launches.append(name))
monkeypatch.setattr(module.compiler, "build_module", lambda *a, **k: kernel_module)
sim.lbm_cfg = SimpleNamespace(validate_runtime=lambda: None, viscosity=.01, omega=1.,
streaming="double_buffer", threads_per_block=128,
is_d2q9=True, nx=120, ny=80, nz=1)
sim._compiled_viscosity = .01
sim._assert_allocation_contract = lambda: None
sim._resolve_compile_arch = lambda: "sm_70"
sim._assert_object_count_contract = lambda **k: None
sim.field = SimpleNamespace(module=None, invalidate_module_constant_cache=lambda: None,
_upload_params=lambda: None, _runtime_params={},
ddf_gpu="ddf", temp_gpu="temp", n_curved=0)
sim.stepper = SimpleNamespace(_step_count=7, blocked=None)
sim._recompile()
assert isinstance(sim.stepper, LBMStepper) and sim.stepper.step_count == 7
with pytest.raises(RuntimeError, match="moving kernels"):
sim.stepper.step(1, action_gpu=None, obs_gpu=None)
assert launches == [] and (sim.field.ddf_gpu, sim.field.temp_gpu) == ("ddf", "temp")
assert sim.stepper.step_count == 7
def validate(monkeypatch, objects, flags=None, **cfg):
sim = routed_simulation(monkeypatch)
for obj in objects:
sim.bodies.add(obj)
for name, value in cfg.items():
setattr(sim.lbm_cfg, name, value)
base = np.full(120 * 80, FLUID, np.uint16) if flags is None else flags
sim.field = SimpleNamespace(nx=120, ny=80, build_channel_flags=lambda: base.copy())
sim._validate_moving_bodies()
def test_initialize_rejects_band_and_modes(monkeypatch):
t = Translation((-1., 1., -3., 3.), .1)
validate(monkeypatch, [body(50., 40., 10., t), body(100., 40., 5.)]) # accepted
walls = np.full(120 * 80, FLUID, np.uint16)
walls[60 + 30 * 120] = SOLID
cases = [([body(10., 40., 10., t)], None, {}, "domain edge"),
([body(50., 40., 10., t)], walls, {}, "non-fluid"),
([body(50., 40., 10., t), body(68., 40., 4.)], None, {}, "overlaps"),
([body(50., 40., 10., t), body(50., 60., 3., is_sensor=True)], None, {}, "overlaps"),
([body(50., 40., 10., t), body(64., 40., 3., is_force_region=True)], None, {}, "overlaps"),
([body(50., 40., 10., t)], None, {"streaming": "esopull"}, "double_buffer"),
([body(50., 40., 10., t)], None, {"ddf_shifting": True}, "unshifted"),
([body(50., 40., 10., t)], None, {"is_d2q9": False}, "D2Q9")]
for objects, flags, cfg, match in cases:
with pytest.raises(ValueError, match=match):
validate(monkeypatch, objects, flags, **cfg)
def test_initialize_validates_before_mutation(monkeypatch):
sim = routed_simulation(monkeypatch)
sim.bodies.add(body(10., 40., 10., Translation((0., 0., 0., 0.), .1)))
uploads = []
sim.field = SimpleNamespace(nx=120, ny=80, build_channel_flags=lambda: np.full(9600, FLUID, np.uint16),
upload_flags=lambda: uploads.append("flags"))
sim._assert_allocation_contract = lambda: None
with pytest.raises(ValueError, match="domain edge"):
sim.initialize()
assert uploads == [] and not sim._initialized
def test_moving_circle_excluded_from_static_links():
def manager(*objects):
mgr = ObjectManager(nx=120, ny=80, nz=1, nq=9, cfg=Cfg())
ids = [mgr.add(obj) for obj in objects]
return mgr, ids
reference_mgr, _ = manager(body(100., 40., 5.), body(20., 40., 6.))
mixed_mgr, ids = manager(body(100., 40., 5.), body(20., 40., 6.),
body(55., 40., 10., Translation((0., 0., -1., 1.), .1)))
reference = reference_mgr.build_compact_lists_for(reference_mgr.objects)
mixed = mixed_mgr.build_compact_lists_for(mixed_mgr.objects)
for a, b in zip(reference[:8], mixed[:8]): # static link columns byte-identical
assert np.asarray(a).tobytes() == np.asarray(b).tobytes()
assert len(reference[0]) > 0 and ids[2] not in set(np.asarray(mixed[3]).tolist())
def test_failed_moving_step_marks_simulation_failed(monkeypatch):
sim, writes, motion = routed_advancing_simulation(monkeypatch)
sim.ctx = ClosableOwner()
sim.set_body(0, offset=(0., .2), vy=.05)
def fail(*_):
raise RuntimeError("sync failed")
with pytest.raises(RuntimeError, match="sync failed"):
sim.run(1, stream=SimpleNamespace(synchronize=fail))
assert sim._failed is not None and motion.applied == (0., 0.)
for call in (lambda: sim.run(0), lambda: sim.run(1), sim.step, sim.sync_bodies,
sim.save_checkpoint, lambda: sim.load_checkpoint("x.h5")):
with pytest.raises(RuntimeError, match="Simulation failed"):
call()
with pytest.raises(RuntimeError, match="unknown"): # no retry on the failed circle
sim.set_body(0, offset=(0., .1), vy=.05)
with sim.ctx.activate(): # a refused close keeps the failed state and resources
with pytest.raises(RuntimeError, match="active CUDA"):
sim.close()
assert sim._failed is not None and sim.bodies is not None and not sim._closed
sim.close() # retry outside the activation succeeds
assert sim._closed and sim._failed is not None
with pytest.raises(RuntimeError, match="CUDA context is closed"): # no re-entry after close
sim.run(1)
with pytest.raises(RuntimeError, match="unknown"): # the failed circle stays refused
motion.record()
+4 -4
View File
@@ -94,7 +94,7 @@ class MultiplePinnedRemap(unittest.TestCase):
run.set_jet_amplitude(0, .017, level=2)
run.set_jet_amplitude(1, -.011, level=2)
before = run.download()
clone = run.remap_finest((PINS+MOVED+ADDED)[::-1], objects=bodies)
clone = run.remap_child((PINS+MOVED+ADDED)[::-1], child_objects=bodies)
try:
self.assertEqual(tuple(j.amplitude for j in clone._boundaries[2].jets), (.017, -.011))
np.testing.assert_array_equal(clone._boundaries[2].tables['surface_velocity'],
@@ -116,7 +116,7 @@ class MultiplePinnedRemap(unittest.TestCase):
start = len(allocations)
module.cuda.memcpy_dtoh.reset_mock()
with self.assertRaisesRegex(ValueError, 'boundary'):
run.remap_finest(PINS+FLUID, objects=changed)
run.remap_child(PINS+FLUID, child_objects=changed)
self.assertEqual(len(allocations), start)
module.cuda.memcpy_dtoh.assert_not_called()
with self.assertRaisesRegex(ValueError, 'pinned'):
@@ -128,7 +128,7 @@ class MultiplePinnedRemap(unittest.TestCase):
cases = [(PINS+FLUID, bodies[:1]), (PINS+FLUID, bodies[::-1]),
(PINS+FLUID, (bodies[0], body(center=(32.1, 10.)))),
(PINS+FLUID, (bodies[0], body(SurfaceActuation(0., .7, 0.), (32., 10.)))),
(PINS+FLUID, run.boundaries[2].objects)]
(PINS+FLUID, run.child_boundary.objects)]
for index in range(2):
changed = list(PINS+FLUID)
x0, y0, x1, y1 = changed[index]
@@ -141,7 +141,7 @@ class MultiplePinnedRemap(unittest.TestCase):
run._stream.synchronize.reset_mock()
for rects, supplied in cases:
with self.subTest(rects=rects), self.assertRaises(ValueError):
run.remap_finest(rects, objects=supplied)
run.remap_child(rects, child_objects=supplied)
self.assertEqual(len(allocations), start)
module.cuda.memcpy_dtoh.assert_not_called()
run._stream.synchronize.assert_not_called()
+1 -1
View File
@@ -135,6 +135,6 @@ def test_d2_entrypoint_compiles_in_isolation(tmp_path, model, shift, storage):
source=re.sub(r"(#define\s+"+key+r"\s+)\S+",lambda match: match[1]+str(value),source)
path.write_text(source)
result=subprocess.run([nvcc,"-ptx","-arch=sm_70","-std=c++17",
str(copy/"uniform.cu"),"-o",str(tmp_path/"kernel.ptx")],
str(copy/"kernel_v2.cu"),"-o",str(tmp_path/"kernel.ptx")],
capture_output=True,text=True)
assert result.returncode == 0, result.stderr
+8 -8
View File
@@ -61,11 +61,11 @@ class PinnedBodyRemap(unittest.TestCase):
run,module,allocations,storage,objects=ready()
run.set_jet_amplitude(0,.017,level=2)
before=run.download()
clone=run.remap_finest(B,objects=objects)
clone=run.remap_child(B,child_objects=objects)
self.assertEqual(clone._boundaries[2].jets[0].amplitude,.017)
np.testing.assert_array_equal(clone._boundaries[2].tables['surface_velocity'],
run._boundaries[2].tables['surface_velocity'])
self.assertTrue(same_physical_boundary(run.boundaries[2],clone.boundaries[2]))
self.assertTrue(same_physical_boundary(run.child_boundary,clone.child_boundary))
clone.step()
self.assertEqual((run.step_count,clone.step_count),(7,8))
for got,want in zip(run.download(),before): np.testing.assert_array_equal(got,want)
@@ -73,7 +73,7 @@ class PinnedBodyRemap(unittest.TestCase):
def test_static_body_without_actuation(self):
run,module,allocations,storage,objects=ready(jets=())
clone=run.remap_finest(A,objects=objects)
clone=run.remap_child(A,child_objects=objects)
self.assertFalse(clone._boundaries[2].actuated)
self.assertNotIn('surface_velocity',clone._boundaries[2].tables)
for a,b in zip(run.download(),clone.download()): np.testing.assert_array_equal(a,b)
@@ -82,13 +82,13 @@ class PinnedBodyRemap(unittest.TestCase):
def test_reject_before_device_reads_or_allocation(self):
run,module,allocations,storage,objects=ready()
start=len(allocations)
cases=[(B,()),(B,run.boundaries[2].objects),(B,(body(center=(12.1,14.)),)),
cases=[(B,()),(B,run.child_boundary.objects),(B,(body(center=(12.1,14.)),)),
(((24,16,36,40),B[1]),objects),(((16,28,32,40),B[1]),objects),
((B[1],),objects)]
module.cuda.memcpy_dtoh.reset_mock()
for rects,objs in cases:
with self.subTest(rects=rects),self.assertRaises(ValueError):
run.remap_finest(rects,objects=objs)
run.remap_child(rects,child_objects=objs)
self.assertEqual(len(allocations),start)
module.cuda.memcpy_dtoh.assert_not_called()
self.assertEqual((run._status,run.step_count),('ready',7))
@@ -100,9 +100,9 @@ class PinnedBodyRemap(unittest.TestCase):
(body(SurfaceActuation(0.,.7,speed)),),())
for spec in (SurfaceActuation(0.,.7,.003),SurfaceActuation(2.,.7,speed)):
module.cuda.memcpy_dtoh.reset_mock()
with self.assertRaises(ValueError): run.remap_finest(B,objects=(body(spec),))
with self.assertRaises(ValueError): run.remap_child(B,child_objects=(body(spec),))
module.cuda.memcpy_dtoh.assert_not_called()
clone=run.remap_finest(B,objects=objects)
clone=run.remap_child(B,child_objects=objects)
clone.close();run.close()
def test_allocation_failure_keeps_old_ready_and_count(self):
@@ -117,7 +117,7 @@ class PinnedBodyRemap(unittest.TestCase):
return allocate(size)
with patch.object(module.cuda,'mem_alloc',side_effect=fail):
with self.assertRaisesRegex(RuntimeError,'allocation'):
run.remap_finest(B,objects=objects)
run.remap_child(B,child_objects=objects)
self.assertEqual((run._status,run.step_count),('ready',7))
for got,want in zip(run.download(),before): np.testing.assert_array_equal(got,want)
for a in saved: a.free.assert_not_called()
+2 -2
View File
@@ -9,7 +9,7 @@ import pytest
import CelerisLab
from CelerisLab.config import LBMConfig, load_lbm_config
from CelerisLab.cuda import compiler
from CelerisLab.cuda import compiler_v2 as compiler
from CelerisLab.common.checkpoint import save_checkpoint, load_checkpoint
from .test_numerical_operators import compile_run
from .test_state_regressions import device, make_field, manager
@@ -18,7 +18,7 @@ from .test_state_regressions import device, make_field, manager
def test_planar_helper_moments_and_known_populations(tmp_path):
root = Path(__file__).resolve().parents[2]
assert Path(CelerisLab.__file__).resolve() == root/'src/CelerisLab/__init__.py'
assert Path(compiler.kernel_path('uniform.cu')).resolve() == root/'src/CelerisLab/lbm/kernels/uniform.cu'
assert Path(compiler.kernel_path('kernel_v2.cu')).resolve() == root/'src/CelerisLab/lbm/kernels/kernel_v2.cu'
lines = compile_run(tmp_path, 'planar_velocity.cpp', dict(WALL_NORMAL_VELOCITY='0.0f'))
cx = np.array([0,1,-1,0,0,1,-1,1,-1])
cy = np.array([0,0,0,1,-1,1,-1,-1,1])
-394
View File
@@ -1,394 +0,0 @@
"""CPU stencil and actual closed-interface packet budgets; no CUDA claims."""
import numpy as np
import unittest
from pathlib import Path
from dataclasses import replace
import shutil
import subprocess
import tempfile
from CelerisLab.config import LBMConfig
from CelerisLab.lbm.blocks import BlockLayout2D
from CelerisLab.lbm.descriptors import D2Q9_EX, D2Q9_EY
from CelerisLab.lbm.refinement import FixedPatch2D, FixedPatchSet2D, ACTIVE, OUTER_GHOST
from CelerisLab.lbm.refinement_tables import projected_explosion_tables
from CelerisLab.lbm.hierarchy import build_hierarchy
from tests.unit.test_hierarchy import CFG, FOUR
E = np.array([D2Q9_EX, D2Q9_EY]).T
W = np.array([4/9] + [1/9]*4 + [1/36]*4)
def setup(union=False, bounds=(4, 4, 12, 12)):
cfg = LBMConfig(nx=16, ny=16, viscosity=.1)
p = (FixedPatchSet2D.from_config(cfg, [bounds]) if union else
FixedPatch2D.from_config(cfg, bounds))
layout = BlockLayout2D.build(16, 16, 4, 4, periodic=(True, True))
neighbors, offsets = projected_explosion_tables(p, layout, np.where(p.covered, OUTER_GHOST, ACTIVE))
inverse = np.full(layout.nblocks * np.prod(layout.local_shape), -1, dtype=int)
inverse[layout.owner.ravel()] = np.arange(256)
return p, inverse[neighbors], offsets
def reconstruct(c, p, neighbors, offsets, project=True):
# Independent NumPy evaluation of the documented stencil; neighbors have
# been decoded from actual blocked owner IDs, not recomputed geometry.
values = c.reshape(9, -1)[:, neighbors]
g = .5 * (values[:, :, 1::2] - values[:, :, ::2])
if project:
g[1:] -= (np.einsum('qra,qa->qr', g[1:], E[1:]) /
np.sum(E[1:]**2, axis=1)[:, None])[:, :, None] * E[1:, None, :]
out = c.reshape(9, -1)[:, p.ghost_coarse] + np.einsum('qra,ra->qr', g, offsets)
return out, g
def check_legal_footprint_mean_and_characteristic(union, bounds):
p, neighbors, offsets = setup(union, bounds)
assert set(neighbors.ravel()) <= set(p.ghost_coarse)
assert not p.covered.ravel()[neighbors].any()
assert (neighbors >= 0).all()
c = np.random.default_rng(72).normal(size=(9,16,16))
c[:, p.covered] = np.nan # poison every forbidden donor
out, g = reconstruct(c, p, neighbors, offsets)
assert np.isfinite(out).all()
np.testing.assert_allclose(np.einsum('qra,qa->qr', g, E), 0, atol=5e-16)
for d in np.unique(p.ghost_coarse):
rows = p.ghost_coarse == d
assert rows.sum() == 4
np.testing.assert_allclose(out[:, rows].mean(axis=1), c.reshape(9,-1)[:,d], atol=5e-16)
for q in range(1,9):
delta = offsets[rows, None, :] - offsets[None, rows, :]
# Parallel displacements have zero 2D cross product.
parallel = np.isclose(delta[...,0]*E[q,1]-delta[...,1]*E[q,0], 0)
diff = out[q, rows][:,None]-out[q, rows][None,:]
np.testing.assert_allclose(diff[parallel], 0, atol=5e-16)
const = np.broadcast_to(W[:,None,None], (9,16,16)).copy()
constant, _ = reconstruct(const,p,neighbors,offsets)
np.testing.assert_array_equal(constant, np.broadcast_to(W[:,None], constant.shape))
def collide(f, tau):
rho = f.sum(axis=0)
u = np.einsum('qa,q...->a...', E, f) / rho
cu = np.einsum('qa,a...->q...', E, u)
eq = W[:,None] * rho * (1 + 3*cu + 4.5*cu**2 - 1.5*np.sum(u*u,axis=0))
return f + (eq-f)/tau
def totals(c, f, p):
q = c[:,~p.covered].sum(axis=1)+.25*f[:,p.fine_kind==ACTIVE].sum(axis=1)
return np.r_[q.sum(), E.T@q]
def check_full_two_hop_closed_corner_budget(union, collision, bounds):
p, neighbors, offsets = setup(union,bounds)
rng=np.random.default_rng(17)
c=W[:,None,None]*(1+rng.normal(0,.002,(9,16,16)))
f=W[:,None,None]*(1+rng.normal(0,.002,(9,*p.fine_shape)))
start=totals(c,f,p)
# Count exactly the eight coarse diagonal links whose source is also
# uncovered, but whose midpoint lies on the closed rectangular interface.
q, k=np.divmod(p.coalesce_coarse.astype(np.int64),256)
y,x=np.divmod(k,16)
grazing=~p.covered[y-E[q,1],x-E[q,0]]
assert grazing.sum()==8
c[:,p.covered]=np.nan
for _ in range(8):
f.reshape(9,-1)[:,p.ghost_fine]=reconstruct(c,p,neighbors,offsets)[0]
for _ in range(2):
f=np.array([np.roll(f[q],(ey,ex),(0,1)) for q,(ex,ey) in enumerate(E)])
f[:,p.fine_kind==OUTER_GHOST]=np.nan
if collision:
f[:,p.fine_kind==ACTIVE]=collide(f[:,p.fine_kind==ACTIVE],p.tau_fine)
out=np.array([np.roll(c[q],(ey,ex),(0,1)) for q,(ex,ey) in enumerate(E)])
packets=f.reshape(-1)[p.coalesce_fine].mean(axis=1)
assert np.isfinite(packets[grazing]).all()
out.reshape(-1)[p.coalesce_coarse]=packets
if collision:out[:,~p.covered]=collide(out[:,~p.covered],p.tau_coarse)
out[:,p.covered]=np.nan
c=out
np.testing.assert_allclose(totals(c,f,p),start,rtol=0,atol=2e-12)
def check_projection_is_distinguished_by_characteristic_not_global_mass():
p,n,o=setup()
c=np.random.default_rng(8).normal(size=(9,16,16))
_,g=reconstruct(c,p,n,o,project=False)
assert np.max(abs(np.einsum('qra,qa->qr',g,E))) > .1
class ProjectedExplosionTests(unittest.TestCase):
def test_stencil(self):
for union in (False, True):
for bounds in ((4,4,12,12),(2,3,9,10),(2,2,3,3)):
with self.subTest(union=union,bounds=bounds):
check_legal_footprint_mean_and_characteristic(union,bounds)
def test_closed_interface(self):
for union in (False,True):
for collision in (False,True):
for bounds in ((4,4,12,12),(2,3,14,12),(2,2,3,3)):
with self.subTest(union=union,collision=collision,bounds=bounds):
check_full_two_hop_closed_corner_budget(union,collision,bounds)
def test_projection(self):
check_projection_is_distinguished_by_characteristic_not_global_mass()
def test_affine_and_shift_invariance(self):
p,n,o=setup()
y,x=np.indices((16,16))
c=np.array([.7+q*.01+x*.03+y*.05 for q in range(9)])
out,g=reconstruct(c,p,n,o)
available=n[:,::2]!=n[:,1::2]
gradient=np.array([.03,.05])*available
expected=np.broadcast_to(gradient,(9,*gradient.shape)).copy()
expected[1:]-=(np.einsum('qra,qa->qr',expected[1:],E[1:])/
np.sum(E[1:]**2,axis=1)[:,None])[:,:,None]*E[1:,None,:]
np.testing.assert_allclose(g,expected,atol=2e-16)
shifted,_=reconstruct(c-W[:,None,None],p,n,o)
np.testing.assert_allclose(out-W[:,None],shifted,atol=5e-16)
class ProjectedKernelTests(unittest.TestCase):
@unittest.skipUnless(shutil.which("g++"), "g++ is required for actual-kernel CPU arithmetic")
def test_actual_kernel_fp32_shifted_and_unshifted(self):
p, neighbors, offsets = setup(True)
parent = BlockLayout2D.build(16,16,4,4,periodic=(True,True))
child = p.fine_layout((8,4)) # deliberately unequal parent/child strides
ids, offsets = projected_explosion_tables(p,parent,np.where(p.covered,OUTER_GHOST,ACTIVE))
cells = int(np.prod(parent.local_shape))
targets = child.owner.ravel()[p.ghost_fine]
donors = parent.owner.ravel()[p.ghost_coarse]
source = (Path(__file__).resolve().parents[2] /
"src/CelerisLab/lbm/kernels/step/block_transfer.cu").read_text()
kernel = source.split('extern "C" __global__ void BlockExplodeProjected(',1)[1]
kernel = 'void BlockExplodeProjected(' + kernel.split('// Targets are receiver-local SOURCE slots',1)[0]
def array(name, values, kind):
v = np.asarray(values).ravel()
text = ','.join(str(int(x)) if kind=='uint64_t' else float(x).hex()+'f' for x in v)
return f"{kind} {name}[] = {{{text}}};\n"
for shifted in (False,True):
c=np.random.default_rng(11).normal(0,.001,(9,16,16))
if not shifted:c+=W[:,None,None]
c=c.astype(np.float32)
packed=np.full(parent.nblocks*9*cells,np.nan,np.float32)
for q in range(9):packed[parent.slot(parent.owner.ravel(),q)]=c[q].ravel()
# All halo/covered inputs are poisoned; kernel must use legal cores.
for q in range(9):packed[parent.slot(parent.owner.ravel()[p.covered.ravel()],q)]=np.nan
# Hex initializer needs a portable expression for poisoned NaNs.
data=array('coarse',packed,'float').replace('nanf','NAN')
code = '#include <cstdint>\n#include <cmath>\n#include <cstdio>\nusing fpxx=float;\n'
code += f'constexpr unsigned NQ=9, TOTAL_CELLS={int(np.prod(child.local_shape))};\n'
code += 'struct Id {unsigned x;}; Id blockIdx{0},blockDim{1},threadIdx{0};\n'
code += 'unsigned index_f(unsigned k,unsigned q){return q*TOTAL_CELLS+k;}\n'
code += 'int d_cx[]={0,1,-1,0,0,1,-1,1,-1},d_cy[]={0,0,0,1,-1,1,-1,-1,1};\n'+kernel
code += data+array('target',targets,'uint64_t')+array('donor',donors,'uint64_t')
code += array('neighbor',ids,'uint64_t')+array('offset',offsets,'float')
code += f'float fine[{child.nblocks*9*int(np.prod(child.local_shape))}]={{}};\n'
code += f'int main(){{for(unsigned r=0;r<{len(targets)};++r){{threadIdx.x=r;BlockExplodeProjected(coarse,fine,target,donor,{len(targets)},{cells},neighbor,offset);}}'
code += f'for(unsigned q=0;q<9;++q)for(unsigned r=0;r<{len(targets)};++r){{auto t=target[r];printf("%.9g\\n",fine[(t/TOTAL_CELLS)*9*TOTAL_CELLS+q*TOTAL_CELLS+t%TOTAL_CELLS]);}}}}'
with tempfile.TemporaryDirectory() as folder:
src=Path(folder)/'kernel.cpp'; binary=Path(folder)/'kernel'
src.write_text(code)
subprocess.run(['g++','-std=c++17','-O2',str(src),'-o',str(binary)],check=True,capture_output=True)
result=np.fromstring(subprocess.check_output([str(binary)],text=True),sep=' ').reshape(9,-1)
expected,_=reconstruct(c.astype(float),p,neighbors,offsets)
np.testing.assert_allclose(result,expected,atol=6e-8,rtol=2e-7)
UNIONS = [
[(8, 8, 24, 20)],
[(8, 8, 16, 20), (16, 8, 24, 20)], # abutting
[(8, 8, 18, 20), (14, 8, 24, 20)], # overlap
[(8, 8, 16, 20), (17, 8, 24, 20)], # gap 1
[(8, 8, 16, 20), (18, 8, 24, 20)], # gap 2
[(8, 8, 16, 20), (19, 8, 24, 20)], # gap 3
[(8, 8, 20, 14), (8, 14, 14, 22)], # L concavity
[(4, 4, 10, 10), (18, 16, 26, 26)], # separated
[(4, 4, 5, 7), (4, 5, 7, 7)], # known concave exit/re-entry path
]
def decode(layout, neighbors):
inverse = np.full(layout.hole, -1, dtype=int)
inverse[layout.owner[layout.owned]] = np.flatnonzero(layout.owned)
return inverse[neighbors]
def check_pair_stencil(pair, layout, kind):
ids, offsets = projected_explosion_tables(pair, layout, kind)
neighbors = decode(layout, ids)
legal = set(map(int, pair.ghost_coarse))
assert len(np.unique(pair.ghost_fine)) == len(pair.ghost_fine)
assert set(neighbors.ravel()) <= legal
assert layout.owned.ravel()[neighbors].all()
assert (kind.ravel()[neighbors] == ACTIVE).all()
assert not pair.covered.ravel()[neighbors].any()
ny, nx = pair.coarse_shape
for row, donor in enumerate(pair.ghost_coarse):
y, x = divmod(int(donor), nx)
for axis, (dx, dy) in enumerate(((1, 0), (0, 1))):
ends = [(x - dx, y - dy), (x + dx, y + dy)]
expected = []
for xx, yy in ends:
outside = not (0 <= xx < nx and 0 <= yy < ny)
if outside and not layout.periodic[axis]:
break
cell = (yy % ny) * nx + xx % nx
if cell not in legal:
break
expected.append(cell)
if len(expected) != 2:
expected = [int(donor)] * 2
np.testing.assert_array_equal(neighbors[row, 2*axis:2*axis+2], expected)
fy, fx = divmod(int(pair.ghost_fine[row]), pair.fine_shape[1])
expected = [pair.fine_origin[0]+(fx+.5)/2-(x+.5),
pair.fine_origin[1]+(fy+.5)/2-(y+.5)]
np.testing.assert_array_equal(offsets[row], expected)
assert set(offsets.ravel()) == {-.25, .25}
assert ids.dtype == np.uint64 and ids.flags.c_contiguous
assert offsets.dtype == np.float32 and offsets.flags.c_contiguous
return neighbors, offsets
class GenericProjectedPairs(unittest.TestCase):
def test_single_window_and_singleton_union_exact_stencils(self):
for bounds in ((4,4,12,12), (2,3,9,10), (2,2,3,3)):
a, an, ao = setup(False, bounds)
b, bn, bo = setup(True, bounds)
np.testing.assert_array_equal(a.ghost_coarse, b.ghost_coarse)
np.testing.assert_array_equal(an, bn)
np.testing.assert_array_equal(ao, bo)
# Coordinates differ between scalar and union arrays; physical rows don't.
ay, ax = np.divmod(a.ghost_fine, a.fine_shape[1])
by, bx = np.divmod(b.ghost_fine, b.fine_shape[1])
np.testing.assert_array_equal(ax + 2*a.fine_origin[0], bx)
np.testing.assert_array_equal(ay + 2*a.fine_origin[1], by)
def test_union_decomposition_permutation_and_topologies(self):
cfg = LBMConfig(nx=30, ny=30, viscosity=.1)
layout = BlockLayout2D.build(30,30,6,5,periodic=(True,True))
stencils = []
for rectangles in UNIONS:
pair = FixedPatchSet2D.from_config(cfg, rectangles)
kind = np.where(pair.covered, OUTER_GHOST, ACTIVE)
n, o = check_pair_stencil(pair, layout, kind)
other = FixedPatchSet2D.from_config(cfg, rectangles[::-1])
on, oo = projected_explosion_tables(other, layout, kind)
np.testing.assert_array_equal(n, decode(layout, on))
np.testing.assert_array_equal(o, oo)
stencils.append((pair.ghost_fine, pair.ghost_coarse, n, o))
c = np.random.default_rng(92).normal(size=(9,30,30))
c[:, pair.covered] = np.nan
values, gradients = reconstruct(c, pair, n, o)
self.assertTrue(np.isfinite(values).all())
np.testing.assert_allclose(np.einsum('qra,qa->qr', gradients, E),0,atol=1e-15)
# q0 keeps its full gradient; no division by zero or forced zero.
expected_q0 = .5*(c[0].ravel()[n[:,1::2]]-c[0].ravel()[n[:,::2]])
np.testing.assert_array_equal(gradients[0], expected_q0)
self.assertGreater(np.max(abs(gradients[0])), .1)
for donor in np.unique(pair.ghost_coarse):
rows = pair.ghost_coarse == donor
self.assertEqual(rows.sum(),4)
np.testing.assert_allclose(values[:,rows].mean(axis=1),c.reshape(9,-1)[:,donor],atol=1e-15)
for q in range(1,9):
delta = o[rows,None,:]-o[None,rows,:]
parallel = delta[...,0]*E[q,1] == delta[...,1]*E[q,0]
diff = values[q,rows][:,None]-values[q,rows][None,:]
np.testing.assert_allclose(diff[parallel],0,atol=1e-15)
for split in stencils[1:3]:
for expected, actual in zip(stencils[0],split):
np.testing.assert_array_equal(actual,expected)
def test_nested_scalar_origins_sparse_holes_and_unequal_strides(self):
cases = [(CFG, FOUR), (LBMConfig(nx=16,ny=16,viscosity=.02),
[(4,4,12,12),(8,8,16,16),(8,8,16,16)])]
for cfg, refs in cases:
hierarchy = build_hierarchy(cfg,refs)
for index,pair in enumerate(hierarchy.pairs):
if index == 0:
layout = BlockLayout2D.build(cfg.ny,cfg.nx,4,4,periodic=(True,True))
else:
prior = hierarchy.pairs[index-1]
layout = (prior.fine_layout((4,8)) if isinstance(prior,FixedPatchSet2D)
else BlockLayout2D.build(*prior.fine_shape,4,8,periodic=(False,False)))
kind = hierarchy.kinds[index]
n,o = check_pair_stencil(pair,layout,kind)
c = np.random.default_rng(index).normal(size=(9,*pair.coarse_shape))
c[:,~layout.owned | (kind != ACTIVE)] = np.nan
self.assertTrue(np.isfinite(reconstruct(c,pair,n,o)[0]).all())
if index and isinstance(pair,FixedPatchSet2D):
self.assertTrue((~layout.owned).any())
self.assertEqual(layout.periodic, (index == 0, index == 0))
def test_periodicity_is_per_axis_and_missing_pairs_repeat_center(self):
cfg=LBMConfig(nx=16,ny=16,viscosity=.1)
p=FixedPatch2D.from_config(cfg,(2,2,14,14))
kind=np.where(p.covered,OUTER_GHOST,ACTIVE)
results=[]
for periodic in ((False,False),(True,False),(False,True),(True,True)):
layout=BlockLayout2D.build(16,16,4,4,periodic=periodic)
n,_=check_pair_stencil(p,layout,kind)
results.append(n)
y,x=np.divmod(p.ghost_coarse,16)
for axis,coordinate in enumerate((x,y)):
edge=(coordinate==0)|(coordinate==15)
if not periodic[axis]:
np.testing.assert_array_equal(n[edge,2*axis:2*axis+2],
np.repeat(p.ghost_coarse[edge,None],2,axis=1))
self.assertTrue((results[0]!=results[1]).any())
self.assertTrue((results[0]!=results[2]).any())
self.assertTrue((results[1]!=results[3]).any())
def test_invalid_parent_donors_reject_before_owner_lookup(self):
p,_,_=setup()
layout=BlockLayout2D.build(16,16,4,4)
kind=np.where(p.covered,OUTER_GHOST,ACTIVE)
for nonactive in (1,2,255):
bad=kind.copy(); bad.ravel()[p.ghost_coarse[0]]=nonactive
with self.assertRaisesRegex(ValueError,'ACTIVE'):
projected_explosion_tables(p,layout,bad)
with self.assertRaisesRegex(ValueError,'shape'):
projected_explosion_tables(p,layout,kind[:1])
hole_layout=BlockLayout2D.build(16,16,4,4,origins=[(0,0)])
with self.assertRaisesRegex(ValueError,'owned'):
projected_explosion_tables(p,hole_layout,kind)
def test_generic_two_hop_mass_and_momentum_budget(self):
cfg=LBMConfig(nx=30,ny=30,viscosity=.1)
layout=BlockLayout2D.build(30,30,6,5)
grazing_counts=set()
for rectangles in UNIONS:
pair=FixedPatchSet2D.from_config(cfg,rectangles)
ids,o=projected_explosion_tables(pair,layout,np.where(pair.covered,OUTER_GHOST,ACTIVE))
n=decode(layout,ids)
q,k=np.divmod(pair.coalesce_coarse.astype(np.int64),900)
y,x=np.divmod(k,30)
grazing=~pair.covered[(y-E[q,1])%30,(x-E[q,0])%30]
grazing_counts.add(int(grazing.sum()))
self.assertTrue(grazing.any())
for collision in (False,True):
rng=np.random.default_rng(17)
c=W[:,None,None]*(1+rng.normal(0,.002,(9,30,30)))
f=W[:,None,None]*(1+rng.normal(0,.002,(9,*pair.fine_shape)))
start=totals(c,f,pair)
c[:,pair.covered]=np.nan
for _ in range(4):
f.reshape(9,-1)[:,pair.ghost_fine]=reconstruct(c,pair,n,o)[0]
for _ in range(2):
f=np.array([np.roll(f[q],(ey,ex),(0,1)) for q,(ex,ey) in enumerate(E)])
f[:,pair.fine_kind==OUTER_GHOST]=np.nan
if collision:
f[:,pair.fine_kind==ACTIVE]=collide(f[:,pair.fine_kind==ACTIVE],pair.tau_fine)
out=np.array([np.roll(c[q],(ey,ex),(0,1)) for q,(ex,ey) in enumerate(E)])
packets=f.reshape(-1)[pair.coalesce_fine].mean(axis=1)
self.assertTrue(np.isfinite(packets[grazing]).all())
out.reshape(-1)[pair.coalesce_coarse]=packets
if collision:
out[:,~pair.covered]=collide(out[:,~pair.covered],pair.tau_coarse)
out[:,pair.covered]=np.nan
c=out
np.testing.assert_allclose(totals(c,f,pair),start,rtol=0,atol=4e-12)
self.assertGreater(len(grazing_counts),1) # not a hard-coded eight-corner oracle
-162
View File
@@ -1,162 +0,0 @@
"""Host-only checks for the opt-in CUDA gate; never imports or compiles CUDA."""
from copy import deepcopy
import importlib.util
from pathlib import Path
import unittest
from unittest.mock import patch
import numpy as np
from CelerisLab.config import LBMConfig
from CelerisLab.lbm.blocks import BlockLayout2D
from CelerisLab.lbm.hierarchy import build_hierarchy
from CelerisLab.lbm.refinement import ACTIVE, OUTER_GHOST, FixedPatch2D, FixedPatchSet2D
from CelerisLab.lbm.refinement_tables import projected_explosion_tables
from tests.unit.test_hierarchy import CFG, FOUR
from tests.unit.test_projected_explosion import decode, reconstruct, totals
PATH = Path(__file__).resolve().parents[1] / 'validation/multigrid/check_projected_pairs.py'
spec = importlib.util.spec_from_file_location('projected_pair_gate', PATH)
gate = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gate)
class ProjectedGateOracle(unittest.TestCase):
def check_pair(self, pair, layout, kind):
ids, offsets = projected_explosion_tables(pair, layout, kind)
neighbors = decode(layout, ids)
coarse = np.random.default_rng(731).normal(size=(9, *pair.coarse_shape))
legal = np.zeros(pair.coarse_shape, bool)
legal.ravel()[pair.ghost_coarse] = True
coarse[:, ~legal] = np.nan
expected, _ = reconstruct(coarse, pair, neighbors, offsets)
# The coordinate oracle must not accidentally share production tables.
with patch('CelerisLab.lbm.refinement_tables.projected_explosion_tables',
side_effect=AssertionError('oracle used production stencil')):
actual = gate.independent_explosion(coarse, pair, layout, kind)
np.testing.assert_allclose(actual, expected, rtol=0, atol=2e-15)
center = coarse.reshape(9, -1)[:, pair.ghost_coarse]
self.assertTrue(np.isfinite(actual).all())
self.assertGreater(np.max(abs(actual[0] - center[0])), .01)
self.assertGreater(np.max(abs(actual[1:] - center[1:])), .01)
return coarse, neighbors, offsets, actual
def test_scalar_union_nested_sparse_and_unequal_strides(self):
def fine_layout(pair, block):
return (pair.fine_layout(block) if isinstance(pair, FixedPatchSet2D) else
BlockLayout2D.build(*pair.fine_shape, *block, periodic=(False,False)))
small = LBMConfig(nx=16, ny=16, viscosity=.02)
fixtures = [(small, [(4,4,12,12), (8,8,16,16), (8,8,16,16)]),
(CFG, FOUR), (small, [[(4,4,5,7), (4,5,7,7)]])]
for cfg, refs in fixtures:
hierarchy = build_hierarchy(cfg, refs)
for index, pair in enumerate(hierarchy.pairs):
block, child_block = ((4,4), (4,8)) if index % 2 == 0 else ((4,8), (4,4))
layout = (BlockLayout2D.build(cfg.ny, cfg.nx, *block) if index == 0
else fine_layout(hierarchy.pairs[index-1], block))
with self.subTest(refs=refs, pair=index):
self.assertNotEqual(np.prod(layout.local_shape),
np.prod(fine_layout(pair, child_block).local_shape))
if index and isinstance(pair, FixedPatchSet2D):
self.assertTrue((~layout.owned).any())
self.check_pair(pair, layout, hierarchy.kinds[index])
def test_axis_periodicity_and_mean_preserving_mutants(self):
pair = FixedPatch2D.from_config(LBMConfig(nx=16,ny=16,viscosity=.1), (2,2,14,14))
kind = np.where(pair.covered, OUTER_GHOST, ACTIVE)
outputs = []
for periodic in ((False,False), (True,False), (False,True), (True,True)):
layout = BlockLayout2D.build(16,16,4,4,periodic=periodic)
coarse, neighbors, offsets, actual = self.check_pair(pair, layout, kind)
outputs.append(actual)
donors, first, inverse = np.unique(pair.ghost_coarse, return_index=True, return_inverse=True)
wrong = neighbors[first[np.roll(np.arange(len(donors)), 1)]][inverse]
for bad_neighbors, bad_offsets in ((neighbors, -offsets), (wrong, offsets)):
mutant, _ = reconstruct(coarse, pair, bad_neighbors, bad_offsets)
self.assertGreater(np.max(abs(mutant - actual)), .01)
for donor in donors:
rows = pair.ghost_coarse == donor
self.assertEqual(rows.sum(), 4)
np.testing.assert_allclose(mutant[:,rows].mean(axis=1),
coarse.reshape(9,-1)[:,donor], rtol=0, atol=1e-15)
bad_kind = kind.copy(); bad_kind.ravel()[pair.ghost_coarse[0]] = OUTER_GHOST
with self.assertRaisesRegex(AssertionError, 'illegal center donor'):
gate.independent_explosion(coarse, pair, layout, bad_kind)
for i, j in ((0,1), (0,2), (1,3), (2,3)):
self.assertGreater(np.max(abs(outputs[i] - outputs[j])), .01)
def test_concave_replay_preserves_existing_fp64_budget(self):
cfg, refs, blocks = gate.cases()['concave']
hierarchy = build_hierarchy(cfg, refs); pair = hierarchy.pairs[0]
layout = BlockLayout2D.build(cfg.ny, cfg.nx, *blocks[0])
rng = np.random.default_rng(17)
values = tuple(gate.W[:,None,None] * (1 + rng.normal(0,.002,(9,*shape)))
for shape in (pair.coarse_shape, pair.fine_shape))
start = totals(*values, pair)
values[0][:,pair.covered] = np.nan
for _ in range(8):
values = gate.replay_two_hops(values, pair, layout, hierarchy.kinds[0])
np.testing.assert_allclose(totals(*values, pair), start, rtol=0, atol=4e-12)
self.assertEqual(gate.REPLAY_ATOL, 3e-6) # CUDA replay bound is separate.
class ProjectedGateReceipts(unittest.TestCase):
def test_digest_exact_finite_bits_and_nan_masks(self):
values = np.array([0., -0., np.nan, 1.], dtype=np.float32)
self.assertEqual(gate.digest(values), gate.digest(values.copy()))
payload = values.copy(); payload.view(np.uint32)[2] = 0x7fc00001
self.assertEqual(gate.digest(values), gate.digest(payload))
variants = [np.array([-0., -0., np.nan, 1.], np.float32),
np.array([0., np.nan, -0., 1.], np.float32),
np.array([0., -0., np.nan, np.nextafter(np.float32(1), np.float32(2))], np.float32),
values.reshape(2,2), values.astype(np.float64)]
for changed in variants:
self.assertNotEqual(gate.digest(values), gate.digest(changed))
with self.assertRaisesRegex(AssertionError, 'infinite'):
gate.digest(np.array([np.inf]))
def test_snapshot_negative_controls_and_exact_dev_options(self):
snapshot = [dict(step=0, levels=[dict(raw='raw',public='public',active=4)],
mass_momentum=[1.,0.,0.],observed_level_hops=[0,0])]
gate.compare_snapshots(snapshot, deepcopy(snapshot))
diagnostic = deepcopy(snapshot); diagnostic[0]['mass_momentum'][0] = 2.
gate.compare_snapshots(snapshot, diagnostic) # Diagnostics are not bitwise gate fields.
mutants = [[]]
for key, value in (('raw','changed'), ('public','changed'), ('active',3)):
changed = deepcopy(snapshot); changed[0]['levels'][0][key] = value
mutants.append(changed)
changed = deepcopy(snapshot); changed[0]['step'] = 1; mutants.append(changed)
changed = deepcopy(snapshot); changed[0]['observed_level_hops'] = [0,1]; mutants.append(changed)
for changed in mutants:
with self.assertRaises(AssertionError):
gate.compare_snapshots(snapshot, changed)
self.assertEqual(gate.default_options('omitted'), {})
self.assertEqual(gate.default_options('constant'), {'explosion':'constant'})
self.assertEqual(gate.default_options('projected_linear'), {'explosion':'projected_linear'})
def test_baseline_receipt_rejects_provenance_and_contract_mutants(self):
keys = [f'{name}:shifted={shifted}' for name in gate.cases() for shifted in (False,True)]
good = dict(completed=True,dry_run=False,mode='baseline',production_revision=gate.BASE,
source=dict(revision=gate.BASE,dirty=False),driver_sha256='driver',
stops=list(gate.STOPS),operator_tolerance=[6e-8,2e-7],
rest_tolerance=[3e-7,1e-6],replay_tolerance=[3e-6,0],default_snapshots=dict.fromkeys(keys, []),
actual_build_sidecars=[{'ptx_sha256':'captured'}])
self.assertIs(gate.baseline_receipt(good, 'driver', keys), good)
mutants = dict(completed=False,dry_run=True,mode='candidate',production_revision='wrong',
driver_sha256='wrong',stops=[0,1,2],operator_tolerance=[1e-6,2e-7],
rest_tolerance=[3e-7,1e-5],replay_tolerance=[3e-6,1e-7],actual_build_sidecars=[],default_snapshots={})
for field, value in mutants.items():
bad = deepcopy(good); bad[field] = value
with self.subTest(field=field), self.assertRaises(ValueError):
gate.baseline_receipt(bad, 'driver', keys)
for changes in (dict(revision='wrong'), dict(dirty=True), dict(dirty=None)):
bad = deepcopy(good); bad['source'].update(changes)
with self.assertRaises(ValueError):
gate.baseline_receipt(bad, 'driver', keys)
bad = deepcopy(good); bad['default_snapshots']['unexpected'] = []
with self.assertRaises(ValueError):
gate.baseline_receipt(bad, 'driver', keys)
if __name__ == '__main__':
unittest.main()
-196
View File
@@ -1,196 +0,0 @@
"""Production host dispatch with CUDA doubles; no CUDA compilation or flow runs."""
from contextlib import nullcontext
from dataclasses import replace
from unittest.mock import Mock
import unittest
import numpy as np
from CelerisLab.config import LBMConfig
from tests.unit.test_hierarchy import CFG, FOUR
from tests.unit.test_refinement_three_level_multipatch import host_module
def backend(**options):
host = host_module()
calls, allocations, storage = [], [], {}
context = Mock(activate=nullcontext, sm_arch='sm_70')
host.cuda.mem_alloc.side_effect = lambda size: allocate(size)
def allocate(size):
pointer = Mock(name=f'buffer{len(allocations)}', size=size)
allocations.append(pointer)
return pointer
def load(_):
module = Mock()
module.get_global.return_value = (Mock(), 20)
module.get_function.side_effect = lambda name: Mock(
name=name, side_effect=lambda *a, **kw: calls.append((name, a, kw)))
return module
host.compiler.build_module.side_effect = lambda *a, **k: load(None)
host.cuda.memcpy_htod.side_effect = lambda ptr, a: storage.__setitem__(id(ptr), np.array(a, copy=True))
host.cuda.memcpy_dtoh.side_effect = lambda a, ptr: np.copyto(a, storage[id(ptr)])
cfg = options.pop('cfg', CFG)
refinements = options.pop('refinements', FOUR)
blocks = options.pop('level_blocks', [(4, 4)] * (len(refinements) + 1))
run = host.FixedPatchStepper(cfg, context=context, refinements=refinements,
level_blocks=blocks, **options)
run.initialize(*(np.full(level.shape, 1/9, np.float32) for level in run._levels))
return run, host, calls, allocations, storage
def trace(run, calls, allocations, storage):
"""Stable pointer identities expose allocations/arguments, not just kernel names."""
pointer = {id(value): index for index, value in enumerate(allocations)}
launches = [(name, tuple(('ptr', pointer[id(a)]) if id(a) in pointer else int(a)
for a in args), kw['grid'], kw['block'])
for name, args, kw in calls]
uploads = [(index, value.dtype.str, value.shape, value.tobytes())
for index, ptr in enumerate(allocations) if (value := storage.get(id(ptr))) is not None]
return [p.size for p in allocations], uploads, launches
class ProjectedPairDispatch(unittest.TestCase):
def test_omitted_and_constant_have_identical_allocations_uploads_and_launches(self):
results = []
for options in ({}, {'explosion': 'constant'}):
run, host, calls, allocations, storage = backend(**options)
self.assertEqual(run._explosion, 'constant')
self.assertTrue(all(p.neighbors is None and p.offsets is None for p in run._pairs))
run.step()
explosion = [args for name, args, _ in calls if name == 'BlockExplode']
self.assertEqual(len(explosion), 7)
self.assertTrue(all(len(args) == 6 for args in explosion))
self.assertFalse(any(name == 'BlockExplodeProjected' for name, _, _ in calls))
results.append(trace(run, calls, allocations, storage))
run.close()
self.assertEqual(*results)
def test_projected_adds_exactly_two_tables_per_adjacent_pair(self):
for depth in (2, 3, 4):
constant, _, _, ca, _ = backend(refinements=FOUR[:depth-1])
projected, _, calls, pa, storage = backend(
refinements=FOUR[:depth-1], explosion='projected_linear')
self.assertEqual(len(pa)-len(ca), 2*(depth-1))
for pair, geometry in zip(projected._pairs, projected.hierarchy.pairs):
self.assertEqual(storage[id(pair.neighbors)].shape, (len(geometry.ghost_fine), 4))
self.assertEqual(storage[id(pair.offsets)].shape, (len(geometry.ghost_fine), 2))
self.assertEqual(pair.neighbors.size, len(geometry.ghost_fine)*4*8)
self.assertEqual(pair.offsets.size, len(geometry.ghost_fine)*2*4)
projected.step()
explosions = [args for name, args, _ in calls if name == 'BlockExplodeProjected']
self.assertEqual(len(explosions), 2**(depth-1)-1)
self.assertTrue(all(len(args) == 8 for args in explosions))
self.assertFalse(any(name == 'BlockExplode' for name, _, _ in calls))
for args in explosions:
pair = next(p for p in projected._pairs if args[2] is p.ghosts)
self.assertIs(args[6], pair.neighbors)
self.assertIs(args[7], pair.offsets)
self.assertEqual(args[5], pair.parent_cells)
constant.close(); projected.close()
def test_three_and_four_level_schedule_has_fresh_midpoint_sources(self):
for depth in (3, 4):
for explosion in ('constant', 'projected_linear'):
run, host, calls, allocations, storage = backend(
refinements=FOUR[:depth-1], explosion=explosion)
before = len(allocations)
events = []
for index, pair in enumerate(run._pairs):
pair.explode.side_effect = lambda source, *a, index=index, **kw: events.append(('explode', index, source))
pair.coalesce.side_effect = lambda child, source, *a, index=index, **kw: events.append(('restrict', index, source))
for index, level in enumerate(run._levels):
level.kernel.side_effect = lambda source, *a, index=index, **kw: events.append(('step', index, source))
level.halo_copy.side_effect = lambda source, *a, index=index, **kw: events.append(('halo', index, source))
expected = []
def hop(index, source):
if index+1 < depth:
expected.append(('explode', index, source))
child = run._levels[index+1]
hop(index+1, child.current)
hop(index+1, child.spare)
expected.append(('halo', index, source))
if index+1 < depth:
expected.append(('restrict', index, source))
expected.append(('step', index, source))
hop(0, run._levels[0].current)
run.step()
self.assertEqual(events, expected)
self.assertEqual([level.kernel.call_count for level in run._levels], [2**i for i in range(depth)])
self.assertEqual(len(allocations), before)
self.assertEqual(run.step_count, 1)
for index in range(1, depth-1):
feeds = [source for name, pair, source in events if name == 'explode' and pair == index]
self.assertEqual(feeds, [run._levels[index].current, run._levels[index].spare] * (2**(index-1)))
run.close()
def test_scalar_windows_follow_the_same_pair_path(self):
run, _, _, _, _ = backend(cfg=LBMConfig(nx=16,ny=16,viscosity=.02),
refinements=[(4,4,12,12),(8,8,16,16),(8,8,16,16)],
level_blocks=[(4,4),(4,8),(8,4),(4,4)], explosion='projected_linear')
self.assertTrue(all(pair.neighbors is not None for pair in run._pairs))
self.assertEqual([pair.parent_cells for pair in run._pairs], [36,60,60])
run.step()
self.assertEqual([level.kernel.call_count for level in run._levels], [1,2,4,8])
run.close()
def test_physics_gate_precedes_context_and_allocations(self):
host = host_module()
context = Mock()
options = dict(refinements=FOUR[:1], level_blocks=[(4,4)]*2,
explosion='projected_linear', context=context)
for cfg, changes in [(CFG, {'explosion':'unknown'}),
(CFG, {'coarse_boundary':'slot_reservoir'}),
(CFG, {'coarse_boundary':'channel'}),
(CFG, {'level_objects':[(),(object(),)]}),
(CFG, {'level_jets':[(),(object(),)]}),
(replace(CFG,collision='MRT'), {}),
(replace(CFG,les_enabled=True), {})]:
with self.subTest(changes=changes, cfg=cfg), self.assertRaisesRegex(ValueError,'explosion'):
host.FixedPatchStepper(cfg, **dict(options, **changes))
context.activate.assert_not_called()
host.cuda.mem_alloc.assert_not_called()
# The constant path still allows MRT.
for options in ({'cfg':replace(CFG,collision='MRT')},):
run, _, _, _, _ = backend(**options)
run.close()
def test_remap_preserves_selector_tables_and_constant_default(self):
for explosion in ('constant','projected_linear'):
run, host, _, _, storage = backend(refinements=FOUR[:2], explosion=explosion)
run._step_count = 7
old = run.download()
clone = run.remap_finest(FOUR[1])
self.assertEqual(clone._explosion, explosion)
self.assertEqual(clone.step_count, 7)
for pair in clone._pairs:
self.assertEqual(pair.neighbors is None, explosion == 'constant')
for expected, actual in zip(old, clone.download()):
np.testing.assert_array_equal(actual,expected)
clone.step()
self.assertEqual((run.step_count,clone.step_count), (7,8))
clone.close(); run.close()
def test_changed_remap_geometry_keeps_projected_in_both_representations(self):
changed = [(22,22,42,42), (72,68,76,76)]
for shifted in (False, True):
run, _, _, _, _ = backend(cfg=replace(CFG,ddf_shifting=shifted),
refinements=FOUR[:2], explosion='projected_linear')
run._step_count = 3
before = run.download()
clone = run.remap_finest(changed)
self.assertEqual(clone._explosion, 'projected_linear')
self.assertEqual(clone.hierarchy.pairs[1].bounds, tuple(changed))
self.assertNotEqual(clone._pairs[1].n_ghosts, run._pairs[1].n_ghosts)
for old, new in zip(run._pairs, clone._pairs):
self.assertIsNot(old.neighbors, new.neighbors)
self.assertIsNotNone(new.neighbors)
for expected, actual in zip(before, run.download()):
np.testing.assert_array_equal(actual, expected)
clone.step()
self.assertEqual((run.step_count, clone.step_count), (3, 4))
clone.close(); run.close()
+11 -3
View File
@@ -25,7 +25,7 @@ def backend(observe=True, obj=None):
module = Mock()
module.get_global.return_value = (Mock(), 20)
module.get_function.side_effect = lambda name: Mock(side_effect=lambda *a, **k: calls.append((name, a, k)))
host.compiler.build_module.return_value = module
host.compiler.load_module.return_value = module
host.cuda.mem_alloc.side_effect = lambda n: Mock(size=n)
host.cuda.memcpy_dtoh.side_effect = lambda array, pointer: array.fill(0)
ctx = Mock(activate=nullcontext, sm_arch='sm_70')
@@ -85,7 +85,7 @@ class RawExchange(unittest.TestCase):
def test_four_level_schedule_publication_and_fixed_storage(self):
run, host, calls, arrays = backend()
b = run._boundaries[3]
b = run._level_boundaries[3]
rows = len(b.tables['cell'])
self.assertEqual(b.last_raw.size, rows * 16)
self.assertEqual(b.interval_integral.size, rows * 32)
@@ -128,7 +128,7 @@ class RawExchange(unittest.TestCase):
def test_disabled_has_nulls_and_no_observation_allocation_or_launch(self):
run, host, calls, _ = backend(False)
b = run._boundaries[3]
b = run._level_boundaries[3]
self.assertIs(b.last_raw, b.interval_integral)
self.assertIs(b.interval_integral, None)
run.step()
@@ -153,6 +153,14 @@ class RawExchange(unittest.TestCase):
run._stream.synchronize.side_effect = None
run.close()
def test_dense_observation_rejected_before_device_access(self):
host = host_module()
ctx = Mock()
with self.assertRaisesRegex(ValueError, 'blocked body'):
host.FixedPatchStepper(LBMConfig(nx=32, ny=32, viscosity=.1), (4, 4, 28, 28),
context=ctx, fine_objects=[body(radius=2.15)], observe_raw_exchange=True)
ctx.activate.assert_not_called()
def test_all_depth_weights(self):
for level in (1, 2, 3, 7):
self._check_weights_split_and_window(level)
+2 -6
View File
@@ -5,7 +5,6 @@ import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tests/validation"))
sys.path.insert(0, str(ROOT / "tests/unit"))
import re100_helpers as h
import periodic as p
@@ -63,16 +62,13 @@ def test_periodic_synthetic():
assert not p.assess(rows()[:100])["periodic"]
def test_production_unmodified(tmp_path):
from historical_tree import historical_tree
def test_production_unmodified():
m = json.loads(
(ROOT / "tests/validation/data/dfg_re100/source_manifest.json").read_text()
)
assert m["commit"] == h.PIN
origin = historical_tree(m["commit"], tmp_path)
for name, digest in m["production_sha256"].items():
assert hashlib.sha256((origin / name).read_bytes()).hexdigest() == digest, name
assert hashlib.sha256((ROOT / name).read_bytes()).hexdigest() == digest, name
def test_reference():

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