Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b920a303a4 | ||
|
|
e25828968b | ||
|
|
0f8a428f07 | ||
|
|
983020b625 | ||
|
|
e1ed27b438 | ||
|
|
71c12aedce | ||
|
|
36eac713f4 | ||
|
|
e682d16c3a | ||
|
|
b448bc3f71 | ||
|
|
8a8e8a03ff | ||
|
|
305ab2409b | ||
|
|
c986343a15 | ||
|
|
b2034f87e0 | ||
|
|
6d046f91c1 | ||
|
|
3361e97d5f | ||
|
|
442979b4db | ||
|
|
c5a3cdb757 | ||
|
|
a4fc46d849 |
@@ -1,8 +1,14 @@
|
||||
# src coherence stage (from main 914906e)
|
||||
|
||||
Owner direction 2026-10-28: unified style over historical compatibility, inside `src`.
|
||||
No numerical change is intended; kernel text was moved verbatim (PTX/bit parity is
|
||||
pending a GPU run).
|
||||
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`)
|
||||
|
||||
@@ -28,20 +34,25 @@ pending a GPU run).
|
||||
|
||||
## 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) | `_device_boundaries[l]` (list from L0, `None` at L0 unless single-level static circles) |
|
||||
| `_level_boundaries` (private, b4eacf7) | `_device_boundaries` |
|
||||
| `_fine_tables`, `_actuated`, `_jet_cache` | `_device_boundaries[1].tables` / `.actuated` / `.jet_cache` |
|
||||
| `_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) | `_boundaries` (private list behind `boundaries`) |
|
||||
| `_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) |
|
||||
@@ -56,13 +67,35 @@ the replaced level relative to its parent).
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Construction: `_check_levels` (inputs), `_kernel_configs` (per-level kernel configs),
|
||||
`Hierarchy.edge_frame` (geometry frames), `_build_level` / `_build_pair` / `_upload_boundary`
|
||||
(device). Observation reads share `_observed_boundary` + `_summarize`.
|
||||
- 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`. MRT shares one path; SRT keeps its two explicit
|
||||
arithmetic variants (level: unclamped, no-force, shifted direct-momentum feq; channel: uniform
|
||||
OneStep arithmetic with omega clamp), selected by `BLOCK_ROOT_BC`.
|
||||
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
|
||||
@@ -72,6 +105,12 @@ the replaced level relative to its parent).
|
||||
`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. immutable blocked hierarchy, edge
|
||||
(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).
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
# Moving circles on the uniform dense grid — S0 DRAFT contract
|
||||
|
||||
Status: **DRAFT, not frozen** for the force RHS, kernels and physical
|
||||
acceptance. Host-only S1 (state, validation, guards; no kernels) is on branch
|
||||
`claude/moving-bodies-s1`; positive-step runs with a moving body are rejected
|
||||
until S2. When S2 lifts that guard, `run(n > 1)` with a pending target must
|
||||
raise `ValueError` before mutation (4a), and `run(0)` must not upload a motion
|
||||
record or consume the pending target. Base: main
|
||||
`b2034f87e03656c5f67ab88562a70d6c338b9918`. Scope approved by Frank (via TRON,
|
||||
thread C0C78UFKGRM/1793335557.460509): uniform-grid prescribed translation and
|
||||
single-cylinder free transverse VIV. Out of scope: refinement/blocked path,
|
||||
pinball assembly, EsoPull, shifted storage, D3Q19, DynamisLab, manuscript.
|
||||
|
||||
Items marked **OPEN** are undecided; items marked **CANDIDATE** are proposals.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
@@ -405,7 +405,9 @@ class ObjectManager:
|
||||
|
||||
for obj in objects:
|
||||
obj.validate_surface_actuation(self.cfg)
|
||||
if (not obj.is_sensor) and (not obj.is_force_region) and hasattr(obj, 'get_curved_list'):
|
||||
# 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'):
|
||||
(
|
||||
fluid_idx, dirs, q_vals, body_ids,
|
||||
rx_vals, ry_vals, rz_vals, fallback_vals
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# CelerisLab/body/motion.py
|
||||
"""Host state of a translating circle (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``. Nothing here touches
|
||||
the device: the caller supplies upload/step/synchronize callables.
|
||||
"""
|
||||
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
|
||||
R_MIN = 4.0 # smallest radius covered by the donor enumeration
|
||||
|
||||
Vec = Tuple[float, float]
|
||||
|
||||
# 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 _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
|
||||
|
||||
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}")
|
||||
object.__setattr__(self, "offset_box", box)
|
||||
object.__setattr__(self, "max_speed", speed)
|
||||
|
||||
|
||||
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}")
|
||||
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 advance(self, upload: Callable, step: Callable, synchronize: Callable) -> None:
|
||||
"""Upload the record, run one step and commit only after a successful sync.
|
||||
|
||||
Any exception after the upload is attempted leaves device geometry
|
||||
unknown: the circle is marked failed, never reports applied state again,
|
||||
and the owner's ``on_failure`` hook is called.
|
||||
"""
|
||||
record = self.record()
|
||||
try:
|
||||
upload(record)
|
||||
step()
|
||||
synchronize()
|
||||
except BaseException as exc:
|
||||
self.failed = True
|
||||
if self._on_failure is not None:
|
||||
self._on_failure(exc)
|
||||
raise
|
||||
if self.pending is not None:
|
||||
self.applied = self.pending[0]
|
||||
self.pending = None
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def _inside(x: int, y: int, c: Vec, r: float) -> bool:
|
||||
return (x - c[0]) ** 2 + (y - c[1]) ** 2 < r * r
|
||||
|
||||
|
||||
def empty_donor_cells(c_old: Vec, c_new: Vec, 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
|
||||
|
||||
|
||||
class MovingTables:
|
||||
"""Device tables of the translating circles for ``MovingCircleLinks`` (fixed centre).
|
||||
|
||||
Immutable per run: band table (x0, y0, width, height, first_slot, body_id) and
|
||||
FP64 geometry (cx, cy, r) at the applied offset; plus the separate per-body
|
||||
diagnostics (cme_x, cme_y, wen_x, wen_y, link_dm, n_links), never part of obs.
|
||||
"""
|
||||
BAND_INTS, DIAG_FLOATS = 6, 6
|
||||
|
||||
def __init__(self, band, geom):
|
||||
import numpy as np
|
||||
import pycuda.driver as cuda
|
||||
self.n_bodies = len(geom) // 3
|
||||
self.n_slots = int(band[-2] + band[-4] * band[-3] * 8)
|
||||
self.band_gpu = cuda.to_device(np.asarray(band, np.int32))
|
||||
self.geom_gpu = cuda.to_device(np.asarray(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 += [*motion.centre(motion.applied), motion.radius]
|
||||
return band, geom
|
||||
|
||||
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
|
||||
@@ -140,6 +140,7 @@ 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 +1,12 @@
|
||||
// CelerisLab – operators/block_collision.cuh
|
||||
// The one collision extension point of the blocked steps (BlockStep, BlockChannelStep).
|
||||
// Compile-time selection: MRT shares one path; SRT keeps two explicit arithmetic
|
||||
// variants, unchanged from their original kernels:
|
||||
// level (BLOCK_ROOT_BC 0/1): unclamped omega, no forcing term; shifted storage
|
||||
// builds feq from the retained drho and direct momentum (numerical variant);
|
||||
// channel (BLOCK_ROOT_BC 2): matches uniform OneStep SRT arithmetic (omega clamp,
|
||||
// zero Guo forcing term, compute_feq).
|
||||
// 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
|
||||
|
||||
@@ -16,17 +18,19 @@
|
||||
#endif
|
||||
|
||||
__device__ __forceinline__ void block_collide(float* f) {
|
||||
#if COLLISION_MODEL == 2
|
||||
collide_mrt_unforced(f);
|
||||
#elif BLOCK_ROOT_BC == 2
|
||||
float rho, ux, uy, feq[NQ], forcing[NQ] = {};
|
||||
float rho, ux, uy;
|
||||
compute_rho_u(f, rho, ux, uy);
|
||||
compute_feq(rho, ux, uy, feq);
|
||||
#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 rho, ux, uy, feq[NQ];
|
||||
compute_rho_u(f, rho, ux, uy);
|
||||
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];
|
||||
|
||||
@@ -114,16 +114,6 @@ __device__ __forceinline__ void collide_mrt_no_force(float* __restrict__ g,
|
||||
collide_mrt(g, rho, ux, uy, Fin, omega);
|
||||
}
|
||||
|
||||
// Unforced MRT with the clamped device omega, as in collide_dispatch with zero
|
||||
// forcing. Shared by the blocked, channel-root and dense-patch collisions.
|
||||
__device__ __forceinline__ void collide_mrt_unforced(float* __restrict__ g)
|
||||
{
|
||||
float rho, ux, uy;
|
||||
compute_rho_u(g, rho, ux, uy);
|
||||
collide_mrt_no_force(g, rho, ux, uy,
|
||||
fminf(OMEGA_COLLISION_MAX, fmaxf(OMEGA_COLLISION_MIN, d_params.omega)));
|
||||
}
|
||||
|
||||
#elif LATTICE_MODEL == LATTICE_D3Q19
|
||||
|
||||
__device__ __forceinline__ void collide_mrt(float* __restrict__ g,
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Translating-circle cut links, fixed centre (S2a; docs/moving_bodies.md, M2).
|
||||
// One thread per (swept-band cell, direction) slot. Geometry comes from an FP64
|
||||
// {cx, cy, r} table per body; the 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_DIAG_FLOATS = 6; // cme_x, cme_y, wen_x, wen_y, link_dm, n_links
|
||||
|
||||
__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 cx = geom[3 * m], cy = geom[3 * m + 1], r = geom[3 * m + 2];
|
||||
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 = -omega * ry, Vw = omega * rx; // fixed centre: no translation term
|
||||
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);
|
||||
}
|
||||
}
|
||||
#endif // DIM == 2
|
||||
|
||||
#endif // CELERIS_MOVING_CIRCLE_CU
|
||||
@@ -71,6 +71,7 @@ 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"
|
||||
|
||||
|
||||
@@ -317,26 +317,25 @@ class FixedPatchStepper:
|
||||
self._window_valid = False
|
||||
self._window_start = None
|
||||
self._exchange_valid = False
|
||||
self._exchange_spacing = tuple(self.hierarchy.edge_frame(l)[1] for l in range(depth))
|
||||
self._exchange_centers = tuple(tuple(tuple(o.geometry.center) for o in objects)
|
||||
for objects in level_objects)
|
||||
root = None
|
||||
if depth == 1 and level_objects[0]:
|
||||
tables = static_circle_tables(self._layouts[0], level_objects[0])
|
||||
root = _BlockedBoundary(None, tables, (), False)
|
||||
# The one boundary/Jet record per level; host geometry and Jets are views of it.
|
||||
self._device_boundaries = [root] + [
|
||||
self._boundaries = [root] + [
|
||||
_prepare_blocked_boundary(hosts[level], self._layouts[level],
|
||||
self._level_cfgs[level], level_jets[level],
|
||||
_shifted_blocked=self._ddf_shifting)
|
||||
for level in range(1, depth)]
|
||||
self._leaf = _fluid_leaves(self.hierarchy, self._device_boundaries)
|
||||
for level, b in enumerate(self._boundaries):
|
||||
if b is not None: # exchange scaling (dt_l = h_l) and root-edge-frame moment centers
|
||||
b.spacing = self.hierarchy.edge_frame(level)[1]
|
||||
b.centers = tuple(tuple(o.geometry.center) for o in level_objects[level])
|
||||
self._leaf = _fluid_leaves(self.hierarchy, self._boundaries)
|
||||
self._state = [mask.copy() for mask in self._leaf]
|
||||
if self._channel:
|
||||
self._leaf[0] &= channel_masks((cfg.ny, cfg.nx))[0]
|
||||
# Private constructor inputs for remap_finest.
|
||||
self._root_cfg = replace(cfg)
|
||||
self._level_blocks = level_blocks
|
||||
self._root_cfg = replace(cfg) # private constructor input for remap_finest
|
||||
self.ctx = context
|
||||
self._lock = RLock()
|
||||
self._status = "new"
|
||||
@@ -391,7 +390,7 @@ class FixedPatchStepper:
|
||||
for index, layout in enumerate(self._layouts):
|
||||
total += 2 * 4 * int(np.prod(layout.blocked_shape))
|
||||
total += layout.halo_dst.nbytes + layout.halo_src.nbytes
|
||||
b = self._device_boundaries[index]
|
||||
b = self._boundaries[index]
|
||||
if b is not None:
|
||||
total += sum(np.asarray(v).nbytes for k, v in b.tables.items()
|
||||
if k == "flags" or k in BLOCKED_BOUNDARY_DEVICE_COLUMNS)
|
||||
@@ -414,7 +413,7 @@ class FixedPatchStepper:
|
||||
grid = ((config.nx + self._block[0] - 1) // self._block[0], config.ny, layout.nblocks)
|
||||
level = _Level(shape, module, self._allocate(size), self._allocate(size),
|
||||
module.get_function(name), grid, layout)
|
||||
level.boundary = self._device_boundaries[index]
|
||||
level.boundary = self._boundaries[index]
|
||||
level.halo_copy = module.get_function("BlockHaloCopy")
|
||||
if len(layout.halo_dst):
|
||||
level.halo_dst = self._upload(layout.halo_dst)
|
||||
@@ -576,12 +575,12 @@ class FixedPatchStepper:
|
||||
pairs = self.hierarchy.pairs
|
||||
if (self._wall_aware or self._channel or self.hierarchy.depth != 3
|
||||
or not isinstance(pairs[1], FixedPatchSet2D)
|
||||
or any(b is not None for b in self._device_boundaries[:2])):
|
||||
or any(b is not None for b in self._boundaries[:2])):
|
||||
raise ValueError("remap_finest requires a periodic three-level hierarchy with a "
|
||||
"level-2 set and no level-0/1 bodies")
|
||||
cfg = self._root_cfg
|
||||
objects = tuple(objects)
|
||||
actual = self._device_boundaries[2]
|
||||
actual = self._boundaries[2]
|
||||
jets = () if actual is None else actual.jets
|
||||
old = RemapTopology.from_config(cfg, pairs[0], pairs[1].bounds,
|
||||
child_objects=objects, child_jets=jets,
|
||||
@@ -603,7 +602,7 @@ class FixedPatchStepper:
|
||||
or not np.array_equal(old.surface_velocity, new.surface_velocity)):
|
||||
raise ValueError("remap requires the identical pinned physical boundary and BFL support")
|
||||
# Check sparse block alignment/launch size before any device read/allocation.
|
||||
layout = new.child.fine_layout(self._level_blocks[2])
|
||||
layout = new.child.fine_layout(self._layouts[2].block)
|
||||
if layout.nblocks > MAX_GRID_DIM or layout.local_shape[0] > MAX_GRID_DIM:
|
||||
raise ValueError("level 2 blocks exceed launch limits")
|
||||
with self.ctx.activate():
|
||||
@@ -621,7 +620,7 @@ class FixedPatchStepper:
|
||||
phase="postcollision_g", **options)
|
||||
candidate = type(self)(
|
||||
cfg, context=self.ctx, refinements=[pairs[0].bounds, new.child.bounds],
|
||||
level_blocks=list(self._level_blocks),
|
||||
level_blocks=[layout.block for layout in self._layouts],
|
||||
level_objects=[(), (), objects], level_jets=[(), (), jets],
|
||||
observe_raw_exchange=self._observe_raw_exchange,
|
||||
observe_window_exchange=self._observe_window, explosion=self._explosion)
|
||||
@@ -651,7 +650,7 @@ class FixedPatchStepper:
|
||||
if (isinstance(level, bool) or not isinstance(level, Integral)
|
||||
or not 1 <= level < self.hierarchy.depth):
|
||||
raise ValueError("Jet level must name an existing refined level (1 .. depth-1)")
|
||||
boundary = self._device_boundaries[level]
|
||||
boundary = self._boundaries[level]
|
||||
# Host preparation: validate and build the new column without touching state.
|
||||
jets = list(() if boundary is None else boundary.jets)
|
||||
jets[index] = jets[index].with_amplitude(amplitude) # validates first
|
||||
@@ -711,7 +710,7 @@ class FixedPatchStepper:
|
||||
b = level.boundary
|
||||
if b is not None and b.last_raw is not None:
|
||||
count = len(b.tables["cell"])
|
||||
h = self._exchange_spacing[index] # acoustic scaling: dt_l = h_l
|
||||
h = b.spacing # acoustic scaling: dt_l = h_l
|
||||
b.accumulate(b.last_raw, b.interval_integral, np.uint32(count),
|
||||
np.float64(h), np.float64(h),
|
||||
grid=((count + self._block[0] - 1) // self._block[0], 1), **kw)
|
||||
@@ -755,7 +754,7 @@ class FixedPatchStepper:
|
||||
self._exchange_valid = False
|
||||
for _ in range(n):
|
||||
if self._observe_raw_exchange: # stream-ordered: holds the last interval only
|
||||
for b in self._device_boundaries:
|
||||
for b in self._boundaries:
|
||||
if b is not None:
|
||||
cuda.memset_d8_async(b.interval_integral, 0,
|
||||
len(b.tables["cell"]) * 32, self._stream)
|
||||
@@ -778,7 +777,7 @@ class FixedPatchStepper:
|
||||
raise RuntimeError("window exchange observation is disabled")
|
||||
self._window_valid = False
|
||||
try:
|
||||
for b in self._device_boundaries:
|
||||
for b in self._boundaries:
|
||||
if b is not None and b.window_integral is not None:
|
||||
cuda.memset_d8(b.window_integral, 0, len(b.tables["cell"]) * 32)
|
||||
except BaseException:
|
||||
@@ -791,7 +790,7 @@ class FixedPatchStepper:
|
||||
if (isinstance(level, bool) or not isinstance(level, Integral)
|
||||
or not 0 <= level < len(self._levels)):
|
||||
raise ValueError("level must name an existing level")
|
||||
b = self._device_boundaries[level]
|
||||
b = self._boundaries[level]
|
||||
if (b is None or isinstance(body_id, bool) or not isinstance(body_id, Integral)
|
||||
or not 0 <= body_id < len(b.host.objects)):
|
||||
raise ValueError("body_id must name a body on this level")
|
||||
@@ -799,9 +798,9 @@ class FixedPatchStepper:
|
||||
|
||||
def _summarize(self, b, rows, level, body_id, window, **kw):
|
||||
return summarize_exchange(rows, b.tables["body_id"], b.tables["aperture"],
|
||||
body_id=body_id, level=level, h=self._exchange_spacing[level],
|
||||
body_id=body_id, level=level, h=b.spacing,
|
||||
time_end=self._step_count, window=window,
|
||||
reference_center=self._exchange_centers[level][body_id], **kw)
|
||||
reference_center=b.centers[body_id], **kw)
|
||||
|
||||
def read_exchange_window(self, body_id, *, level):
|
||||
"""Non-destructive read of [window start, current step count] in base-lattice units."""
|
||||
@@ -888,7 +887,7 @@ class FixedPatchStepper:
|
||||
self._stream.synchronize()
|
||||
except Exception as exc:
|
||||
error = exc
|
||||
for b in getattr(self, "_device_boundaries", ()):
|
||||
for b in getattr(self, "_boundaries", ()):
|
||||
if b is not None:
|
||||
b.flags = b.device = b.kernel = None
|
||||
b.last_raw = b.interval_integral = b.accumulate = None
|
||||
@@ -923,11 +922,11 @@ class FixedPatchStepper:
|
||||
@property
|
||||
def boundaries(self):
|
||||
"""Host boundary geometry per level (None: no bodies/Jets on a refined level)."""
|
||||
return tuple(None if b is None else b.host for b in self._device_boundaries)
|
||||
return tuple(None if b is None else b.host for b in self._boundaries)
|
||||
|
||||
@property
|
||||
def jets(self):
|
||||
return tuple(() if b is None else b.jets for b in self._device_boundaries)
|
||||
return tuple(() if b is None else b.jets for b in self._boundaries)
|
||||
|
||||
@property
|
||||
def leaf(self):
|
||||
|
||||
@@ -130,6 +130,8 @@ 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):
|
||||
|
||||
@@ -20,6 +20,10 @@ class LBMStepper:
|
||||
self.cfg = cfg
|
||||
|
||||
self._esopull = (cfg.streaming == "esopull")
|
||||
# Set by the owner when a step would be wrong (translating circles
|
||||
# excluded from the packed links before the moving kernels exist).
|
||||
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")
|
||||
@@ -35,6 +39,7 @@ class LBMStepper:
|
||||
self.apply_esopull_curved_fn = module.get_function("ApplyEsoPullCurvedKernel")
|
||||
self.sensor_fn = module.get_function("SensorKernel")
|
||||
self.force_region_fn = module.get_function("ForceRegionKernel")
|
||||
self.moving_fn = None if self._esopull else module.get_function("MovingCircleLinks")
|
||||
|
||||
tpb = cfg.threads_per_block
|
||||
self.block = (tpb, 1, 1)
|
||||
@@ -70,6 +75,8 @@ class LBMStepper:
|
||||
stream: cuda.Stream | None = None,
|
||||
):
|
||||
"""Advance *n* time steps."""
|
||||
if n > 0 and self.blocked is not None:
|
||||
raise RuntimeError(self.blocked)
|
||||
f = self.field
|
||||
launch_kw = {}
|
||||
if stream is not None:
|
||||
@@ -81,6 +88,8 @@ 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, **launch_kw)
|
||||
self._launch_curved(action_gpu, obs_gpu, **launch_kw)
|
||||
|
||||
if self._esopull:
|
||||
@@ -106,6 +115,15 @@ class LBMStepper:
|
||||
# Any completed GPU step invalidates host DDF mirror.
|
||||
f.invalidate_host_ddf_cache()
|
||||
|
||||
def _launch_moving(self, action_gpu, obs_gpu, **launch_kw):
|
||||
m, tpb = self.moving, self.cfg.threads_per_block
|
||||
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:
|
||||
|
||||
@@ -26,6 +26,12 @@ 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 (fixed centre in S2a).
|
||||
MOVING_BLOCKED = ("translating circles need the moving kernels and their tables (attached "
|
||||
"at initialize); "
|
||||
"positive-step runs are rejected")
|
||||
from .lbm.field import LBMField
|
||||
from .lbm.stepper import LBMStepper
|
||||
from .body.objects import SimObject
|
||||
@@ -97,7 +103,8 @@ class Simulation:
|
||||
|
||||
# -- Object management ---------------------------------------------------
|
||||
def add_body(self, type: str = "circle", center=None,
|
||||
radius: float = 0.0, *, surface_actuation=None, **kwargs) -> int:
|
||||
radius: float = 0.0, *, surface_actuation=None, translation=None,
|
||||
**kwargs) -> int:
|
||||
"""Add a simulation body. Returns body_id.
|
||||
|
||||
Before ``initialize()`` the object is added to the formal registry
|
||||
@@ -113,6 +120,9 @@ 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). Until
|
||||
the moving kernels exist, positive-step runs are rejected.
|
||||
**kwargs: passed to the geometry constructor.
|
||||
|
||||
Returns:
|
||||
@@ -123,6 +133,10 @@ 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
|
||||
@@ -131,6 +145,11 @@ 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])
|
||||
@@ -190,6 +209,71 @@ class Simulation:
|
||||
raise
|
||||
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}"
|
||||
|
||||
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
|
||||
self.stepper.blocked = MOVING_BLOCKED if self._moving_bodies() and tables is None else 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): cme_x, cme_y, wen_x, wen_y, link_dm,
|
||||
n_links accumulated since obs were last zeroed (diagnostics only)."""
|
||||
self._require_usable()
|
||||
tables = self.stepper.moving
|
||||
if tables is None:
|
||||
return np.zeros((0, 6), 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
|
||||
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:
|
||||
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:
|
||||
@@ -260,7 +344,7 @@ class Simulation:
|
||||
|
||||
# -- Runtime control -----------------------------------------------------
|
||||
def set_body(self, id: int, omega: float = None,
|
||||
vx: float = None, vy: float = None) -> None:
|
||||
vx: float = None, vy: float = None, offset=None) -> None:
|
||||
"""Set runtime body state (host action array only, no H2D).
|
||||
|
||||
The GPU action buffer is updated at the next ``run()`` call.
|
||||
@@ -270,8 +354,19 @@ class Simulation:
|
||||
omega: rotation speed (lattice units). ``None`` = no change.
|
||||
vx: translation velocity x (future). ``None`` = no change.
|
||||
vy: translation velocity y (future). ``None`` = no change.
|
||||
offset: target centre offset of a translating circle. Displacement
|
||||
is disabled until the moving cell update exists (S3): any offset
|
||||
raises before mutation; translating circles hold at the applied
|
||||
offset and accept omega only.
|
||||
"""
|
||||
obj = self.bodies.get(id)
|
||||
motion = obj.motion
|
||||
if motion is not None:
|
||||
if offset is not None or vx is not None or vy is not None:
|
||||
raise ValueError("translating-circle displacement is disabled until S3; "
|
||||
"only omega may be set")
|
||||
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})
|
||||
@@ -443,6 +538,7 @@ 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)
|
||||
|
||||
@@ -468,6 +564,8 @@ class Simulation:
|
||||
# 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)
|
||||
@@ -484,6 +582,8 @@ class Simulation:
|
||||
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
|
||||
|
||||
@@ -499,6 +599,7 @@ class Simulation:
|
||||
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
|
||||
@@ -599,6 +700,8 @@ class Simulation:
|
||||
checkpoint_interval = operator.index(checkpoint_interval)
|
||||
if steps < 0 or checkpoint_interval < 0:
|
||||
raise ValueError("steps and checkpoint_interval must be nonnegative")
|
||||
if steps > 0 and self._moving_bodies() and self.stepper.moving is None:
|
||||
raise RuntimeError(MOVING_BLOCKED)
|
||||
self._assert_runtime_contracts()
|
||||
# Discard any uncommitted body edits before stepping.
|
||||
if self.bodies.has_pending_edit():
|
||||
@@ -615,6 +718,8 @@ 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
|
||||
@@ -698,6 +803,7 @@ class Simulation:
|
||||
"""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,
|
||||
@@ -708,6 +814,7 @@ class Simulation:
|
||||
"""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,
|
||||
@@ -726,6 +833,7 @@ 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
|
||||
|
||||
@@ -102,7 +102,7 @@ def test_simulation_before_initialize_commits_host_only(monkeypatch):
|
||||
|
||||
|
||||
def stepper_state(run):
|
||||
boundary = run._device_boundaries[2]
|
||||
boundary = run._boundaries[2]
|
||||
return boundary.tables["surface_velocity"].copy(), boundary.jets, run.jets[2]
|
||||
|
||||
|
||||
@@ -116,8 +116,8 @@ def test_stepper_invalid_input_writes_nothing_and_stays_ready():
|
||||
with pytest.raises(error):
|
||||
run.set_jet_amplitude(*args, **kw)
|
||||
module.cuda.memcpy_htod.assert_not_called()
|
||||
np.testing.assert_array_equal(run._device_boundaries[2].tables["surface_velocity"], velocity)
|
||||
assert (run._device_boundaries[2].jets, run.jets[2]) == (jets, level_jets)
|
||||
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
|
||||
@@ -136,8 +136,8 @@ def test_stepper_device_failure_keeps_host_and_blocks_reuse(where):
|
||||
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._device_boundaries[2].tables["surface_velocity"], velocity)
|
||||
assert (run._device_boundaries[2].jets, run.jets[2]) == (jets, level_jets)
|
||||
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
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
"""Translating-circle host contract, S1 (docs/moving_bodies.md 4a, 5, 7).
|
||||
|
||||
Host doubles only: no kernels exist yet, so every positive-step run with a
|
||||
moving body must be rejected before any device write.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from CelerisLab.body.motion import (MAX_STEP, R_MIN, MovingCircle, Translation,
|
||||
enumerate_donor_coverage)
|
||||
|
||||
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():
|
||||
c = circle()
|
||||
assert c.record() == ((50., 40.), (50., 40.), (0., 0.)) # hold: no translation velocity
|
||||
c.set_target((0., .25), (0., .05))
|
||||
assert c.record() == ((50., 40.), (50., 40.25), (0., .05))
|
||||
uploads = []
|
||||
c.advance(uploads.append, lambda: None, lambda: None)
|
||||
assert uploads == [((50., 40.), (50., 40.25), (0., .05))]
|
||||
assert c.applied == (0., .25) and c.pending is None # offset-only commit
|
||||
c.advance(uploads.append, lambda: None, lambda: None) # no new target: hold step
|
||||
assert uploads[-1] == ((50., 40.25), (50., 40.25), (0., 0.)) and c.applied == (0., .25)
|
||||
c.set_target((0., 0.), (0., -.05)) # return to zero offset is a real target
|
||||
assert c.pending == ((0., 0.), (0., -.05))
|
||||
c.advance(uploads.append, lambda: None, lambda: None)
|
||||
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(stage):
|
||||
c = circle()
|
||||
c.set_target((0., .25), (0., .05))
|
||||
|
||||
def call(name):
|
||||
def run(*_):
|
||||
if name == stage:
|
||||
raise RuntimeError(f"{name} failed")
|
||||
return run
|
||||
with pytest.raises(RuntimeError, match=f"{stage} failed"):
|
||||
c.advance(call("upload"), call("step"), call("synchronize"))
|
||||
assert c.failed and c.applied == (0., 0.)
|
||||
for later in (c.record, lambda: c.set_target((0., .1), (0., 0.)),
|
||||
lambda: c.advance(*(lambda *_: None,) * 3)):
|
||||
with pytest.raises(RuntimeError, match="unknown"):
|
||||
later()
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@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
|
||||
assert sim.stepper.blocked is not None # declaring a moving circle blocks direct steps
|
||||
for initialized in (False, True): # S2a: displacement disabled before and after initialize
|
||||
sim._initialized = initialized
|
||||
for kwargs in (dict(offset=(0., .3), vy=.05), dict(offset=(0., 0.)), dict(vy=.05),
|
||||
dict(vx=.05), dict(offset=(0., .1), omega=.01)):
|
||||
with pytest.raises(ValueError, match="disabled until S3"):
|
||||
sim.set_body(bid, **kwargs)
|
||||
assert motion.pending is None and motion.applied == (0., 0.)
|
||||
sim._initialized = False
|
||||
obj = sim.bodies.get(bid)
|
||||
assert obj.state.omega == 0.
|
||||
sim.set_body(bid, omega=.01) # spin on hold stays allowed
|
||||
assert obj.state.omega == .01 and (obj.state.vx, obj.state.vy) == (0., 0.)
|
||||
assert motion.pending is None
|
||||
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)
|
||||
|
||||
|
||||
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., 10., 90.25, 30.5, 5.]
|
||||
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 = routed_simulation(monkeypatch)
|
||||
sim.ctx = ClosableOwner()
|
||||
bid = sim.add_body("circle", (50., 40.), 10., translation=Translation((0., 0., -1., 1.), .1))
|
||||
motion = sim.bodies.get(bid).motion
|
||||
motion.set_target((0., .2), (0., .05))
|
||||
|
||||
def fail(*_):
|
||||
raise RuntimeError("sync failed")
|
||||
with pytest.raises(RuntimeError, match="sync failed"):
|
||||
motion.advance(lambda *_: None, lambda: None, 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
|
||||
motion.advance(lambda *_: None, lambda: None, lambda: None)
|
||||
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()
|
||||
@@ -96,11 +96,11 @@ class MultiplePinnedRemap(unittest.TestCase):
|
||||
before = run.download()
|
||||
clone = run.remap_finest((PINS+MOVED+ADDED)[::-1], objects=bodies)
|
||||
try:
|
||||
self.assertEqual(tuple(j.amplitude for j in clone._device_boundaries[2].jets), (.017, -.011))
|
||||
np.testing.assert_array_equal(clone._device_boundaries[2].tables['surface_velocity'],
|
||||
run._device_boundaries[2].tables['surface_velocity'])
|
||||
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'],
|
||||
run._boundaries[2].tables['surface_velocity'])
|
||||
clone.set_jet_amplitude(1, .005, level=2)
|
||||
self.assertEqual(run._device_boundaries[2].jets[1].amplitude, -.011)
|
||||
self.assertEqual(run._boundaries[2].jets[1].amplitude, -.011)
|
||||
clone.step()
|
||||
self.assertEqual((run.step_count, clone.step_count), (7, 8))
|
||||
for got, want in zip(run.download(), before):
|
||||
|
||||
@@ -62,9 +62,9 @@ class PinnedBodyRemap(unittest.TestCase):
|
||||
run.set_jet_amplitude(0,.017,level=2)
|
||||
before=run.download()
|
||||
clone=run.remap_finest(B,objects=objects)
|
||||
self.assertEqual(clone._device_boundaries[2].jets[0].amplitude,.017)
|
||||
np.testing.assert_array_equal(clone._device_boundaries[2].tables['surface_velocity'],
|
||||
run._device_boundaries[2].tables['surface_velocity'])
|
||||
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]))
|
||||
clone.step()
|
||||
self.assertEqual((run.step_count,clone.step_count),(7,8))
|
||||
@@ -74,8 +74,8 @@ 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)
|
||||
self.assertFalse(clone._device_boundaries[2].actuated)
|
||||
self.assertNotIn('surface_velocity',clone._device_boundaries[2].tables)
|
||||
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)
|
||||
clone.close();run.close()
|
||||
|
||||
|
||||
@@ -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._device_boundaries[3]
|
||||
b = run._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._device_boundaries[3]
|
||||
b = run._boundaries[3]
|
||||
self.assertIs(b.last_raw, b.interval_integral)
|
||||
self.assertIs(b.interval_integral, None)
|
||||
run.step()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Host guards and source contract for MRT reuse in refined/blocked stepping (no CUDA)."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import unittest
|
||||
|
||||
from CelerisLab.config import LBMConfig
|
||||
@@ -32,22 +33,25 @@ class RefinementMRTHost(unittest.TestCase):
|
||||
"collide_srt_no_force(f, feq, d_params.omega);"):
|
||||
self.assertIn(text, block_collision)
|
||||
self.assertIn("collide_srt(f, feq, forcing, omega);", block_collision)
|
||||
self.assertIn("#elif BLOCK_ROOT_BC == 2", block_collision)
|
||||
self.assertIn("#if COLLISION_MODEL == 2 || BLOCK_ROOT_BC == 2", block_collision)
|
||||
self.assertEqual(channel.count("block_collide(f);"), 1)
|
||||
self.assertNotIn("collide_mrt_unforced(f);", channel)
|
||||
# Executable text only: the header comment explains the absent force read.
|
||||
code = re.sub(r"//[^\n]*", "", channel + "\n" + block_collision)
|
||||
self.assertIn("d_params.omega", code)
|
||||
for text in ("collide_mrt_unforced", "collide_dispatch(", "d_params.fx", "d_params.fy"):
|
||||
self.assertNotIn(text, code)
|
||||
for source in (blocks,):
|
||||
self.assertIn("(COLLISION_MODEL != 0 && COLLISION_MODEL != 2)", source)
|
||||
self.assertIn("USE_LES", source)
|
||||
self.assertIn("STORE_PRECISION != 0", source)
|
||||
self.assertIn('#if COLLISION_MODEL == 2\n#include "operators/collision_mrt.cuh"\n#endif', source)
|
||||
# One shared unforced MRT body; each blocked/patch path calls it exactly once.
|
||||
mrt = (KERNELS / "operators/collision_mrt.cuh").read_text()
|
||||
self.assertEqual(mrt.count("void collide_mrt_unforced("), 1)
|
||||
for source in (blocks + "\n" + block_collision, channel + "\n" + block_collision):
|
||||
self.assertEqual(source.count("collide_mrt_unforced(f);"), 1)
|
||||
self.assertNotIn("collide_mrt_no_force(", source)
|
||||
self.assertNotIn("compute_rho_u(f, rho, ux, uy);\n collide_mrt", source)
|
||||
self.assertNotRegex(source, r"compute_rho_u\(f, rho, ux, uy\);\s*collide_mrt")
|
||||
# One unforced MRT call, in block_collide only; the steps never collide directly.
|
||||
self.assertEqual(block_collision.count("collide_mrt_no_force(f, rho, ux, uy, omega);"), 1)
|
||||
self.assertEqual(block_collision.count("compute_rho_u(f, rho, ux, uy);"), 1)
|
||||
self.assertEqual(block_collision.count("fmaxf(OMEGA_COLLISION_MIN, d_params.omega)"), 1)
|
||||
for source in (blocks, channel):
|
||||
self.assertNotIn("collide_mrt", source)
|
||||
self.assertNotIn("collide_srt", source)
|
||||
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
|
||||
@@ -70,11 +70,11 @@ class RuntimeChildRemap(unittest.TestCase):
|
||||
run._status = 'ready'
|
||||
for bad in ([], [(40,16,48,40)], [(16,16,32,24),(16,27,32,55)]):
|
||||
with self.assertRaises(ValueError): run.remap_finest(bad)
|
||||
records, pairs = run._device_boundaries, run.hierarchy.pairs
|
||||
records, pairs = run._boundaries, run.hierarchy.pairs
|
||||
for attribute, value in (('_wall_aware', True),
|
||||
# level-2 record whose host geometry differs from objects=()
|
||||
('_device_boundaries', [*records[:2], SimpleNamespace(host=object(), jets=())]),
|
||||
('_device_boundaries', [records[0], object(), records[2]]), # level-1 body
|
||||
('_boundaries', [*records[:2], SimpleNamespace(host=object(), jets=())]),
|
||||
('_boundaries', [records[0], object(), records[2]]), # level-1 body
|
||||
('hierarchy', replace(run.hierarchy, pairs=(pairs[0], None)))):
|
||||
with patch.object(run, attribute, value):
|
||||
with self.assertRaises(ValueError): run.remap_finest(B)
|
||||
|
||||
@@ -67,7 +67,7 @@ class ShiftedJets(unittest.TestCase):
|
||||
def test_switch_upload_only_then_raw_remap_keeps_live_velocity(self):
|
||||
run,module,allocations,storage,objects=ready();clone=None
|
||||
try:
|
||||
boundary=run._device_boundaries[2]
|
||||
boundary=run._boundaries[2]
|
||||
before=run._download_raw()
|
||||
original=boundary.tables['surface_velocity'].copy()
|
||||
module.cuda.memcpy_htod.reset_mock()
|
||||
@@ -79,7 +79,7 @@ class ShiftedJets(unittest.TestCase):
|
||||
for a,b in zip(before.values,run._download_raw().values):self.assertEqual(a.tobytes(),b.tobytes())
|
||||
clone=run.remap_finest(B,objects=objects)
|
||||
self.assertEqual(clone.jets[2][0].amplitude,-jet().amplitude)
|
||||
np.testing.assert_array_equal(clone._device_boundaries[2].tables['surface_velocity'],
|
||||
np.testing.assert_array_equal(clone._boundaries[2].tables['surface_velocity'],
|
||||
boundary.tables['surface_velocity'])
|
||||
self.assertEqual(clone.step_count,7)
|
||||
saved=run._download_raw()
|
||||
@@ -92,12 +92,12 @@ class ShiftedJets(unittest.TestCase):
|
||||
def test_invalid_amplitude_does_not_upload_or_commit(self):
|
||||
run,module,_,_,_=ready()
|
||||
try:
|
||||
before=run._device_boundaries[2].tables['surface_velocity'].copy()
|
||||
before=run._boundaries[2].tables['surface_velocity'].copy()
|
||||
module.cuda.memcpy_htod.reset_mock()
|
||||
for amplitude in (float('nan'),float('inf'),1.):
|
||||
with self.assertRaises(ValueError):run.set_jet_amplitude(0,amplitude,level=2)
|
||||
module.cuda.memcpy_htod.assert_not_called()
|
||||
np.testing.assert_array_equal(before,run._device_boundaries[2].tables['surface_velocity'])
|
||||
np.testing.assert_array_equal(before,run._boundaries[2].tables['surface_velocity'])
|
||||
finally:run.close()
|
||||
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ class SingleLevel(unittest.TestCase):
|
||||
|
||||
def test_circle_solids_are_not_state(self):
|
||||
run, _, _ = backend((circle(),))
|
||||
solid = run._device_boundaries[0].tables["solid"]
|
||||
solid = run._boundaries[0].tables["solid"]
|
||||
self.assertTrue(solid.any())
|
||||
np.testing.assert_array_equal(run._state[0], ~solid)
|
||||
|
||||
|
||||
@@ -294,8 +294,11 @@ def test_passive_overlap_failure_keeps_live_state(device, entry, second_x):
|
||||
sim = SimpleNamespace(bodies=bodies, field=field, lbm_cfg=field.cfg,
|
||||
_initialized=entry == "runtime_edit",
|
||||
_assert_allocation_contract=lambda: None,
|
||||
stream=SimpleNamespace(synchronize=device.synchronize))
|
||||
sim._require_usable = Simulation._require_usable.__get__(sim)
|
||||
stream=SimpleNamespace(synchronize=device.synchronize),
|
||||
stepper=SimpleNamespace(blocked=None))
|
||||
for name in ("_require_usable", "_moving_bodies", "_reject_moving_bodies",
|
||||
"_validate_moving_bodies", "_attach_moving"):
|
||||
setattr(sim, name, getattr(Simulation, name).__get__(sim))
|
||||
with pytest.raises(ValueError, match="active fluid receivers|unique cut-link writers"):
|
||||
if entry == "sync":
|
||||
bodies.sync_to_gpu(field)
|
||||
|
||||
@@ -37,7 +37,7 @@ class UnionBodySupport(unittest.TestCase):
|
||||
run.set_jet_amplitude(0,.017,level=2)
|
||||
clone=run.remap_finest(((16,16,24,40),(24,16,32,40),A[1]),objects=objects)
|
||||
try:
|
||||
self.assertEqual(clone._device_boundaries[2].jets[0].amplitude,.017)
|
||||
self.assertEqual(clone._boundaries[2].jets[0].amplitude,.017)
|
||||
self.assertTrue(same_physical_boundary(run.boundaries[2],clone.boundaries[2]))
|
||||
for a,b in zip(run.download(),clone.download()):np.testing.assert_array_equal(a,b)
|
||||
finally:clone.close()
|
||||
|
||||
@@ -240,7 +240,7 @@ class SingleLevelGPU(unittest.TestCase):
|
||||
with CudaContext(0) as context:
|
||||
for split in SPLITS:
|
||||
with single(context=context, block=split, objects=[circle()]) as backend:
|
||||
tables = backend._device_boundaries[0].tables
|
||||
tables = backend._boundaries[0].tables
|
||||
for key, values, dtype in (("q", [l.q for l in links], np.float32),
|
||||
("dir", [l.dir for l in links], np.uint8),
|
||||
("fallback", [l.fallback for l in links], np.uint8)):
|
||||
|
||||
@@ -59,7 +59,7 @@ class ExchangeWindowCPU(unittest.TestCase):
|
||||
stack.enter_context(patch.object(cuda, "memcpy_dtoh",
|
||||
side_effect=lambda out, src: out.fill(0)))
|
||||
backend = build(m)
|
||||
b = backend._device_boundaries[1]
|
||||
b = backend._boundaries[1]
|
||||
self.assertIsNotNone(b.window_integral)
|
||||
with self.assertRaises(RuntimeError):
|
||||
backend.start_exchange_window() # not initialized
|
||||
@@ -96,7 +96,7 @@ class ExchangeWindowCPU(unittest.TestCase):
|
||||
with mocked_backend() as m, ExitStack() as stack:
|
||||
stack.enter_context(patch.object(cuda, "memset_d8_async"))
|
||||
backend = build(m, window=False)
|
||||
self.assertIsNone(backend._device_boundaries[1].window_integral)
|
||||
self.assertIsNone(backend._boundaries[1].window_integral)
|
||||
backend.initialize(*[np.full(l.shape, 1 / 9, np.float32) for l in backend._levels])
|
||||
backend.step(1)
|
||||
self.assertEqual(len(accumulations(m)), 2)
|
||||
@@ -120,7 +120,7 @@ class ExchangeWindowGPU(unittest.TestCase):
|
||||
windowed.step(2)
|
||||
plain.step(2)
|
||||
windowed.start_exchange_window()
|
||||
b = windowed._device_boundaries[1]
|
||||
b = windowed._boundaries[1]
|
||||
count = len(b.tables["cell"])
|
||||
factors = np.array([.25, .25, .125, .25]) # h*dt, h*h*dt, h*h for level-1 h=.5
|
||||
scale = np.zeros(4)
|
||||
@@ -212,7 +212,7 @@ class ExchangeWindowShiftedMRTGPU(unittest.TestCase):
|
||||
run.step(2)
|
||||
if run is not plain:
|
||||
run.start_exchange_window()
|
||||
b = windowed._device_boundaries[1]
|
||||
b = windowed._boundaries[1]
|
||||
count = len(b.tables["cell"])
|
||||
record = dict(scale=np.zeros(4), substeps=0)
|
||||
audit_substeps(windowed, b, count, record)
|
||||
@@ -228,7 +228,7 @@ class ExchangeWindowShiftedMRTGPU(unittest.TestCase):
|
||||
same_bits(self, a, e)
|
||||
rows = [np.empty((count, 4), np.float64) for _ in range(2)]
|
||||
cuda.memcpy_dtoh(rows[0], b.window_integral)
|
||||
cuda.memcpy_dtoh(rows[1], split._device_boundaries[1].window_integral)
|
||||
cuda.memcpy_dtoh(rows[1], split._boundaries[1].window_integral)
|
||||
np.testing.assert_array_equal(rows[0].view(np.uint64), rows[1].view(np.uint64))
|
||||
|
||||
out = windowed.read_exchange_window(0, level=1)
|
||||
|
||||
@@ -57,7 +57,7 @@ def make(context):
|
||||
def topology(run, rectangles, objects):
|
||||
return RemapTopology.from_config(
|
||||
CFG, run.hierarchy.pairs[0], rectangles, child_objects=objects,
|
||||
child_jets=run._device_boundaries[2].jets,
|
||||
child_jets=run._boundaries[2].jets,
|
||||
)
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ class MultiplePinnedRemapGPU(unittest.TestCase):
|
||||
def change_amplitudes(self, run, amplitudes):
|
||||
# Each jet selects a different body. Updating one must preserve the
|
||||
# other body's current per-link velocities, including its driven links.
|
||||
boundary = run._device_boundaries[2]
|
||||
boundary = run._boundaries[2]
|
||||
body_ids = run.boundaries[2].curved_host[3]
|
||||
for index, amplitude in enumerate(amplitudes):
|
||||
before = boundary.tables['surface_velocity'].copy()
|
||||
@@ -91,7 +91,7 @@ class MultiplePinnedRemapGPU(unittest.TestCase):
|
||||
self.assertTrue(same_physical_boundary(new.boundaries[2], mesh.boundary))
|
||||
# Compare physical cut-link rows, never layout-dependent blocked addresses.
|
||||
for run in (old, new):
|
||||
boundary = run._device_boundaries[2]
|
||||
boundary = run._boundaries[2]
|
||||
self.assertEqual(tuple(jet.amplitude for jet in boundary.jets), amplitudes)
|
||||
exact(self, boundary.tables['surface_velocity'], mesh.surface_velocity,
|
||||
'latest physical per-link velocities')
|
||||
|
||||
@@ -39,9 +39,9 @@ class PinnedBodyRemapGPU(unittest.TestCase):
|
||||
exact(self,getattr(after.boundaries[2],name),getattr(before.boundaries[2],name),name)
|
||||
for a,b in zip(after.boundaries[2].curved_host,before.boundaries[2].curved_host):
|
||||
exact(self,a,b,'physical cut links')
|
||||
exact(self,after._device_boundaries[2].tables['surface_velocity'],
|
||||
before._device_boundaries[2].tables['surface_velocity'],'current per-link velocities')
|
||||
self.assertEqual(after._device_boundaries[2].jets[0].amplitude,.017)
|
||||
exact(self,after._boundaries[2].tables['surface_velocity'],
|
||||
before._boundaries[2].tables['surface_velocity'],'current per-link velocities')
|
||||
self.assertEqual(after._boundaries[2].jets[0].amplitude,.017)
|
||||
|
||||
def test_noop_next_step_bitwise(self):
|
||||
with CudaContext(0) as ctx, ExitStack() as owned:
|
||||
@@ -63,7 +63,7 @@ class PinnedBodyRemapGPU(unittest.TestCase):
|
||||
current.set_jet_amplitude(0,.017,level=2)
|
||||
for rects in (B,A):
|
||||
previous=current;before=previous.download();count=previous.step_count
|
||||
jets=previous._device_boundaries[2].jets
|
||||
jets=previous._boundaries[2].jets
|
||||
old=RemapTopology.from_config(CFG,previous.hierarchy.pairs[0],previous.hierarchy.pairs[1].bounds,
|
||||
child_objects=objects,child_jets=jets)
|
||||
new=RemapTopology.from_config(CFG,previous.hierarchy.pairs[0],rects,
|
||||
|
||||
@@ -105,7 +105,7 @@ class RawExchangeGPU(unittest.TestCase):
|
||||
arrays = [state(l.shape) for l in measured._levels]
|
||||
measured.initialize(*arrays)
|
||||
plain.initialize(*arrays)
|
||||
b = measured._device_boundaries[1]
|
||||
b = measured._boundaries[1]
|
||||
count = len(b.tables['cell'])
|
||||
kernel = measured._levels[1].kernel
|
||||
samples = []
|
||||
@@ -158,7 +158,7 @@ class RawExchangeGPU(unittest.TestCase):
|
||||
FixedPatchStepper(cfg, context=ctx, **settings) as plain:
|
||||
arrays = [state(l.shape, ux=0.) for l in run._levels]
|
||||
run.initialize(*arrays); plain.initialize(*arrays)
|
||||
b = run._device_boundaries[3]
|
||||
b = run._boundaries[3]
|
||||
count = len(b.tables['cell'])
|
||||
kernel = run._levels[3].kernel
|
||||
samples, independent_samples = [], []
|
||||
@@ -213,7 +213,7 @@ class RawExchangeGPU(unittest.TestCase):
|
||||
FixedPatchStepper(CFG, context=ctx, **fine((16, 20)), observe_raw_exchange=True) as amr:
|
||||
amr.initialize(*(state(l.shape) for l in amr._levels))
|
||||
uniform.initialize(state((9, 48, 64)))
|
||||
b = amr._device_boundaries[1]
|
||||
b = amr._boundaries[1]
|
||||
count = len(b.tables['cell'])
|
||||
links = obj.geometry.build_cut_links(64, 48)
|
||||
np.testing.assert_array_equal([v.dir for v in links], b.tables['dir'])
|
||||
@@ -221,7 +221,7 @@ class RawExchangeGPU(unittest.TestCase):
|
||||
# Same fine-grid force arms after translation to the same center: the
|
||||
# single level's static BFL gets the AMR Jet column and force arms; the raw
|
||||
# output pointer is substituted at launch (no stepper-side accumulation).
|
||||
root = uniform._device_boundaries[0]
|
||||
root = uniform._boundaries[0]
|
||||
velocity = uniform._upload(b.tables['surface_velocity'])
|
||||
root.device.update(surface_velocity=velocity, rx=uniform._upload(b.tables['rx']),
|
||||
ry=uniform._upload(b.tables['ry']))
|
||||
|
||||
@@ -187,8 +187,8 @@ class BlockedFineCPU(unittest.TestCase):
|
||||
backend, calls = self.mocked_backend([circle(spec)])
|
||||
c, f = backend._levels
|
||||
coarse, cur, spare = c.current, f.current, f.spare
|
||||
flags, kind = backend._device_boundaries[1].flags, backend._levels[1].kind
|
||||
velocity = backend._device_boundaries[1].device.get("surface_velocity")
|
||||
flags, kind = backend._boundaries[1].flags, backend._levels[1].kind
|
||||
velocity = backend._boundaries[1].device.get("surface_velocity")
|
||||
self.assertEqual(velocity is None, spec is None)
|
||||
backend.step(1)
|
||||
backend.close()
|
||||
@@ -213,8 +213,8 @@ class BlockedFineCPU(unittest.TestCase):
|
||||
def test_actuation_without_velocity_buffer_fails_before_bfl(self):
|
||||
for speed in (0.0, .002): # any spec, even zero amplitude
|
||||
backend, calls = self.mocked_backend([circle(actuation(speed))])
|
||||
self.assertIsNotNone(backend._device_boundaries[1].tables["surface_velocity"])
|
||||
del backend._device_boundaries[1].device["surface_velocity"]
|
||||
self.assertIsNotNone(backend._boundaries[1].tables["surface_velocity"])
|
||||
del backend._boundaries[1].device["surface_velocity"]
|
||||
with self.assertRaisesRegex(RuntimeError, "Surface velocity device buffer is missing"):
|
||||
backend.step(1)
|
||||
names = [name for name, _ in calls]
|
||||
|
||||
@@ -62,8 +62,8 @@ class JetStepperCPU(unittest.TestCase):
|
||||
C = (16.0, 12.0)
|
||||
whole = Jet(Sector(C, .1, math.pi, 3.), Radial(C, inward=False), amplitude=0.)
|
||||
backend, _ = mocked([circle()], [whole], FINE_BLOCKS[1])
|
||||
_, jet_id = backend._device_boundaries[1].jet_cache
|
||||
self.assertGreater(len(np.unique(backend._device_boundaries[1].tables["block"][jet_id == 0])), 1)
|
||||
_, jet_id = backend._boundaries[1].jet_cache
|
||||
self.assertGreater(len(np.unique(backend._boundaries[1].tables["block"][jet_id == 0])), 1)
|
||||
backend.close()
|
||||
|
||||
def test_fine_ownership_uses_remapped_physical_owner(self):
|
||||
@@ -76,10 +76,10 @@ class JetStepperCPU(unittest.TestCase):
|
||||
source = SimObject(99, geometry=CircleGeometry(*C, 2.15), center=C, radius=2.15)
|
||||
whole = Jet(Sector(C, .1, math.pi, 3.), Radial(C, inward=False), amplitude=0.)
|
||||
backend, _ = mocked([source], [whole], FINE_BLOCKS[1])
|
||||
_, jet_id = backend._device_boundaries[1].jet_cache
|
||||
_, jet_id = backend._boundaries[1].jet_cache
|
||||
owners = backend.boundaries[1].curved_host[3][jet_id == 0]
|
||||
self.assertEqual(np.unique(owners).tolist(), [0])
|
||||
self.assertGreater(len(np.unique(backend._device_boundaries[1].tables["block"][jet_id == 0])), 1)
|
||||
self.assertGreater(len(np.unique(backend._boundaries[1].tables["block"][jet_id == 0])), 1)
|
||||
self.assertEqual(source.obj_id, 99)
|
||||
backend.close()
|
||||
cfg = config("periodic")
|
||||
@@ -97,8 +97,8 @@ class JetStepperCPU(unittest.TestCase):
|
||||
def test_blocked_jet_velocity_matches_legacy_rows(self):
|
||||
legacy, _ = mocked([circle(actuation(SPEED))], (), FINE_BLOCKS[1])
|
||||
driven, _ = mocked([circle()], [jet()], FINE_BLOCKS[1])
|
||||
a = legacy._device_boundaries[1].tables["surface_velocity"]
|
||||
b = driven._device_boundaries[1].tables["surface_velocity"]
|
||||
a = legacy._boundaries[1].tables["surface_velocity"]
|
||||
b = driven._boundaries[1].tables["surface_velocity"]
|
||||
diff = np.abs(a - b).max()
|
||||
print(f"fine jet vs legacy velocity rows: max |dU| = {diff:.3e} (exact={diff == 0})")
|
||||
self.assertLessEqual(diff, 4 * np.finfo(np.float32).eps * SPEED)
|
||||
@@ -106,8 +106,8 @@ class JetStepperCPU(unittest.TestCase):
|
||||
|
||||
def test_blocked_amplitude_update_rewrites_only_velocity_column(self):
|
||||
backend, htod = mocked([circle()], [jet()], FINE_BLOCKS[1])
|
||||
before = backend._device_boundaries[1].tables["surface_velocity"].copy()
|
||||
device = backend._device_boundaries[1].device["surface_velocity"]
|
||||
before = backend._boundaries[1].tables["surface_velocity"].copy()
|
||||
device = backend._boundaries[1].device["surface_velocity"]
|
||||
with patch.object(cuda, "memcpy_htod", htod):
|
||||
htod.reset_mock()
|
||||
backend.set_jet_amplitude(0, -SPEED, level=1)
|
||||
@@ -115,7 +115,7 @@ class JetStepperCPU(unittest.TestCase):
|
||||
self.assertEqual(htod.call_count, 1)
|
||||
self.assertIs(ptr, device)
|
||||
np.testing.assert_allclose(sent, -before, rtol=1e-6, atol=0)
|
||||
np.testing.assert_array_equal(backend._device_boundaries[1].tables["surface_velocity"], sent)
|
||||
np.testing.assert_array_equal(backend._boundaries[1].tables["surface_velocity"], sent)
|
||||
with self.assertRaises(ValueError):
|
||||
backend.set_jet_amplitude(0, 1.5, level=1)
|
||||
self.assertEqual(htod.call_count, 1)
|
||||
@@ -133,7 +133,7 @@ class JetStepperCPU(unittest.TestCase):
|
||||
with self.assertRaises(IndexError):
|
||||
backend.set_jet_amplitude(1, -SPEED, level=1)
|
||||
self.assertEqual(backend._status, status)
|
||||
before = backend._device_boundaries[1].tables["surface_velocity"].copy()
|
||||
before = backend._boundaries[1].tables["surface_velocity"].copy()
|
||||
boom = Mock(side_effect=RuntimeError("copy failed"))
|
||||
with patch.object(cuda, "memcpy_htod", boom), patch.object(cuda, "memcpy_htod_async", boom), \
|
||||
patch.object(cuda, "pagelocked_empty", side_effect=lambda shape, dtype: np.empty(shape, dtype)):
|
||||
@@ -141,7 +141,7 @@ class JetStepperCPU(unittest.TestCase):
|
||||
backend.set_jet_amplitude(0, -SPEED, level=1)
|
||||
self.assertEqual(backend._status, "failed", fine_block)
|
||||
self.assertEqual(backend.jets[1][0].amplitude, SPEED)
|
||||
np.testing.assert_array_equal(backend._device_boundaries[1].tables["surface_velocity"], before)
|
||||
np.testing.assert_array_equal(backend._boundaries[1].tables["surface_velocity"], before)
|
||||
with self.assertRaisesRegex(RuntimeError, "failed"):
|
||||
backend.step(1)
|
||||
with self.assertRaisesRegex(RuntimeError, "failed"):
|
||||
|
||||
@@ -102,7 +102,7 @@ class Ledger:
|
||||
|
||||
def step(self):
|
||||
run=self.run;self.samples.clear();run.step()
|
||||
b=run._device_boundaries[self.index];h=run._exchange_spacing[self.index]
|
||||
b=run._boundaries[self.index];h=b.spacing
|
||||
self.test.assertEqual(len(self.samples),2**self.index)
|
||||
expected=np.zeros_like(self.samples[0],dtype=np.float64)
|
||||
weights=np.array([h*h,h*h,h*h*h,h*h])
|
||||
@@ -140,7 +140,7 @@ class ShiftedJetsGPU(unittest.TestCase):
|
||||
level_objects=[(),(obj,)],level_jets=[(),(whole,)],observe_raw_exchange=True) as run:
|
||||
run._initialize_raw(initial(run,rho,u=.013,v=0.))
|
||||
expected=run._download_raw();ledger=Ledger(self,run,1)
|
||||
self.assertTrue(run._device_boundaries[1].tables['aperture'].all())
|
||||
self.assertTrue(run._boundaries[1].tables['aperture'].all())
|
||||
for _ in range(4):ledger.step()
|
||||
error=max(float(abs(a[:,leaf]-b[:,leaf]).max()) for a,b,leaf in
|
||||
zip(run._download_raw().values,expected.values,run.leaf))
|
||||
@@ -201,7 +201,7 @@ class ShiftedJetsGPU(unittest.TestCase):
|
||||
self.exact_raw(before,old._download_raw())
|
||||
self.exact_raw(_StoredPopulations(expected.g,True),new._download_raw())
|
||||
self.assertEqual(new.jets[2][0].amplitude,-.001)
|
||||
np.testing.assert_array_equal(new._device_boundaries[2].tables['surface_velocity'],old._device_boundaries[2].tables['surface_velocity'])
|
||||
np.testing.assert_array_equal(new._boundaries[2].tables['surface_velocity'],old._boundaries[2].tables['surface_velocity'])
|
||||
newledger=Ledger(self,new,2);ledger.step();newledger.step()
|
||||
self.assertEqual((old.step_count,new.step_count),(count+1,count+1))
|
||||
ledger.report('L2 source');newledger.report('L2 remapped')
|
||||
|
||||
Reference in New Issue
Block a user