Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f8fdbc30e | ||
|
|
e4538219b2 | ||
|
|
888c80381c | ||
|
|
bf5088f84c | ||
|
|
af406b44bf | ||
|
|
78c0f1811b | ||
|
|
0ddc6c3d62 | ||
|
|
43e226c59a | ||
|
|
93e692a40f | ||
|
|
33837697da | ||
|
|
b47af12c9d | ||
|
|
a3df5855a2 | ||
|
|
11e96a188b | ||
|
|
3a374faace | ||
|
|
fce24a64e8 | ||
|
|
d437faa501 | ||
|
|
26e49451ec | ||
|
|
e214480452 | ||
|
|
c414f6c7f6 | ||
|
|
decd419e94 | ||
|
|
1c47cb8570 | ||
|
|
ee7074a53d | ||
|
|
b97fc79af9 | ||
|
|
8e9fc72e19 | ||
|
|
d2cd1114ed | ||
|
|
c95a7baae4 | ||
|
|
f4372a84c0 | ||
|
|
e48498d089 | ||
|
|
531739454e | ||
|
|
b93de2ce24 | ||
|
|
a454f02355 | ||
|
|
580fcf6525 | ||
|
|
18f018befe | ||
|
|
ec9b3d721c | ||
|
|
84b76f49cc | ||
|
|
050d12b76c | ||
|
|
ec3e3b928a | ||
|
|
2be80324ad | ||
|
|
0fcd4ac4cb | ||
|
|
af0566db98 | ||
|
|
0b1b3d938f | ||
|
|
5167c79378 | ||
|
|
aedddc35b1 | ||
|
|
1cbdc479cf | ||
|
|
7821c6ad71 | ||
|
|
392314e1ae | ||
|
|
f3f1e3d588 | ||
|
|
3648ad70ef | ||
|
|
5399c9c0a6 | ||
|
|
54be33e006 | ||
|
|
1fa498c7d7 | ||
|
|
781333407e | ||
|
|
750e8a483e | ||
|
|
6a03f463bc | ||
|
|
46dc6ba086 | ||
|
|
e1efc18274 | ||
|
|
9660894a1b | ||
|
|
ba3772e3b8 | ||
|
|
b4692e6ec4 | ||
|
|
5731c5171b | ||
|
|
05de02ee60 | ||
|
|
87c3a3165e | ||
|
|
eec4677db1 | ||
|
|
2cf9853952 | ||
|
|
1aa8110ec8 | ||
|
|
a135252ae7 | ||
|
|
ed9d8c937d |
@@ -112,8 +112,9 @@ from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
|
||||
cfg = LBMConfig(nx=32, ny=24, viscosity=.1)
|
||||
body = SimObject(0, geometry=CircleGeometry(16.0, 12.0, 2.15), center=(16.0, 12.0), radius=2.15,
|
||||
surface_actuation=SurfaceActuation(0.7, 0.85, 0.002)) # angle, half-width, speed
|
||||
with CudaContext(0) as ctx, FixedPatchStepper(cfg, (8, 6, 24, 18), context=ctx,
|
||||
fine_objects=[body], fine_block=(16, 20)) as run:
|
||||
with CudaContext(0) as ctx, FixedPatchStepper(cfg, context=ctx, refinements=[(8, 6, 24, 18)],
|
||||
level_blocks=[None, (16, 20)],
|
||||
level_objects=[(), (body,)]) as run:
|
||||
rest = np.asarray(compute_feq_d2q9(1.0, 0.0, 0.0), np.float32)[:, None, None]
|
||||
# (9, ny, nx) postcollision populations per level; non-leaf cells are discarded.
|
||||
run.initialize(np.broadcast_to(rest, (9, *run.patch.coarse_shape)),
|
||||
@@ -123,7 +124,9 @@ with CudaContext(0) as ctx, FixedPatchStepper(cfg, (8, 6, 24, 18), context=ctx,
|
||||
```
|
||||
|
||||
Scope and limits:
|
||||
- D2Q9, SRT, FP32, double buffer, unshifted, no LES or forcing; fixed 2:1 patch(es), two levels (or one nested third).
|
||||
- The examples below use D2Q9, SRT, FP32, double buffer, unshifted, no LES or
|
||||
forcing. Fixed 2:1 hierarchies use level lists of arbitrary depth; two- and
|
||||
three-level examples below use that same constructor.
|
||||
- `coarse_boundary="periodic"` (default) or `"slot_reservoir"` (bottom slot, top
|
||||
reservoir; patch needs `y0 >= 4`, `y1 <= ny-4`). Periodic mode ignores the wall/slot config fields.
|
||||
- Object centers/radii use the coarse **cell-edge** frame (cell `I` spans `[I, I+1)`),
|
||||
@@ -131,46 +134,51 @@ Scope and limits:
|
||||
- Bodies must be stationary circles whose whole boundary stencil lies in the fine
|
||||
active region, clear of the coarse/fine transfer cells; violations are rejected
|
||||
before any GPU allocation.
|
||||
- `fine_block=(by, bx)` stores the fine level in fixed-size blocks; `by`/`bx` must
|
||||
- `level_blocks=[None, (by, bx)]` stores the fine level in fixed-size blocks; `by`/`bx` must
|
||||
divide the WHOLE fine allocation, i.e. `2*(y1-y0)+8` by `2*(x1-x0)+8` including the
|
||||
four-cell ghost band. It gives results identical to dense storage and is
|
||||
infrastructure for future block AMR, not a speedup.
|
||||
- Several separated patches: pass a sequence of `(x0, y0, x1, y1)`
|
||||
rectangles as `bounds`, at least 4 coarse cells apart, with both `fine_block`
|
||||
and `base_block`. The fine level is then one level-wide grid `(2*ny, 2*nx)`:
|
||||
- Several regions: pass a sequence of `(x0, y0, x1, y1)` rectangles as
|
||||
`refinements[0]`, with both levels blocked (`level_blocks`). Transfers are built once
|
||||
from their union; adjacent, overlapping and concave unions are accepted when
|
||||
their actual dependencies close. There is no fixed rectangle-gap rule.
|
||||
The fine level is then one level-wide grid `(2*ny, 2*nx)`:
|
||||
`run.patch.fine_shape` gives it, `initialize`/`download` use it, and only the
|
||||
patch windows (active region plus 4-cell ghost band, origin `(2*y0-4, 2*x0-4)`)
|
||||
are stored; each window must align with `fine_block`. Order of rectangles does
|
||||
not matter. `fine_objects`/`jets` work as for one patch: each circle (same
|
||||
coarse cell-edge frame) must lie wholly inside one patch's active region with
|
||||
grid-aligned tiles intersecting the union plus its four-cell ghost band are stored.
|
||||
Block sizes must divide the level-wide grid; rectangle edges need not align
|
||||
with tiles. Order of rectangles does not matter. L1 bodies/Jets (`level_objects[1]`/`level_jets[1]`) work
|
||||
as for one patch: each circle (same coarse cell-edge frame) must lie wholly
|
||||
inside the union's active region with
|
||||
its boundary stencil clear of transfer cells; a jet may not select links of two
|
||||
different bodies, even across patches. No wall-touching patches or nesting yet.
|
||||
different bodies, even across regions. Wall-touching patches remain unsupported.
|
||||
For nesting, add further `refinements` entries as shown below.
|
||||
|
||||
```python
|
||||
cfg = LBMConfig(nx=32, ny=16, viscosity=.1)
|
||||
rest = np.asarray(compute_feq_d2q9(1.0, 0.0, 0.0), np.float32)[:, None, None]
|
||||
with CudaContext(0) as ctx, FixedPatchStepper(
|
||||
cfg, ((4, 4, 12, 12), (16, 4, 24, 12)), context=ctx,
|
||||
fine_block=(4, 4), base_block=(4, 4)) as run:
|
||||
cfg, context=ctx, refinements=[((4, 4, 12, 12), (16, 4, 24, 12))],
|
||||
level_blocks=[(4, 4), (4, 4)]) as run:
|
||||
run.initialize(np.broadcast_to(rest, (9, *run.patch.coarse_shape)),
|
||||
np.broadcast_to(rest, (9, *run.patch.fine_shape))) # (9, 32, 64)
|
||||
run.step(32)
|
||||
coarse_g, fine_g = run.download() # coarse: NaN on every covered cell;
|
||||
# fine: NaN on every non-ACTIVE cell
|
||||
```
|
||||
- Three levels (one nested chain): add `child=(x0, y0, x1, y1)` in
|
||||
**L1 window cells** (the patch's fine grid including its 4-cell ghost band) and
|
||||
`child_block`, with `fine_block` and `base_block`. L2 must sit inside L1's
|
||||
ACTIVE cells, away from L1's ghost band and transfer footprint (checked before
|
||||
any GPU allocation). `initialize` and `download` take/return three arrays; each
|
||||
level is NaN on its non-ACTIVE (and child-covered) cells. One coarse step = 2 L1
|
||||
steps = 4 L2 steps. One L1 patch only. Optional stationary circles and Jets
|
||||
live on L2 via `child_objects` and `child_jets`; `fine_objects`/`jets` retain
|
||||
their L1 meaning and must be empty in this mode. All object and Jet geometry
|
||||
uses the **L0 edge frame**, not the child bounds' L1 window frame. L2 has
|
||||
dx=dt=0.25, so prescribed lattice velocity is unchanged. Each whole body and
|
||||
its BFL stencil must be inside L2 ACTIVE cells, outside the transfer footprint.
|
||||
A Jet may select one physical body across multiple blocks, never two bodies.
|
||||
- Three-level nested windows: pass two scalar rectangles in `refinements` —
|
||||
L1 in L0 cells, L2 in **L1 window cells** (the parent's fine grid including its
|
||||
4-cell ghost band) — plus all three `level_blocks` entries. L2 must sit inside
|
||||
L1's ACTIVE cells, away from L1's ghost band and transfer footprint (checked
|
||||
before any GPU allocation). `initialize` and `download` take/return three
|
||||
arrays; each level is NaN on its non-ACTIVE (and child-covered) cells. One
|
||||
coarse step = 2 L1 steps = 4 L2 steps. One L1 window only in this scalar
|
||||
chain. Optional stationary circles and Jets live on L2 via `level_objects[2]`
|
||||
and `level_jets[2]`; L1 bodies/Jets retain their L1 meaning and must be empty
|
||||
in this mode. All object and Jet geometry uses the **L0 edge frame**, not the
|
||||
child bounds' L1 window frame. L2 has dx=dt=0.25, so prescribed lattice
|
||||
velocity is unchanged. Each whole body and its BFL stencil must be inside L2
|
||||
ACTIVE cells, outside the transfer footprint. A Jet may select one physical
|
||||
body across multiple blocks, never two bodies.
|
||||
|
||||
```python
|
||||
from CelerisLab.body.geometry.circle import CircleGeometry
|
||||
@@ -182,9 +190,9 @@ Scope and limits:
|
||||
jet = Jet(Tube((6.5, 8.), 0., 3., .3), Parallel(0.), amplitude=.002)
|
||||
rest = np.asarray(compute_feq_d2q9(1.0, 0.0, 0.0), np.float32)[:, None, None]
|
||||
with CudaContext(0) as ctx, FixedPatchStepper(
|
||||
cfg, (4, 4, 12, 12), context=ctx, fine_block=(4, 4), base_block=(4, 4),
|
||||
child=(8, 8, 16, 16), child_block=(4, 4),
|
||||
child_objects=[body], child_jets=[jet]) as run:
|
||||
cfg, context=ctx, refinements=[(4, 4, 12, 12), (8, 8, 16, 16)],
|
||||
level_blocks=[(4, 4)] * 3, level_objects=[(), (), (body,)],
|
||||
level_jets=[(), (), (jet,)]) as run:
|
||||
shapes = (run.patch.coarse_shape, run.patch.fine_shape, run.child_patch.fine_shape)
|
||||
run.initialize(*(np.broadcast_to(rest, (9, *s)) for s in shapes)) # 16², 24², 24²
|
||||
run.step(4)
|
||||
@@ -192,14 +200,30 @@ Scope and limits:
|
||||
run.step(4)
|
||||
coarse_g, l1_g, l2_g = run.download() # solids/covered/nonleaves are NaN
|
||||
```
|
||||
- Not available: per-link surface exchange/force readout in blocked mode,
|
||||
checkpoints, moving bodies, L1 bodies/Jets in a three-level chain,
|
||||
several patches per level with nesting, dynamic refinement, `Simulation`
|
||||
integration. Validation so far is storage/operator equivalence and short runs,
|
||||
not accuracy, convergence or long-time stability. The new L2-body GPU gates
|
||||
(layouts, masks, amplitude reversal, legacy equivalence) and independent
|
||||
every-substep numerical replay remain required acceptance checks; CPU tests
|
||||
alone do not establish GPU numerical correctness.
|
||||
- Level lists (the only constructor): `refinements[l]` describes level `l+1`
|
||||
in allocated level-`l` integer cells; use a rectangle list for a union, or a
|
||||
scalar rectangle for a single window (a scalar child requires a scalar
|
||||
parent). `level_blocks`, `level_objects` and `level_jets` each have one entry
|
||||
per level, including L0. Three or more levels require all-blocked storage and
|
||||
bodies/Jets only on the finest level. Every adjacent pair must preserve
|
||||
inherited ACTIVE coverage, valid donors/receivers, surviving pulls and the
|
||||
upstream transfer footprint. Level `l` takes `2**l` steps per base interval.
|
||||
This describes implemented topology and scheduling, not accuracy or
|
||||
convergence qualification.
|
||||
- Blocked body levels can enable `observe_raw_exchange=True` and call
|
||||
`read_raw_exchange(body_id, level=...)` after a completed base interval.
|
||||
It reduces unique cut-link observations into wall/aperture/total force,
|
||||
torque and mass exchange in base-lattice units per unit depth, for the last
|
||||
base interval (default) or `window="last_substep"`.
|
||||
`observe_window_exchange=True`, alongside `observe_raw_exchange=True`, enables
|
||||
`start_exchange_window()` / `read_exchange_window(body_id, level=...)` across
|
||||
completed base intervals. These are raw boundary-reaction diagnostics, not a general
|
||||
stress or nozzle-load accuracy claim; dense body layouts are unsupported.
|
||||
- Not available: checkpoints, moving bodies, L1 bodies/Jets in a three-level
|
||||
chain, automatic dynamic refinement, `Simulation` integration. Qualification
|
||||
depends on the tested configuration; see [the validation scope](docs/validation/2d_scope.md).
|
||||
CPU topology/schedule tests alone do not establish GPU numerical correctness,
|
||||
general convergence or long-time stability.
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Compatibility and API changes
|
||||
|
||||
Changes that can make a previously accepted setup or call behave differently.
|
||||
Each entry names the commit, the affected entry points and the observable result.
|
||||
|
||||
## 87c3a31: cut-link ownership is validated for every body
|
||||
|
||||
`validate_cut_link_ownership` (`body/preprocess/flag_overlay.py`) now runs for
|
||||
passive bodies as well as Jet/aperture bodies. Every cut link must have an
|
||||
active fluid receiver (FLUID set, SOLID clear), and each `(receiver, direction)`
|
||||
population may have only one writer.
|
||||
|
||||
Rejected with `ValueError`, before flags, populations or device buffers change:
|
||||
|
||||
1. Overlapping or touching passive solids whose cut links write the same
|
||||
population ("Boundary requires unique cut-link writers").
|
||||
2. A body whose cut-link receiver is a non-fluid cell, e.g. a channel wall row
|
||||
or the inlet/outlet column ("Cut links must have active fluid receivers").
|
||||
|
||||
Both were previously accepted silently for passive bodies; the interpolated
|
||||
boundary then had undefined results. The rules apply to the cut links of all
|
||||
bodies together, so a passive body overlapping a Jet body is rejected as well.
|
||||
The check runs in `Simulation.initialize`, runtime body sync (`sync_bodies`),
|
||||
checkpoint save and checkpoint load (`common/checkpoint.py`). Load validates
|
||||
before restoring anything, so a checkpoint whose saved geometry violates either
|
||||
rule no longer restores; rebuild the geometry with separated bodies.
|
||||
|
||||
## 87c3a31: `Simulation.read_surface_exchange` accepts Jet-driven bodies
|
||||
|
||||
`read_surface_exchange(id)` previously required `SurfaceActuation` on the body.
|
||||
It now also accepts bodies that own Jet aperture links, including Jets with zero
|
||||
or negative amplitude (the aperture test is geometric). Bodies with neither
|
||||
`SurfaceActuation` nor aperture links still raise
|
||||
`ValueError("Body has no surface actuation")`. The value is unchanged: the latest
|
||||
step's wall/aperture exchange, not total nozzle force.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Experimental projected G explosion
|
||||
|
||||
`FixedPatchStepper(..., explosion="projected_linear")` changes only the existing
|
||||
G-to-G explosion reconstruction. The omitted/default `"constant"` selector retains
|
||||
the same kernel, launch arguments and device allocations. Projected mode adds two
|
||||
compact tables per adjacent pair; recursion, collision, BFL, restriction, buffer
|
||||
ownership and synchronized publication are unchanged. `remap_child` preserves it.
|
||||
|
||||
Runtime eligibility is periodic-root, fully blocked D2Q9/SRT/FP32, body/Jet-free,
|
||||
without LES; the fixed-patch backend has no forcing interface. Shifted and unshifted
|
||||
storage use the same reconstruction. One generic builder handles scalar windows,
|
||||
unique union rows and all adjacent hierarchy pairs without depth/rectangle caps.
|
||||
Donors are original explosion donors intersected with owned, uncovered ACTIVE
|
||||
cells. An axis uses centered differences only if both neighbors are legal;
|
||||
otherwise both addresses repeat the center. Nested layout periodicity is explicit.
|
||||
No covered cells, holes, inactive cells or stale halo copies enter the stencil.
|
||||
|
||||
The 33-line kernel is unchanged from our `519473a` experiment and retains its
|
||||
mathematical attribution. Host tests cover union decomposition, concavity, narrow
|
||||
gaps, grazing packet paths, offsets, sparse/nested owner addresses, three/four-level
|
||||
source timing, unchanged constant dispatch, remapping and shifted/unshifted actual
|
||||
kernel arithmetic compiled as host C++. These are not CUDA or flow validation.
|
||||
|
||||
## Reviewed bounded evidence
|
||||
|
||||
The `1aa8110` CUDA gate, with production exactly `a135252`, passed exact-dev
|
||||
omitted/default and explicit-constant equivalence, per-population reconstruction,
|
||||
scalar/union/four-level scheduling, alternate layouts, and changed remapping in
|
||||
both storage modes. Operator maximum error was 2.98023224e-8; the eight-step
|
||||
concave replay maximum was 1.58037614e-7. Whole-step mass/momentum remain
|
||||
finite diagnostics, not an FP32 conservation certificate. Reviewed evidence:
|
||||
`7aa66ff0542175092753ea099f40b903ece0527e`.
|
||||
|
||||
The fresh four-case `2cf9853` N128/256 Taylor–Green repeat retained all `11901a7`
|
||||
settings and gates. All 20 main scalar rows and 260 early-pressure samples matched
|
||||
the older receipts exactly; this is not field-array equality. Projected worst
|
||||
velocity was 0.001995299260844 versus 0.002 (only 0.235% margin), pressure
|
||||
0.012157174904932 and energy 0.001184362645162. Constant velocity
|
||||
0.004418076615223 failed its gate, so the aggregate verdict remains false.
|
||||
The projected early sampled pressure peak was 0.021621453573334; it remains a
|
||||
descriptive sampled maximum. Reviewed evidence: `90849a55cad4a0da9b72747da7bfe6eb75962a44`.
|
||||
|
||||
The old N64 projected failures remain relevant. These results support only this
|
||||
bounded plumbing gate and short periodic Re100 smooth-decay experiment. They do
|
||||
not establish union/multilevel physics accuracy, convergence, long-time stability,
|
||||
body/Jet, boundary, forcing, MRT or LES suitability. Constant remains the default.
|
||||
|
||||
## Separate LES source profile
|
||||
|
||||
The two older LES source manifests and the historical default are unchanged and
|
||||
correctly reject this different source tree. The existing LES runner now offers
|
||||
`--experiment a135252`, pinning all 101 actual committed corrected source files.
|
||||
Its explicit baseline-header override describes only a synthetic isolated reverse
|
||||
control, never the committed a135252 header. The unchanged corrected-only uniform
|
||||
LES regression (Cs=0/.16/1) is the remaining integration gate; no LES result is
|
||||
inherited from the projected evidence. The separate historical jet-CV `channel.py`
|
||||
pin mismatch predates this port and is not addressed here.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Blocked AMR root channel increment
|
||||
|
||||
Opt in with `FixedPatchStepper(..., coarse_boundary="channel", base_block=(by,bx), fine_block=(fy,fx))`, or the existing level-list form. Every level must be blocked. The supported boundary is the existing uniform D2Q9/SRT/FP32 double-buffer channel: local Zou-He west inlet, pressure-NEQ east outlet, stationary halfway bounce-back y walls. Uniform and parabolic inlet profiles and shifted/unshifted storage are supported; the existing configuration guards still reject MRT/LES and incompatible storage. This does not change the external DFG MRT reference.
|
||||
Opt in with `FixedPatchStepper(..., coarse_boundary="channel", refinements=[bounds], level_blocks=[(by,bx), (fy,fx)])`. Every level must be blocked. The supported boundary is the existing uniform D2Q9/SRT/FP32 double-buffer channel: local Zou-He west inlet, pressure-NEQ east outlet, stationary halfway bounce-back y walls. Uniform and parabolic inlet profiles and shifted/unshifted storage are supported; the existing configuration guards still reject MRT/LES and incompatible storage. This does not change the external DFG MRT reference.
|
||||
|
||||
`step/block_channel.cu` is a small root-only kernel. Fine kernels, transfer kernels and recursive hop scheduling are unchanged. Global coordinates and the global profile height/velocity are separate from block-local storage strides. Corner ownership and collision phases match `OneStep`:
|
||||
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
# Fixed three-level separated patches
|
||||
# Fixed three-level rectangle unions
|
||||
|
||||
`FixedPatchStepper` supports exactly three static, blocked levels with separated
|
||||
rectangle sets on either adjacent pair. This is not general AMR: there is no
|
||||
per-region timestep, moving geometry, dynamic refinement or L1 body support.
|
||||
This page describes three-level `FixedPatchStepper` setups built from the
|
||||
`refinements` level list, with rectangle unions on either adjacent pair. The
|
||||
same constructor supports arbitrary depth; three or more levels require
|
||||
all-blocked storage and bodies/Jets only on the finest level. This is not
|
||||
general AMR: there is no per-region timestep, moving geometry, automatic
|
||||
dynamic refinement or L1 body support in the three-level form.
|
||||
All regions share one current/spare buffer pair per level. The existing schedule
|
||||
advances L0 once, L1 twice and L2 four times per base step, with aggregate transfers.
|
||||
No CUDA kernel changes are involved.
|
||||
|
||||
## Explicit coordinate frames
|
||||
|
||||
Keep the existing scalar `child=(x0,y0,x1,y1)` for a single scalar parent patch.
|
||||
Its coordinates retain their original meaning: half-open integer indices in the
|
||||
allocated L1 window, including its ghost band.
|
||||
For a single scalar parent patch, pass a scalar rectangle as `refinements[1]`:
|
||||
`(x0,y0,x1,y1)` in half-open integer indices of the allocated L1 window,
|
||||
including its ghost band.
|
||||
|
||||
The new keyword `child_rectangles_in_parent=[...]` is mutually exclusive with
|
||||
`child`. It accepts a scalar or a set parent and interprets every rectangle in
|
||||
the actual allocated L1 array, including ghost bands and holes. It never infers
|
||||
region-local coordinates. The physical root cell edge corresponding to an L1
|
||||
index `(x,y)` is `parent.fine_origin + (x,y)/2`:
|
||||
A rectangle list as `refinements[1]` accepts a scalar or a set parent and
|
||||
interprets every rectangle in the actual allocated L1 array, including ghost
|
||||
bands and holes. It never infers region-local coordinates. The physical root
|
||||
cell edge corresponding to an L1 index `(x,y)` is
|
||||
`parent.fine_origin + (x,y)/2`:
|
||||
|
||||
- A scalar L1 parent has a shifted window origin.
|
||||
- A set L1 parent has origin `(0,0)` and public shape `(2*ny,2*nx)`.
|
||||
@@ -30,20 +33,23 @@ are physically equivalent to parent set `[(4,4,12,12)]` with child set
|
||||
`[(8,8,16,16)]`. Reusing the same numbers after changing the parent frame can
|
||||
move the physical region.
|
||||
|
||||
Both adjacent pairs require a Chebyshev gap of at least four in their own
|
||||
parent-cell units. The whole child union must preserve inherited ACTIVE
|
||||
coverage, uncovered ACTIVE donors/receivers, surviving pulls, and the upstream
|
||||
Both adjacent pairs are built from their unions, without a fixed rectangle-gap
|
||||
rule. Adjacent, overlapping and concave unions are accepted only when their
|
||||
actual transfer dependencies close. The whole child union must preserve
|
||||
inherited ACTIVE coverage, uncovered ACTIVE donors/receivers, surviving pulls, and the upstream
|
||||
restriction footprint. Sparse holes remain unowned; inherited INNER remains
|
||||
stream-only and OUTER remains invalid. Block sizes must align with every
|
||||
support edge, including its four-cell ghost band.
|
||||
stream-only and OUTER remains invalid. Block sizes must divide their allocated
|
||||
level grids. Sparse storage owns every grid-aligned tile intersecting the union
|
||||
plus its four-cell ghost band; rectangle and support edges need not align with
|
||||
tile edges.
|
||||
|
||||
## Example
|
||||
|
||||
```python
|
||||
run = FixedPatchStepper(
|
||||
cfg, [(4, 4, 20, 28), (24, 4, 44, 28)], context=ctx,
|
||||
base_block=(4, 4), fine_block=(4, 4), child_block=(4, 4),
|
||||
child_rectangles_in_parent=[(16, 16, 32, 40), (56, 16, 80, 40)],
|
||||
cfg, context=ctx,
|
||||
refinements=[[(4, 4, 20, 28), (24, 4, 44, 28)], [(16, 16, 32, 40), (56, 16, 80, 40)]],
|
||||
level_blocks=[(4, 4)] * 3,
|
||||
)
|
||||
```
|
||||
|
||||
@@ -51,7 +57,7 @@ This example uses a 48x32 root. `initialize` and `download` use three Q-major
|
||||
dense arrays. Public arrays and masks still scale with the full allocated
|
||||
extent; sparse device buffers do not remove that host-memory limit.
|
||||
|
||||
Stationary whole-circle `child_objects` and `child_jets` use root edge
|
||||
Stationary whole-circle L2 bodies and Jets (`level_objects[2]`, `level_jets[2]`) use root edge
|
||||
coordinates and a single L2 boundary object. Body IDs are physical object-list
|
||||
positions, not region IDs or caller `obj_id`. Complete boundary stencils must
|
||||
avoid the transfer footprint. Change a child Jet with
|
||||
@@ -65,6 +71,12 @@ CPU geometry, table, pre-CUDA rejection, schedule and cleanup tests:
|
||||
PYTHONPATH=src python -m unittest discover -s tests/unit -p 'test_refinement*.py' -v
|
||||
```
|
||||
|
||||
`tests/unit/test_union_refinement.py` covers union dependencies and tile
|
||||
ownership; `tests/unit/test_hierarchy.py` covers composed geometry through four
|
||||
levels. `tests/unit/test_raw_exchange.py` checks the four-level host schedule
|
||||
with CUDA mocks. These establish topology/control-flow coverage, not numerical
|
||||
qualification at every depth or interface shape.
|
||||
|
||||
Explicit GPU gates are in
|
||||
`tests/validation/multigrid/test_refinement_three_level_multipatch_gpu.py`.
|
||||
They cover physical-frame equivalence, twins, asymmetric refinement, multiple
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# Supported settings: 2D Jet + fixed multilevel refinement (candidate 888c8038)
|
||||
|
||||
Every entry names the guard that enforces it (file and function or kernel guard,
|
||||
at source revision 888c80381c006e976be2af8a452288729fc17c1b) and, where one
|
||||
exists, an existing test that exercises it. A setting not listed here is not
|
||||
claimed. Observations at the end are tied to the exact case in which they were
|
||||
measured; they are not general bounds.
|
||||
|
||||
## Lattice, storage, collision
|
||||
|
||||
| Setting | Supported | Guard |
|
||||
|---|---|---|
|
||||
| Dimension / lattice | 2D D2Q9 only (3D refinement is future work) | `lbm/refinement.py` `FixedPatch2D.from_config`; `#error` guards in `lbm/kernels/blocks.cu` and `lbm/kernels/refinement.cu` |
|
||||
| Precision / streaming | FP32 storage, double buffer | same guards |
|
||||
| Collision on refinement levels | SRT or MRT; TRT and LES rejected | `lbm/refinement.py` `FixedPatch2D.from_config` (re-checked for fine boundaries in `prepare_fine_boundary`) |
|
||||
| Shifted (ddf_shifting) storage | only when every level is blocked; dense or mixed rejected | `lbm/refinement_stepper.py` `FixedPatchStepper.__init__`, `FixedPatchStepper._check_storage`; `#error` guard in `lbm/kernels/refinement.cu` (dense patch kernel unshifted only) |
|
||||
| Fine relaxation | 1/tau_fine must lie inside the configured omega guards | `lbm/refinement.py` `FixedPatch2D.from_config` |
|
||||
| Uniform blocked backend `PeriodicBlockStepper` | doubly periodic, SRT only, stationary passive circles | `lbm/block_stepper.py` `PeriodicBlockStepper.__init__`, `_static_circles` |
|
||||
|
||||
Tests: `tests/unit/test_refinement_mrt.py`, `tests/validation/multigrid/test_refinement_mrt_gpu.py`,
|
||||
`tests/unit/test_shifted_storage.py`, `tests/unit/test_shifted_block_operators.py`.
|
||||
|
||||
## Refinement hierarchy (`FixedPatchStepper(cfg, *, context, refinements, ...)`)
|
||||
|
||||
| Setting | Supported | Guard |
|
||||
|---|---|---|
|
||||
| Depth | at least one refinement level; one entry per level in `level_blocks`/`level_objects`/`level_jets` | `lbm/refinement_stepper.py` `_check_levels` |
|
||||
| Single L1 rectangle | two-coarse-cell margin to the domain edge | `lbm/refinement.py` `FixedPatch2D.from_config` |
|
||||
| Several L1 rectangles (union) | require `level_blocks[0]` and `level_blocks[1]`; transfers must close (dependency checks, no fixed gap rule) | `lbm/refinement_stepper.py` `_check_levels`; `lbm/refinement.py` `_check_parent_pull_closure` |
|
||||
| Nested levels | depth >= 3 must be all blocked; a blocked level requires blocked levels below; a scalar child needs a scalar parent | `lbm/refinement_stepper.py` `_check_levels`; `lbm/hierarchy.py` `build_hierarchy` |
|
||||
| Nested transfer support | child only over inherited ACTIVE cells, donors/receivers uncovered ACTIVE, no child over the parent restriction footprint | `lbm/refinement.py` `_nested_kind` |
|
||||
| Tile shape | positive `(by, bx)`; the level allocation must be divisible by the tile (no partial tiles yet) | `lbm/refinement_stepper.py` `_check_levels`, `lbm/refinement.py` `FixedPatchSet2D.fine_layout` (shape); `lbm/blocks.py` `BlockLayout2D.build` (divisibility) |
|
||||
| Coarse boundary | `periodic`, `slot_reservoir` or `channel` | `lbm/refinement_stepper.py` `FixedPatchStepper.__init__` |
|
||||
| `slot_reservoir` | on-site velocity bottom slot, reservoir top, velocity 0, 2 <= slot width < nx, patch rows 4..ny-4 | `lbm/refinement_stepper.py` `FixedPatchStepper.__init__`; `lbm/refinement.py` `prepare_fine_boundary` |
|
||||
| `channel` | all levels blocked; Zou-He inlet, pressure-NEQ outlet, stationary bounce-back walls; transfers away from the closure | `lbm/channel.py` `validate_channel_config`, `validate_channel_transfers` |
|
||||
| Explosion | `constant` (default); `projected_linear` only periodic, all-blocked SRT, no bodies/Jets, no LES (experimental) | `lbm/refinement_stepper.py` `FixedPatchStepper.__init__` |
|
||||
| L2 runtime remap (`remap_child`) | periodic three-level L2 sets only, no L0/L1 bodies, identical pinned boundary | `lbm/refinement_stepper.py` `FixedPatchStepper.remap_child` |
|
||||
|
||||
Tests: `tests/unit/test_refinement_three_level_multipatch.py`, `tests/validation/multigrid/test_refinement_multipatch.py`,
|
||||
`test_refinement_three_level.py`, `test_refinement_three_level_multipatch_gpu.py`, `test_refinement_blocked_base.py`,
|
||||
`test_refinement_blocked_fine.py`, `test_channel_root_gpu.py`, `tests/unit/test_channel_root.py`,
|
||||
`tests/unit/test_projected_pair_stepper.py`, `test_runtime_child_remap_gpu.py`, `test_pinned_body_remap_gpu.py`.
|
||||
|
||||
## Bodies and Jets on refined levels
|
||||
|
||||
| Setting | Supported | Guard |
|
||||
|---|---|---|
|
||||
| Bodies | stationary circles without sensor/force-region roles, entirely inside the patch union, fully resolved, disjoint, stencil away from the two-hop transfer footprint | `lbm/refinement.py` `prepare_fine_boundary` |
|
||||
| Level | not on L0; only on the finest level of a nested hierarchy | `lbm/refinement_stepper.py` `_check_levels` |
|
||||
| Jet condition | velocity only; `pressure` is reserved and raises NotImplementedError | `body/jets.py` `Jet.__post_init__` |
|
||||
| Jet geometry | each Jet selects resolved links of exactly one stationary body; no Jet/Jet or Jet/SurfaceActuation overlap; abs(amplitude) < 1 lattice | `body/jets.py` `Jet.__post_init__`, `jet_link_shapes`, `_surface_links` |
|
||||
| Jets with shifted storage | only in the all-blocked stepper with SRT and no LES | `body/jets.py` `_surface_links` |
|
||||
| SurfaceActuation | unshifted storage only | `body/objects.py` `SimObject.validate_surface_actuation` |
|
||||
| Raw boundary exchange (force) | blocked body levels only; the window ledger requires `observe_raw_exchange` | `lbm/refinement_stepper.py` `FixedPatchStepper.__init__` |
|
||||
| Checkpoint with Jets | refused | `simulation.py` `Simulation.save_checkpoint` |
|
||||
|
||||
Raw exchange is the cut-link momentum reaction (wall + aperture), not a validated
|
||||
nozzle thrust or stress load. Jet amplitude is a lattice velocity, not a flow rate.
|
||||
|
||||
Tests: `tests/unit/test_jets.py`, `test_jets_review.py`, `test_shifted_jets.py`, `test_raw_exchange.py`,
|
||||
`tests/validation/multigrid/test_refinement_jets.py`, `test_shifted_jets_gpu.py`, `test_raw_exchange_gpu.py`,
|
||||
`test_exchange_window.py`, `test_fine_surface_gpu.py`.
|
||||
|
||||
## Observations from specific tested cases (not general bounds)
|
||||
|
||||
- SRT channel root vs uniform OneStep, strict-parity test
|
||||
`tests/validation/multigrid/test_channel_root_gpu.py::ChannelRootGPU::test_uniform_matched_root`:
|
||||
in one of its 18 sub-cases (2 storage modes x 3 profile/clamp/damp settings x 3 blocks; which
|
||||
one is not recorded), 1 of the 4320 compared values differs, max abs 2.33e-10, max rel 1.77e-7
|
||||
(about 2 ULP). The test remains failed; its cause and any general bound are unresolved.
|
||||
- MRT root parity test
|
||||
`tests/validation/multigrid/test_refinement_mrt_gpu.py::RefinementMRTGPU::test_uniform_matched_mrt_root`
|
||||
(12 sub-cases: 2 storage modes x 2 profiles x 3 blocks, default outlet settings) passed bitwise
|
||||
in the Horsea focused GPU run at K1 7813334 (2026-10-21).
|
||||
- Periodic Taylor-Green Re 100, measured at 36ffc8b1 (V3 matched pair, N128 depth 2):
|
||||
every per-sample screen passed; the constant explosion's aggregate worst velocity L2,
|
||||
0.004418, exceeds its preregistered 0.002 budget, while projected_linear passed that budget.
|
||||
- Curved-wall pressure (pilot harness, exact-q analytic annulus, tau 0.8): the coarse swirl r1
|
||||
case exceeded its terminal density-gain cap (1.105e-4 vs 1e-4), so the frozen 9-case matrix
|
||||
is NOT_ESTABLISHED. No validated fix exists. This is a pilot harness, not production code.
|
||||
- DFG 2D-1 Re 20 (V4, SRT, t* 60, pressure-NEQ outlet and local Zou-He inlet): refined and uniform
|
||||
runs were NOT_STEADY under the frozen steady rule, so no interval verdict was given.
|
||||
|
||||
## Not supported in this release
|
||||
|
||||
3D, dynamic/automatic AMR (tagging, time-varying hierarchies beyond `remap_child`),
|
||||
moving bodies on refined levels, pressure Jets, Jet checkpoints, LES or TRT on refined
|
||||
levels, partial (non-dividing) tiles.
|
||||
@@ -1,11 +1,15 @@
|
||||
# Synchronized leaf G remap and manual continuation
|
||||
|
||||
This is a CPU state-transfer component, **not a runtime adaptive solver**.
|
||||
It changes only a separated L2 rectangle set in a three-level, body-free,
|
||||
periodic hierarchy. L0/L1 topology, relaxation parameters, direction order,
|
||||
It changes only an L2 rectangle union in a three-level periodic hierarchy.
|
||||
The body-free case is shown first; stationary L2 bodies/Jets follow the bounded
|
||||
support contract below. L0/L1 topology, relaxation parameters, direction order,
|
||||
physical frame and completed time remain unchanged. Existing geometry checks
|
||||
validate child support/nesting and separation. The CPU helper itself adds no CUDA, scheduler, allocator, boundary handling,
|
||||
collision or ghost refill. A bounded runtime adapter is described below.
|
||||
validate child support/nesting and actual transfer dependencies, without a fixed
|
||||
rectangle-gap rule. The three-level remap limit is unchanged by the stepper's
|
||||
arbitrary-depth level-list support. The CPU helper itself adds no CUDA,
|
||||
scheduler, allocator, boundary handling, collision or ghost refill. A bounded
|
||||
runtime adapter is described below.
|
||||
|
||||
```python
|
||||
from CelerisLab.lbm.refinement import FixedPatchSet2D
|
||||
@@ -29,11 +33,13 @@ origin is retained. Physical lower edges are `origin + spacing*(x,y)` on L0,
|
||||
`origin + spacing*(parent.fine_origin + (x,y)/4)` on L2.
|
||||
|
||||
Factory options `coarse_boundary` and `objects` must declare `"periodic"` and
|
||||
an empty body collection (all levels). LBMConfig's channel settings do not
|
||||
select this operator's boundary mode. Geometry metadata is factory-created;
|
||||
an empty collection; L2 bodies use `child_objects` as described below.
|
||||
LBMConfig's channel settings do not select this operator's boundary mode.
|
||||
Geometry metadata is factory-created;
|
||||
manual mutation/forging of metadata is unsupported. Caller certifies the
|
||||
actual state has no bodies/forcing, that all device work and old readers are
|
||||
quiescent, and that inputs are the **current owned-leaf postcollision G**.
|
||||
actual state matches the declared boundary and has no forcing, that all device
|
||||
work and old readers are quiescent, and that inputs are the **current owned-leaf
|
||||
postcollision G**.
|
||||
Count/phase stamps check consistency, not freshness or active-buffer identity.
|
||||
|
||||
`current_g` is three NumPy Q-major arrays `(9,ny,nx)`. For blocked inputs,
|
||||
@@ -73,9 +79,10 @@ PYTHONPATH=src python -m unittest discover -s tests/unit -p 'test_synchronized_r
|
||||
|
||||
`replacement = current.remap_child(rectangles)` returns a new ready
|
||||
`FixedPatchStepper` at the **same absolute step_count**. It supports only
|
||||
body-free periodic three-level runs whose existing L2 uses
|
||||
`child_rectangles_in_parent`. Scalar or union L1 parents are supported; scalar
|
||||
`child=` is deliberately rejected. L0/L1, relaxation and block sizes stay fixed.
|
||||
periodic three-level runs with no L0/L1 bodies and an existing L2
|
||||
`FixedPatchSet2D`; stationary L2 bodies/Jets follow the support contract below.
|
||||
Scalar or union L1 parents are supported; scalar `child=` is deliberately
|
||||
rejected. L0/L1, relaxation and block sizes stay fixed.
|
||||
No tagger, scheduler, in-place replacement, or general rollback is provided.
|
||||
|
||||
The method holds the stepper lock, validates geometry, synchronizes its stream,
|
||||
@@ -113,13 +120,14 @@ Per-population conservation is not an advancement target under collision.
|
||||
### Bounded pinned-body extension
|
||||
|
||||
`replacement = current.remap_child(rectangles, child_objects=original_objects)`
|
||||
also accepts stationary Circles in separated L2 rectangles. Every body’s
|
||||
**complete containing rectangle must remain identical**. Several bodies may
|
||||
share one pinned rectangle; there need not be any fluid-only rectangles.
|
||||
Separated fluid-only rectangles may be added, removed or moved. Existing
|
||||
nesting, four-cell gap, full-circle containment, reverse cut-link coverage and two-hop BFL
|
||||
transfer-footprint checks still apply. This is manual remapping, not adaptive
|
||||
body tracking or runtime body editing.
|
||||
also accepts stationary Circles inside the L2 union. Each body's **physical
|
||||
boundary, complete BFL support and current per-link velocity must remain
|
||||
identical**. Rectangles are not pinned: they may be split, merged or resized,
|
||||
and other fluid coverage may change, including within the same connected union,
|
||||
provided that physical support is unchanged. Existing nesting, union transfer
|
||||
dependencies, full-circle containment, reverse cut-link
|
||||
coverage and two-hop BFL transfer-footprint checks still apply. This is manual
|
||||
remapping, not adaptive body tracking or runtime body editing.
|
||||
|
||||
Supply all original root edge-frame definitions in their construction order.
|
||||
Do not pass `current.child_boundary.objects`: those are projected fine-local copies.
|
||||
@@ -128,8 +136,8 @@ state, remapping reprojects the supplied definition onto the old topology and
|
||||
compares exact ordered circle centers/radii, flags, physical cut links, body
|
||||
IDs, q, lever arms, hits, BFL support,
|
||||
legacy SurfaceActuation specification and current per-link velocity against
|
||||
the existing boundary. Omission, changed geometry/actuation and changed body
|
||||
rectangle fail without device reads or allocations; reordered bodies fail
|
||||
the existing boundary. Omission, changed geometry/actuation or changed physical
|
||||
boundary/BFL support fail without device reads or allocations; reordered bodies fail
|
||||
too. Root object IDs are still assigned by list position, as in the constructor.
|
||||
|
||||
Jets are taken from the live L2 boundary, including the most recent successful
|
||||
@@ -141,12 +149,16 @@ boundary and velocities must match exactly.
|
||||
typed `FineBoundary2D`; it derives L2 fluid leaves as ACTIVE minus SOLID. No
|
||||
caller mask is accepted. Solids, ghosts and holes are never donors and remain
|
||||
NaN, physical-fluid overlap is exact, and exposed L1 still restricts all four
|
||||
old fluid children. The helper checks the old/new body pins and boundary too.
|
||||
Its immutable `pinned_rectangles` is a canonical, deduplicated tuple, empty for body-free layouts; rectangle input
|
||||
order does not affect it. Scalar-parent window offsets remain part of the
|
||||
coordinate conversion.
|
||||
old fluid children. The helper checks old/new physical boundary and velocity
|
||||
equality too. Its immutable `pinned_rectangles` is legacy containment metadata,
|
||||
not a remap eligibility constraint; it may be empty for a seam-spanning body.
|
||||
Scalar-parent window offsets remain part of the coordinate conversion.
|
||||
|
||||
Focused CPU mocks: `tests/unit/test_pinned_body_remap.py`. GPU declarations:
|
||||
Focused CPU mocks: `tests/unit/test_pinned_body_remap.py`.
|
||||
`tests/unit/test_union_body_support.py` also checks seam-spanning circles,
|
||||
rectangle repartition/resize, unchanged physical support and live-Jet
|
||||
preservation. These host checks do not establish GPU evolution accuracy.
|
||||
GPU declarations:
|
||||
`tests/validation/multigrid/test_pinned_body_remap_gpu.py`, covering no-op
|
||||
next-step bitwise identity, moved-fluid A→B→A, current-amplitude preservation,
|
||||
physical boundary/velocity equality, source independence and absolute counts.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# DFG 2D-1 (Re 20) against the official reference intervals
|
||||
|
||||
`tests/validation/run_dfg_re20.py` runs the steady DFG 2D-1 case on the refined
|
||||
channel root (D40 root, D80 wide fine patch) and on the uniform D80 grid. Geometry,
|
||||
inflow, initial state, layout and observation are those of the pinned Re100 runner
|
||||
(`run_refined_dfg.py`, `re100_helpers.py`, both unchanged); only the lattice
|
||||
viscosity is set for Re 20 (root tau 0.56, fine 0.62, uniform 0.62, lattice speed 0.01).
|
||||
|
||||
Label: "compared against the official reference bands", not a full benchmark
|
||||
certification: the outlet is pressure-NEQ (`neq_extrap`), not do-nothing, and the
|
||||
inlet is local Zou-He.
|
||||
|
||||
## Quantities
|
||||
|
||||
- Cd = 2 Fx / (rho U^2 D), Cl = 2 Fy / (rho U^2 D), interval means over each sample.
|
||||
- p_diff = p(0.15, 0.2) - p(0.25, 0.2), linear extrapolation to the exact surface
|
||||
points (`re100_helpers.pressure_probes`). The helper returns dp / (rho U^2) in
|
||||
scaled units; p_diff = dp * 0.04 for U = 0.2 m/s, rho = 1. Both are reported.
|
||||
- Official intervals (Schaefer & Turek 1996): Cd [5.57, 5.59], Cl [0.0104, 0.0110],
|
||||
p_diff [0.1172, 0.1176].
|
||||
|
||||
## Bounds and steady rule (frozen before any run)
|
||||
|
||||
- End time t* = 60 (30 s physical): 240,000 refined root steps, 480,000 uniform steps.
|
||||
- One sample every t* = 0.5 (0.25 s) at the end of a synchronized root step; Cd and
|
||||
Cl are interval means. Trailing window t* 50..60 inclusive (21 samples).
|
||||
- Steady rule (our engineering choice, not official): in the window, for each of Cd,
|
||||
Cl, p_diff, max - min <= 10 % and |least-squares slope x window| <= 2 % of its
|
||||
official interval width (range 2e-3 / 6e-5 / 4e-5; slope 4e-4 / 1.2e-5 / 8e-6).
|
||||
Absolute limits, so the small Cl is never a divisor. Never relaxed after a run.
|
||||
- The verdict uses the window mean; the distance to the nearest interval edge is
|
||||
reported for each quantity (negative outside).
|
||||
- Force sign: fluid on cylinder, x along the inflow, y up from the bottom wall; Cd > 0
|
||||
and the official Cl > 0 (`cd_positive` is reported).
|
||||
- Not steady at t* = 60: status `NOT_STEADY`, no interval verdict. Health limits
|
||||
(Mach <= 0.2, rho in [0.5, 1.5]) stop the run with `UNHEALTHY`.
|
||||
- Steady: `WITHIN_INTERVALS` or `OUTSIDE_INTERVALS`, with the per-quantity verdict.
|
||||
|
||||
## Run
|
||||
|
||||
```
|
||||
python tests/validation/run_dfg_re20.py --output <new-dir> --plan
|
||||
CUDA_VISIBLE_DEVICES=<n> python tests/validation/run_dfg_re20.py --case both \
|
||||
--collision SRT --output <new-dir> --max-wall-seconds <cap>
|
||||
```
|
||||
|
||||
The uniform D80 control separates method error from refinement error. `--plan` writes `plan.json` only. A GPU run writes `plan.json` and `result.json`
|
||||
(per case: lattice parameters, every sample, window ranges, status, verdict).
|
||||
Host contract tests: `tests/unit/test_dfg_re20.py`.
|
||||
@@ -56,3 +56,62 @@ Root must independently review and admit any execution. An admitted paired pilot
|
||||
the existing watchdog, device selection, `--gpu-gates-approved`, positive
|
||||
`--max-wall-seconds` and a new output directory, adding only `--exchange-window` to the
|
||||
selected collision's pilot command. No GPU or remote action was performed for this patch.
|
||||
|
||||
## Public Jet diagnostic option
|
||||
|
||||
`--collision MRT --exchange-window --jet-scenario zero|fixed|reverse` selects
|
||||
unshifted FP32, no LES, constant transfer on both existing public backends. Default
|
||||
`none` and the historical manifests remain unchanged. `zero` registers both Jets at
|
||||
zero amplitude and retains every existing DFG verdict, including pressure. `fixed`
|
||||
uses outward radial parabolic sectors at pi/2 and 3pi/2, half-width pi/12, peaks
|
||||
(+.001,-.001). Both use the existing D40/D80 geometry and U=.01. Sector origins are
|
||||
(80.5,81) in root edge coordinates and (160,160.5) in uniform node coordinates;
|
||||
radial extent is [0,1.005R]. `reverse` keeps those peaks through t*=300, updates both
|
||||
public setters at that completed boundary, and continues to 305 with opposite signs.
|
||||
|
||||
Fixed diagnostic windows are 250–260, 260–270, 270–280, 280–290 and 290–300; reversal
|
||||
adds 300–305. Histories retain exact interval endpoints and root/uniform lattice
|
||||
impulses. The refined window uses total exchange; uniform uses public `read_force`
|
||||
batch averages. Driven windows report mean/min/max/RMS Cd/Cl without applying DFG
|
||||
reference or periodicity gates. Exit zero means healthy execution, not qualification;
|
||||
`matched_case_pass` remains false. No independent CV or stress-load claim is made.
|
||||
|
||||
This opt-in records current source/helper hashes with the existing manifest format,
|
||||
checkout revision/dirty state and runner hash. Pilot/full must match that snapshot
|
||||
and the exact scenario. It does not relabel current source as historical evidence.
|
||||
Use the existing bounded plan/pilot/full commands with the three option flags above;
|
||||
full requires the matching completed t*=5 paired pilot. `step_seconds` includes force
|
||||
readback; observation/overhead and total wall time are separate, not kernel throughput.
|
||||
|
||||
Add `--observe-cv` to record independent scalar control-volume samples from the
|
||||
already downloaded public macros. The pilot exercises this path at t*=.02..5;
|
||||
full records 501 endpoints at 290..300. Each `cv_history.jsonl` row contains actual
|
||||
root time, preceding interval start/duration/impulse, and two inventories/outward
|
||||
fluxes ordered [mass,Px,Py]. Exclude the first row's preceding impulse: that row
|
||||
supplies the initial inventory. Uniform native impulse converts by .5² to root
|
||||
units; refined ledger impulse is already in root units. Explicit native-aligned
|
||||
contours differ by .25 root cells in x and are recorded in the plan. Cropping
|
||||
retains the two-cell face-gradient stencils. No fields or per-link arrays are saved.
|
||||
`cv_observation_seconds` is the CV portion of observation/overhead time. Helper hash
|
||||
and the observation flag must match pilot/full. Time integration, temporal
|
||||
coarsening and any physical assessment remain separate offline work.
|
||||
|
||||
## Bounded resolution and placement controls
|
||||
|
||||
Defaults remain `--case both --uniform-diameter 80 --placement wide`. Single-case
|
||||
runs use `--case uniform` or `--case refined` and never report a matched-case pass.
|
||||
Their own reference/periodicity or driven-diagnostic results remain available.
|
||||
A single zero-Jet full run exits successfully only when its periodicity and the
|
||||
unchanged complete DFG reference verdict pass; driven completion is not qualification.
|
||||
|
||||
The D160 option requires `--case uniform --uniform-diameter 160` and an explicit
|
||||
public Jet scenario. U and Jet speeds stay .01 and ±.001; viscosity is .016,
|
||||
grid 3521×658, center (320,320.5), radius80. CV uses root spacing/time factor .25,
|
||||
origin (.375,.75), impulse factor .0625, root viscosity .004, and x-face shift .125.
|
||||
|
||||
`--case refined --placement compact` selects root bounds (48,48,128,128), fine
|
||||
168×168 and origin (46,46), with the same D40/fineD80 body/Jet resolution. Its CV
|
||||
contours are (52,52,112,112) and (50,50,120,120); complete face-gradient stencils
|
||||
are clear of both solids and the transfer footprint. Compact is refined-only.
|
||||
CV metadata gives each grid's spacing/origin/contours explicitly. Case, uniform
|
||||
diameter and placement must match the newly completed pilot in every receipt.
|
||||
|
||||
@@ -90,8 +90,8 @@ def main():
|
||||
# The caller owns the CUDA context; the stepper must be closed first (the
|
||||
# nested with-statement does that).
|
||||
with CudaContext(0) as context, FixedPatchStepper(
|
||||
cfg, BOUNDS, context=context, coarse_boundary=args.mode,
|
||||
fine_objects=[body], fine_block=fine_block) as run:
|
||||
cfg, context=context, coarse_boundary=args.mode, refinements=[BOUNDS],
|
||||
level_blocks=[None, fine_block], level_objects=[(), (body,)]) as run:
|
||||
p = run.patch
|
||||
# Rest equilibrium is already postcollision G, so no initial collision is
|
||||
# needed. Non-leaf entries (covered, ghost, solid) are discarded by initialize.
|
||||
|
||||
@@ -148,8 +148,8 @@ def main():
|
||||
samples=[], status='running', last_synchronized_coarse_step=0)
|
||||
start, run = time.monotonic(), None
|
||||
try:
|
||||
with CudaContext(0) as context, FixedPatchStepper(cfg, BOUNDS, context=context,
|
||||
coarse_boundary='slot_reservoir') as run:
|
||||
with CudaContext(0) as context, FixedPatchStepper(cfg, context=context,
|
||||
coarse_boundary='slot_reservoir', refinements=[BOUNDS]) as run:
|
||||
p = run.patch
|
||||
masks = [np.ones(p.coarse_shape, bool), p.fine_kind == ACTIVE]
|
||||
masks[0][[0, -1]] = False
|
||||
|
||||
@@ -48,7 +48,7 @@ def main():
|
||||
nx=16, ny=16, viscosity=.1, velocity=1e-3, rho=1.0,
|
||||
lattice_model="D2Q9", collision="SRT", streaming="double_buffer",
|
||||
store_precision="FP32", ddf_shifting=False, les_enabled=False)
|
||||
with CudaContext(0) as context, PeriodicPatchStepper(cfg, (4, 4, 12, 12), context=context) as run:
|
||||
with CudaContext(0) as context, PeriodicPatchStepper(cfg, context=context, refinements=[(4, 4, 12, 12)]) as run:
|
||||
p = run.patch
|
||||
coarse = shear_equilibrium(p.coarse_shape, (0, 0), 1.0, cfg.ny)
|
||||
fine = shear_equilibrium(p.fine_shape, p.fine_origin, .5, cfg.ny)
|
||||
|
||||
@@ -207,22 +207,30 @@ def surface_link_velocity(objects, cfg, flags, fluid_idx, dir, body_id, rx, ry,
|
||||
jets=(), hits=None, *, _shifted_blocked=False):
|
||||
"""Per-link prescribed surface velocity (n, 2) FP32 and aperture mask.
|
||||
|
||||
See _surface_links, which also returns the unit-amplitude jet shapes.
|
||||
"""
|
||||
return _surface_links(objects, cfg, flags, fluid_idx, dir, body_id, rx, ry, jets, hits,
|
||||
_shifted_blocked=_shifted_blocked)[:2]
|
||||
|
||||
|
||||
def _surface_links(objects, cfg, flags, fluid_idx, dir, body_id, rx, ry,
|
||||
jets=(), hits=None, *, _shifted_blocked=False):
|
||||
"""(velocity, aperture, jet shape, jet id); the last two are None without jets.
|
||||
|
||||
Host-only and storage-neutral: rows follow the given physical cut-link
|
||||
order. Returns (None, None) when neither an object carries surface
|
||||
order. Returns all None when neither an object carries surface
|
||||
actuation nor a jet is given. Jets need true FP64 ``hits`` (n, 2) and may
|
||||
not share a link with each other or with a legacy aperture.
|
||||
Shifted storage is privately enabled only by the all-blocked stepper.
|
||||
"""
|
||||
enabled = [obj for obj in objects if obj.surface_actuation is not None]
|
||||
if not enabled and not jets:
|
||||
return None, None
|
||||
from ..lbm.descriptors import FLUID, SOLID
|
||||
if np.any((flags[fluid_idx] & FLUID) == 0) or np.any(flags[fluid_idx] & SOLID):
|
||||
raise ValueError("Surface cut links must have active fluid receivers")
|
||||
if len(np.unique(fluid_idx.astype(np.uint64)*cfg.nq+dir)) != len(dir):
|
||||
raise ValueError("Surface boundary requires unique cut-link writers")
|
||||
return None, None, None, None
|
||||
from .preprocess.flag_overlay import validate_cut_link_ownership
|
||||
validate_cut_link_ownership(flags, fluid_idx, dir, cfg.nq)
|
||||
surface_velocity = np.zeros((len(dir), 2), np.float32)
|
||||
surface_aperture = np.zeros(len(dir), bool)
|
||||
shape = jet_id = None
|
||||
for obj in enabled:
|
||||
obj.validate_surface_actuation(cfg)
|
||||
selected = body_id == obj.obj_id
|
||||
@@ -255,4 +263,4 @@ def surface_link_velocity(objects, cfg, flags, fluid_idx, dir, body_id, rx, ry,
|
||||
raise ValueError("Jet overlaps a SurfaceActuation aperture")
|
||||
surface_velocity[driven] = jet_link_velocity(jets, shape, jet_id)[driven]
|
||||
surface_aperture |= driven
|
||||
return surface_velocity, surface_aperture
|
||||
return surface_velocity, surface_aperture, shape, jet_id
|
||||
|
||||
@@ -27,7 +27,7 @@ import pycuda.driver as cuda
|
||||
|
||||
from .objects import SimObject
|
||||
from .registry import BodyRegistry
|
||||
from .preprocess.flag_overlay import merge_flag_masks
|
||||
from .preprocess.flag_overlay import merge_flag_masks, validate_cut_link_ownership
|
||||
from ..cuda.compiler_v2 import obs_layout
|
||||
from ..cuda.obs_layout import ObsLayout
|
||||
from ..lbm.descriptors import (
|
||||
@@ -369,9 +369,9 @@ class ObjectManager:
|
||||
|
||||
Args:
|
||||
objects: List of SimObject instances to process.
|
||||
domain_flags: Optional uint16 flags for donor-cell classification.
|
||||
domain_flags: Optional uint16 flags for receiver/donor classification.
|
||||
sensor_cell_counts_out: Int array to fill with per-object sensor
|
||||
cell counts. If provided, ``fill(0)`` is called first.
|
||||
cell counts after the full geometry has passed validation.
|
||||
hits_out: List that receives one FP64 (n, 2) array of true hit
|
||||
points row-aligned with the cut-link columns (for Jets).
|
||||
|
||||
@@ -394,8 +394,8 @@ class ObjectManager:
|
||||
active_fluid = ((domain_flags & FLUID) != 0) & ((domain_flags & SOLID) == 0)
|
||||
force_owners: set[int] = set()
|
||||
|
||||
if sensor_cell_counts_out is not None:
|
||||
sensor_cell_counts_out.fill(0)
|
||||
sensor_counts = (np.zeros_like(sensor_cell_counts_out)
|
||||
if sensor_cell_counts_out is not None else None)
|
||||
|
||||
ez = None
|
||||
if self.cfg and self.cfg.is_d3q19:
|
||||
@@ -430,9 +430,9 @@ class ObjectManager:
|
||||
if len(cells) > 0:
|
||||
s_cells.append(cells)
|
||||
s_ids.append(ids)
|
||||
if (sensor_cell_counts_out is not None
|
||||
and 0 <= obj.obj_id < sensor_cell_counts_out.size):
|
||||
sensor_cell_counts_out[obj.obj_id] = int(len(cells))
|
||||
if (sensor_counts is not None
|
||||
and 0 <= obj.obj_id < sensor_counts.size):
|
||||
sensor_counts[obj.obj_id] = int(len(cells))
|
||||
if obj.is_force_region and hasattr(obj, 'get_sensor_list'):
|
||||
cells, ids = obj.get_sensor_list(self.nx, self.ny, self.nz)
|
||||
keep = active_fluid[cells]
|
||||
@@ -461,8 +461,9 @@ class ObjectManager:
|
||||
r_fr_ids = np.concatenate(fr_ids) if fr_ids else np.zeros(0, dtype=np.int32)
|
||||
|
||||
r_hit = np.concatenate(cl_hit) if cl_hit else np.zeros((0, 2))
|
||||
if hits_out is not None:
|
||||
hits_out.append(r_hit)
|
||||
# Passive walls need the same ownership guarantees as driven walls.
|
||||
# Overlapping solids are not a supported union-boundary operation.
|
||||
validate_cut_link_ownership(domain_flags, r_idx, r_dir, self.nq)
|
||||
if self.jets or any(obj.surface_actuation is not None for obj in objects):
|
||||
# Reject unresolved apertures/ownership before sync or restart mutates state.
|
||||
from ..lbm.curved_links import CurvedLinkSoA
|
||||
@@ -470,6 +471,11 @@ class ObjectManager:
|
||||
links.assign_host(r_idx, r_dir, r_q, r_bid, r_rx, r_ry, r_rz, r_fallback)
|
||||
links.assign_surface(objects, self.cfg, domain_flags, jets=self.jets, hits=r_hit)
|
||||
|
||||
if sensor_cell_counts_out is not None:
|
||||
sensor_cell_counts_out[:] = sensor_counts
|
||||
if hits_out is not None:
|
||||
hits_out.append(r_hit)
|
||||
|
||||
return (
|
||||
r_idx, r_dir, r_q, r_bid,
|
||||
r_rx, r_ry, r_rz, r_fallback,
|
||||
@@ -487,8 +493,6 @@ class ObjectManager:
|
||||
the caller has already prepared and uploaded the final flag field
|
||||
for this geometry, such as during initialization.
|
||||
"""
|
||||
self._telemetry_field = field
|
||||
|
||||
next_flags = self.build_flags(field.build_channel_flags()) if rebuild_flags else field.flag
|
||||
hits = []
|
||||
|
||||
@@ -499,6 +503,7 @@ class ObjectManager:
|
||||
fr_cells, fr_obj_id,
|
||||
) = self.build_compact_lists(domain_flags=next_flags, hits_out=hits)
|
||||
|
||||
self._telemetry_field = field
|
||||
if rebuild_flags:
|
||||
field.flag = next_flags
|
||||
field.upload_flags()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CelerisLab/body/preprocess/flag_overlay.py
|
||||
"""
|
||||
Flag overlay: merge per-object flag masks onto a clean domain base.
|
||||
Flag overlay and cut-link ownership checks against the merged domain.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,6 +12,15 @@ import numpy as np
|
||||
from ...lbm.descriptors import FLUID, SOLID, SENSOR_FLAG, FRC_REGION
|
||||
|
||||
|
||||
def validate_cut_link_ownership(flags, fluid_idx, directions, nq):
|
||||
"""Reject solid receivers and multiple writers to one population."""
|
||||
if np.any((flags[fluid_idx] & FLUID) == 0) or np.any(flags[fluid_idx] & SOLID):
|
||||
raise ValueError("Cut links must have active fluid receivers")
|
||||
writers = fluid_idx.astype(np.uint64) * nq + directions
|
||||
if len(np.unique(writers)) != len(directions):
|
||||
raise ValueError("Boundary requires unique cut-link writers")
|
||||
|
||||
|
||||
def merge_flag_masks(
|
||||
base: np.ndarray,
|
||||
masks: List[np.ndarray],
|
||||
|
||||
@@ -54,14 +54,18 @@ def _write(path: str, content: str):
|
||||
|
||||
|
||||
def generate_config(cfg: LBMConfig, n_objects: int = 0,
|
||||
*, build_root: Optional[str] = None) -> str:
|
||||
*, build_root: Optional[str] = None, block_root_bc: int = 0) -> str:
|
||||
"""Snapshot kernels and render headers in a fresh private directory.
|
||||
|
||||
Returns its path. Pass it explicitly to compile_kernel for interleaved
|
||||
builds. Legacy generate_config(); compile_kernel() remains context-local.
|
||||
The caller owns cleanup_build_dir() once the loaded module is no longer
|
||||
needed for recompilation/debugging. No package files are changed.
|
||||
block_root_bc specializes a blocked root module; fine/dense builds use 0.
|
||||
"""
|
||||
if (isinstance(block_root_bc, bool) or not isinstance(block_root_bc, int)
|
||||
or block_root_bc not in (0, 1, 2)):
|
||||
raise ValueError("block_root_bc must be an integer mode 0, 1 or 2")
|
||||
macros = cfg.to_macros()
|
||||
if isinstance(n_objects, bool) or not isinstance(n_objects, int) or n_objects < 0:
|
||||
raise ValueError("n_objects must be a nonnegative integer")
|
||||
@@ -71,14 +75,15 @@ def generate_config(cfg: LBMConfig, n_objects: int = 0,
|
||||
try:
|
||||
shutil.copytree(_KERNEL_DIR, build_dir, dirs_exist_ok=True,
|
||||
ignore=shutil.ignore_patterns("*.ptx", "*.cubin", "*.tmp"))
|
||||
_render_config(macros, n_objects, os.path.join(build_dir, "config"))
|
||||
_render_config(macros, n_objects, os.path.join(build_dir, "config"),
|
||||
block_root_bc=block_root_bc)
|
||||
digest = hashlib.sha256()
|
||||
for path in sorted(Path(build_dir).rglob("*")):
|
||||
if path.is_file():
|
||||
digest.update(str(path.relative_to(build_dir)).encode())
|
||||
digest.update(path.read_bytes())
|
||||
metadata = {"source_sha256": digest.hexdigest(), "config": asdict(cfg),
|
||||
"n_objects": n_objects}
|
||||
"n_objects": n_objects, "block_root_bc": block_root_bc}
|
||||
_write(os.path.join(build_dir, ".celeris-build"), json.dumps(metadata, sort_keys=True))
|
||||
except BaseException:
|
||||
shutil.rmtree(build_dir)
|
||||
@@ -101,7 +106,7 @@ def cleanup_build_dir(build_dir: Optional[str]) -> None:
|
||||
_last_ptx_path.set(None)
|
||||
|
||||
|
||||
def _render_config(m: dict, n_objects: int, cdir: str):
|
||||
def _render_config(m: dict, n_objects: int, cdir: str, *, block_root_bc: int = 0):
|
||||
_write(os.path.join(cdir, "config_grid.h"), f"""{_HEADER}\
|
||||
// Layer: Global / Grid
|
||||
#ifndef CELERIS_CONFIG_GRID_H
|
||||
@@ -165,6 +170,9 @@ def _render_config(m: dict, n_objects: int, cdir: str):
|
||||
#define STORE_PRECISION {m['STORE_PRECISION']}
|
||||
#define USE_DDF_SHIFTING {m['USE_DDF_SHIFTING']}
|
||||
|
||||
// Internal blocked root mode: 0=periodic/fine, 1=slot/reservoir, 2=channel.
|
||||
#define BLOCK_ROOT_BC {block_root_bc}
|
||||
|
||||
#define USE_LES {m['USE_LES']}
|
||||
#define LES_CS {m['LES_CS']}
|
||||
#define LES_CLOSED_FORM {m['LES_CLOSED_FORM']}
|
||||
|
||||
@@ -204,11 +204,21 @@ class PeriodicBlockStepper:
|
||||
raise
|
||||
|
||||
def step(self, n=1):
|
||||
"""Advance n steps: halo refresh, then core pull/collide, per step."""
|
||||
"""Advance n steps: halo refresh, then core pull/collide, per step.
|
||||
|
||||
All n steps are enqueued in order on the one stream and the host waits
|
||||
once before returning (same launches and bits as n step(1) calls).
|
||||
Asynchronous device errors surface at that single wait, so they are not
|
||||
attributed to the step that caused them; call step(1) repeatedly to
|
||||
localize one. On any failure the instance is failed and step_count keeps
|
||||
its entry value.
|
||||
"""
|
||||
if isinstance(n, bool) or not isinstance(n, Integral) or n < 0:
|
||||
raise ValueError("n must be a nonnegative integer")
|
||||
with self._lock, self.ctx.activate():
|
||||
self._require("ready")
|
||||
if n == 0:
|
||||
return
|
||||
kw = dict(block=self._block, stream=self._stream)
|
||||
try:
|
||||
self._status = "advancing"
|
||||
@@ -224,9 +234,9 @@ class PeriodicBlockStepper:
|
||||
self._step(self._current, self._spare, np.uint32(self.layout.halo),
|
||||
self._tables.get("flags", np.uintp(0)), np.uintp(0), # no kind
|
||||
grid=self._grid, **kw)
|
||||
self._stream.synchronize()
|
||||
self._current, self._spare = self._spare, self._current
|
||||
self._step_count += 1
|
||||
self._current, self._spare = self._spare, self._current # host handles only
|
||||
self._stream.synchronize()
|
||||
self._step_count += n
|
||||
self._status = "ready"
|
||||
except BaseException:
|
||||
self._status = "failed"
|
||||
|
||||
@@ -94,12 +94,10 @@ class CurvedLinkSoA:
|
||||
"""
|
||||
self.surface_velocity = self.aperture = None
|
||||
self.jets, self.jet_shape, self.jet_id = tuple(jets), None, None
|
||||
self.surface_velocity, self.aperture = surface_link_velocity(
|
||||
from ..body.jets import _surface_links
|
||||
self.surface_velocity, self.aperture, self.jet_shape, self.jet_id = _surface_links(
|
||||
objects, cfg, flags, self.fluid_idx, self.dir, self.body_id, self.rx, self.ry,
|
||||
jets=self.jets, hits=hits)
|
||||
if self.jets:
|
||||
from ..body.jets import jet_link_shapes
|
||||
self.jet_shape, self.jet_id = jet_link_shapes(self.jets, hits)
|
||||
|
||||
def set_jet_amplitude(self, index, amplitude, stream=None):
|
||||
"""Rewrite jet rows from cached shapes; re-upload only the velocity column.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// CelerisLab/lbm/kernels/blocks.cu
|
||||
// One periodic level of fixed-size blocks (lbm/blocks.py layout). Separate
|
||||
// One fixed-size blocked level (lbm/blocks.py layout), specialized at compile
|
||||
// time for periodic/fine, slot/reservoir or channel stepping. Separate
|
||||
// compilation entry: NX, NY are the block window including halo, so index_f
|
||||
// and linear_index address one block; blockIdx.z selects the block, whose
|
||||
// populations start at b*NQ*TOTAL_CELLS (block-contiguous, Q-major inside).
|
||||
@@ -12,6 +13,7 @@
|
||||
#include "core/params.cuh"
|
||||
#include "core/flags.cuh"
|
||||
#include "core/obs.cuh"
|
||||
#include "core/cell_kind.cuh"
|
||||
#include "operators/macro.cuh"
|
||||
#include "operators/collision_srt.cuh"
|
||||
#if COLLISION_MODEL == 2
|
||||
@@ -19,155 +21,29 @@
|
||||
#endif
|
||||
#include "streaming/pull_double_buffer.cuh"
|
||||
#include "boundary/curved_boundary.cuh"
|
||||
#include "boundary/planar_velocity.cuh"
|
||||
|
||||
#if DIM != 2 || NQ != 9 || (COLLISION_MODEL != 0 && COLLISION_MODEL != 2) || STORE_PRECISION != 0 || USE_LES || STREAMING_MODEL != 0
|
||||
#error "Blocks require FP32 D2Q9 SRT or MRT double buffers without LES"
|
||||
#endif
|
||||
|
||||
// Fixed-patch cell kinds, matching refinement.py ACTIVE/INNER_GHOST/OUTER_GHOST.
|
||||
constexpr unsigned char KIND_ACTIVE = 0u, KIND_INNER = 1u, KIND_OUTER = 2u;
|
||||
|
||||
// Zero-initialized for ordinary blocks/fine levels. Set only for parent walls;
|
||||
// NX/NY remain local storage strides, never physical wall coordinates.
|
||||
struct BlockWall { unsigned int nx, ny, slot_width; float amplitude; };
|
||||
extern "C" { __constant__ BlockWall d_block_wall; }
|
||||
|
||||
// Halo refresh: block cell ids b*TOTAL_CELLS+c, sources are always core cells.
|
||||
extern "C" __global__ void BlockHaloCopy(
|
||||
fpxx* f, const uint64_t* dst, const uint64_t* src, unsigned int count)
|
||||
{
|
||||
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (row >= count) return;
|
||||
const uint64_t d = dst[row], s = src[row];
|
||||
fpxx* to = f + (d / TOTAL_CELLS) * NQ * TOTAL_CELLS;
|
||||
const fpxx* from = f + (s / TOTAL_CELLS) * NQ * TOTAL_CELLS;
|
||||
for (unsigned int q = 0; q < NQ; ++q)
|
||||
to[index_f(d % TOTAL_CELLS, q)] = from[index_f(s % TOTAL_CELLS, q)];
|
||||
}
|
||||
|
||||
// Fixed-patch explosion into blocked fine storage: target = owner block cell
|
||||
// id of a patch-ghost cell; donors are parent block cell ids. Dense parent
|
||||
// storage is the one-window case with coarse_cells = full domain size.
|
||||
extern "C" __global__ void BlockExplode(
|
||||
const fpxx* coarse, fpxx* fine, const uint64_t* target, const uint64_t* donor,
|
||||
unsigned int count, unsigned long coarse_cells)
|
||||
{
|
||||
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (row >= count) return;
|
||||
const uint64_t t = target[row];
|
||||
fpxx* to = fine + (t / TOTAL_CELLS) * NQ * TOTAL_CELLS;
|
||||
const uint64_t d = donor[row];
|
||||
const fpxx* from = coarse + (d / coarse_cells) * NQ * coarse_cells;
|
||||
for (unsigned int q = 0; q < NQ; ++q)
|
||||
to[index_f(t % TOTAL_CELLS, q)] = from[q * coarse_cells + d % coarse_cells];
|
||||
}
|
||||
|
||||
// Targets are receiver-local SOURCE slots for the immediately following pull.
|
||||
// Keep child summation order identical to PatchCoarseStep; no collision here.
|
||||
extern "C" __global__ void BlockCoalesce(
|
||||
const fpxx* fine, fpxx* coarse, const uint64_t* target, const uint64_t* children,
|
||||
unsigned int count)
|
||||
{
|
||||
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (row >= count) return;
|
||||
const uint64_t* s = children + 4ul * row;
|
||||
coarse[target[row]] = 0.25f * (fine[s[0]] + fine[s[1]] + fine[s[2]] + fine[s[3]]);
|
||||
}
|
||||
|
||||
// Static cut links, receiver-local staging: cell is the receiver's local core
|
||||
// cell, so the existing BFL writes the solid-source window image that this
|
||||
// block's next pull reads (no halo copy between this kernel and BlockStep).
|
||||
// surface_velocity (nullable, 2 floats per link): prescribed fluid velocity on
|
||||
// fixed geometry, same convention as CurvedBoundaryKernel. Optional raw rows
|
||||
// (jx,jy,torque,dm) are unique physical links, never body-observation slots.
|
||||
extern "C" __global__ void BlockCurvedBoundary(
|
||||
fpxx* f, const uint32_t* block, const uint32_t* cell, const unsigned char* dir,
|
||||
const float* q, const unsigned char* fallback, unsigned int count,
|
||||
const float* surface_velocity, const float* rx, const float* ry, float* raw)
|
||||
{
|
||||
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (row >= count) return;
|
||||
float Uw = 0.0f, Vw = 0.0f;
|
||||
if (surface_velocity != nullptr) {
|
||||
const float u = surface_velocity[2 * row], v = surface_velocity[2 * row + 1];
|
||||
if (u != 0.0f || v != 0.0f) { Uw = u; Vw = v; }
|
||||
}
|
||||
apply_bouzidi_link(dir[row], q[row], cell[row], f + (unsigned long)block[row] * NQ * TOTAL_CELLS,
|
||||
Uw, Vw, rx ? rx[row] : 0.0f, ry ? ry[row] : 0.0f,
|
||||
fallback[row], nullptr, -1, raw ? raw + 4ul * row : nullptr,
|
||||
raw ? raw + 4ul * row + 3 : nullptr);
|
||||
}
|
||||
|
||||
// Same-stream BFL -> this row-owned accumulation -> pull step. No atomics,
|
||||
// no cross-row reduction and no history allocation proportional to substeps.
|
||||
extern "C" __global__ void BlockAccumulateExchange(
|
||||
const float* raw, double* integral, unsigned int count, double h, double dt)
|
||||
{
|
||||
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (row >= count) return;
|
||||
const unsigned long k = 4ul * row;
|
||||
const double impulse = h * dt;
|
||||
integral[k] += impulse * (double)raw[k];
|
||||
integral[k + 1] += impulse * (double)raw[k + 1];
|
||||
integral[k + 2] += h * impulse * (double)raw[k + 2];
|
||||
integral[k + 3] += h * h * (double)raw[k + 3];
|
||||
}
|
||||
|
||||
// Pull/collide/store core cells only; halo cells of out are left untouched.
|
||||
// flags (block cell ids, nullable): solid cores store NaN and never collide.
|
||||
// kind (block cell ids, nullable = all ACTIVE): OUTER skips the pull and
|
||||
// stores NaN, INNER pulls without collision, ACTIVE pulls and collides.
|
||||
extern "C" __global__ void BlockStep(const fpxx* in, fpxx* out, unsigned int halo,
|
||||
const uint16_t* flags, const unsigned char* kind) {
|
||||
unsigned int x, y; unsigned long k;
|
||||
index_from_thread(x, y, k);
|
||||
if (x < halo || y < halo || x >= NX - halo || y >= NY - halo) return;
|
||||
const unsigned long base = (unsigned long)blockIdx.z * NQ * TOTAL_CELLS;
|
||||
const unsigned long cell = (unsigned long)blockIdx.z * TOTAL_CELLS + k;
|
||||
const unsigned char cell_kind = kind != nullptr ? kind[cell] : KIND_ACTIVE;
|
||||
float f[NQ];
|
||||
if (cell_kind == KIND_OUTER || (flags != nullptr && is_solid(flags[cell]))) {
|
||||
for (int q = 0; q < NQ; ++q) f[q] = NAN;
|
||||
} else {
|
||||
unsigned long j[NQ];
|
||||
compute_neighbors(x, y, j); // Core neighbors stay inside the window: no wrap.
|
||||
stream_pull_load(k, f, in + base, j);
|
||||
if (cell_kind == KIND_ACTIVE) {
|
||||
if (d_block_wall.nx != 0) {
|
||||
const unsigned int bx = NX - 2 * halo, by = NY - 2 * halo;
|
||||
const unsigned int nbx = d_block_wall.nx / bx;
|
||||
const unsigned int gx = (blockIdx.z % nbx) * bx + x - halo;
|
||||
const unsigned int gy = (blockIdx.z / nbx) * by + y - halo;
|
||||
if (gy == 1)
|
||||
reconstruct_bottom_velocity(f, 0.0f, bottom_wall_normal_velocity(
|
||||
gx, d_block_wall.nx, d_block_wall.slot_width, d_block_wall.amplitude));
|
||||
else if (gy == d_block_wall.ny - 2) reconstruct_top_reservoir(f, RHO);
|
||||
}
|
||||
#if COLLISION_MODEL == 2
|
||||
// Uniform MRT sequence (collide_dispatch, zero forcing); SRT path below unchanged.
|
||||
float rho, ux, uy;
|
||||
compute_rho_u(f, rho, ux, uy);
|
||||
collide_mrt_no_force(f, rho, ux, uy,
|
||||
fminf(OMEGA_COLLISION_MAX, fmaxf(OMEGA_COLLISION_MIN, d_params.omega)));
|
||||
#else
|
||||
float rho, ux, uy, feq[NQ];
|
||||
compute_rho_u(f, rho, ux, uy);
|
||||
#if USE_DDF_SHIFTING
|
||||
// Preserve the density perturbation before adding the unit reference.
|
||||
const float drho = f[0]+f[1]+f[2]+f[3]+f[4]+f[5]+f[6]+f[7]+f[8];
|
||||
const float jx = f[1]-f[2]+f[5]-f[6]+f[7]-f[8];
|
||||
const float jy = f[3]-f[4]+f[5]-f[6]-f[7]+f[8];
|
||||
compute_feq_from_momentum(drho, jx, jy, ux, uy, feq);
|
||||
#else
|
||||
compute_feq(rho, ux, uy, feq);
|
||||
#ifndef BLOCK_ROOT_BC
|
||||
#error "Blocks require a generated BLOCK_ROOT_BC mode"
|
||||
#elif BLOCK_ROOT_BC < 0 || BLOCK_ROOT_BC > 2
|
||||
#error "BLOCK_ROOT_BC must be 0, 1 or 2"
|
||||
#endif
|
||||
collide_srt_no_force(f, feq, d_params.omega);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
stream_pull_store(k, f, out + base);
|
||||
}
|
||||
|
||||
// Root channel closure; fine and periodic levels keep BlockStep.
|
||||
#if BLOCK_ROOT_BC == 1
|
||||
#include "boundary/block_wall.cuh"
|
||||
#endif
|
||||
#if BLOCK_ROOT_BC != 2
|
||||
#include "operators/block_collision.cuh"
|
||||
#endif
|
||||
|
||||
// Kernels, in definition order: transfers, boundary staging, then the one full
|
||||
// step selected for this module (BlockStep, or BlockChannelStep for a channel root).
|
||||
#include "step/block_transfer.cu"
|
||||
#include "step/block_boundary.cu"
|
||||
#if BLOCK_ROOT_BC != 2
|
||||
#include "step/block_step.cu"
|
||||
#else
|
||||
#include "step/block_channel.cu"
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Physical parent walls; NX/NY remain local storage strides.
|
||||
#ifndef CELERIS_BOUNDARY_BLOCK_WALL_CUH
|
||||
#define CELERIS_BOUNDARY_BLOCK_WALL_CUH
|
||||
|
||||
#include "planar_velocity.cuh"
|
||||
|
||||
struct BlockWall { unsigned int nx, ny, slot_width; float amplitude; };
|
||||
extern "C" { __constant__ BlockWall d_block_wall; }
|
||||
|
||||
__device__ __forceinline__ void block_wall(float* f, unsigned int x, unsigned int y,
|
||||
unsigned int halo) {
|
||||
unsigned int gx, gy;
|
||||
block_global_xy(x, y, halo, d_block_wall.nx, gx, gy);
|
||||
if (gy == 1)
|
||||
reconstruct_bottom_velocity(f, 0.0f, bottom_wall_normal_velocity(
|
||||
gx, d_block_wall.nx, d_block_wall.slot_width, d_block_wall.amplitude));
|
||||
else if (gy == d_block_wall.ny - 2) reconstruct_top_reservoir(f, RHO);
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -15,10 +15,13 @@
|
||||
// D2Q9 y-wall half-way bounce-back for pull double-buffer streaming.
|
||||
// ---------------------------------------------------------------------------
|
||||
#if LATTICE_MODEL == LATTICE_D2Q9
|
||||
__device__ inline void apply_wall_bb_d2q9_y_pull(unsigned int y,
|
||||
float* __restrict__ f,
|
||||
const fpxx* __restrict__ fi_in,
|
||||
unsigned long k)
|
||||
// Rows 1 and top_row (= physical ny - 2) next to walls at rows 0 and ny - 1.
|
||||
// y is the physical row; k addresses the node in fi_in (a block window may
|
||||
// have NX/NY strides different from the physical extent).
|
||||
__device__ inline void apply_wall_bb_d2q9_rows(unsigned int y, unsigned int top_row,
|
||||
float* __restrict__ f,
|
||||
const fpxx* __restrict__ fi_in,
|
||||
unsigned long k)
|
||||
{
|
||||
if (y == 1) {
|
||||
// Directions sourced from y=0 wall in pull step: +y, +x+y, -x+y.
|
||||
@@ -26,14 +29,22 @@ __device__ inline void apply_wall_bb_d2q9_y_pull(unsigned int y,
|
||||
f[5] = load_ddf(fi_in, index_f(k, 6u));
|
||||
f[8] = load_ddf(fi_in, index_f(k, 7u));
|
||||
}
|
||||
else if (y == (unsigned int)(NY - 2)) {
|
||||
// Directions sourced from y=NY-1 wall in pull step: -y, -x-y, +x-y.
|
||||
else if (y == top_row) {
|
||||
// Directions sourced from the y = ny - 1 wall in pull step: -y, -x-y, +x-y.
|
||||
f[4] = load_ddf(fi_in, index_f(k, 3u));
|
||||
f[6] = load_ddf(fi_in, index_f(k, 5u));
|
||||
f[7] = load_ddf(fi_in, index_f(k, 8u));
|
||||
}
|
||||
}
|
||||
|
||||
__device__ inline void apply_wall_bb_d2q9_y_pull(unsigned int y,
|
||||
float* __restrict__ f,
|
||||
const fpxx* __restrict__ fi_in,
|
||||
unsigned long k)
|
||||
{
|
||||
apply_wall_bb_d2q9_rows(y, (unsigned int)(NY - 2), f, fi_in, k);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// D2Q9 y-wall free-slip (specular reflection) for pull double-buffer streaming.
|
||||
//
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
#define STORE_PRECISION 0
|
||||
#define USE_DDF_SHIFTING 0
|
||||
|
||||
// Internal blocked root mode: 0=periodic/fine, 1=slot/reservoir, 2=channel.
|
||||
#define BLOCK_ROOT_BC 0
|
||||
|
||||
#define USE_LES 0
|
||||
#define LES_CS 0.160000f
|
||||
#define LES_CLOSED_FORM 1
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// CelerisLab – core/cell_kind.cuh
|
||||
// Fixed-patch cell kinds stored per block cell, matching lbm/refinement.py
|
||||
// ACTIVE/INNER_GHOST/OUTER_GHOST: OUTER is invalid (stores NaN, no pull),
|
||||
// INNER streams without collision, ACTIVE pulls and collides.
|
||||
#ifndef CELERIS_CORE_CELL_KIND_CUH
|
||||
#define CELERIS_CORE_CELL_KIND_CUH
|
||||
|
||||
constexpr unsigned char KIND_ACTIVE = 0u, KIND_INNER = 1u, KIND_OUTER = 2u;
|
||||
|
||||
// NaN populations mark a cell without valid state (OUTER ghost or solid core).
|
||||
__device__ __forceinline__ void fill_invalid(float* f)
|
||||
{
|
||||
for (int q = 0; q < NQ; ++q) f[q] = NAN;
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -53,6 +53,18 @@ __device__ __forceinline__ void index_from_thread(unsigned int& x,
|
||||
k = (unsigned long)y * NX + x;
|
||||
}
|
||||
|
||||
// Global cell of window cell (x, y) in block blockIdx.z of a row-major,
|
||||
// full-domain block layout domain_nx cells wide. NX/NY are the local strides.
|
||||
__device__ __forceinline__ void block_global_xy(unsigned int x, unsigned int y,
|
||||
unsigned int halo, unsigned int domain_nx,
|
||||
unsigned int& gx, unsigned int& gy)
|
||||
{
|
||||
const unsigned int bx = NX - 2 * halo, by = NY - 2 * halo;
|
||||
const unsigned int nbx = domain_nx / bx;
|
||||
gx = (blockIdx.z % nbx) * bx + x - halo;
|
||||
gy = (blockIdx.z / nbx) * by + y - halo;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Neighbor list (periodic BCs via modular arithmetic)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Collision used by periodic/fine and slot/reservoir BlockStep.
|
||||
#ifndef CELERIS_OPERATORS_BLOCK_COLLISION_CUH
|
||||
#define CELERIS_OPERATORS_BLOCK_COLLISION_CUH
|
||||
|
||||
#include "../core/params.cuh"
|
||||
#include "macro.cuh"
|
||||
#include "collision_srt.cuh"
|
||||
#if COLLISION_MODEL == 2
|
||||
#include "collision_mrt.cuh"
|
||||
#endif
|
||||
|
||||
__device__ __forceinline__ void block_collide(float* f) {
|
||||
#if COLLISION_MODEL == 2
|
||||
collide_mrt_unforced(f); // SRT path below unchanged.
|
||||
#else
|
||||
float rho, ux, uy, feq[NQ];
|
||||
compute_rho_u(f, rho, ux, uy);
|
||||
#if USE_DDF_SHIFTING
|
||||
// Preserve the density perturbation before adding the unit reference.
|
||||
const float drho = f[0]+f[1]+f[2]+f[3]+f[4]+f[5]+f[6]+f[7]+f[8];
|
||||
const float jx = f[1]-f[2]+f[5]-f[6]+f[7]-f[8];
|
||||
const float jy = f[3]-f[4]+f[5]-f[6]-f[7]+f[8];
|
||||
compute_feq_from_momentum(drho, jx, jy, ux, uy, feq);
|
||||
#else
|
||||
compute_feq(rho, ux, uy, feq);
|
||||
#endif
|
||||
collide_srt_no_force(f, feq, d_params.omega);
|
||||
#endif
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,14 @@
|
||||
// CelerisLab – operators/bounce_back_swap.cuh
|
||||
// Full-way bounce-back at a wall node: swap every opposite pair (paired
|
||||
// order q, q+1). Needs only NQ; shared by the uniform and blocked steps.
|
||||
#ifndef CELERIS_OPERATORS_BOUNCE_BACK_SWAP_CUH
|
||||
#define CELERIS_OPERATORS_BOUNCE_BACK_SWAP_CUH
|
||||
|
||||
__device__ __forceinline__ void bounce_back_swap(float* f) {
|
||||
#pragma unroll
|
||||
for (int i = 1; i < NQ; i += 2) {
|
||||
float t = f[i]; f[i] = f[i+1]; f[i+1] = t;
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -114,6 +114,16 @@ __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,
|
||||
|
||||
@@ -5,12 +5,7 @@
|
||||
#ifndef CELERIS_OPERATORS_HELPERS_CUH
|
||||
#define CELERIS_OPERATORS_HELPERS_CUH
|
||||
|
||||
__device__ __forceinline__ void bounce_back_swap(float* f) {
|
||||
#pragma unroll
|
||||
for (int i = 1; i < NQ; i += 2) {
|
||||
float t = f[i]; f[i] = f[i+1]; f[i+1] = t;
|
||||
}
|
||||
}
|
||||
#include "bounce_back_swap.cuh"
|
||||
|
||||
__device__ __forceinline__ void collide_dispatch(
|
||||
float* f, float rho_n,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "core/params.cuh"
|
||||
#include "core/flags.cuh"
|
||||
#include "core/obs.cuh"
|
||||
#include "core/cell_kind.cuh"
|
||||
#include "operators/macro.cuh"
|
||||
#include "operators/collision_srt.cuh"
|
||||
#if COLLISION_MODEL == 2
|
||||
@@ -35,96 +36,5 @@ __device__ unsigned int incoming_counts[NQ * TOTAL_CELLS];
|
||||
}
|
||||
#endif
|
||||
|
||||
__device__ __forceinline__ void patch_collide(float* f, unsigned long k) {
|
||||
#if COLLISION_MODEL == 2
|
||||
float rho, ux, uy;
|
||||
compute_rho_u(f, rho, ux, uy);
|
||||
collide_mrt_no_force(f, rho, ux, uy,
|
||||
fminf(OMEGA_COLLISION_MAX, fmaxf(OMEGA_COLLISION_MIN, d_params.omega)));
|
||||
#else
|
||||
float rho, ux, uy, feq[NQ];
|
||||
compute_rho_u(f, rho, ux, uy);
|
||||
compute_feq(rho, ux, uy, feq);
|
||||
collide_srt_no_force(f, feq, d_params.omega);
|
||||
#endif
|
||||
#ifdef CELERIS_REFINEMENT_AUDIT
|
||||
++collision_counts[k];
|
||||
#endif
|
||||
}
|
||||
|
||||
extern "C" __global__ void PatchExplode(
|
||||
const float* coarse, float* fine, const uint32_t* targets,
|
||||
const uint32_t* donors, unsigned int count, unsigned long coarse_cells)
|
||||
{
|
||||
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (row >= count) return;
|
||||
for (unsigned int q = 0; q < NQ; ++q)
|
||||
fine[index_f(targets[row], q)] = coarse[q * coarse_cells + donors[row]];
|
||||
#ifdef CELERIS_REFINEMENT_AUDIT
|
||||
++explosion_counts[targets[row]];
|
||||
#endif
|
||||
}
|
||||
|
||||
extern "C" __global__ void PatchFineStep(const float* in, float* out, const uint16_t* flags) {
|
||||
unsigned int x, y; unsigned long k;
|
||||
index_from_thread(x, y, k);
|
||||
if (x >= NX || y >= NY) return;
|
||||
float f[NQ];
|
||||
// Outer halo is invalid after EACH hop, including when reusing the buffer
|
||||
// that held explosion data. Inner halo only streams; it never collides.
|
||||
if (x < 2 || y < 2 || x >= NX - 2 || y >= NY - 2
|
||||
|| (flags != nullptr && is_solid(flags[k]))) {
|
||||
for (int q = 0; q < NQ; ++q) f[q] = NAN;
|
||||
} else {
|
||||
unsigned long j[NQ];
|
||||
compute_neighbors(x, y, j); // These interior targets never wrap.
|
||||
stream_pull_load(k, f, in, j);
|
||||
if (x >= 4 && y >= 4 && x < NX - 4 && y < NY - 4)
|
||||
patch_collide(f, k);
|
||||
}
|
||||
stream_pull_store(k, f, out);
|
||||
}
|
||||
|
||||
extern "C" __global__ void PatchCoarseStep(
|
||||
const float* in, float* out, const float* fine,
|
||||
const uint64_t* targets, const uint64_t* children, unsigned int count,
|
||||
unsigned int x0, unsigned int y0, unsigned int x1, unsigned int y1,
|
||||
unsigned int wall_aware)
|
||||
{
|
||||
unsigned int x, y; unsigned long k;
|
||||
index_from_thread(x, y, k);
|
||||
if (x >= NX || y >= NY) return;
|
||||
// Both buffers were poisoned at initialization; covered cells stay invalid.
|
||||
if (x >= x0 && x < x1 && y >= y0 && y < y1) return;
|
||||
if (wall_aware && (y == 0 || y == NY - 1)) return;
|
||||
unsigned long j[NQ];
|
||||
compute_neighbors(x, y, j);
|
||||
float f[NQ];
|
||||
stream_pull_load(k, f, in, j);
|
||||
if (x >= x0 - 1 && x <= x1 && y >= y0 - 1 && y <= y1) {
|
||||
for (unsigned int q = 1; q < NQ; ++q) {
|
||||
uint64_t slot = index_f(k, q);
|
||||
unsigned int lo = 0, hi = count;
|
||||
while (lo < hi) {
|
||||
unsigned int mid = lo + (hi - lo) / 2;
|
||||
if (targets[mid] < slot) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
if (lo < count && targets[lo] == slot) {
|
||||
const uint64_t* s = children + 4ul * lo;
|
||||
f[q] = 0.25f * (fine[s[0]] + fine[s[1]] + fine[s[2]] + fine[s[3]]);
|
||||
#ifdef CELERIS_REFINEMENT_AUDIT
|
||||
++incoming_counts[slot];
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
// Includes the eight grazing-corner links with UNCOVERED coarse donors.
|
||||
// All replacement arrivals must be in place before computing rho/u.
|
||||
if (wall_aware) {
|
||||
if (y == 1) reconstruct_bottom_velocity(f, 0.0f, bottom_wall_normal_velocity(x));
|
||||
else if (y == NY - 2) reconstruct_top_reservoir(f, RHO);
|
||||
}
|
||||
patch_collide(f, k);
|
||||
stream_pull_store(k, f, out);
|
||||
}
|
||||
// Kernels: patch collision helper, PatchExplode, PatchFineStep, PatchCoarseStep.
|
||||
#include "step/patch_step.cu"
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// CelerisLab/lbm/kernels/step/block_boundary.cu
|
||||
// Receiver-local curved-boundary staging and its raw-exchange accumulation.
|
||||
// Part of the blocks.cu compilation entry; relies on its includes and guards.
|
||||
|
||||
// Static cut links, receiver-local staging: cell is the receiver's local core
|
||||
// cell, so the existing BFL writes the solid-source window image that this
|
||||
// block's next pull reads (no halo copy between this kernel and BlockStep).
|
||||
// surface_velocity (nullable, 2 floats per link): prescribed fluid velocity on
|
||||
// fixed geometry, same convention as CurvedBoundaryKernel. Optional raw rows
|
||||
// (jx,jy,torque,dm) are unique physical links, never body-observation slots.
|
||||
extern "C" __global__ void BlockCurvedBoundary(
|
||||
fpxx* f, const uint32_t* block, const uint32_t* cell, const unsigned char* dir,
|
||||
const float* q, const unsigned char* fallback, unsigned int count,
|
||||
const float* surface_velocity, const float* rx, const float* ry, float* raw)
|
||||
{
|
||||
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (row >= count) return;
|
||||
float Uw = 0.0f, Vw = 0.0f;
|
||||
if (surface_velocity != nullptr) {
|
||||
const float u = surface_velocity[2 * row], v = surface_velocity[2 * row + 1];
|
||||
if (u != 0.0f || v != 0.0f) { Uw = u; Vw = v; }
|
||||
}
|
||||
apply_bouzidi_link(dir[row], q[row], cell[row], f + (unsigned long)block[row] * NQ * TOTAL_CELLS,
|
||||
Uw, Vw, rx ? rx[row] : 0.0f, ry ? ry[row] : 0.0f,
|
||||
fallback[row], nullptr, -1, raw ? raw + 4ul * row : nullptr,
|
||||
raw ? raw + 4ul * row + 3 : nullptr);
|
||||
}
|
||||
|
||||
// Same-stream BFL -> this row-owned accumulation -> pull step. No atomics,
|
||||
// no cross-row reduction and no history allocation proportional to substeps.
|
||||
extern "C" __global__ void BlockAccumulateExchange(
|
||||
const float* raw, double* integral, unsigned int count, double h, double dt)
|
||||
{
|
||||
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (row >= count) return;
|
||||
const unsigned long k = 4ul * row;
|
||||
const double impulse = h * dt;
|
||||
integral[k] += impulse * (double)raw[k];
|
||||
integral[k + 1] += impulse * (double)raw[k + 1];
|
||||
integral[k + 2] += h * impulse * (double)raw[k + 2];
|
||||
integral[k + 3] += h * h * (double)raw[k + 3];
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
#if BLOCK_ROOT_BC != 2
|
||||
#error "BlockChannelStep requires BLOCK_ROOT_BC == 2"
|
||||
#endif
|
||||
|
||||
// Root-only blocked counterpart of OneStep's D2Q9/SRT channel closure.
|
||||
// NX/NY are LOCAL strides; all physical boundary decisions use global coordinates.
|
||||
#include "../core/cell_kind.cuh"
|
||||
#include "../operators/bounce_back_swap.cuh"
|
||||
#include "../boundary/bounce_back.cuh"
|
||||
#include "../boundary/inlet/common.cuh"
|
||||
#include "../boundary/inlet/zou_he_local.cuh"
|
||||
#include "../boundary/outlet/pressure_neq.cuh"
|
||||
@@ -15,15 +22,13 @@ extern "C" __global__ void BlockChannelStep(
|
||||
unsigned int x, y; unsigned long k;
|
||||
index_from_thread(x, y, k);
|
||||
if (x < halo || y < halo || x >= NX - halo || y >= NY - halo) return;
|
||||
const unsigned int bx = NX - 2 * halo, by = NY - 2 * halo;
|
||||
const unsigned int nbx = d_block_channel.nx / bx;
|
||||
const unsigned int gx = (blockIdx.z % nbx) * bx + x - halo;
|
||||
const unsigned int gy = (blockIdx.z / nbx) * by + y - halo;
|
||||
unsigned int gx, gy;
|
||||
block_global_xy(x, y, halo, d_block_channel.nx, gx, gy);
|
||||
const unsigned long base = (unsigned long)blockIdx.z * NQ * TOTAL_CELLS;
|
||||
const unsigned long cell = (unsigned long)blockIdx.z * TOTAL_CELLS + k;
|
||||
float f[NQ];
|
||||
if (kind != nullptr && kind[cell] == KIND_OUTER) {
|
||||
for (int q = 0; q < NQ; ++q) f[q] = NAN;
|
||||
fill_invalid(f);
|
||||
} else {
|
||||
unsigned long j[NQ];
|
||||
compute_neighbors(x, y, j);
|
||||
@@ -31,35 +36,21 @@ extern "C" __global__ void BlockChannelStep(
|
||||
const bool wall = gy == 0 || gy == d_block_channel.ny - 1;
|
||||
const bool outlet = gx == d_block_channel.nx - 1;
|
||||
if (wall) {
|
||||
// Wall owns all four corners. Ghosts pull and swap, never collide.
|
||||
for (int q = 1; q < NQ; q += 2) {
|
||||
const float t = f[q]; f[q] = f[q + 1]; f[q + 1] = t;
|
||||
}
|
||||
bounce_back_swap(f); // Wall owns all four corners; ghosts never collide.
|
||||
} else if (gx == 0) {
|
||||
apply_zou_he_left_velocity_inlet_d2q9(
|
||||
f, inlet_target_u((float)gy, d_block_channel.ny, d_block_channel.velocity), 0.0f);
|
||||
} else if (outlet) {
|
||||
// Immutable input donor, receiver-local (also valid across a block seam).
|
||||
float donor[NQ];
|
||||
for (int q = 0; q < NQ; ++q)
|
||||
donor[q] = load_ddf(in + base, index_f(k - 1, q));
|
||||
load_node(in + base, k - 1, donor);
|
||||
apply_pressure_outlet_d2q9(f, donor);
|
||||
} else if (gy == 1) {
|
||||
f[3] = load_ddf(in + base, index_f(k, 4));
|
||||
f[5] = load_ddf(in + base, index_f(k, 6));
|
||||
f[8] = load_ddf(in + base, index_f(k, 7));
|
||||
} else if (gy == d_block_channel.ny - 2) {
|
||||
f[4] = load_ddf(in + base, index_f(k, 3));
|
||||
f[6] = load_ddf(in + base, index_f(k, 5));
|
||||
f[7] = load_ddf(in + base, index_f(k, 8));
|
||||
} else {
|
||||
apply_wall_bb_d2q9_rows(gy, d_block_channel.ny - 2, f, in + base, k);
|
||||
}
|
||||
if (!wall && !outlet) {
|
||||
#if COLLISION_MODEL == 2
|
||||
// Match uniform MRT arithmetic (collide_dispatch, zero forcing).
|
||||
float rho, ux, uy;
|
||||
compute_rho_u(f, rho, ux, uy);
|
||||
collide_mrt_no_force(f, rho, ux, uy,
|
||||
fminf(OMEGA_COLLISION_MAX, fmaxf(OMEGA_COLLISION_MIN, d_params.omega)));
|
||||
collide_mrt_unforced(f);
|
||||
#else
|
||||
// Match uniform SRT arithmetic, including its shifted equilibrium path.
|
||||
float rho, ux, uy, feq[NQ], forcing[NQ] = {};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// CelerisLab/lbm/kernels/step/block_step.cu
|
||||
// Generic blocked pull/collide/store for periodic/fine and slot/reservoir levels.
|
||||
// Part of the blocks.cu compilation entry; relies on its includes and guards.
|
||||
#if BLOCK_ROOT_BC == 2
|
||||
#error "BlockStep is not built for the channel root (BLOCK_ROOT_BC == 2)"
|
||||
#endif
|
||||
|
||||
// Pull/collide/store core cells only; halo cells of out are left untouched.
|
||||
// flags (block cell ids, nullable): solid cores store NaN and never collide.
|
||||
// kind (block cell ids, nullable = all ACTIVE): OUTER skips the pull and
|
||||
// stores NaN, INNER pulls without collision, ACTIVE pulls and collides.
|
||||
extern "C" __global__ void BlockStep(const fpxx* in, fpxx* out, unsigned int halo,
|
||||
const uint16_t* flags, const unsigned char* kind) {
|
||||
unsigned int x, y; unsigned long k;
|
||||
index_from_thread(x, y, k);
|
||||
if (x < halo || y < halo || x >= NX - halo || y >= NY - halo) return;
|
||||
const unsigned long base = (unsigned long)blockIdx.z * NQ * TOTAL_CELLS;
|
||||
const unsigned long cell = (unsigned long)blockIdx.z * TOTAL_CELLS + k;
|
||||
const unsigned char cell_kind = kind != nullptr ? kind[cell] : KIND_ACTIVE;
|
||||
float f[NQ];
|
||||
if (cell_kind == KIND_OUTER || (flags != nullptr && is_solid(flags[cell]))) {
|
||||
fill_invalid(f);
|
||||
} else {
|
||||
unsigned long j[NQ];
|
||||
compute_neighbors(x, y, j); // Core neighbors stay inside the window: no wrap.
|
||||
stream_pull_load(k, f, in + base, j);
|
||||
if (cell_kind == KIND_ACTIVE) {
|
||||
#if BLOCK_ROOT_BC == 1
|
||||
block_wall(f, x, y, halo);
|
||||
#endif
|
||||
block_collide(f);
|
||||
}
|
||||
}
|
||||
stream_pull_store(k, f, out + base);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// CelerisLab/lbm/kernels/step/block_transfer.cu
|
||||
// Same-level halo refresh and 2:1 explosion/coalescence between blocked levels.
|
||||
// Part of the blocks.cu compilation entry; relies on its includes and guards.
|
||||
|
||||
// Halo refresh: block cell ids b*TOTAL_CELLS+c, sources are always core cells.
|
||||
extern "C" __global__ void BlockHaloCopy(
|
||||
fpxx* f, const uint64_t* dst, const uint64_t* src, unsigned int count)
|
||||
{
|
||||
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (row >= count) return;
|
||||
const uint64_t d = dst[row], s = src[row];
|
||||
fpxx* to = f + (d / TOTAL_CELLS) * NQ * TOTAL_CELLS;
|
||||
const fpxx* from = f + (s / TOTAL_CELLS) * NQ * TOTAL_CELLS;
|
||||
for (unsigned int q = 0; q < NQ; ++q)
|
||||
to[index_f(d % TOTAL_CELLS, q)] = from[index_f(s % TOTAL_CELLS, q)];
|
||||
}
|
||||
|
||||
// Fixed-patch explosion into blocked fine storage: target = owner block cell
|
||||
// id of a patch-ghost cell; donors are parent block cell ids. Dense parent
|
||||
// storage is the one-window case with coarse_cells = full domain size.
|
||||
extern "C" __global__ void BlockExplode(
|
||||
const fpxx* coarse, fpxx* fine, const uint64_t* target, const uint64_t* donor,
|
||||
unsigned int count, unsigned long coarse_cells)
|
||||
{
|
||||
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (row >= count) return;
|
||||
const uint64_t t = target[row];
|
||||
fpxx* to = fine + (t / TOTAL_CELLS) * NQ * TOTAL_CELLS;
|
||||
const uint64_t d = donor[row];
|
||||
const fpxx* from = coarse + (d / coarse_cells) * NQ * coarse_cells;
|
||||
for (unsigned int q = 0; q < NQ; ++q)
|
||||
to[index_f(t % TOTAL_CELLS, q)] = from[q * coarse_cells + d % coarse_cells];
|
||||
}
|
||||
|
||||
// Experimental Schornbaum--Ruede (arXiv:1508.07982, Algorithm 2)
|
||||
// and Chen et al. (doi:10.1016/j.physa.2005.09.036) population interpolation.
|
||||
// Independent implementation of the mathematical stencil; no upstream source copied.
|
||||
// Stencil cells are owner cores; a disabled axis repeats its center twice.
|
||||
extern "C" __global__ void BlockExplodeProjected(
|
||||
const fpxx* coarse, fpxx* fine, const uint64_t* target, const uint64_t* donor,
|
||||
unsigned int count, unsigned long coarse_cells,
|
||||
const uint64_t* neighbors, const float* offsets)
|
||||
{
|
||||
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (row >= count) return;
|
||||
const uint64_t t = target[row], d = donor[row];
|
||||
fpxx* to = fine + (t / TOTAL_CELLS) * NQ * TOTAL_CELLS;
|
||||
for (unsigned int q = 0; q < NQ; ++q) {
|
||||
float v[4];
|
||||
for (unsigned int a = 0; a < 4; ++a) {
|
||||
const uint64_t n = neighbors[4ul * row + a];
|
||||
v[a] = coarse[(n / coarse_cells) * NQ * coarse_cells
|
||||
+ q * coarse_cells + n % coarse_cells];
|
||||
}
|
||||
float gx = .5f * (v[1] - v[0]), gy = .5f * (v[3] - v[2]);
|
||||
const float ex = d_cx[q], ey = d_cy[q];
|
||||
if (q != 0) {
|
||||
const float along = (ex * gx + ey * gy) / (ex * ex + ey * ey);
|
||||
gx -= ex * along;
|
||||
gy -= ey * along;
|
||||
}
|
||||
to[index_f(t % TOTAL_CELLS, q)] =
|
||||
coarse[(d / coarse_cells) * NQ * coarse_cells + q * coarse_cells + d % coarse_cells]
|
||||
+ offsets[2ul * row] * gx + offsets[2ul * row + 1] * gy;
|
||||
}
|
||||
}
|
||||
|
||||
// Targets are receiver-local SOURCE slots for the immediately following pull.
|
||||
// Keep child summation order identical to PatchCoarseStep; no collision here.
|
||||
extern "C" __global__ void BlockCoalesce(
|
||||
const fpxx* fine, fpxx* coarse, const uint64_t* target, const uint64_t* children,
|
||||
unsigned int count)
|
||||
{
|
||||
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (row >= count) return;
|
||||
const uint64_t* s = children + 4ul * row;
|
||||
coarse[target[row]] = 0.25f * (fine[s[0]] + fine[s[1]] + fine[s[2]] + fine[s[3]]);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// CelerisLab/lbm/kernels/step/patch_step.cu
|
||||
// Dense fixed-patch collision, explosion and the fine/coarse steps.
|
||||
// Part of the refinement.cu compilation entry; relies on its includes, guards
|
||||
// and the optional CELERIS_REFINEMENT_AUDIT counters declared there.
|
||||
|
||||
__device__ __forceinline__ void patch_collide(float* f, unsigned long k) {
|
||||
#if COLLISION_MODEL == 2
|
||||
collide_mrt_unforced(f);
|
||||
#else
|
||||
float rho, ux, uy, feq[NQ];
|
||||
compute_rho_u(f, rho, ux, uy);
|
||||
compute_feq(rho, ux, uy, feq);
|
||||
collide_srt_no_force(f, feq, d_params.omega);
|
||||
#endif
|
||||
#ifdef CELERIS_REFINEMENT_AUDIT
|
||||
++collision_counts[k];
|
||||
#endif
|
||||
}
|
||||
|
||||
extern "C" __global__ void PatchExplode(
|
||||
const float* coarse, float* fine, const uint32_t* targets,
|
||||
const uint32_t* donors, unsigned int count, unsigned long coarse_cells)
|
||||
{
|
||||
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (row >= count) return;
|
||||
for (unsigned int q = 0; q < NQ; ++q)
|
||||
fine[index_f(targets[row], q)] = coarse[q * coarse_cells + donors[row]];
|
||||
#ifdef CELERIS_REFINEMENT_AUDIT
|
||||
++explosion_counts[targets[row]];
|
||||
#endif
|
||||
}
|
||||
|
||||
extern "C" __global__ void PatchFineStep(const float* in, float* out, const uint16_t* flags) {
|
||||
unsigned int x, y; unsigned long k;
|
||||
index_from_thread(x, y, k);
|
||||
if (x >= NX || y >= NY) return;
|
||||
float f[NQ];
|
||||
// Outer halo is invalid after EACH hop, including when reusing the buffer
|
||||
// that held explosion data. Inner halo only streams; it never collides.
|
||||
if (x < 2 || y < 2 || x >= NX - 2 || y >= NY - 2
|
||||
|| (flags != nullptr && is_solid(flags[k]))) {
|
||||
fill_invalid(f);
|
||||
} else {
|
||||
unsigned long j[NQ];
|
||||
compute_neighbors(x, y, j); // These interior targets never wrap.
|
||||
stream_pull_load(k, f, in, j);
|
||||
if (x >= 4 && y >= 4 && x < NX - 4 && y < NY - 4)
|
||||
patch_collide(f, k);
|
||||
}
|
||||
stream_pull_store(k, f, out);
|
||||
}
|
||||
|
||||
extern "C" __global__ void PatchCoarseStep(
|
||||
const float* in, float* out, const float* fine,
|
||||
const uint64_t* targets, const uint64_t* children, unsigned int count,
|
||||
unsigned int x0, unsigned int y0, unsigned int x1, unsigned int y1,
|
||||
unsigned int wall_aware)
|
||||
{
|
||||
unsigned int x, y; unsigned long k;
|
||||
index_from_thread(x, y, k);
|
||||
if (x >= NX || y >= NY) return;
|
||||
// Both buffers were poisoned at initialization; covered cells stay invalid.
|
||||
if (x >= x0 && x < x1 && y >= y0 && y < y1) return;
|
||||
if (wall_aware && (y == 0 || y == NY - 1)) return;
|
||||
unsigned long j[NQ];
|
||||
compute_neighbors(x, y, j);
|
||||
float f[NQ];
|
||||
stream_pull_load(k, f, in, j);
|
||||
if (x >= x0 - 1 && x <= x1 && y >= y0 - 1 && y <= y1) {
|
||||
for (unsigned int q = 1; q < NQ; ++q) {
|
||||
uint64_t slot = index_f(k, q);
|
||||
unsigned int lo = 0, hi = count;
|
||||
while (lo < hi) {
|
||||
unsigned int mid = lo + (hi - lo) / 2;
|
||||
if (targets[mid] < slot) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
if (lo < count && targets[lo] == slot) {
|
||||
const uint64_t* s = children + 4ul * lo;
|
||||
f[q] = 0.25f * (fine[s[0]] + fine[s[1]] + fine[s[2]] + fine[s[3]]);
|
||||
#ifdef CELERIS_REFINEMENT_AUDIT
|
||||
++incoming_counts[slot];
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
// Includes the eight grazing-corner links with UNCOVERED coarse donors.
|
||||
// All replacement arrivals must be in place before computing rho/u.
|
||||
if (wall_aware) {
|
||||
if (y == 1) reconstruct_bottom_velocity(f, 0.0f, bottom_wall_normal_velocity(x));
|
||||
else if (y == NY - 2) reconstruct_top_reservoir(f, RHO);
|
||||
}
|
||||
patch_collide(f, k);
|
||||
stream_pull_store(k, f, out);
|
||||
}
|
||||
@@ -21,6 +21,14 @@
|
||||
// i.e. the population traveling in direction i at node n
|
||||
// arrived from the node at n - c_i = neighbor in direction opp(i)
|
||||
// ---------------------------------------------------------------------------
|
||||
// load_node: all NQ populations of node n as stored (no streaming).
|
||||
__device__ __forceinline__ void load_node(const fpxx* __restrict__ fi, unsigned long n,
|
||||
float* __restrict__ f)
|
||||
{
|
||||
for (int q = 0; q < NQ; ++q)
|
||||
f[q] = load_ddf(fi, index_f(n, q));
|
||||
}
|
||||
|
||||
__device__ __forceinline__ void stream_pull_load(unsigned long n,
|
||||
float* __restrict__ f,
|
||||
const fpxx* __restrict__ fi,
|
||||
|
||||
@@ -22,7 +22,7 @@ from .block_stepper import MAX_GRID_DIM
|
||||
from .hierarchy import build_hierarchy, is_rectangle
|
||||
from .refinement import ACTIVE, HALO, FixedPatch2D, FixedPatchSet2D, prepare_fine_boundary
|
||||
from .refinement_tables import (blocked_base_tables, blocked_fine_tables, # noqa: F401 (re-export)
|
||||
blocked_kind, _prepare_blocked_boundary,
|
||||
blocked_kind, projected_explosion_tables, _prepare_blocked_boundary,
|
||||
BLOCKED_BOUNDARY_DEVICE_COLUMNS)
|
||||
from .descriptors import SOLID
|
||||
from .raw_exchange import summarize_exchange
|
||||
@@ -41,6 +41,8 @@ class _Pair:
|
||||
n_ghosts: int
|
||||
n_rows: int
|
||||
parent_cells: int # parent block window cells (explode donor stride)
|
||||
neighbors: object = None # projected-only owner-core cells; constant allocates neither
|
||||
offsets: object = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -63,52 +65,8 @@ def _level_name(level):
|
||||
return ("base", "fine", "child")[level] if level < 3 else f"level {level}"
|
||||
|
||||
|
||||
def _legacy_levels(bounds, *, fine_block, base_block, child, child_block, child_rectangles_in_parent,
|
||||
fine_objects, jets, child_objects, child_jets):
|
||||
"""Normalise the original two/three-level keywords to per-level lists (original errors)."""
|
||||
if child is not None and child_rectangles_in_parent is not None:
|
||||
raise ValueError("child and child_rectangles_in_parent are mutually exclusive")
|
||||
nested = child is not None or child_rectangles_in_parent is not None
|
||||
bounds = tuple(bounds)
|
||||
if not is_rectangle(bounds) and (fine_block is None or base_block is None):
|
||||
raise ValueError("several patches require fine_block and base_block")
|
||||
fine_objects, jets = tuple(fine_objects), tuple(jets)
|
||||
child_objects, child_jets = tuple(child_objects), tuple(child_jets)
|
||||
if not nested and (child_objects or child_jets):
|
||||
raise ValueError("child_objects/child_jets require a nested child")
|
||||
if base_block is not None and fine_block is None:
|
||||
raise ValueError("base_block requires fine_block")
|
||||
refinements, blocks = [bounds], [base_block, fine_block]
|
||||
objects, level_jets = [(), fine_objects], [(), jets]
|
||||
if nested:
|
||||
if child is not None and not is_rectangle(bounds):
|
||||
raise ValueError("scalar child requires one parent patch; use child_rectangles_in_parent")
|
||||
if fine_block is None or base_block is None or child_block is None:
|
||||
raise ValueError("a nested child needs fine_block, base_block and child_block")
|
||||
if fine_objects or jets:
|
||||
raise ValueError("a nested child supports bodies/Jets only on L2; use child_objects/child_jets")
|
||||
refinements.append(tuple(child) if child is not None else tuple(child_rectangles_in_parent))
|
||||
blocks.append(child_block)
|
||||
objects.append(child_objects)
|
||||
level_jets.append(child_jets)
|
||||
return refinements, blocks, objects, level_jets
|
||||
|
||||
|
||||
def _initial_levels(level_g, coarse_g, fine_g, child_g):
|
||||
"""One tuple of per-level arrays from initialize()'s positional or legacy named form."""
|
||||
if coarse_g is None and fine_g is None:
|
||||
if child_g is not None and len(level_g) != 2:
|
||||
raise TypeError("child_g needs exactly the coarse and fine arrays before it")
|
||||
return tuple(level_g) + (() if child_g is None else (child_g,))
|
||||
if level_g:
|
||||
raise TypeError("pass level arrays positionally or as coarse_g/fine_g/child_g, not both")
|
||||
if coarse_g is None or fine_g is None:
|
||||
raise TypeError("coarse_g and fine_g are both required in the named form")
|
||||
return (coarse_g, fine_g) + (() if child_g is None else (child_g,))
|
||||
|
||||
|
||||
def _check_levels(refinements, blocks, objects, jets):
|
||||
"""Candidate limits shared by the list and the legacy entry points."""
|
||||
"""Candidate limits of the level lists, checked before any device work."""
|
||||
depth = len(refinements) + 1
|
||||
if depth < 2:
|
||||
raise ValueError("at least one refinement level is required")
|
||||
@@ -117,13 +75,13 @@ def _check_levels(refinements, blocks, objects, jets):
|
||||
raise ValueError(f"{name} needs one entry per level ({depth})")
|
||||
for level, block in enumerate(blocks):
|
||||
if block is not None and len(block) != 2:
|
||||
raise ValueError(f"{_level_name(level)}_block must be (by, bx)")
|
||||
raise ValueError(f"level_blocks[{level}] must be (by, bx)")
|
||||
if objects[0] or jets[0]:
|
||||
raise ValueError("base-level bodies/Jets are not supported")
|
||||
if any(blocks[level - 1] is not None and blocks[level] is None for level in range(1, depth)):
|
||||
raise ValueError("a blocked level requires blocked refined levels below it")
|
||||
if not is_rectangle(refinements[0]) and (blocks[0] is None or blocks[1] is None):
|
||||
raise ValueError("several patches require fine_block and base_block")
|
||||
raise ValueError("several L1 patches require level_blocks[0] and level_blocks[1]")
|
||||
if depth > 2:
|
||||
if any(block is None for block in blocks):
|
||||
raise ValueError("three or more levels must all be blocked")
|
||||
@@ -132,7 +90,14 @@ def _check_levels(refinements, blocks, objects, jets):
|
||||
|
||||
|
||||
class FixedPatchStepper:
|
||||
"""D2Q9/SRT/FP32, one unforced internal 2:1 patch in an x-periodic box.
|
||||
"""D2Q9 SRT/MRT FP32 fixed 2:1 refinement hierarchy, built from level lists.
|
||||
|
||||
``refinements[l]`` gives level l+1 in level-l integer cells: one rectangle
|
||||
(x0, y0, x1, y1) (FixedPatch2D, a fine window with its own origin) or a
|
||||
sequence of rectangles (FixedPatchSet2D, a dependency-validated union on one
|
||||
level-wide grid, public fine arrays (2*ny, 2*nx), NaN outside ACTIVE cells);
|
||||
see lbm.hierarchy. ``level_blocks``, ``level_objects`` and ``level_jets``
|
||||
hold one entry per level, L0 included (L0 bodies/Jets are not supported).
|
||||
|
||||
``coarse_boundary`` is ``periodic`` (default) or ``slot_reservoir``:
|
||||
a bottom on-site slot and top fixed-density reservoir from ``cfg``.
|
||||
@@ -143,111 +108,91 @@ class FixedPatchStepper:
|
||||
actual channel closure footprint. Channel remapping is not supported.
|
||||
Periodic mode does not apply the wall/slot boundary fields in ``cfg``.
|
||||
Outer walls remain coarse; fine halo donors must be ordinary fluid.
|
||||
``fine_objects`` accepts fixed circles in coarse physical coordinates;
|
||||
velocities are in coarse lattice units (dx=dt=1). Independent local copies
|
||||
are projected at fine cell centers. Their complete stencil must be active
|
||||
and disjoint from the actual two-hop transfer footprint.
|
||||
These use the edge/cell-center frame (coarse centers I+.5), whereas ordinary Simulation objects use the integer-node frame.
|
||||
|
||||
``initialize(coarse_g, fine_g)`` accepts physical Q-major (9,ny,nx) arrays
|
||||
already in postcollision phase. It does not collide or build channel flags.
|
||||
Every ``step()`` publishes only after both levels complete on one stream.
|
||||
On a device/launch failure the instance becomes unusable: close and recreate
|
||||
it, rather than pretending the partially advanced fine buffer rolled back.
|
||||
Bodies are fixed circles in root (L0) physical coordinates; velocities are
|
||||
in root lattice units (dx=dt=1). Independent local copies are projected at
|
||||
fine cell centers; their complete stencil must be active and disjoint from
|
||||
the actual two-hop transfer footprint. These use the edge/cell-center frame
|
||||
(coarse centers I+.5), whereas ordinary Simulation objects use the
|
||||
integer-node frame. Jets (body.jets.Jet) use the same frame and drive cut
|
||||
links selected at their true hits; ``set_jet_amplitude(index, amplitude,
|
||||
level=l)`` re-uploads the per-link velocity column only.
|
||||
|
||||
``initialize(*level_g)`` accepts one physical Q-major (9, ny, nx) array per
|
||||
level, already in postcollision phase. It does not collide or build channel
|
||||
flags. Every ``step()`` publishes only after all levels complete on one
|
||||
stream. On a device/launch failure the instance becomes unusable: close and
|
||||
recreate it, rather than pretending a partially advanced buffer rolled back.
|
||||
|
||||
A ``level_blocks`` entry (by, bx) stores that level in blocks.cu storage:
|
||||
the ENTIRE level shape, including the four-cell patch ghost band, is
|
||||
partitioned by a non-periodic BlockLayout2D with one-cell same-level halos.
|
||||
Patch kinds stay per cell (OUTER invalid, INNER stream only, ACTIVE
|
||||
collide); halos are gathered before each hop. Public arrays keep the dense
|
||||
shapes; only the allocation differs. A blocked level requires blocked
|
||||
refined levels below it; several L1 rectangles and depth >= 3 require all
|
||||
levels blocked. Blocked bodies are stationary circles, optionally
|
||||
surface-actuated: prepare_fine_boundary decides legality on the dense grid,
|
||||
then cut links are staged receiver-local per hop: halo -> BFL -> step, same
|
||||
buffer. A blocked parent gathers halos before receiver-local coalescence
|
||||
staging and pull/collision; covered cells are poisoned on every step.
|
||||
|
||||
``fine_block=(by, bx)`` (opt-in) stores the fine level in
|
||||
blocks.cu storage: the ENTIRE fine_shape, including the four-cell patch
|
||||
ghost band, is partitioned by a non-periodic BlockLayout2D with one-cell
|
||||
same-level halos. Patch kinds stay per cell (OUTER invalid, INNER stream
|
||||
only, ACTIVE collide); halos are gathered before each fine hop. Public
|
||||
arrays keep the dense (9, ny, nx) fine shape; only the allocation differs.
|
||||
Blocked fine_objects are stationary circles, optionally surface-actuated:
|
||||
prepare_fine_boundary decides legality on the dense fine grid, then cut
|
||||
links are staged receiver-local per hop: halo -> BFL -> step, same buffer.
|
||||
``base_block=(by, bx)`` also blocks the parent; it requires ``fine_block``.
|
||||
Parent halos precede receiver-local coalescence staging and pull/collision;
|
||||
covered cells are explicitly poisoned on every step. Public shapes stay dense.
|
||||
``observe_raw_exchange=True`` records blocked physical cut-link exchange
|
||||
without changing populations. read_raw_exchange reports the last complete
|
||||
base interval or last substep in base-lattice units per unit depth. This
|
||||
is boundary momentum reaction, not a validated nozzle/stress load. Dense
|
||||
body layouts are not supported by this new diagnostic in this candidate.
|
||||
body levels are not supported by this diagnostic.
|
||||
``observe_window_exchange=True`` (requires observe_raw_exchange) adds a second
|
||||
per-link FP64 ledger accumulated by the same kernel between
|
||||
start_exchange_window() and read_exchange_window(); disabled by default.
|
||||
``jets`` (body.jets.Jet) use the same edge frame as ``fine_objects`` and
|
||||
drive fine cut links selected at their true hits; ``set_jet_amplitude``
|
||||
re-uploads the per-link velocity column only.
|
||||
|
||||
Several patches: pass ``bounds`` as a sequence of (x0, y0, x1, y1)
|
||||
rectangles (FixedPatchSet2D, dependency-validated union). This requires
|
||||
fine_block and base_block; fine_objects/jets are projected once onto the
|
||||
level-wide fine grid (each circle inside the union, body ids = object
|
||||
positions, jet and writer checks level-wide), and
|
||||
uses one level-wide fine grid (2*ny, 2*nx): public fine arrays have that
|
||||
shape, NaN outside the patches' ACTIVE cells. The step sequence is the
|
||||
blocked one above, with unique union transfers in one launch each.
|
||||
Nested levels: a child set's coordinates index the allocated parent array,
|
||||
including ghost bands and holes: root edge = parent fine_origin + index / 2
|
||||
(zero origin for a parent set; a scalar parent keeps its shifted window
|
||||
origin). A scalar child requires a scalar parent. Rectangle unions impose no
|
||||
fixed gap rule: adjacent, overlapping and concave unions are accepted when
|
||||
their actual transfer dependencies close (FixedPatchSet2D /
|
||||
nest_patch_set). All regions share one buffer pair per level and the fixed
|
||||
schedule of 2**l hops per base step at level l. Bodies/Jets are supported only on the
|
||||
finest level of a nested hierarchy (L2 dx=dt=.25 in root units, unchanged
|
||||
lattice velocity); every whole circle and its BFL stencil must fit strictly
|
||||
away from that level's transfer footprint.
|
||||
|
||||
Three-level separated unions: ``child_rectangles_in_parent=[...]`` is
|
||||
mutually exclusive with scalar ``child`` and accepts either parent form.
|
||||
Coordinates index the allocated L1 array, including ghost bands and holes:
|
||||
root edge = patch.fine_origin + L1 index / 2. A parent set has origin zero;
|
||||
a scalar parent retains its shifted window origin. The child set's public
|
||||
extent is twice the allocated L1 shape, even though storage is sparse.
|
||||
Each pair requires gap >= 4 in its own parent-cell units. All regions share
|
||||
one buffer pair per level and the fixed 1:2:4 schedule; L2-only bodies/Jets
|
||||
below apply unchanged, with object IDs assigned over the whole level.
|
||||
``explosion="projected_linear"`` selects experimental transverse linear
|
||||
reconstruction on each adjacent pair. Default "constant" is unchanged.
|
||||
Projected mode requires periodic root, all-blocked SRT levels without
|
||||
bodies/Jets or LES; this backend supplies no forcing. Geometry is generic,
|
||||
but union/multilevel GPU and matched-physics qualification remain pending.
|
||||
|
||||
Three-level single chain: ``child`` bounds use L1 window cells; all three
|
||||
levels must be blocked. ``child_objects`` and ``child_jets`` use the L0
|
||||
edge frame, with L2 dx=dt=.25 and unchanged lattice velocity. L1 bodies
|
||||
and Jets remain unsupported in a chain. Every whole circle and its BFL
|
||||
stencil must fit strictly away from the L2 transfer footprint. L2 runs
|
||||
halo -> BFL -> step four times per base step. Runtime child Jet changes
|
||||
require ``set_jet_amplitude(index, amplitude, level=2)``.
|
||||
|
||||
Level lists (any depth): ``refinements[l]`` gives level l+1 in level-l
|
||||
integer cells (scalar window or rectangle list, see lbm.hierarchy);
|
||||
``level_blocks``/``level_objects``/``level_jets`` hold one entry per level,
|
||||
L0 included. They are mutually exclusive with the legacy keywords above,
|
||||
which are normalised to the same lists. One recursive hop advances every
|
||||
level: a child explodes from its parent's current hop source, takes two
|
||||
hops, then the parent gathers halos, stages restriction, applies BFL and
|
||||
steps. Depth >= 3 must be fully blocked with bodies/Jets on the finest level.
|
||||
One recursive hop advances every level: a child explodes from its parent's
|
||||
current hop source, takes two hops, then the parent gathers halos, stages
|
||||
restriction, applies BFL and steps.
|
||||
"""
|
||||
def __init__(self, cfg, bounds=None, *, context, coarse_boundary="periodic", fine_objects=(),
|
||||
fine_block=None, base_block=None, jets=(), child=None, child_block=None,
|
||||
child_objects=(), child_jets=(), child_rectangles_in_parent=None,
|
||||
refinements=None, level_blocks=None, level_objects=None, level_jets=None,
|
||||
observe_raw_exchange=False, observe_window_exchange=False):
|
||||
def __init__(self, cfg, *, context, refinements, coarse_boundary="periodic",
|
||||
level_blocks=None, level_objects=None, level_jets=None,
|
||||
observe_raw_exchange=False, observe_window_exchange=False, explosion="constant"):
|
||||
if HALO != 4:
|
||||
raise RuntimeError("refinement.cu requires a four-cell fine halo")
|
||||
if coarse_boundary not in ("periodic", "slot_reservoir", "channel"):
|
||||
raise ValueError("coarse_boundary must be periodic, slot_reservoir or channel")
|
||||
if refinements is None:
|
||||
if bounds is None:
|
||||
raise ValueError("bounds or refinements is required")
|
||||
if level_blocks is not None or level_objects is not None or level_jets is not None:
|
||||
raise ValueError("level_blocks/level_objects/level_jets require refinements")
|
||||
refinements, level_blocks, level_objects, level_jets = _legacy_levels(
|
||||
bounds, fine_block=fine_block, base_block=base_block, child=child,
|
||||
child_block=child_block, child_rectangles_in_parent=child_rectangles_in_parent,
|
||||
fine_objects=fine_objects, jets=jets, child_objects=child_objects,
|
||||
child_jets=child_jets)
|
||||
else:
|
||||
if (bounds is not None or child is not None or child_rectangles_in_parent is not None
|
||||
or fine_block is not None or base_block is not None or child_block is not None
|
||||
or tuple(fine_objects) or tuple(jets) or tuple(child_objects) or tuple(child_jets)):
|
||||
raise ValueError("refinements and the legacy patch keywords are mutually exclusive")
|
||||
depth = len(refinements) + 1
|
||||
level_blocks = [None] * depth if level_blocks is None else list(level_blocks)
|
||||
level_objects = [()] * depth if level_objects is None else list(level_objects)
|
||||
level_jets = [()] * depth if level_jets is None else list(level_jets)
|
||||
depth = len(refinements) + 1
|
||||
level_blocks = [None] * depth if level_blocks is None else list(level_blocks)
|
||||
level_objects = [()] * depth if level_objects is None else list(level_objects)
|
||||
level_jets = [()] * depth if level_jets is None else list(level_jets)
|
||||
level_objects = [tuple(o) for o in level_objects]
|
||||
level_jets = [tuple(j) for j in level_jets]
|
||||
level_blocks = [None if b is None else tuple(b) for b in level_blocks]
|
||||
_check_levels(refinements, level_blocks, level_objects, level_jets)
|
||||
depth = len(refinements) + 1
|
||||
if explosion not in ("constant", "projected_linear"):
|
||||
raise ValueError("explosion must be constant or projected_linear")
|
||||
if explosion == "projected_linear" and (
|
||||
coarse_boundary != "periodic" or any(b is None for b in level_blocks)
|
||||
or any(level_objects) or any(level_jets)
|
||||
or cfg.collision != "SRT" or cfg.les_enabled):
|
||||
raise ValueError("projected explosion requires periodic all-blocked SRT levels "
|
||||
"without bodies/Jets or LES")
|
||||
self._explosion = explosion
|
||||
self._ddf_shifting = bool(cfg.ddf_shifting)
|
||||
if self._ddf_shifting and any(b is None for b in level_blocks):
|
||||
raise ValueError("shifted populations require all-blocked levels; dense/mixed is unsupported")
|
||||
@@ -417,7 +362,10 @@ class FixedPatchStepper:
|
||||
raise RuntimeError("level omega/tau mismatch")
|
||||
host = self._boundary_hosts[index]
|
||||
n_objects = len(host.objects) if name == "PatchFineStep" and host is not None else 0
|
||||
build = compiler.generate_config(config, n_objects=n_objects)
|
||||
block_root_bc = 0
|
||||
if index == 0 and layout is not None:
|
||||
block_root_bc = 2 if self._channel else 1 if self._wall_aware else 0
|
||||
build = compiler.generate_config(config, n_objects=n_objects, block_root_bc=block_root_bc)
|
||||
try:
|
||||
ptx = compiler.compile_kernel(self.ctx.sm_arch, source=source, build_dir=build)
|
||||
module = compiler.load_module(ptx)
|
||||
@@ -479,6 +427,11 @@ class FixedPatchStepper:
|
||||
self._upload(children), len(ghosts), len(targets),
|
||||
int(np.prod(pair.coarse_shape if parent.layout is None
|
||||
else parent.layout.local_shape)))
|
||||
if self._explosion == "projected_linear":
|
||||
neighbors, offsets = projected_explosion_tables(
|
||||
pair, parent.layout, self.hierarchy.kinds[index])
|
||||
result.explode = child.module.get_function("BlockExplodeProjected")
|
||||
result.neighbors, result.offsets = self._upload(neighbors), self._upload(offsets)
|
||||
if child.layout is not None:
|
||||
child.kind = self._upload(blocked_kind(child.layout, self.hierarchy.kinds[index + 1]))
|
||||
if index == 0 and base is not None:
|
||||
@@ -518,13 +471,13 @@ class FixedPatchStepper:
|
||||
if self._ddf_shifting and any(layout is None for layout in self._layouts):
|
||||
raise ValueError("shifted populations require all-blocked levels")
|
||||
|
||||
def initialize(self, *level_g, coarse_g=None, fine_g=None, child_g=None):
|
||||
def initialize(self, *level_g):
|
||||
"""Upload physical postcollision G, encoding before any FP32 rounding.
|
||||
|
||||
Shifted input should be physical FP64 to retain small perturbations.
|
||||
Public initialization resets the step count; non-leaves are discarded.
|
||||
"""
|
||||
given = _initial_levels(level_g, coarse_g, fine_g, child_g)
|
||||
given = tuple(level_g)
|
||||
with self._lock, self.ctx.activate():
|
||||
self._require("new", "ready")
|
||||
self._check_storage()
|
||||
@@ -648,12 +601,11 @@ class FixedPatchStepper:
|
||||
old, new, source, base_count=count, level_steps=(count, 2*count, 4*count),
|
||||
phase="postcollision_g", **options)
|
||||
candidate = type(self)(
|
||||
cfg, pairs[0].bounds, context=self.ctx, base_block=base_block,
|
||||
fine_block=fine_block, child_block=child_block,
|
||||
child_rectangles_in_parent=new.child.bounds,
|
||||
child_objects=child_objects, child_jets=jets,
|
||||
cfg, context=self.ctx, refinements=[pairs[0].bounds, new.child.bounds],
|
||||
level_blocks=[base_block, fine_block, child_block],
|
||||
level_objects=[(), (), child_objects], level_jets=[(), (), jets],
|
||||
observe_raw_exchange=self._observe_raw_exchange,
|
||||
observe_window_exchange=self._observe_window)
|
||||
observe_window_exchange=self._observe_window, explosion=self._explosion)
|
||||
try:
|
||||
if self._ddf_shifting:
|
||||
candidate._initialize_raw(_StoredPopulations(transferred.g, transferred.shifted))
|
||||
@@ -746,6 +698,7 @@ class FixedPatchStepper:
|
||||
pair, level = self._pairs[index - 1], self._levels[index]
|
||||
pair.explode(parent_buffer, level.current, pair.ghosts, pair.donors, np.uint32(pair.n_ghosts),
|
||||
np.uint64(pair.parent_cells),
|
||||
*((pair.neighbors, pair.offsets) if pair.neighbors is not None else ()),
|
||||
grid=((pair.n_ghosts + self._block[0] - 1) // self._block[0], 1), **kw)
|
||||
self._hop(index, level.current, level.spare, **kw)
|
||||
self._hop(index, level.spare, level.current, **kw)
|
||||
@@ -799,27 +752,41 @@ class FixedPatchStepper:
|
||||
grid=((pairs + self._block[0] - 1) // self._block[0], 1), **kw)
|
||||
|
||||
def step(self, n=1):
|
||||
"""Advance G→G by n base intervals: level l takes 2**l hops per interval."""
|
||||
"""Advance G→G by n base intervals: level l takes 2**l hops per interval.
|
||||
|
||||
All n intervals are enqueued in order on the one stream, and the host waits
|
||||
once, before returning; step(0) launches and waits for nothing. On return
|
||||
the device work is complete, step_count and an open window cover all n
|
||||
intervals, and the raw-exchange ledger holds the final interval only.
|
||||
|
||||
Asynchronous device errors surface at that single wait, so they are not
|
||||
attributed to the interval that caused them; call step(1) repeatedly to
|
||||
localize one (same launches, same bits). On any failure the instance is
|
||||
failed, the exchange ledger and window are invalid, and step_count keeps
|
||||
its value from entry to the failing call.
|
||||
"""
|
||||
if isinstance(n, bool) or not isinstance(n, Integral) or n < 0:
|
||||
raise ValueError("n must be a nonnegative integer")
|
||||
with self._lock, self.ctx.activate():
|
||||
self._require("ready")
|
||||
if n == 0:
|
||||
return
|
||||
root = self._levels[0]
|
||||
kw = dict(block=self._block, stream=self._stream)
|
||||
try:
|
||||
self._status = "advancing"
|
||||
self._exchange_valid = False
|
||||
for _ in range(n):
|
||||
if self._observe_raw_exchange:
|
||||
self._exchange_valid = False
|
||||
if self._observe_raw_exchange: # stream-ordered: holds the last interval only
|
||||
for b in self._level_boundaries:
|
||||
if b is not None:
|
||||
cuda.memset_d8_async(b.interval_integral, 0,
|
||||
len(b.tables["cell"]) * 32, self._stream)
|
||||
self._hop(0, root.current, root.spare, **kw)
|
||||
self._stream.synchronize()
|
||||
root.current, root.spare = root.spare, root.current
|
||||
self._step_count += 1
|
||||
self._exchange_valid = self._observe_raw_exchange
|
||||
root.current, root.spare = root.spare, root.current # host handles only
|
||||
self._stream.synchronize()
|
||||
self._step_count += n
|
||||
self._exchange_valid = self._observe_raw_exchange
|
||||
self._status = "ready"
|
||||
except BaseException:
|
||||
self._window_valid = False
|
||||
@@ -982,8 +949,8 @@ class FixedPatchStepper:
|
||||
if exc[0] is None:
|
||||
raise
|
||||
|
||||
# Read-only compatibility views over the level lists. Internal code must not use them;
|
||||
# REMOVE AFTER the existing tests/docs migrate to hierarchy/_layouts/_level_* lists.
|
||||
# Read-only compatibility views over the level lists, kept on purpose for the existing
|
||||
# tests and docs. Internal code must not use them; it reads hierarchy/_layouts/_level_*.
|
||||
patch = property(lambda self: self.hierarchy.pairs[0])
|
||||
child_patch = property(lambda self: self.hierarchy.pairs[1] if self.hierarchy.depth > 2 else None)
|
||||
base_layout = property(lambda self: self._layouts[0])
|
||||
|
||||
@@ -8,7 +8,7 @@ from dataclasses import dataclass, replace
|
||||
import numpy as np
|
||||
|
||||
from .blocks import blocked_link_tables
|
||||
from ..body.jets import jet_link_shapes, surface_link_velocity
|
||||
from ..body.jets import _surface_links
|
||||
from .descriptors import D2Q9_EX, D2Q9_EY
|
||||
from .refinement import ACTIVE, OUTER_GHOST
|
||||
|
||||
@@ -66,6 +66,50 @@ def blocked_base_tables(patch, layout, order, wall_aware=False, *, composed_kind
|
||||
blocked_kind(layout, dense_kind))
|
||||
|
||||
|
||||
def projected_explosion_tables(pair, parent_layout, composed_parent_kind):
|
||||
"""Experimental transverse linear explosion, in the pair's existing row order.
|
||||
|
||||
Schornbaum & Rüde (2016), arXiv:1508.07982, Algorithm 2, following
|
||||
Chen et al., doi:10.1016/j.physa.2005.09.036. Independently implemented
|
||||
from the mathematics: centered gradients, perpendicular projection and
|
||||
child offsets +/-1/4. The kernel applies the projection per population.
|
||||
|
||||
Only original explosion donors that are owned, uncovered ACTIVE cells
|
||||
may supply gradients. Both axis neighbors must be legal; otherwise repeat
|
||||
the center twice for zero slope. Owner-core addresses avoid stale halos.
|
||||
The same rule applies to scalar windows, unions and every adjacent pair;
|
||||
nested layouts retain their own per-axis (usually nonperiodic) topology.
|
||||
"""
|
||||
ny, nx = pair.coarse_shape
|
||||
kind = np.asarray(composed_parent_kind)
|
||||
if parent_layout.shape != pair.coarse_shape or kind.shape != pair.coarse_shape:
|
||||
raise ValueError("projected explosion parent layout/kind has the wrong shape")
|
||||
donor = pair.ghost_coarse.astype(np.intp)
|
||||
legal = np.zeros(ny * nx, bool)
|
||||
legal[donor] = True
|
||||
legal &= (parent_layout.owned.ravel() & ~pair.covered.ravel()
|
||||
& (kind.ravel() == ACTIVE))
|
||||
if not legal[donor].all():
|
||||
raise ValueError("projected explosion donors need owned uncovered parent ACTIVE cells")
|
||||
y, x = np.divmod(donor, nx)
|
||||
neighbors = np.repeat(donor[:, None], 4, axis=1)
|
||||
for axis, (dx, dy) in enumerate(((1, 0), (0, 1))):
|
||||
minus = ((y - dy) % ny) * nx + (x - dx) % nx
|
||||
plus = ((y + dy) % ny) * nx + (x + dx) % nx
|
||||
valid = legal[minus] & legal[plus]
|
||||
if not parent_layout.periodic[axis]:
|
||||
coordinate, extent = (x, nx) if axis == 0 else (y, ny)
|
||||
valid &= (coordinate > 0) & (coordinate < extent - 1)
|
||||
neighbors[valid, 2 * axis] = minus[valid]
|
||||
neighbors[valid, 2 * axis + 1] = plus[valid]
|
||||
fy, fx = np.divmod(pair.ghost_fine, pair.fine_shape[1])
|
||||
ox, oy = pair.fine_origin
|
||||
offsets = np.column_stack((ox + (fx + .5) / 2 - (x + .5),
|
||||
oy + (fy + .5) / 2 - (y + .5)))
|
||||
return (np.ascontiguousarray(parent_layout.owner.ravel()[neighbors], dtype=np.uint64),
|
||||
np.ascontiguousarray(offsets, dtype=np.float32))
|
||||
|
||||
|
||||
# Required device columns plus optional actuation; observation arms are uploaded
|
||||
# separately only when enabled. body_id/aperture remain host reduction metadata.
|
||||
BLOCKED_BOUNDARY_DEVICE_COLUMNS = ("block", "cell", "dir", "q", "fallback", "surface_velocity")
|
||||
@@ -98,11 +142,13 @@ def _prepare_blocked_boundary(boundary, layout, cfg, jets, *, _shifted_blocked=F
|
||||
tables.update(body_id=np.asarray(body_id, np.int32), rx=np.asarray(rx, np.float32),
|
||||
ry=np.asarray(ry, np.float32), aperture=np.zeros(len(dirs), bool))
|
||||
actuated = bool(jets) or any(o.surface_actuation is not None for o in boundary.objects)
|
||||
jet_cache = None
|
||||
if actuated:
|
||||
cfg = replace(cfg)
|
||||
cfg.validate()
|
||||
tables["surface_velocity"], tables["aperture"] = surface_link_velocity(
|
||||
tables["surface_velocity"], tables["aperture"], shape, jet_id = _surface_links(
|
||||
boundary.objects, cfg, boundary.flags, fluid_idx, dirs, body_id, rx, ry,
|
||||
jets=jets, hits=boundary.curved_hits, _shifted_blocked=_shifted_blocked)
|
||||
return _BlockedBoundary(boundary, tables, jets, actuated,
|
||||
jet_link_shapes(jets, boundary.curved_hits) if jets else None)
|
||||
if jets:
|
||||
jet_cache = (shape, jet_id)
|
||||
return _BlockedBoundary(boundary, tables, jets, actuated, jet_cache)
|
||||
|
||||
@@ -336,13 +336,19 @@ class Simulation:
|
||||
def read_surface_exchange(self, id: int):
|
||||
"""Latest step's wall/aperture (Fx,Fy,Tz) exchange, not total nozzle force.
|
||||
|
||||
Supports SurfaceActuation and Jet-driven bodies, including zero-amplitude
|
||||
apertures. Passive bodies without an aperture are rejected.
|
||||
Unlike read_body this is not accumulated/time-averaged. It resets after
|
||||
geometry sync or checkpoint load; advance before interpreting it.
|
||||
"""
|
||||
if self.bodies.get(id).surface_actuation is None:
|
||||
obj = self.bodies.get(id)
|
||||
curved = self.field.curved
|
||||
has_aperture = (curved.aperture is not None
|
||||
and np.any(curved.aperture & (curved.body_id == id)))
|
||||
if obj.surface_actuation is None and not has_aperture:
|
||||
raise ValueError("Body has no surface actuation")
|
||||
self.stream.synchronize()
|
||||
return self.field.curved.surface_exchange(id)
|
||||
return curved.surface_exchange(id)
|
||||
|
||||
@context_bound
|
||||
def read_body(self, id: int, *,
|
||||
@@ -451,18 +457,21 @@ class Simulation:
|
||||
@context_bound
|
||||
def initialize(self):
|
||||
"""Initialize flow field and sync objects to GPU."""
|
||||
# Recompile if objects were added after construction.
|
||||
self._assert_allocation_contract()
|
||||
# Validate the complete geometry before flags, populations or modules
|
||||
# change. Compact-list construction publishes no outputs on failure.
|
||||
next_flags = self.bodies.build_flags(self.field.build_channel_flags())
|
||||
if self.bodies.jets and self.bodies.count == 0:
|
||||
raise ValueError("Jets need bodies with cut links")
|
||||
self.bodies.build_compact_lists_for(self.bodies.objects, next_flags)
|
||||
|
||||
# Recompile if objects were added after construction.
|
||||
if self._read_generated_n_objects() != self.bodies.count:
|
||||
self.recompile()
|
||||
|
||||
self.field.flag = self.field.build_channel_flags()
|
||||
if self.bodies.count > 0:
|
||||
self.field.flag = self.bodies.build_flags(self.field.flag)
|
||||
self.field.flag = next_flags
|
||||
self.field.upload_flags()
|
||||
|
||||
if self.bodies.jets and self.bodies.count == 0:
|
||||
raise ValueError("Jets need bodies with cut links")
|
||||
self.stepper.initialize()
|
||||
if self.bodies.count > 0:
|
||||
self.bodies.sync_to_gpu(self.field, rebuild_flags=False)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Checkpoint save -> load round trip for a scene with passive bodies. Requires GPU.
|
||||
|
||||
A fresh Simulation with the same scene, restored from the checkpoint must hold the same populations,
|
||||
flags, body count and step count, and must stay bitwise identical to the original
|
||||
after both continue for the same number of steps. Checkpoints with Jets are
|
||||
refused (documented limitation) and are not covered here.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ._cases import make_sim
|
||||
|
||||
# Three separated cylinders (pinball-like); cut-link receivers stay in bulk fluid.
|
||||
CIRCLES = (((40, 32), 6), ((56, 22), 6), ((56, 42), 6))
|
||||
|
||||
|
||||
def scene():
|
||||
sim = make_sim()
|
||||
sim.initialize()
|
||||
for center, radius in CIRCLES:
|
||||
sim.add_body("circle", center=center, radius=radius)
|
||||
sim.sync_bodies()
|
||||
return sim
|
||||
|
||||
|
||||
class CheckpointBodiesGPU(unittest.TestCase):
|
||||
def test_round_trip_is_bitwise_and_continues_identically(self):
|
||||
sim = scene()
|
||||
sim.run(60)
|
||||
with tempfile.NamedTemporaryFile(suffix=".h5", delete=False) as f:
|
||||
path = f.name
|
||||
restored = None
|
||||
try:
|
||||
saved = sim.save_checkpoint(path)
|
||||
# The loader requires the same body count and geometry: rebuild the scene.
|
||||
restored = scene()
|
||||
restored.load_checkpoint(saved)
|
||||
|
||||
self.assertEqual(restored.bodies.count, len(CIRCLES))
|
||||
self.assertEqual(restored.stepper.step_count, sim.stepper.step_count)
|
||||
np.testing.assert_array_equal(restored.get_flags(), sim.get_flags())
|
||||
a, b = sim.get_ddf(), restored.get_ddf()
|
||||
self.assertEqual(a.dtype, b.dtype)
|
||||
self.assertEqual(a.tobytes(), b.tobytes())
|
||||
|
||||
sim.run(40)
|
||||
restored.run(40)
|
||||
self.assertEqual(restored.stepper.step_count, sim.stepper.step_count)
|
||||
a, b = sim.get_ddf(), restored.get_ddf()
|
||||
self.assertTrue(np.isfinite(a).all())
|
||||
self.assertEqual(a.tobytes(), b.tobytes())
|
||||
for i in range(len(CIRCLES)):
|
||||
self.assertTrue(np.all(np.isfinite(restored.read_force(i))))
|
||||
finally:
|
||||
if restored is not None:
|
||||
restored.close()
|
||||
os.unlink(path)
|
||||
sim.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -16,6 +16,7 @@
|
||||
#define NZ 1
|
||||
#define NT 1
|
||||
#define COLLISION_MODEL 0
|
||||
#define BLOCK_ROOT_BC 2
|
||||
#define USE_LES 0
|
||||
#define RHO 1.f
|
||||
#define U0 .025f
|
||||
@@ -40,7 +41,7 @@ inline float __fdiv_rn(float a,float b){return a/b;}
|
||||
#include "boundary/inlet_outlet.cuh"
|
||||
#include "step/one_step_double.cu"
|
||||
#else
|
||||
constexpr unsigned char KIND_OUTER=2;
|
||||
#include "core/cell_kind.cuh"
|
||||
#include "step/block_channel.cu"
|
||||
#endif
|
||||
int main(){
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Check historical source manifests against their own recorded commit, not today's checkout."""
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tarfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _require(commit):
|
||||
"""Missing recorded history is an explicit failure, never a skip or a repin."""
|
||||
found = subprocess.run(
|
||||
["git", "-C", str(ROOT), "cat-file", "-e", commit + "^{commit}"],
|
||||
capture_output=True,
|
||||
)
|
||||
if found.returncode:
|
||||
raise AssertionError(
|
||||
f"Required history missing: commit {commit} is not in this clone; "
|
||||
"fetch it instead of repinning or substituting"
|
||||
)
|
||||
|
||||
|
||||
def tracked(commit, prefix="src"):
|
||||
"""Tracked paths under prefix at the recorded commit."""
|
||||
_require(commit)
|
||||
return set(
|
||||
subprocess.check_output(
|
||||
["git", "-C", str(ROOT), "ls-tree", "-r", "--name-only", commit, prefix],
|
||||
text=True,
|
||||
).splitlines()
|
||||
)
|
||||
|
||||
|
||||
def historical_tree(commit, dest):
|
||||
"""Extract the tracked tree of the recorded commit into dest."""
|
||||
_require(commit)
|
||||
archive = subprocess.check_output(
|
||||
["git", "-C", str(ROOT), "archive", "--format=tar", commit]
|
||||
)
|
||||
dest = Path(dest)
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
extra = {"filter": "data"} if hasattr(tarfile, "data_filter") else {}
|
||||
with tarfile.open(fileobj=io.BytesIO(archive)) as tar:
|
||||
tar.extractall(dest, **extra)
|
||||
return dest
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Host contract of PeriodicBlockStepper.step(n): one stream wait per call (mocked driver)."""
|
||||
import ast
|
||||
from contextlib import nullcontext
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import types
|
||||
from unittest.mock import Mock
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from CelerisLab.config import LBMConfig
|
||||
from CelerisLab.lbm.blocks import BlockLayout2D
|
||||
|
||||
|
||||
def host_module():
|
||||
"""block_stepper with pycuda/compiler replaced by mocks; no GPU context."""
|
||||
# Locate the source without importing it: importing block_stepper loads pycuda.driver,
|
||||
# which CPU-only import-boundary tests (test_build_contracts) must never see.
|
||||
path = Path(importlib.util.find_spec("CelerisLab.lbm.block_stepper").origin)
|
||||
tree = ast.parse(path.read_text())
|
||||
tree.body = [node for node in tree.body if not (
|
||||
isinstance(node, ast.Import) and any(a.name == "pycuda.driver" for a in node.names)
|
||||
or isinstance(node, ast.ImportFrom) and node.module == "cuda")]
|
||||
module = types.ModuleType("CelerisLab.lbm._test_host_block_stepper")
|
||||
module.__package__ = "CelerisLab.lbm"
|
||||
module.__dict__.update(cuda=Mock(), compiler=Mock())
|
||||
exec(compile(tree, str(path), "exec"), module.__dict__)
|
||||
return module
|
||||
|
||||
|
||||
def backend():
|
||||
host, log = host_module(), []
|
||||
host.cuda.mem_get_info.return_value = (1 << 40, 1 << 40)
|
||||
host.cuda.mem_alloc.side_effect = lambda size: Mock(size=size) # distinct buffers
|
||||
module = Mock()
|
||||
module.get_global.return_value = (Mock(), 20)
|
||||
module.get_function.side_effect = lambda name: Mock(
|
||||
side_effect=lambda *a, **k: log.append((name, a[:2])))
|
||||
host.compiler.load_module.return_value = module
|
||||
cfg = LBMConfig(nx=16, ny=16, viscosity=.1)
|
||||
run = host.PeriodicBlockStepper(cfg, BlockLayout2D.build(16, 16, 8, 8),
|
||||
context=Mock(activate=nullcontext, sm_arch="sm_70"))
|
||||
run._stream.synchronize.side_effect = lambda: log.append(("sync", ()))
|
||||
run.initialize(np.full((9, 16, 16), 1 / 9))
|
||||
log.clear()
|
||||
return run, log
|
||||
|
||||
|
||||
class BlockStepSync(unittest.TestCase):
|
||||
def test_step_n_waits_once_and_matches_n_single_steps(self):
|
||||
many, log_many = backend()
|
||||
many.step(3)
|
||||
single, log_single = backend()
|
||||
for _ in range(3):
|
||||
single.step(1)
|
||||
names = [name for name, _ in log_many]
|
||||
self.assertEqual(names.count("sync"), 1)
|
||||
self.assertEqual(names[-1], "sync")
|
||||
self.assertEqual(names.count("BlockStep"), 3)
|
||||
# Same launch order and buffer roles as three step(1) calls.
|
||||
strip = lambda log: [(n, tuple(id(x) for x in a)) for n, a in log if n != "sync"]
|
||||
roles = lambda run, log: [(n, tuple({id(run._current): "c", id(run._spare): "s"}.get(x, "-")
|
||||
for x in ids)) for n, ids in strip(log)]
|
||||
self.assertEqual([n for n, _ in strip(log_many)], [n for n, _ in strip(log_single)])
|
||||
self.assertEqual(roles(many, log_many), roles(single, log_single))
|
||||
self.assertIsNot(many._current, many._spare)
|
||||
self.assertEqual((many.step_count, single.step_count), (3, 3))
|
||||
|
||||
def test_step_zero_launches_and_waits_for_nothing(self):
|
||||
run, log = backend()
|
||||
run.step(0)
|
||||
self.assertEqual((log, run.step_count), ([], 0))
|
||||
|
||||
def test_failure_keeps_entry_step_count(self):
|
||||
run, log = backend()
|
||||
run.step(1)
|
||||
run._stream.synchronize.side_effect = RuntimeError("device fault")
|
||||
with self.assertRaises(RuntimeError):
|
||||
run.step(2)
|
||||
self.assertEqual((run.step_count, run._status), (1, "failed"))
|
||||
|
||||
def test_launch_failure_mid_batch_fails_closed_without_waiting(self):
|
||||
run, log = backend()
|
||||
calls = []
|
||||
step = run._step
|
||||
|
||||
def failing(*args, **kwargs):
|
||||
calls.append(1)
|
||||
if len(calls) == 2:
|
||||
raise RuntimeError("launch failure")
|
||||
return step(*args, **kwargs)
|
||||
|
||||
run._step = failing
|
||||
with self.assertRaises(RuntimeError):
|
||||
run.step(3)
|
||||
self.assertNotIn("sync", [name for name, _ in log])
|
||||
self.assertEqual((run.step_count, run._status), (0, "failed"))
|
||||
with self.assertRaises(RuntimeError):
|
||||
run.download()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -83,6 +83,32 @@ assert abs(sum(compute_feq_d2q9(1.,0.,0.)) - 1.) < 1e-6
|
||||
|
||||
|
||||
class BuildContracts(unittest.TestCase):
|
||||
def test_block_root_modes_are_rendered_and_recorded(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
for mode in (0, 1, 2):
|
||||
with self.subTest(mode=mode):
|
||||
build = compiler.generate_config(LBMConfig(), build_root=root, block_root_bc=mode)
|
||||
try:
|
||||
method = Path(build, "config/config_method.h").read_text()
|
||||
self.assertIn(f"#define BLOCK_ROOT_BC {mode}\n", method)
|
||||
metadata = json.loads(Path(build, ".celeris-build").read_text())
|
||||
self.assertEqual(metadata["block_root_bc"], mode)
|
||||
self.assertNotIn("block_root_bc", metadata["config"])
|
||||
finally:
|
||||
compiler.cleanup_build_dir(build)
|
||||
build = compiler.generate_config(LBMConfig(), build_root=root)
|
||||
try:
|
||||
self.assertEqual(json.loads(Path(build, ".celeris-build").read_text())["block_root_bc"], 0)
|
||||
finally:
|
||||
compiler.cleanup_build_dir(build)
|
||||
|
||||
def test_invalid_block_root_mode_fails_before_snapshot(self):
|
||||
for mode in (-1, 3, True, False, 1.0, "1", None):
|
||||
with self.subTest(mode=mode), patch.object(compiler.tempfile, "mkdtemp") as allocate:
|
||||
with self.assertRaisesRegex(ValueError, "block_root_bc"):
|
||||
compiler.generate_config(LBMConfig(), block_root_bc=mode)
|
||||
allocate.assert_not_called()
|
||||
|
||||
@staticmethod
|
||||
def fake_compile(command, check):
|
||||
source = Path(command[-3])
|
||||
|
||||
@@ -50,7 +50,7 @@ def test_masks_and_minimal_footprint():
|
||||
def test_actual_closure_read_conflicts():
|
||||
pair, layout, targets = transfer((6, 6, 18, 14))
|
||||
validate_channel_transfers(pair, layout, targets)
|
||||
for target in (layout.slot(layout.owner[5, -1]-1, 2),
|
||||
for target in (layout.slot(int(layout.owner[5, -1]) - 1, 2), # integer under NumPy 1 and 2
|
||||
layout.slot(layout.owner[1, 4], 4),
|
||||
layout.slot(layout.owner[-2, 4], 3)):
|
||||
with pytest.raises(ValueError, match="immutable"):
|
||||
@@ -80,8 +80,8 @@ def test_evolved_ghost_snapshot_and_physical_download():
|
||||
host.cuda.memcpy_dtoh.side_effect = lambda a, ptr: np.copyto(a, storage[id(ptr)])
|
||||
context = Mock(activate=nullcontext, sm_arch="sm_70")
|
||||
cfg = LBMConfig(nx=24, ny=20, viscosity=.1, velocity=.025)
|
||||
backend = host.FixedPatchStepper(cfg, (6, 6, 18, 14), context=context,
|
||||
coarse_boundary="channel", base_block=(4, 6), fine_block=(4, 4))
|
||||
backend = host.FixedPatchStepper(cfg, context=context, coarse_boundary="channel",
|
||||
refinements=[(6, 6, 18, 14)], level_blocks=[(4, 6), (4, 4)])
|
||||
try:
|
||||
weights = np.array([4/9]+[1/9]*4+[1/36]*4)[:, None, None]
|
||||
arrays = [np.broadcast_to(weights, level.shape).copy() for level in backend._levels]
|
||||
@@ -97,5 +97,6 @@ def test_evolved_ghost_snapshot_and_physical_download():
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
backend.initialize(*arrays)
|
||||
assert backend._levels[0].kernel == host.compiler.load_module.return_value.get_function("BlockChannelStep")
|
||||
assert [call.kwargs["block_root_bc"] for call in host.compiler.generate_config.call_args_list] == [2, 0]
|
||||
finally:
|
||||
backend.close()
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""CPU-only contract tests for run_dfg_re20. Not CUDA or DFG acceptance evidence."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "validation"))
|
||||
import run_dfg_re20 as v # noqa: E402
|
||||
import run_refined_dfg as rr # noqa: E402
|
||||
|
||||
h, _ = rr.helpers(rr.DEFAULT_SOURCE)
|
||||
|
||||
|
||||
def rows(values, tstar_end=v.TSTAR_MAX):
|
||||
n = int(round(tstar_end / v.SAMPLE_TSTAR))
|
||||
return [dict(tstar=(i + 1) * v.SAMPLE_TSTAR, **values(i)) for i in range(n)]
|
||||
|
||||
|
||||
class Re20Contract(unittest.TestCase):
|
||||
def test_lattice_parameters_and_bounds(self):
|
||||
root, uniform = v.lattice_parameters(40), v.lattice_parameters(80)
|
||||
self.assertAlmostEqual(root["tau"], 0.56, places=12)
|
||||
self.assertAlmostEqual(uniform["tau"], 0.62, places=12)
|
||||
self.assertEqual((root["steps"], root["interval"]), (240000, 2000))
|
||||
self.assertEqual((uniform["steps"], uniform["interval"]), (480000, 4000))
|
||||
self.assertAlmostEqual(v.plan()["refined_fine_tau"], 0.62, places=12)
|
||||
|
||||
def test_config_only_changes_viscosity(self):
|
||||
base = v._re100_config(h, 40, "SRT", "none")
|
||||
cfg, body, p = v.config(h, 40, "SRT")
|
||||
self.assertAlmostEqual(cfg["physics"]["viscosity"], 0.01 * 40 / 20, places=15)
|
||||
self.assertEqual((p["re"], p["viscosity"]), (20.0, cfg["physics"]["viscosity"]))
|
||||
base[0]["physics"]["viscosity"] = cfg["physics"]["viscosity"]
|
||||
self.assertEqual(base[0], cfg)
|
||||
self.assertEqual(base[1], body)
|
||||
with self.assertRaisesRegex(ValueError, "no Jets"):
|
||||
v.config(h, 40, "MRT", "zero")
|
||||
|
||||
def test_units(self):
|
||||
s = v.sample([2.0, -0.5], 4, 40, 3.0)
|
||||
den = rr.SPEED**2 * 40 / 2
|
||||
self.assertAlmostEqual(s["cd"], 2.0 / 4 / den)
|
||||
self.assertAlmostEqual(s["cl"], -0.5 / 4 / den)
|
||||
self.assertAlmostEqual(s["p_diff"], 3.0 * 0.04)
|
||||
self.assertEqual(s["dp_scaled"], 3.0)
|
||||
|
||||
def test_steady_and_verdict(self):
|
||||
inside = dict(cd=5.58, cl=0.0106, p_diff=0.1174)
|
||||
out = v.summarize(rows(lambda i: inside))
|
||||
self.assertEqual((out["status"], out["steady"], out["window_samples"]),
|
||||
("WITHIN_INTERVALS", True, 21))
|
||||
self.assertTrue(out["cd_positive"])
|
||||
self.assertAlmostEqual(out["edge_distance"]["cl"], 0.0002)
|
||||
self.assertTrue(all(out["verdict"].values()))
|
||||
low = v.summarize(rows(lambda i: dict(inside, cl=0.0100)))
|
||||
self.assertEqual(low["status"], "OUTSIDE_INTERVALS")
|
||||
self.assertFalse(low["verdict"]["cl"])
|
||||
|
||||
def test_slow_drift_gives_no_verdict(self):
|
||||
# Range 2e-5 passes (limit 6e-5); slope * window 2e-5 exceeds 1.2e-5.
|
||||
out = v.summarize(rows(lambda i: dict(cd=5.58, cl=0.0106 + 1e-6 * i, p_diff=0.1174)))
|
||||
self.assertEqual((out["status"], out["verdict"]), ("NOT_STEADY", None))
|
||||
self.assertLessEqual(out["ranges"]["cl"], out["range_limits"]["cl"])
|
||||
self.assertGreater(out["drifts"]["cl"], out["slope_limits"]["cl"])
|
||||
|
||||
def test_noise_range(self):
|
||||
quiet = v.summarize(rows(lambda i: dict(cd=5.58, cl=0.0106 + 2e-5 * (i % 2), p_diff=0.1174)))
|
||||
self.assertTrue(quiet["steady"]) # within the range limit, no trend
|
||||
noisy = v.summarize(rows(lambda i: dict(cd=5.58, cl=0.0106 + 1e-4 * (i % 2), p_diff=0.1174)))
|
||||
self.assertEqual(noisy["status"], "NOT_STEADY")
|
||||
self.assertGreater(noisy["ranges"]["cl"], noisy["range_limits"]["cl"])
|
||||
|
||||
def test_incomplete_run_gives_no_verdict(self):
|
||||
out = v.summarize(rows(lambda i: dict(cd=5.58, cl=0.0106, p_diff=0.1174), tstar_end=30))
|
||||
self.assertEqual((out["status"], out["verdict"]), ("INCOMPLETE", None))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -8,6 +8,7 @@ import numpy as np
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
REPO = ROOT
|
||||
sys.path.insert(0, str(ROOT / "tests/validation"))
|
||||
sys.path.insert(0, str(ROOT / "tests/unit"))
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
from run_jet_circle_cv import (
|
||||
CX,
|
||||
@@ -16,8 +17,10 @@ from run_jet_circle_cv import (
|
||||
case,
|
||||
state,
|
||||
cv,
|
||||
cv_from_macros,
|
||||
aperture_probe,
|
||||
measured_aperture_flux,
|
||||
PIN,
|
||||
verify,
|
||||
)
|
||||
|
||||
@@ -28,6 +31,39 @@ def eq(rho, u, v):
|
||||
|
||||
|
||||
class Host(unittest.TestCase):
|
||||
def test_public_macros_pressure_advection_and_cropped_frames(self):
|
||||
for h, origin, shape, rect in (
|
||||
(.5, (0., 0.), (48, 64), (5., 4., 25., 20.)),
|
||||
(.5, (.25, .5), (48, 64), (5.25, 4., 25.25, 20.)),
|
||||
(.25, (.375, .75), (96, 128), (5.125, 4., 25.125, 20.)),
|
||||
):
|
||||
y, x = np.indices(shape)
|
||||
x = origin[0] + h * (x + .5)
|
||||
k, u, v = 1e-5, .02, -.01
|
||||
rho = 1 + 3 * k * x # p=(rho-1)/3=k*x
|
||||
ux, uy, fluid = np.full(shape, u), np.full(shape, v), np.ones(shape, bool)
|
||||
result = cv_from_macros(rho, ux, uy, fluid, origin, rect, .004, h)
|
||||
expected = 320 * np.array([3*k*u, k + 3*k*u*u, 3*k*u*v])
|
||||
np.testing.assert_allclose(result[1], expected, rtol=1e-11, atol=1e-13)
|
||||
np.testing.assert_allclose(result, cv(eq(rho, u, v), fluid, origin, rect, .004, h), atol=1e-12)
|
||||
x0, y0, x1, y1 = np.rint((np.array(rect)-np.tile(origin, 2))/h).astype(int)
|
||||
selection = np.s_[y0-2:y1+2, x0-2:x1+2]
|
||||
cropped_origin = np.asarray(origin) + h*np.array([x0-2, y0-2])
|
||||
cropped = cv_from_macros(rho[selection], ux[selection], uy[selection],
|
||||
fluid[selection], cropped_origin, rect, .004, h)
|
||||
np.testing.assert_array_equal(cropped, result)
|
||||
|
||||
def test_public_macros_full_normal_viscous_stress(self):
|
||||
y, x = np.indices((48, 64))
|
||||
x = (x + .5) * .5
|
||||
rho, ux, uy, fluid = np.ones_like(x), 1e-4*x*x, np.zeros_like(x), np.ones(x.shape, bool)
|
||||
args = (rho, ux, uy, fluid, (0., 0.), (5., 4., 25., 20.))
|
||||
_, viscous = cv_from_macros(*args, .004)
|
||||
_, inviscid = cv_from_macros(*args, 0.)
|
||||
np.testing.assert_allclose(viscous-inviscid, [0, -4*.004*1e-4*320, 0], atol=1e-13)
|
||||
with self.assertRaisesRegex(ValueError, "matching 2D"):
|
||||
cv_from_macros(rho, ux, uy[:-1], fluid, (0, 0), (5, 4, 25, 20), .004)
|
||||
|
||||
def test_descriptor_and_uniform_closed_flux(self):
|
||||
from CelerisLab.lbm.descriptors import D2Q9_EX, D2Q9_EY
|
||||
|
||||
@@ -132,7 +168,13 @@ class Host(unittest.TestCase):
|
||||
self.assertEqual(a["steps"] * 4, b["steps"])
|
||||
|
||||
def test_source_pin(self):
|
||||
verify(REPO)
|
||||
from tempfile import TemporaryDirectory
|
||||
from historical_tree import historical_tree
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "Source hash mismatch"):
|
||||
verify(REPO)
|
||||
with TemporaryDirectory() as directory:
|
||||
verify(historical_tree(PIN, directory))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -188,6 +188,55 @@ def jet_case(device, jets):
|
||||
return field, bodies
|
||||
|
||||
|
||||
def test_public_surface_exchange_for_jet_preserves_aperture_at_zero_and_reverse(device):
|
||||
from CelerisLab.simulation import Simulation
|
||||
jet = Jet(Sector(CENTER, 0., .6, 3.), Radial(CENTER, inward=False), "quartic", 2e-3)
|
||||
field, bodies = jet_case(device, [jet])
|
||||
sim = SimpleNamespace(bodies=bodies, field=field, _initialized=True,
|
||||
stream=SimpleNamespace(synchronize=device.synchronize))
|
||||
cl = field.curved
|
||||
assert bodies.get(0).surface_actuation is None
|
||||
aperture, velocity = cl.aperture, cl.surface_velocity.copy()
|
||||
assert aperture.any() and (~aperture).any()
|
||||
# Synthetic per-link exchanges test readback/splitting, not kernel physics.
|
||||
values = np.arange(cl.count * 3, dtype=np.float32).reshape(-1, 3)
|
||||
device.htod(cl.link_exchange_gpu, values)
|
||||
for amplitude in (2e-3, 0., -1e-3):
|
||||
Simulation.set_jet_amplitude(sim, 0, amplitude)
|
||||
result = Simulation.read_surface_exchange(sim, 0)
|
||||
assert cl.aperture is aperture
|
||||
np.testing.assert_array_equal(aperture, cl.jet_id >= 0)
|
||||
np.testing.assert_allclose(cl.surface_velocity, velocity * (amplitude / 2e-3))
|
||||
for name, mask in (("wall", ~aperture), ("aperture", aperture)):
|
||||
np.testing.assert_array_equal(result[name], values[mask].sum(axis=0, dtype=np.float64))
|
||||
|
||||
|
||||
def test_public_surface_exchange_keeps_legacy_and_passive_guards(device):
|
||||
from CelerisLab.simulation import Simulation
|
||||
field = make_field(device)
|
||||
bodies = manager(field, [circle(SurfaceActuation(0., .6, .002), radius=2.)])
|
||||
bodies.sync_to_gpu(field)
|
||||
sim = SimpleNamespace(bodies=bodies, field=field,
|
||||
stream=SimpleNamespace(synchronize=device.synchronize))
|
||||
for value in Simulation.read_surface_exchange(sim, 0).values():
|
||||
np.testing.assert_array_equal(value, 0.)
|
||||
field.curved.free_gpu_columns()
|
||||
with pytest.raises(RuntimeError, match="diagnostic is allocated"):
|
||||
Simulation.read_surface_exchange(sim, 0)
|
||||
|
||||
# An exchange buffer elsewhere in the scene must not admit a passive body.
|
||||
for jets in ([], [Jet(Sector(CENTER, 0., .6, 3.), Radial(CENTER))]):
|
||||
field, bodies = jet_case(device, jets)
|
||||
passive = bodies.add(circle(center=(2., 3.), radius=.6))
|
||||
bodies.sync_to_gpu(field)
|
||||
sim.bodies, sim.field = bodies, field
|
||||
with pytest.raises(ValueError, match="Body has no surface actuation"):
|
||||
Simulation.read_surface_exchange(sim, passive)
|
||||
if not jets:
|
||||
with pytest.raises(ValueError, match="Body has no surface actuation"):
|
||||
Simulation.read_surface_exchange(sim, 0)
|
||||
|
||||
|
||||
def test_manager_passes_true_hits_and_reuploads_only_velocity(device):
|
||||
# CPU memory double only: checks host/device bookkeeping, not GPU numerics.
|
||||
jet = Jet(Sector(CENTER, 0., .6, 3.), Radial(CENTER, inward=False), "quartic", 2e-3)
|
||||
|
||||
@@ -15,6 +15,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "validation"))
|
||||
from run_uniform_les_flow import source_guard, decode_macros, HERE, HEADER
|
||||
|
||||
ROOT = HERE.parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tests/unit"))
|
||||
from historical_tree import historical_tree, tracked
|
||||
|
||||
A135 = "a135252ae78b47b57d7e77d19001700b3bedac8b"
|
||||
|
||||
|
||||
class GuardTests(unittest.TestCase):
|
||||
@@ -42,52 +46,80 @@ class GuardTests(unittest.TestCase):
|
||||
decode_macros(invalid, w, cx, cy)
|
||||
|
||||
def test_current_corrected_pins(self):
|
||||
self.assertEqual(
|
||||
source_guard(ROOT, "corrected", "dev8403956")["base_commit"],
|
||||
"8403956a9afc0b0bb89265cec374392013d89016",
|
||||
)
|
||||
with TemporaryDirectory() as directory:
|
||||
root = historical_tree(A135, directory)
|
||||
with patch("run_uniform_les_flow.subprocess.check_output", return_value="historical-tree\n"):
|
||||
identity = source_guard(root, "corrected", "a135252")
|
||||
# base_commit comes from the pins file; the mock only feeds checkout_commit.
|
||||
self.assertEqual(identity["base_commit"], A135)
|
||||
self.assertEqual(identity["checkout_commit"], "historical-tree")
|
||||
|
||||
def test_corrected_and_tamper_guard(self):
|
||||
pins = json.loads((HERE / "data/les_flow/source_pins_dev8403956.json").read_text())
|
||||
pins = json.loads((HERE / "data/les_flow/source_pins_a135252.json").read_text())
|
||||
with TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
origin = historical_tree(A135, Path(directory) / "origin")
|
||||
root = Path(directory) / "fixture"
|
||||
for name in pins["sources"]:
|
||||
dest = root / name
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(ROOT / name, dest)
|
||||
shutil.copyfile(origin / name, dest)
|
||||
with patch(
|
||||
"run_uniform_les_flow.subprocess.check_output",
|
||||
return_value="isolated-fixture\n",
|
||||
):
|
||||
source_guard(root, "corrected", "dev8403956")
|
||||
source_guard(root, "corrected", "a135252")
|
||||
with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"):
|
||||
source_guard(root, "baseline", "dev8403956")
|
||||
target = root / "src/CelerisLab/lbm/kernels/step/one_step_double.cu"
|
||||
target.write_bytes(target.read_bytes() + b"\n// tamper\n")
|
||||
with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"):
|
||||
source_guard(root, "corrected", "dev8403956")
|
||||
source_guard(root, "baseline", "a135252")
|
||||
for name in ("kernels/step/one_step_double.cu", "kernels/blocks.cu",
|
||||
"refinement_stepper.py", "refinement_tables.py"):
|
||||
target = root / "src/CelerisLab/lbm" / name
|
||||
original = target.read_bytes()
|
||||
target.write_bytes(original + b"\n// tamper\n")
|
||||
with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"):
|
||||
source_guard(root, "corrected", "a135252")
|
||||
target.write_bytes(original)
|
||||
|
||||
def test_historical_default_and_manifest_unchanged(self):
|
||||
manifest = HERE / "data/les_flow/source_pins.json"
|
||||
self.assertEqual(hashlib.sha256(manifest.read_bytes()).hexdigest(),
|
||||
"87ca033619da1fc02bee822bfc5681b518a92a155c3c3d1dbbe5265c96066b39")
|
||||
for variant in ("baseline", "corrected"):
|
||||
historical = {
|
||||
"historical": ("source_pins.json", "87ca033619da1fc02bee822bfc5681b518a92a155c3c3d1dbbe5265c96066b39"),
|
||||
"dev8403956": ("source_pins_dev8403956.json", "58187908485f2af7c78677d53de98015878372444265ce9e667683b8ef5d410f"),
|
||||
}
|
||||
for experiment, (name, expected) in historical.items():
|
||||
manifest = HERE / "data/les_flow" / name
|
||||
self.assertEqual(hashlib.sha256(manifest.read_bytes()).hexdigest(), expected)
|
||||
for variant in ("baseline", "corrected"):
|
||||
with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"):
|
||||
source_guard(ROOT, variant, experiment)
|
||||
with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"):
|
||||
source_guard(ROOT, "corrected") # Omission still uses the historical profile.
|
||||
|
||||
def test_current_manifest_pins_actual_corrected_tree(self):
|
||||
pins = json.loads((HERE / "data/les_flow/source_pins_a135252.json").read_text())
|
||||
self.assertEqual(pins["base_commit"], A135)
|
||||
self.assertEqual(set(pins["sources"]), tracked(A135))
|
||||
with TemporaryDirectory() as directory:
|
||||
root = historical_tree(A135, directory)
|
||||
for name, expected in pins["sources"].items():
|
||||
self.assertEqual(hashlib.sha256((root / name).read_bytes()).hexdigest(), expected)
|
||||
self.assertEqual(pins["sources"][HEADER], pins["candidate_header_sha256"])
|
||||
self.assertNotEqual(pins["sources"][HEADER], pins["baseline_header_sha256"])
|
||||
with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"):
|
||||
source_guard(ROOT, variant)
|
||||
source_guard(root, "baseline", "a135252")
|
||||
|
||||
def test_named_baseline_accepts_only_reversed_header(self):
|
||||
pins = json.loads((HERE / "data/les_flow/source_pins_dev8403956.json").read_text())
|
||||
pins = json.loads((HERE / "data/les_flow/source_pins_a135252.json").read_text())
|
||||
with TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
origin = historical_tree(A135, Path(directory) / "origin")
|
||||
root = Path(directory) / "fixture"
|
||||
for name in pins["sources"]:
|
||||
dest = root / name
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(ROOT / name, dest)
|
||||
shutil.copyfile(origin / name, dest)
|
||||
subprocess.run(["git", "apply", "--reverse", str(HERE / "les/data/smagorinsky_normalization.patch")], cwd=root, check=True)
|
||||
with patch("run_uniform_les_flow.subprocess.check_output", return_value="baseline-fixture\n"):
|
||||
source_guard(root, "baseline", "dev8403956")
|
||||
source_guard(root, "baseline", "a135252")
|
||||
with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"):
|
||||
source_guard(root, "corrected", "dev8403956")
|
||||
source_guard(root, "corrected", "a135252")
|
||||
|
||||
def test_gpu_requires_opt_in_before_import(self):
|
||||
with TemporaryDirectory() as directory:
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
"""CPU stencil and actual closed-interface packet budgets; no CUDA claims."""
|
||||
import numpy as np
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from dataclasses import replace
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from CelerisLab.config import LBMConfig
|
||||
from CelerisLab.lbm.blocks import BlockLayout2D
|
||||
from CelerisLab.lbm.descriptors import D2Q9_EX, D2Q9_EY
|
||||
from CelerisLab.lbm.refinement import FixedPatch2D, FixedPatchSet2D, ACTIVE, OUTER_GHOST
|
||||
from CelerisLab.lbm.refinement_tables import projected_explosion_tables
|
||||
from CelerisLab.lbm.hierarchy import build_hierarchy
|
||||
from tests.unit.test_hierarchy import CFG, FOUR
|
||||
|
||||
E = np.array([D2Q9_EX, D2Q9_EY]).T
|
||||
W = np.array([4/9] + [1/9]*4 + [1/36]*4)
|
||||
|
||||
|
||||
def setup(union=False, bounds=(4, 4, 12, 12)):
|
||||
cfg = LBMConfig(nx=16, ny=16, viscosity=.1)
|
||||
p = (FixedPatchSet2D.from_config(cfg, [bounds]) if union else
|
||||
FixedPatch2D.from_config(cfg, bounds))
|
||||
layout = BlockLayout2D.build(16, 16, 4, 4, periodic=(True, True))
|
||||
neighbors, offsets = projected_explosion_tables(p, layout, np.where(p.covered, OUTER_GHOST, ACTIVE))
|
||||
inverse = np.full(layout.nblocks * np.prod(layout.local_shape), -1, dtype=int)
|
||||
inverse[layout.owner.ravel()] = np.arange(256)
|
||||
return p, inverse[neighbors], offsets
|
||||
|
||||
|
||||
def reconstruct(c, p, neighbors, offsets, project=True):
|
||||
# Independent NumPy evaluation of the documented stencil; neighbors have
|
||||
# been decoded from actual blocked owner IDs, not recomputed geometry.
|
||||
values = c.reshape(9, -1)[:, neighbors]
|
||||
g = .5 * (values[:, :, 1::2] - values[:, :, ::2])
|
||||
if project:
|
||||
g[1:] -= (np.einsum('qra,qa->qr', g[1:], E[1:]) /
|
||||
np.sum(E[1:]**2, axis=1)[:, None])[:, :, None] * E[1:, None, :]
|
||||
out = c.reshape(9, -1)[:, p.ghost_coarse] + np.einsum('qra,ra->qr', g, offsets)
|
||||
return out, g
|
||||
|
||||
|
||||
def check_legal_footprint_mean_and_characteristic(union, bounds):
|
||||
p, neighbors, offsets = setup(union, bounds)
|
||||
assert set(neighbors.ravel()) <= set(p.ghost_coarse)
|
||||
assert not p.covered.ravel()[neighbors].any()
|
||||
assert (neighbors >= 0).all()
|
||||
c = np.random.default_rng(72).normal(size=(9,16,16))
|
||||
c[:, p.covered] = np.nan # poison every forbidden donor
|
||||
out, g = reconstruct(c, p, neighbors, offsets)
|
||||
assert np.isfinite(out).all()
|
||||
np.testing.assert_allclose(np.einsum('qra,qa->qr', g, E), 0, atol=5e-16)
|
||||
for d in np.unique(p.ghost_coarse):
|
||||
rows = p.ghost_coarse == d
|
||||
assert rows.sum() == 4
|
||||
np.testing.assert_allclose(out[:, rows].mean(axis=1), c.reshape(9,-1)[:,d], atol=5e-16)
|
||||
for q in range(1,9):
|
||||
delta = offsets[rows, None, :] - offsets[None, rows, :]
|
||||
# Parallel displacements have zero 2D cross product.
|
||||
parallel = np.isclose(delta[...,0]*E[q,1]-delta[...,1]*E[q,0], 0)
|
||||
diff = out[q, rows][:,None]-out[q, rows][None,:]
|
||||
np.testing.assert_allclose(diff[parallel], 0, atol=5e-16)
|
||||
const = np.broadcast_to(W[:,None,None], (9,16,16)).copy()
|
||||
constant, _ = reconstruct(const,p,neighbors,offsets)
|
||||
np.testing.assert_array_equal(constant, np.broadcast_to(W[:,None], constant.shape))
|
||||
|
||||
|
||||
def collide(f, tau):
|
||||
rho = f.sum(axis=0)
|
||||
u = np.einsum('qa,q...->a...', E, f) / rho
|
||||
cu = np.einsum('qa,a...->q...', E, u)
|
||||
eq = W[:,None] * rho * (1 + 3*cu + 4.5*cu**2 - 1.5*np.sum(u*u,axis=0))
|
||||
return f + (eq-f)/tau
|
||||
|
||||
|
||||
def totals(c, f, p):
|
||||
q = c[:,~p.covered].sum(axis=1)+.25*f[:,p.fine_kind==ACTIVE].sum(axis=1)
|
||||
return np.r_[q.sum(), E.T@q]
|
||||
|
||||
|
||||
def check_full_two_hop_closed_corner_budget(union, collision, bounds):
|
||||
p, neighbors, offsets = setup(union,bounds)
|
||||
rng=np.random.default_rng(17)
|
||||
c=W[:,None,None]*(1+rng.normal(0,.002,(9,16,16)))
|
||||
f=W[:,None,None]*(1+rng.normal(0,.002,(9,*p.fine_shape)))
|
||||
start=totals(c,f,p)
|
||||
# Count exactly the eight coarse diagonal links whose source is also
|
||||
# uncovered, but whose midpoint lies on the closed rectangular interface.
|
||||
q, k=np.divmod(p.coalesce_coarse.astype(np.int64),256)
|
||||
y,x=np.divmod(k,16)
|
||||
grazing=~p.covered[y-E[q,1],x-E[q,0]]
|
||||
assert grazing.sum()==8
|
||||
c[:,p.covered]=np.nan
|
||||
for _ in range(8):
|
||||
f.reshape(9,-1)[:,p.ghost_fine]=reconstruct(c,p,neighbors,offsets)[0]
|
||||
for _ in range(2):
|
||||
f=np.array([np.roll(f[q],(ey,ex),(0,1)) for q,(ex,ey) in enumerate(E)])
|
||||
f[:,p.fine_kind==OUTER_GHOST]=np.nan
|
||||
if collision:
|
||||
f[:,p.fine_kind==ACTIVE]=collide(f[:,p.fine_kind==ACTIVE],p.tau_fine)
|
||||
out=np.array([np.roll(c[q],(ey,ex),(0,1)) for q,(ex,ey) in enumerate(E)])
|
||||
packets=f.reshape(-1)[p.coalesce_fine].mean(axis=1)
|
||||
assert np.isfinite(packets[grazing]).all()
|
||||
out.reshape(-1)[p.coalesce_coarse]=packets
|
||||
if collision:out[:,~p.covered]=collide(out[:,~p.covered],p.tau_coarse)
|
||||
out[:,p.covered]=np.nan
|
||||
c=out
|
||||
np.testing.assert_allclose(totals(c,f,p),start,rtol=0,atol=2e-12)
|
||||
|
||||
|
||||
def check_projection_is_distinguished_by_characteristic_not_global_mass():
|
||||
p,n,o=setup()
|
||||
c=np.random.default_rng(8).normal(size=(9,16,16))
|
||||
_,g=reconstruct(c,p,n,o,project=False)
|
||||
assert np.max(abs(np.einsum('qra,qa->qr',g,E))) > .1
|
||||
|
||||
|
||||
|
||||
|
||||
class ProjectedExplosionTests(unittest.TestCase):
|
||||
def test_stencil(self):
|
||||
for union in (False, True):
|
||||
for bounds in ((4,4,12,12),(2,3,9,10),(2,2,3,3)):
|
||||
with self.subTest(union=union,bounds=bounds):
|
||||
check_legal_footprint_mean_and_characteristic(union,bounds)
|
||||
|
||||
def test_closed_interface(self):
|
||||
for union in (False,True):
|
||||
for collision in (False,True):
|
||||
for bounds in ((4,4,12,12),(2,3,14,12),(2,2,3,3)):
|
||||
with self.subTest(union=union,collision=collision,bounds=bounds):
|
||||
check_full_two_hop_closed_corner_budget(union,collision,bounds)
|
||||
|
||||
def test_projection(self):
|
||||
check_projection_is_distinguished_by_characteristic_not_global_mass()
|
||||
|
||||
def test_affine_and_shift_invariance(self):
|
||||
p,n,o=setup()
|
||||
y,x=np.indices((16,16))
|
||||
c=np.array([.7+q*.01+x*.03+y*.05 for q in range(9)])
|
||||
out,g=reconstruct(c,p,n,o)
|
||||
available=n[:,::2]!=n[:,1::2]
|
||||
gradient=np.array([.03,.05])*available
|
||||
expected=np.broadcast_to(gradient,(9,*gradient.shape)).copy()
|
||||
expected[1:]-=(np.einsum('qra,qa->qr',expected[1:],E[1:])/
|
||||
np.sum(E[1:]**2,axis=1)[:,None])[:,:,None]*E[1:,None,:]
|
||||
np.testing.assert_allclose(g,expected,atol=2e-16)
|
||||
shifted,_=reconstruct(c-W[:,None,None],p,n,o)
|
||||
np.testing.assert_allclose(out-W[:,None],shifted,atol=5e-16)
|
||||
|
||||
|
||||
class ProjectedKernelTests(unittest.TestCase):
|
||||
@unittest.skipUnless(shutil.which("g++"), "g++ is required for actual-kernel CPU arithmetic")
|
||||
def test_actual_kernel_fp32_shifted_and_unshifted(self):
|
||||
p, neighbors, offsets = setup(True)
|
||||
parent = BlockLayout2D.build(16,16,4,4,periodic=(True,True))
|
||||
child = p.fine_layout((8,4)) # deliberately unequal parent/child strides
|
||||
ids, offsets = projected_explosion_tables(p,parent,np.where(p.covered,OUTER_GHOST,ACTIVE))
|
||||
cells = int(np.prod(parent.local_shape))
|
||||
targets = child.owner.ravel()[p.ghost_fine]
|
||||
donors = parent.owner.ravel()[p.ghost_coarse]
|
||||
source = (Path(__file__).resolve().parents[2] /
|
||||
"src/CelerisLab/lbm/kernels/step/block_transfer.cu").read_text()
|
||||
kernel = source.split('extern "C" __global__ void BlockExplodeProjected(',1)[1]
|
||||
kernel = 'void BlockExplodeProjected(' + kernel.split('// Targets are receiver-local SOURCE slots',1)[0]
|
||||
def array(name, values, kind):
|
||||
v = np.asarray(values).ravel()
|
||||
text = ','.join(str(int(x)) if kind=='uint64_t' else float(x).hex()+'f' for x in v)
|
||||
return f"{kind} {name}[] = {{{text}}};\n"
|
||||
for shifted in (False,True):
|
||||
c=np.random.default_rng(11).normal(0,.001,(9,16,16))
|
||||
if not shifted:c+=W[:,None,None]
|
||||
c=c.astype(np.float32)
|
||||
packed=np.full(parent.nblocks*9*cells,np.nan,np.float32)
|
||||
for q in range(9):packed[parent.slot(parent.owner.ravel(),q)]=c[q].ravel()
|
||||
# All halo/covered inputs are poisoned; kernel must use legal cores.
|
||||
for q in range(9):packed[parent.slot(parent.owner.ravel()[p.covered.ravel()],q)]=np.nan
|
||||
# Hex initializer needs a portable expression for poisoned NaNs.
|
||||
data=array('coarse',packed,'float').replace('nanf','NAN')
|
||||
code = '#include <cstdint>\n#include <cmath>\n#include <cstdio>\nusing fpxx=float;\n'
|
||||
code += f'constexpr unsigned NQ=9, TOTAL_CELLS={int(np.prod(child.local_shape))};\n'
|
||||
code += 'struct Id {unsigned x;}; Id blockIdx{0},blockDim{1},threadIdx{0};\n'
|
||||
code += 'unsigned index_f(unsigned k,unsigned q){return q*TOTAL_CELLS+k;}\n'
|
||||
code += 'int d_cx[]={0,1,-1,0,0,1,-1,1,-1},d_cy[]={0,0,0,1,-1,1,-1,-1,1};\n'+kernel
|
||||
code += data+array('target',targets,'uint64_t')+array('donor',donors,'uint64_t')
|
||||
code += array('neighbor',ids,'uint64_t')+array('offset',offsets,'float')
|
||||
code += f'float fine[{child.nblocks*9*int(np.prod(child.local_shape))}]={{}};\n'
|
||||
code += f'int main(){{for(unsigned r=0;r<{len(targets)};++r){{threadIdx.x=r;BlockExplodeProjected(coarse,fine,target,donor,{len(targets)},{cells},neighbor,offset);}}'
|
||||
code += f'for(unsigned q=0;q<9;++q)for(unsigned r=0;r<{len(targets)};++r){{auto t=target[r];printf("%.9g\\n",fine[(t/TOTAL_CELLS)*9*TOTAL_CELLS+q*TOTAL_CELLS+t%TOTAL_CELLS]);}}}}'
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
src=Path(folder)/'kernel.cpp'; binary=Path(folder)/'kernel'
|
||||
src.write_text(code)
|
||||
subprocess.run(['g++','-std=c++17','-O2',str(src),'-o',str(binary)],check=True,capture_output=True)
|
||||
result=np.fromstring(subprocess.check_output([str(binary)],text=True),sep=' ').reshape(9,-1)
|
||||
expected,_=reconstruct(c.astype(float),p,neighbors,offsets)
|
||||
np.testing.assert_allclose(result,expected,atol=6e-8,rtol=2e-7)
|
||||
|
||||
|
||||
UNIONS = [
|
||||
[(8, 8, 24, 20)],
|
||||
[(8, 8, 16, 20), (16, 8, 24, 20)], # abutting
|
||||
[(8, 8, 18, 20), (14, 8, 24, 20)], # overlap
|
||||
[(8, 8, 16, 20), (17, 8, 24, 20)], # gap 1
|
||||
[(8, 8, 16, 20), (18, 8, 24, 20)], # gap 2
|
||||
[(8, 8, 16, 20), (19, 8, 24, 20)], # gap 3
|
||||
[(8, 8, 20, 14), (8, 14, 14, 22)], # L concavity
|
||||
[(4, 4, 10, 10), (18, 16, 26, 26)], # separated
|
||||
[(4, 4, 5, 7), (4, 5, 7, 7)], # known concave exit/re-entry path
|
||||
]
|
||||
|
||||
|
||||
def decode(layout, neighbors):
|
||||
inverse = np.full(layout.hole, -1, dtype=int)
|
||||
inverse[layout.owner[layout.owned]] = np.flatnonzero(layout.owned)
|
||||
return inverse[neighbors]
|
||||
|
||||
|
||||
def check_pair_stencil(pair, layout, kind):
|
||||
ids, offsets = projected_explosion_tables(pair, layout, kind)
|
||||
neighbors = decode(layout, ids)
|
||||
legal = set(map(int, pair.ghost_coarse))
|
||||
assert len(np.unique(pair.ghost_fine)) == len(pair.ghost_fine)
|
||||
assert set(neighbors.ravel()) <= legal
|
||||
assert layout.owned.ravel()[neighbors].all()
|
||||
assert (kind.ravel()[neighbors] == ACTIVE).all()
|
||||
assert not pair.covered.ravel()[neighbors].any()
|
||||
ny, nx = pair.coarse_shape
|
||||
for row, donor in enumerate(pair.ghost_coarse):
|
||||
y, x = divmod(int(donor), nx)
|
||||
for axis, (dx, dy) in enumerate(((1, 0), (0, 1))):
|
||||
ends = [(x - dx, y - dy), (x + dx, y + dy)]
|
||||
expected = []
|
||||
for xx, yy in ends:
|
||||
outside = not (0 <= xx < nx and 0 <= yy < ny)
|
||||
if outside and not layout.periodic[axis]:
|
||||
break
|
||||
cell = (yy % ny) * nx + xx % nx
|
||||
if cell not in legal:
|
||||
break
|
||||
expected.append(cell)
|
||||
if len(expected) != 2:
|
||||
expected = [int(donor)] * 2
|
||||
np.testing.assert_array_equal(neighbors[row, 2*axis:2*axis+2], expected)
|
||||
fy, fx = divmod(int(pair.ghost_fine[row]), pair.fine_shape[1])
|
||||
expected = [pair.fine_origin[0]+(fx+.5)/2-(x+.5),
|
||||
pair.fine_origin[1]+(fy+.5)/2-(y+.5)]
|
||||
np.testing.assert_array_equal(offsets[row], expected)
|
||||
assert set(offsets.ravel()) == {-.25, .25}
|
||||
assert ids.dtype == np.uint64 and ids.flags.c_contiguous
|
||||
assert offsets.dtype == np.float32 and offsets.flags.c_contiguous
|
||||
return neighbors, offsets
|
||||
|
||||
|
||||
class GenericProjectedPairs(unittest.TestCase):
|
||||
def test_single_window_and_singleton_union_exact_stencils(self):
|
||||
for bounds in ((4,4,12,12), (2,3,9,10), (2,2,3,3)):
|
||||
a, an, ao = setup(False, bounds)
|
||||
b, bn, bo = setup(True, bounds)
|
||||
np.testing.assert_array_equal(a.ghost_coarse, b.ghost_coarse)
|
||||
np.testing.assert_array_equal(an, bn)
|
||||
np.testing.assert_array_equal(ao, bo)
|
||||
# Coordinates differ between scalar and union arrays; physical rows don't.
|
||||
ay, ax = np.divmod(a.ghost_fine, a.fine_shape[1])
|
||||
by, bx = np.divmod(b.ghost_fine, b.fine_shape[1])
|
||||
np.testing.assert_array_equal(ax + 2*a.fine_origin[0], bx)
|
||||
np.testing.assert_array_equal(ay + 2*a.fine_origin[1], by)
|
||||
|
||||
def test_union_decomposition_permutation_and_topologies(self):
|
||||
cfg = LBMConfig(nx=30, ny=30, viscosity=.1)
|
||||
layout = BlockLayout2D.build(30,30,6,5,periodic=(True,True))
|
||||
stencils = []
|
||||
for rectangles in UNIONS:
|
||||
pair = FixedPatchSet2D.from_config(cfg, rectangles)
|
||||
kind = np.where(pair.covered, OUTER_GHOST, ACTIVE)
|
||||
n, o = check_pair_stencil(pair, layout, kind)
|
||||
other = FixedPatchSet2D.from_config(cfg, rectangles[::-1])
|
||||
on, oo = projected_explosion_tables(other, layout, kind)
|
||||
np.testing.assert_array_equal(n, decode(layout, on))
|
||||
np.testing.assert_array_equal(o, oo)
|
||||
stencils.append((pair.ghost_fine, pair.ghost_coarse, n, o))
|
||||
c = np.random.default_rng(92).normal(size=(9,30,30))
|
||||
c[:, pair.covered] = np.nan
|
||||
values, gradients = reconstruct(c, pair, n, o)
|
||||
self.assertTrue(np.isfinite(values).all())
|
||||
np.testing.assert_allclose(np.einsum('qra,qa->qr', gradients, E),0,atol=1e-15)
|
||||
# q0 keeps its full gradient; no division by zero or forced zero.
|
||||
expected_q0 = .5*(c[0].ravel()[n[:,1::2]]-c[0].ravel()[n[:,::2]])
|
||||
np.testing.assert_array_equal(gradients[0], expected_q0)
|
||||
self.assertGreater(np.max(abs(gradients[0])), .1)
|
||||
for donor in np.unique(pair.ghost_coarse):
|
||||
rows = pair.ghost_coarse == donor
|
||||
self.assertEqual(rows.sum(),4)
|
||||
np.testing.assert_allclose(values[:,rows].mean(axis=1),c.reshape(9,-1)[:,donor],atol=1e-15)
|
||||
for q in range(1,9):
|
||||
delta = o[rows,None,:]-o[None,rows,:]
|
||||
parallel = delta[...,0]*E[q,1] == delta[...,1]*E[q,0]
|
||||
diff = values[q,rows][:,None]-values[q,rows][None,:]
|
||||
np.testing.assert_allclose(diff[parallel],0,atol=1e-15)
|
||||
for split in stencils[1:3]:
|
||||
for expected, actual in zip(stencils[0],split):
|
||||
np.testing.assert_array_equal(actual,expected)
|
||||
|
||||
def test_nested_scalar_origins_sparse_holes_and_unequal_strides(self):
|
||||
cases = [(CFG, FOUR), (LBMConfig(nx=16,ny=16,viscosity=.02),
|
||||
[(4,4,12,12),(8,8,16,16),(8,8,16,16)])]
|
||||
for cfg, refs in cases:
|
||||
hierarchy = build_hierarchy(cfg,refs)
|
||||
for index,pair in enumerate(hierarchy.pairs):
|
||||
if index == 0:
|
||||
layout = BlockLayout2D.build(cfg.ny,cfg.nx,4,4,periodic=(True,True))
|
||||
else:
|
||||
prior = hierarchy.pairs[index-1]
|
||||
layout = (prior.fine_layout((4,8)) if isinstance(prior,FixedPatchSet2D)
|
||||
else BlockLayout2D.build(*prior.fine_shape,4,8,periodic=(False,False)))
|
||||
kind = hierarchy.kinds[index]
|
||||
n,o = check_pair_stencil(pair,layout,kind)
|
||||
c = np.random.default_rng(index).normal(size=(9,*pair.coarse_shape))
|
||||
c[:,~layout.owned | (kind != ACTIVE)] = np.nan
|
||||
self.assertTrue(np.isfinite(reconstruct(c,pair,n,o)[0]).all())
|
||||
if index and isinstance(pair,FixedPatchSet2D):
|
||||
self.assertTrue((~layout.owned).any())
|
||||
self.assertEqual(layout.periodic, (index == 0, index == 0))
|
||||
|
||||
def test_periodicity_is_per_axis_and_missing_pairs_repeat_center(self):
|
||||
cfg=LBMConfig(nx=16,ny=16,viscosity=.1)
|
||||
p=FixedPatch2D.from_config(cfg,(2,2,14,14))
|
||||
kind=np.where(p.covered,OUTER_GHOST,ACTIVE)
|
||||
results=[]
|
||||
for periodic in ((False,False),(True,False),(False,True),(True,True)):
|
||||
layout=BlockLayout2D.build(16,16,4,4,periodic=periodic)
|
||||
n,_=check_pair_stencil(p,layout,kind)
|
||||
results.append(n)
|
||||
y,x=np.divmod(p.ghost_coarse,16)
|
||||
for axis,coordinate in enumerate((x,y)):
|
||||
edge=(coordinate==0)|(coordinate==15)
|
||||
if not periodic[axis]:
|
||||
np.testing.assert_array_equal(n[edge,2*axis:2*axis+2],
|
||||
np.repeat(p.ghost_coarse[edge,None],2,axis=1))
|
||||
self.assertTrue((results[0]!=results[1]).any())
|
||||
self.assertTrue((results[0]!=results[2]).any())
|
||||
self.assertTrue((results[1]!=results[3]).any())
|
||||
|
||||
def test_invalid_parent_donors_reject_before_owner_lookup(self):
|
||||
p,_,_=setup()
|
||||
layout=BlockLayout2D.build(16,16,4,4)
|
||||
kind=np.where(p.covered,OUTER_GHOST,ACTIVE)
|
||||
for nonactive in (1,2,255):
|
||||
bad=kind.copy(); bad.ravel()[p.ghost_coarse[0]]=nonactive
|
||||
with self.assertRaisesRegex(ValueError,'ACTIVE'):
|
||||
projected_explosion_tables(p,layout,bad)
|
||||
with self.assertRaisesRegex(ValueError,'shape'):
|
||||
projected_explosion_tables(p,layout,kind[:1])
|
||||
hole_layout=BlockLayout2D.build(16,16,4,4,origins=[(0,0)])
|
||||
with self.assertRaisesRegex(ValueError,'owned'):
|
||||
projected_explosion_tables(p,hole_layout,kind)
|
||||
|
||||
def test_generic_two_hop_mass_and_momentum_budget(self):
|
||||
cfg=LBMConfig(nx=30,ny=30,viscosity=.1)
|
||||
layout=BlockLayout2D.build(30,30,6,5)
|
||||
grazing_counts=set()
|
||||
for rectangles in UNIONS:
|
||||
pair=FixedPatchSet2D.from_config(cfg,rectangles)
|
||||
ids,o=projected_explosion_tables(pair,layout,np.where(pair.covered,OUTER_GHOST,ACTIVE))
|
||||
n=decode(layout,ids)
|
||||
q,k=np.divmod(pair.coalesce_coarse.astype(np.int64),900)
|
||||
y,x=np.divmod(k,30)
|
||||
grazing=~pair.covered[(y-E[q,1])%30,(x-E[q,0])%30]
|
||||
grazing_counts.add(int(grazing.sum()))
|
||||
self.assertTrue(grazing.any())
|
||||
for collision in (False,True):
|
||||
rng=np.random.default_rng(17)
|
||||
c=W[:,None,None]*(1+rng.normal(0,.002,(9,30,30)))
|
||||
f=W[:,None,None]*(1+rng.normal(0,.002,(9,*pair.fine_shape)))
|
||||
start=totals(c,f,pair)
|
||||
c[:,pair.covered]=np.nan
|
||||
for _ in range(4):
|
||||
f.reshape(9,-1)[:,pair.ghost_fine]=reconstruct(c,pair,n,o)[0]
|
||||
for _ in range(2):
|
||||
f=np.array([np.roll(f[q],(ey,ex),(0,1)) for q,(ex,ey) in enumerate(E)])
|
||||
f[:,pair.fine_kind==OUTER_GHOST]=np.nan
|
||||
if collision:
|
||||
f[:,pair.fine_kind==ACTIVE]=collide(f[:,pair.fine_kind==ACTIVE],pair.tau_fine)
|
||||
out=np.array([np.roll(c[q],(ey,ex),(0,1)) for q,(ex,ey) in enumerate(E)])
|
||||
packets=f.reshape(-1)[pair.coalesce_fine].mean(axis=1)
|
||||
self.assertTrue(np.isfinite(packets[grazing]).all())
|
||||
out.reshape(-1)[pair.coalesce_coarse]=packets
|
||||
if collision:
|
||||
out[:,~pair.covered]=collide(out[:,~pair.covered],pair.tau_coarse)
|
||||
out[:,pair.covered]=np.nan
|
||||
c=out
|
||||
np.testing.assert_allclose(totals(c,f,pair),start,rtol=0,atol=4e-12)
|
||||
self.assertGreater(len(grazing_counts),1) # not a hard-coded eight-corner oracle
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Host-only checks for the opt-in CUDA gate; never imports or compiles CUDA."""
|
||||
from copy import deepcopy
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
from CelerisLab.config import LBMConfig
|
||||
from CelerisLab.lbm.blocks import BlockLayout2D
|
||||
from CelerisLab.lbm.hierarchy import build_hierarchy
|
||||
from CelerisLab.lbm.refinement import ACTIVE, OUTER_GHOST, FixedPatch2D, FixedPatchSet2D
|
||||
from CelerisLab.lbm.refinement_tables import projected_explosion_tables
|
||||
from tests.unit.test_hierarchy import CFG, FOUR
|
||||
from tests.unit.test_projected_explosion import decode, reconstruct, totals
|
||||
|
||||
PATH = Path(__file__).resolve().parents[1] / 'validation/multigrid/check_projected_pairs.py'
|
||||
spec = importlib.util.spec_from_file_location('projected_pair_gate', PATH)
|
||||
gate = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(gate)
|
||||
|
||||
|
||||
class ProjectedGateOracle(unittest.TestCase):
|
||||
def check_pair(self, pair, layout, kind):
|
||||
ids, offsets = projected_explosion_tables(pair, layout, kind)
|
||||
neighbors = decode(layout, ids)
|
||||
coarse = np.random.default_rng(731).normal(size=(9, *pair.coarse_shape))
|
||||
legal = np.zeros(pair.coarse_shape, bool)
|
||||
legal.ravel()[pair.ghost_coarse] = True
|
||||
coarse[:, ~legal] = np.nan
|
||||
expected, _ = reconstruct(coarse, pair, neighbors, offsets)
|
||||
# The coordinate oracle must not accidentally share production tables.
|
||||
with patch('CelerisLab.lbm.refinement_tables.projected_explosion_tables',
|
||||
side_effect=AssertionError('oracle used production stencil')):
|
||||
actual = gate.independent_explosion(coarse, pair, layout, kind)
|
||||
np.testing.assert_allclose(actual, expected, rtol=0, atol=2e-15)
|
||||
center = coarse.reshape(9, -1)[:, pair.ghost_coarse]
|
||||
self.assertTrue(np.isfinite(actual).all())
|
||||
self.assertGreater(np.max(abs(actual[0] - center[0])), .01)
|
||||
self.assertGreater(np.max(abs(actual[1:] - center[1:])), .01)
|
||||
return coarse, neighbors, offsets, actual
|
||||
|
||||
def test_scalar_union_nested_sparse_and_unequal_strides(self):
|
||||
def fine_layout(pair, block):
|
||||
return (pair.fine_layout(block) if isinstance(pair, FixedPatchSet2D) else
|
||||
BlockLayout2D.build(*pair.fine_shape, *block, periodic=(False,False)))
|
||||
small = LBMConfig(nx=16, ny=16, viscosity=.02)
|
||||
fixtures = [(small, [(4,4,12,12), (8,8,16,16), (8,8,16,16)]),
|
||||
(CFG, FOUR), (small, [[(4,4,5,7), (4,5,7,7)]])]
|
||||
for cfg, refs in fixtures:
|
||||
hierarchy = build_hierarchy(cfg, refs)
|
||||
for index, pair in enumerate(hierarchy.pairs):
|
||||
block, child_block = ((4,4), (4,8)) if index % 2 == 0 else ((4,8), (4,4))
|
||||
layout = (BlockLayout2D.build(cfg.ny, cfg.nx, *block) if index == 0
|
||||
else fine_layout(hierarchy.pairs[index-1], block))
|
||||
with self.subTest(refs=refs, pair=index):
|
||||
self.assertNotEqual(np.prod(layout.local_shape),
|
||||
np.prod(fine_layout(pair, child_block).local_shape))
|
||||
if index and isinstance(pair, FixedPatchSet2D):
|
||||
self.assertTrue((~layout.owned).any())
|
||||
self.check_pair(pair, layout, hierarchy.kinds[index])
|
||||
|
||||
def test_axis_periodicity_and_mean_preserving_mutants(self):
|
||||
pair = FixedPatch2D.from_config(LBMConfig(nx=16,ny=16,viscosity=.1), (2,2,14,14))
|
||||
kind = np.where(pair.covered, OUTER_GHOST, ACTIVE)
|
||||
outputs = []
|
||||
for periodic in ((False,False), (True,False), (False,True), (True,True)):
|
||||
layout = BlockLayout2D.build(16,16,4,4,periodic=periodic)
|
||||
coarse, neighbors, offsets, actual = self.check_pair(pair, layout, kind)
|
||||
outputs.append(actual)
|
||||
donors, first, inverse = np.unique(pair.ghost_coarse, return_index=True, return_inverse=True)
|
||||
wrong = neighbors[first[np.roll(np.arange(len(donors)), 1)]][inverse]
|
||||
for bad_neighbors, bad_offsets in ((neighbors, -offsets), (wrong, offsets)):
|
||||
mutant, _ = reconstruct(coarse, pair, bad_neighbors, bad_offsets)
|
||||
self.assertGreater(np.max(abs(mutant - actual)), .01)
|
||||
for donor in donors:
|
||||
rows = pair.ghost_coarse == donor
|
||||
self.assertEqual(rows.sum(), 4)
|
||||
np.testing.assert_allclose(mutant[:,rows].mean(axis=1),
|
||||
coarse.reshape(9,-1)[:,donor], rtol=0, atol=1e-15)
|
||||
bad_kind = kind.copy(); bad_kind.ravel()[pair.ghost_coarse[0]] = OUTER_GHOST
|
||||
with self.assertRaisesRegex(AssertionError, 'illegal center donor'):
|
||||
gate.independent_explosion(coarse, pair, layout, bad_kind)
|
||||
for i, j in ((0,1), (0,2), (1,3), (2,3)):
|
||||
self.assertGreater(np.max(abs(outputs[i] - outputs[j])), .01)
|
||||
|
||||
def test_concave_replay_preserves_existing_fp64_budget(self):
|
||||
cfg, refs, blocks = gate.cases()['concave']
|
||||
hierarchy = build_hierarchy(cfg, refs); pair = hierarchy.pairs[0]
|
||||
layout = BlockLayout2D.build(cfg.ny, cfg.nx, *blocks[0])
|
||||
rng = np.random.default_rng(17)
|
||||
values = tuple(gate.W[:,None,None] * (1 + rng.normal(0,.002,(9,*shape)))
|
||||
for shape in (pair.coarse_shape, pair.fine_shape))
|
||||
start = totals(*values, pair)
|
||||
values[0][:,pair.covered] = np.nan
|
||||
for _ in range(8):
|
||||
values = gate.replay_two_hops(values, pair, layout, hierarchy.kinds[0])
|
||||
np.testing.assert_allclose(totals(*values, pair), start, rtol=0, atol=4e-12)
|
||||
self.assertEqual(gate.REPLAY_ATOL, 3e-6) # CUDA replay bound is separate.
|
||||
|
||||
|
||||
class ProjectedGateReceipts(unittest.TestCase):
|
||||
def test_digest_exact_finite_bits_and_nan_masks(self):
|
||||
values = np.array([0., -0., np.nan, 1.], dtype=np.float32)
|
||||
self.assertEqual(gate.digest(values), gate.digest(values.copy()))
|
||||
payload = values.copy(); payload.view(np.uint32)[2] = 0x7fc00001
|
||||
self.assertEqual(gate.digest(values), gate.digest(payload))
|
||||
variants = [np.array([-0., -0., np.nan, 1.], np.float32),
|
||||
np.array([0., np.nan, -0., 1.], np.float32),
|
||||
np.array([0., -0., np.nan, np.nextafter(np.float32(1), np.float32(2))], np.float32),
|
||||
values.reshape(2,2), values.astype(np.float64)]
|
||||
for changed in variants:
|
||||
self.assertNotEqual(gate.digest(values), gate.digest(changed))
|
||||
with self.assertRaisesRegex(AssertionError, 'infinite'):
|
||||
gate.digest(np.array([np.inf]))
|
||||
|
||||
def test_snapshot_negative_controls_and_exact_dev_options(self):
|
||||
snapshot = [dict(step=0, levels=[dict(raw='raw',public='public',active=4)],
|
||||
mass_momentum=[1.,0.,0.],observed_level_hops=[0,0])]
|
||||
gate.compare_snapshots(snapshot, deepcopy(snapshot))
|
||||
diagnostic = deepcopy(snapshot); diagnostic[0]['mass_momentum'][0] = 2.
|
||||
gate.compare_snapshots(snapshot, diagnostic) # Diagnostics are not bitwise gate fields.
|
||||
mutants = [[]]
|
||||
for key, value in (('raw','changed'), ('public','changed'), ('active',3)):
|
||||
changed = deepcopy(snapshot); changed[0]['levels'][0][key] = value
|
||||
mutants.append(changed)
|
||||
changed = deepcopy(snapshot); changed[0]['step'] = 1; mutants.append(changed)
|
||||
changed = deepcopy(snapshot); changed[0]['observed_level_hops'] = [0,1]; mutants.append(changed)
|
||||
for changed in mutants:
|
||||
with self.assertRaises(AssertionError):
|
||||
gate.compare_snapshots(snapshot, changed)
|
||||
self.assertEqual(gate.default_options('omitted'), {})
|
||||
self.assertEqual(gate.default_options('constant'), {'explosion':'constant'})
|
||||
self.assertEqual(gate.default_options('projected_linear'), {'explosion':'projected_linear'})
|
||||
|
||||
def test_baseline_receipt_rejects_provenance_and_contract_mutants(self):
|
||||
keys = [f'{name}:shifted={shifted}' for name in gate.cases() for shifted in (False,True)]
|
||||
good = dict(completed=True,dry_run=False,mode='baseline',production_revision=gate.BASE,
|
||||
source=dict(revision=gate.BASE,dirty=False),driver_sha256='driver',
|
||||
stops=list(gate.STOPS),operator_tolerance=[6e-8,2e-7],
|
||||
rest_tolerance=[3e-7,1e-6],replay_tolerance=[3e-6,0],default_snapshots=dict.fromkeys(keys, []),
|
||||
actual_build_sidecars=[{'ptx_sha256':'captured'}])
|
||||
self.assertIs(gate.baseline_receipt(good, 'driver', keys), good)
|
||||
mutants = dict(completed=False,dry_run=True,mode='candidate',production_revision='wrong',
|
||||
driver_sha256='wrong',stops=[0,1,2],operator_tolerance=[1e-6,2e-7],
|
||||
rest_tolerance=[3e-7,1e-5],replay_tolerance=[3e-6,1e-7],actual_build_sidecars=[],default_snapshots={})
|
||||
for field, value in mutants.items():
|
||||
bad = deepcopy(good); bad[field] = value
|
||||
with self.subTest(field=field), self.assertRaises(ValueError):
|
||||
gate.baseline_receipt(bad, 'driver', keys)
|
||||
for changes in (dict(revision='wrong'), dict(dirty=True), dict(dirty=None)):
|
||||
bad = deepcopy(good); bad['source'].update(changes)
|
||||
with self.assertRaises(ValueError):
|
||||
gate.baseline_receipt(bad, 'driver', keys)
|
||||
bad = deepcopy(good); bad['default_snapshots']['unexpected'] = []
|
||||
with self.assertRaises(ValueError):
|
||||
gate.baseline_receipt(bad, 'driver', keys)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Production host dispatch with CUDA doubles; no CUDA compilation or flow runs."""
|
||||
from contextlib import nullcontext
|
||||
from dataclasses import replace
|
||||
from unittest.mock import Mock
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from CelerisLab.config import LBMConfig
|
||||
from tests.unit.test_hierarchy import CFG, FOUR
|
||||
from tests.unit.test_refinement_three_level_multipatch import host_module
|
||||
|
||||
|
||||
def backend(**options):
|
||||
host = host_module()
|
||||
calls, allocations, storage = [], [], {}
|
||||
context = Mock(activate=nullcontext, sm_arch='sm_70')
|
||||
host.cuda.mem_alloc.side_effect = lambda size: allocate(size)
|
||||
|
||||
def allocate(size):
|
||||
pointer = Mock(name=f'buffer{len(allocations)}', size=size)
|
||||
allocations.append(pointer)
|
||||
return pointer
|
||||
|
||||
def load(_):
|
||||
module = Mock()
|
||||
module.get_global.return_value = (Mock(), 20)
|
||||
module.get_function.side_effect = lambda name: Mock(
|
||||
name=name, side_effect=lambda *a, **kw: calls.append((name, a, kw)))
|
||||
return module
|
||||
|
||||
host.compiler.load_module.side_effect = load
|
||||
host.cuda.memcpy_htod.side_effect = lambda ptr, a: storage.__setitem__(id(ptr), np.array(a, copy=True))
|
||||
host.cuda.memcpy_dtoh.side_effect = lambda a, ptr: np.copyto(a, storage[id(ptr)])
|
||||
cfg = options.pop('cfg', CFG)
|
||||
refinements = options.pop('refinements', FOUR)
|
||||
blocks = options.pop('level_blocks', [(4, 4)] * (len(refinements) + 1))
|
||||
run = host.FixedPatchStepper(cfg, context=context, refinements=refinements,
|
||||
level_blocks=blocks, **options)
|
||||
run.initialize(*(np.full(level.shape, 1/9, np.float32) for level in run._levels))
|
||||
return run, host, calls, allocations, storage
|
||||
|
||||
|
||||
def trace(run, calls, allocations, storage):
|
||||
"""Stable pointer identities expose allocations/arguments, not just kernel names."""
|
||||
pointer = {id(value): index for index, value in enumerate(allocations)}
|
||||
launches = [(name, tuple(('ptr', pointer[id(a)]) if id(a) in pointer else int(a)
|
||||
for a in args), kw['grid'], kw['block'])
|
||||
for name, args, kw in calls]
|
||||
uploads = [(index, value.dtype.str, value.shape, value.tobytes())
|
||||
for index, ptr in enumerate(allocations) if (value := storage.get(id(ptr))) is not None]
|
||||
return [p.size for p in allocations], uploads, launches
|
||||
|
||||
|
||||
class ProjectedPairDispatch(unittest.TestCase):
|
||||
def test_omitted_and_constant_have_identical_allocations_uploads_and_launches(self):
|
||||
results = []
|
||||
for options in ({}, {'explosion': 'constant'}):
|
||||
run, host, calls, allocations, storage = backend(**options)
|
||||
self.assertEqual(run._explosion, 'constant')
|
||||
self.assertTrue(all(p.neighbors is None and p.offsets is None for p in run._pairs))
|
||||
run.step()
|
||||
explosion = [args for name, args, _ in calls if name == 'BlockExplode']
|
||||
self.assertEqual(len(explosion), 7)
|
||||
self.assertTrue(all(len(args) == 6 for args in explosion))
|
||||
self.assertFalse(any(name == 'BlockExplodeProjected' for name, _, _ in calls))
|
||||
results.append(trace(run, calls, allocations, storage))
|
||||
run.close()
|
||||
self.assertEqual(*results)
|
||||
|
||||
def test_projected_adds_exactly_two_tables_per_adjacent_pair(self):
|
||||
for depth in (2, 3, 4):
|
||||
constant, _, _, ca, _ = backend(refinements=FOUR[:depth-1])
|
||||
projected, _, calls, pa, storage = backend(
|
||||
refinements=FOUR[:depth-1], explosion='projected_linear')
|
||||
self.assertEqual(len(pa)-len(ca), 2*(depth-1))
|
||||
for pair, geometry in zip(projected._pairs, projected.hierarchy.pairs):
|
||||
self.assertEqual(storage[id(pair.neighbors)].shape, (len(geometry.ghost_fine), 4))
|
||||
self.assertEqual(storage[id(pair.offsets)].shape, (len(geometry.ghost_fine), 2))
|
||||
self.assertEqual(pair.neighbors.size, len(geometry.ghost_fine)*4*8)
|
||||
self.assertEqual(pair.offsets.size, len(geometry.ghost_fine)*2*4)
|
||||
projected.step()
|
||||
explosions = [args for name, args, _ in calls if name == 'BlockExplodeProjected']
|
||||
self.assertEqual(len(explosions), 2**(depth-1)-1)
|
||||
self.assertTrue(all(len(args) == 8 for args in explosions))
|
||||
self.assertFalse(any(name == 'BlockExplode' for name, _, _ in calls))
|
||||
for args in explosions:
|
||||
pair = next(p for p in projected._pairs if args[2] is p.ghosts)
|
||||
self.assertIs(args[6], pair.neighbors)
|
||||
self.assertIs(args[7], pair.offsets)
|
||||
self.assertEqual(args[5], pair.parent_cells)
|
||||
constant.close(); projected.close()
|
||||
|
||||
def test_three_and_four_level_schedule_has_fresh_midpoint_sources(self):
|
||||
for depth in (3, 4):
|
||||
for explosion in ('constant', 'projected_linear'):
|
||||
run, host, calls, allocations, storage = backend(
|
||||
refinements=FOUR[:depth-1], explosion=explosion)
|
||||
before = len(allocations)
|
||||
events = []
|
||||
for index, pair in enumerate(run._pairs):
|
||||
pair.explode.side_effect = lambda source, *a, index=index, **kw: events.append(('explode', index, source))
|
||||
pair.coalesce.side_effect = lambda child, source, *a, index=index, **kw: events.append(('restrict', index, source))
|
||||
for index, level in enumerate(run._levels):
|
||||
level.kernel.side_effect = lambda source, *a, index=index, **kw: events.append(('step', index, source))
|
||||
level.halo_copy.side_effect = lambda source, *a, index=index, **kw: events.append(('halo', index, source))
|
||||
expected = []
|
||||
|
||||
def hop(index, source):
|
||||
if index+1 < depth:
|
||||
expected.append(('explode', index, source))
|
||||
child = run._levels[index+1]
|
||||
hop(index+1, child.current)
|
||||
hop(index+1, child.spare)
|
||||
expected.append(('halo', index, source))
|
||||
if index+1 < depth:
|
||||
expected.append(('restrict', index, source))
|
||||
expected.append(('step', index, source))
|
||||
|
||||
hop(0, run._levels[0].current)
|
||||
run.step()
|
||||
self.assertEqual(events, expected)
|
||||
self.assertEqual([level.kernel.call_count for level in run._levels], [2**i for i in range(depth)])
|
||||
self.assertEqual(len(allocations), before)
|
||||
self.assertEqual(run.step_count, 1)
|
||||
for index in range(1, depth-1):
|
||||
feeds = [source for name, pair, source in events if name == 'explode' and pair == index]
|
||||
self.assertEqual(feeds, [run._levels[index].current, run._levels[index].spare] * (2**(index-1)))
|
||||
run.close()
|
||||
|
||||
def test_scalar_windows_follow_the_same_pair_path(self):
|
||||
run, _, _, _, _ = backend(cfg=LBMConfig(nx=16,ny=16,viscosity=.02),
|
||||
refinements=[(4,4,12,12),(8,8,16,16),(8,8,16,16)],
|
||||
level_blocks=[(4,4),(4,8),(8,4),(4,4)], explosion='projected_linear')
|
||||
self.assertTrue(all(pair.neighbors is not None for pair in run._pairs))
|
||||
self.assertEqual([pair.parent_cells for pair in run._pairs], [36,60,60])
|
||||
run.step()
|
||||
self.assertEqual([level.kernel.call_count for level in run._levels], [1,2,4,8])
|
||||
run.close()
|
||||
|
||||
def test_physics_gate_precedes_context_and_allocations(self):
|
||||
host = host_module()
|
||||
context = Mock()
|
||||
options = dict(refinements=FOUR[:1], level_blocks=[(4,4)]*2,
|
||||
explosion='projected_linear', context=context)
|
||||
for cfg, changes in [(CFG, {'explosion':'unknown'}),
|
||||
(CFG, {'coarse_boundary':'slot_reservoir'}),
|
||||
(CFG, {'coarse_boundary':'channel'}),
|
||||
(CFG, {'level_blocks':[None,None], 'refinements':[(6,6,26,26)]}),
|
||||
(CFG, {'level_objects':[(),(object(),)]}),
|
||||
(CFG, {'level_jets':[(),(object(),)]}),
|
||||
(replace(CFG,collision='MRT'), {}),
|
||||
(replace(CFG,les_enabled=True), {})]:
|
||||
with self.subTest(changes=changes, cfg=cfg), self.assertRaisesRegex(ValueError,'explosion'):
|
||||
host.FixedPatchStepper(cfg, **dict(options, **changes))
|
||||
context.activate.assert_not_called()
|
||||
host.cuda.mem_alloc.assert_not_called()
|
||||
# The legacy constant path still allows MRT and dense storage.
|
||||
for options in ({'cfg':replace(CFG,collision='MRT')},
|
||||
{'level_blocks':[None,None], 'refinements':[(6,6,26,26)]}):
|
||||
run, _, _, _, _ = backend(**options)
|
||||
run.close()
|
||||
|
||||
def test_remap_preserves_selector_tables_and_constant_default(self):
|
||||
for explosion in ('constant','projected_linear'):
|
||||
run, host, _, _, storage = backend(refinements=FOUR[:2], explosion=explosion)
|
||||
run._step_count = 7
|
||||
old = run.download()
|
||||
clone = run.remap_child(FOUR[1])
|
||||
self.assertEqual(clone._explosion, explosion)
|
||||
self.assertEqual(clone.step_count, 7)
|
||||
for pair in clone._pairs:
|
||||
self.assertEqual(pair.neighbors is None, explosion == 'constant')
|
||||
for expected, actual in zip(old, clone.download()):
|
||||
np.testing.assert_array_equal(actual,expected)
|
||||
clone.step()
|
||||
self.assertEqual((run.step_count,clone.step_count), (7,8))
|
||||
clone.close(); run.close()
|
||||
|
||||
def test_changed_remap_geometry_keeps_projected_in_both_representations(self):
|
||||
changed = [(22,22,42,42), (72,68,76,76)]
|
||||
for shifted in (False, True):
|
||||
run, _, _, _, _ = backend(cfg=replace(CFG,ddf_shifting=shifted),
|
||||
refinements=FOUR[:2], explosion='projected_linear')
|
||||
run._step_count = 3
|
||||
before = run.download()
|
||||
clone = run.remap_child(changed)
|
||||
self.assertEqual(clone._explosion, 'projected_linear')
|
||||
self.assertEqual(clone.hierarchy.pairs[1].bounds, tuple(changed))
|
||||
self.assertNotEqual(clone._pairs[1].n_ghosts, run._pairs[1].n_ghosts)
|
||||
for old, new in zip(run._pairs, clone._pairs):
|
||||
self.assertIsNot(old.neighbors, new.neighbors)
|
||||
self.assertIsNotNone(new.neighbors)
|
||||
for expected, actual in zip(before, run.download()):
|
||||
np.testing.assert_array_equal(actual, expected)
|
||||
clone.step()
|
||||
self.assertEqual((run.step_count, clone.step_count), (3, 4))
|
||||
clone.close(); run.close()
|
||||
@@ -157,8 +157,9 @@ class RawExchange(unittest.TestCase):
|
||||
host = host_module()
|
||||
ctx = Mock()
|
||||
with self.assertRaisesRegex(ValueError, 'blocked body'):
|
||||
host.FixedPatchStepper(LBMConfig(nx=32, ny=32, viscosity=.1), (4, 4, 28, 28),
|
||||
context=ctx, fine_objects=[body(radius=2.15)], observe_raw_exchange=True)
|
||||
host.FixedPatchStepper(LBMConfig(nx=32, ny=32, viscosity=.1), context=ctx,
|
||||
refinements=[(4, 4, 28, 28)],
|
||||
level_objects=[(), (body(radius=2.15),)], observe_raw_exchange=True)
|
||||
ctx.activate.assert_not_called()
|
||||
|
||||
def test_all_depth_weights(self):
|
||||
|
||||
@@ -5,6 +5,7 @@ import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "tests/validation"))
|
||||
sys.path.insert(0, str(ROOT / "tests/unit"))
|
||||
import re100_helpers as h
|
||||
import periodic as p
|
||||
|
||||
@@ -62,13 +63,16 @@ def test_periodic_synthetic():
|
||||
assert not p.assess(rows()[:100])["periodic"]
|
||||
|
||||
|
||||
def test_production_unmodified():
|
||||
def test_production_unmodified(tmp_path):
|
||||
from historical_tree import historical_tree
|
||||
|
||||
m = json.loads(
|
||||
(ROOT / "tests/validation/data/dfg_re100/source_manifest.json").read_text()
|
||||
)
|
||||
assert m["commit"] == h.PIN
|
||||
origin = historical_tree(m["commit"], tmp_path)
|
||||
for name, digest in m["production_sha256"].items():
|
||||
assert hashlib.sha256((ROOT / name).read_bytes()).hexdigest() == digest, name
|
||||
assert hashlib.sha256((origin / name).read_bytes()).hexdigest() == digest, name
|
||||
|
||||
|
||||
def test_reference():
|
||||
|
||||
+227
-10
@@ -7,7 +7,10 @@ import numpy as np
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "validation"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import run_refined_dfg as r
|
||||
from historical_tree import historical_tree
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
h, periodic = r.helpers(r.DEFAULT_SOURCE)
|
||||
|
||||
@@ -17,11 +20,24 @@ def test_source_pin():
|
||||
assert historical["commit"].startswith("cd3a657")
|
||||
mrt_history = json.loads((r.HERE / "data/refined_dfg/source_manifest_mrt.json").read_text())
|
||||
assert mrt_history["commit"].startswith("3ea131e")
|
||||
assert r.verify_source(r.DEFAULT_SOURCE, "MRT", "window")["commit"].startswith("a233c90")
|
||||
with unittest.TestCase().assertRaisesRegex(RuntimeError, "Source mismatch"):
|
||||
r.verify_source(r.DEFAULT_SOURCE, "MRT", "window")
|
||||
with unittest.TestCase().assertRaisesRegex(RuntimeError, "Source mismatch"):
|
||||
r.verify_source(r.DEFAULT_SOURCE, "MRT")
|
||||
with unittest.TestCase().assertRaisesRegex(RuntimeError, "Source mismatch"):
|
||||
r.verify_source(r.DEFAULT_SOURCE, "SRT")
|
||||
with TemporaryDirectory() as directory:
|
||||
for collision, mode, prefix in (
|
||||
("SRT", "last_base_step", "cd3a657"),
|
||||
("MRT", "last_base_step", "3ea131e"),
|
||||
("MRT", "window", "a233c90"),
|
||||
):
|
||||
name = {"SRT": "source_manifest.json", "MRT": "source_manifest_mrt.json"}[collision]
|
||||
name = "source_manifest_window.json" if mode == "window" else name
|
||||
commit = json.loads((r.HERE / "data/refined_dfg" / name).read_text())["commit"]
|
||||
assert commit.startswith(prefix)
|
||||
root = historical_tree(commit, Path(directory) / commit)
|
||||
assert r.verify_source(root, collision, mode)["commit"] == commit
|
||||
|
||||
|
||||
def test_matched_units_and_planes():
|
||||
@@ -141,28 +157,62 @@ def test_axis_specific_sampling_phase():
|
||||
np.testing.assert_allclose(fine_y - ((k + 43) - 0.5) * 0.00125, 0, atol=1e-15)
|
||||
|
||||
|
||||
def frozen_manifest(observation_mode):
|
||||
"""Frozen MRT manifest JSON as recorded, without hashing the working tree."""
|
||||
name = "source_manifest_window.json" if observation_mode == "window" else "source_manifest_mrt.json"
|
||||
return json.loads((r.HERE / "data/refined_dfg" / name).read_text())
|
||||
|
||||
|
||||
def selected_manifest(collision, observation_mode):
|
||||
"""verify_source's manifest selection with its working-tree hash comparison neutralised.
|
||||
|
||||
Source identity is owned by test_source_pin; this exercises only which frozen manifest
|
||||
verify_source selects and returns for (collision, observation_mode).
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
class Digest(str):
|
||||
def __ne__(self, other):
|
||||
return False
|
||||
|
||||
class Hash:
|
||||
def __init__(self, data=b""):
|
||||
pass
|
||||
|
||||
def hexdigest(self):
|
||||
return Digest()
|
||||
|
||||
with patch.object(r.hashlib, "sha256", Hash):
|
||||
return r.verify_source(r.DEFAULT_SOURCE, collision, observation_mode)
|
||||
|
||||
|
||||
def test_full_requires_completed_pilot():
|
||||
with unittest.TestCase().assertRaisesRegex(ValueError, "pilot-results"):
|
||||
r.verify_pilot(None, r.verify_source(r.DEFAULT_SOURCE, "MRT", "window"))
|
||||
r.verify_pilot(None, frozen_manifest("window"))
|
||||
|
||||
|
||||
def pilot_fixture(path, observation_mode="window"):
|
||||
def pilot_fixture(path, observation_mode="window", jet_scenario="none", case_selection="both", uniform_diameter=80, placement="wide"):
|
||||
"""Synthetic contract fixture only; never a physical result or run receipt."""
|
||||
manifest = (r.verify_source(r.DEFAULT_SOURCE, "MRT", "window")
|
||||
if observation_mode == "window" else
|
||||
json.loads((r.HERE / "data/refined_dfg/source_manifest_mrt.json").read_text()))
|
||||
# Receipt-logic fixture: the frozen manifest verify_source returns for a matching tree.
|
||||
# Source identity itself is checked only by test_source_pin.
|
||||
manifest = (r.verify_source(r.DEFAULT_SOURCE, "MRT", "window", jet_scenario)
|
||||
if observation_mode == "window" and jet_scenario != "none" else
|
||||
frozen_manifest(observation_mode))
|
||||
selection = dict(case_selection=case_selection, uniform_diameter=uniform_diameter, placement=placement)
|
||||
(path / "plan.json").write_text(
|
||||
json.dumps(
|
||||
dict(
|
||||
source_manifest=manifest,
|
||||
**selection,
|
||||
collision="MRT",
|
||||
jet_scenario=jet_scenario,
|
||||
observation_mode=observation_mode,
|
||||
bounds_root_edge=list(r.BOUNDS),
|
||||
bounds_root_edge=list(r.PLACEMENTS[placement]),
|
||||
runner_sha256=r.runner_hash(),
|
||||
)
|
||||
)
|
||||
)
|
||||
for case, diameter in (("uniform", 80), ("refined", 40)):
|
||||
for case, diameter in r.selected_cases(case_selection, uniform_diameter):
|
||||
dest = path / case
|
||||
dest.mkdir()
|
||||
interval = round(0.02 * diameter / r.SPEED)
|
||||
@@ -189,7 +239,9 @@ def pilot_fixture(path, observation_mode="window"):
|
||||
json.dumps(
|
||||
dict(
|
||||
case=case,
|
||||
**selection,
|
||||
collision="MRT",
|
||||
jet_scenario=jet_scenario,
|
||||
observation_mode=observation_mode,
|
||||
mode="pilot",
|
||||
status="pilot_complete",
|
||||
@@ -206,7 +258,9 @@ def pilot_fixture(path, observation_mode="window"):
|
||||
json.dumps(
|
||||
dict(
|
||||
case=case,
|
||||
**selection,
|
||||
collision="MRT",
|
||||
jet_scenario=jet_scenario,
|
||||
observation_mode=observation_mode,
|
||||
mode="pilot",
|
||||
step=interval,
|
||||
@@ -341,7 +395,7 @@ def test_record_actual_checkout_separately():
|
||||
actual = checkout_provenance(r.DEFAULT_SOURCE)
|
||||
assert len(actual["checkout_revision"]) == 40
|
||||
assert isinstance(actual["checkout_dirty"], bool)
|
||||
assert r.verify_source(r.DEFAULT_SOURCE, "MRT", "window")["production_origin_commit"].startswith("d930")
|
||||
assert selected_manifest("MRT", "window")["production_origin_commit"].startswith("d930")
|
||||
|
||||
|
||||
def test_window_plan_preserves_physics():
|
||||
@@ -351,7 +405,7 @@ def test_window_plan_preserves_physics():
|
||||
assert baseline.pop("observation_mode") == "last_base_step"
|
||||
assert window.pop("observation_mode") == "window"
|
||||
assert baseline == window
|
||||
assert r.verify_source(r.DEFAULT_SOURCE, collision, "window")["observation_mode"] == "window"
|
||||
assert selected_manifest(collision, "window")["observation_mode"] == "window"
|
||||
with unittest.TestCase().assertRaises(ValueError):
|
||||
r.verify_source(r.DEFAULT_SOURCE, "MRT", "unknown")
|
||||
|
||||
@@ -451,6 +505,169 @@ def test_window_contract_rejection():
|
||||
assert not backend.events
|
||||
|
||||
|
||||
def test_public_jet_configuration_and_geometry():
|
||||
for diameter, center, radius in ((40, (80.5, 81), 20), (80, (160, 160.5), 40)):
|
||||
cfg, _, _ = r.config(h, diameter, "MRT", "fixed")
|
||||
assert not cfg["method"]["ddf_shifting"] and not cfg["method"]["les"]["enabled"]
|
||||
jets = r.public_jets(center, radius, "fixed")
|
||||
assert [j.amplitude for j in jets] == [.001, -.001]
|
||||
assert all(j.field.inward is False and j.profile == "parabolic" for j in jets)
|
||||
assert all(j.region.r_max == radius * 1.005 for j in jets)
|
||||
assert all(j.amplitude == 0 for j in r.public_jets(center, radius, "zero"))
|
||||
cfg, _, _, host = r.host_geometry(r.DEFAULT_SOURCE, h, "MRT", "fixed")
|
||||
assert not cfg.ddf_shifting and len(host.boundary_cells) > 0
|
||||
assert r.public_jets((0, 0), 1, "none") == ()
|
||||
with unittest.TestCase().assertRaises(ValueError):
|
||||
r.config(h, 40, "SRT", "fixed")
|
||||
|
||||
|
||||
def test_public_jet_reversal_boundary_and_windows():
|
||||
from types import SimpleNamespace
|
||||
|
||||
calls = []
|
||||
handle = SimpleNamespace(set_jet_amplitude=lambda i, a: calls.append((i, a)))
|
||||
for diameter in (40, 80):
|
||||
boundary = round(250 * diameter / r.SPEED)
|
||||
assert r.jet_interval(handle, "fixed", boundary - 3, 80, diameter) == (3, None)
|
||||
switch = round(300 * diameter / r.SPEED)
|
||||
assert r.jet_interval(handle, "reverse", switch - 3, 80, diameter) == (3, None)
|
||||
assert not calls
|
||||
n, event = r.jet_interval(handle, "reverse", switch, 80, diameter)
|
||||
assert n == 80 and event["step"] == switch and event["tstar"] == 300
|
||||
assert calls == [(0, -.001), (1, .001)]
|
||||
calls.clear()
|
||||
assert r.jet_interval(handle, "reverse", switch + 80, 80, diameter) == (80, None)
|
||||
assert r.jet_interval(handle, "fixed", switch, 80, diameter) == (80, None)
|
||||
assert not calls
|
||||
rows = [dict(tstar=t, tstar_force=t - .5, cd=2.0, cl=-.1) for t in range(251, 306)]
|
||||
windows = r.jet_diagnostics(rows, "reverse")
|
||||
assert len(windows) == 6 and all(w["complete"] for w in windows)
|
||||
assert all(w["cd"]["mean"] == 2 and np.isclose(w["cl"]["mean"], -.1) for w in windows)
|
||||
assert windows[-1]["covered_tstar"] == 5
|
||||
with unittest.TestCase().assertRaisesRegex(ValueError, "crosses"):
|
||||
r.jet_diagnostics([dict(tstar=250.5, tstar_force=250, cd=1, cl=0)], "fixed")
|
||||
assert r.plan(h, "MRT", "window", "reverse")["modes"]["full_tstar"] == 305
|
||||
|
||||
|
||||
def test_public_jet_source_and_pilot_separation():
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
with TemporaryDirectory() as tmp:
|
||||
path = Path(tmp)
|
||||
manifest = pilot_fixture(path, jet_scenario="fixed")
|
||||
assert manifest["source_capture"].startswith("current checkout")
|
||||
assert len(manifest["checkout_revision"]) == 40
|
||||
assert r.verify_pilot(path, manifest, "MRT", "window", "fixed") == 3606.0
|
||||
for scenario in ("none", "zero", "reverse"):
|
||||
with unittest.TestCase().assertRaisesRegex(ValueError, "Jet scenario"):
|
||||
r.verify_pilot(path, manifest, "MRT", "window", scenario)
|
||||
for relative in ("uniform/summary.json", "refined/ready.json"):
|
||||
receipt = path / relative
|
||||
record = json.loads(receipt.read_text())
|
||||
receipt.write_text(json.dumps(dict(record, jet_scenario="zero")))
|
||||
with unittest.TestCase().assertRaises(ValueError):
|
||||
r.verify_pilot(path, manifest, "MRT", "window", "fixed")
|
||||
receipt.write_text(json.dumps(record))
|
||||
with unittest.TestCase().assertRaises(ValueError):
|
||||
r.verify_source(r.DEFAULT_SOURCE, "MRT", "last_base_step", "fixed")
|
||||
|
||||
|
||||
def test_cv_snapshot_common_units_and_window_endpoints():
|
||||
records = []
|
||||
for refined, shape, origin, diameter, impulse in (
|
||||
(True, (240, 568), (38., 22.), 40, [4., -8.]),
|
||||
(False, (330, 1761), (.25, .5), 80, [16., -32.]),
|
||||
(False, (658, 3521), (.375, .75), 160, [64., -128.]),
|
||||
):
|
||||
rho, ux, uy, fluid = np.ones(shape), np.full(shape, .01), np.zeros(shape), np.ones(shape, bool)
|
||||
begin, n = round(290*diameter/r.SPEED), round(.02*diameter/r.SPEED)
|
||||
record = r.cv_snapshot(rho, ux, uy, fluid, origin, refined, begin, n, diameter, impulse)
|
||||
records.append(record)
|
||||
np.testing.assert_allclose(record["outward_flux"], 0, atol=1e-12)
|
||||
assert r.cv_selected(begin, diameter, "full") and not r.cv_selected(begin-n, diameter, "full")
|
||||
end = round(300*diameter/r.SPEED)
|
||||
assert r.cv_selected(end, diameter, "full") and not r.cv_selected(end+n, diameter, "full")
|
||||
assert r.cv_selected(n, diameter, "pilot")
|
||||
assert sum(r.cv_selected(step, diameter, "full") for step in range(begin-n, end+n+1, n)) == 501
|
||||
for key in ("time_root", "interval_start_root", "interval_dt_root", "interval_impulse_root", "inventory"):
|
||||
for record in records[1:]:
|
||||
np.testing.assert_allclose(records[0][key], record[key], rtol=0, atol=1e-10)
|
||||
assert records[0]["interval_dt_root"] == 80 and np.isclose(records[0]["tstar"], 290.02)
|
||||
np.testing.assert_array_equal(records[0]["interval_impulse_root"], [4, -8])
|
||||
|
||||
|
||||
def test_cv_pilot_mode_and_helper_provenance():
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
manifest = r.verify_source(r.DEFAULT_SOURCE, "MRT", "window", "fixed", True)
|
||||
assert "tests/validation/run_jet_circle_cv.py" in manifest["sha256"]
|
||||
with TemporaryDirectory() as tmp:
|
||||
path = Path(tmp)
|
||||
old = pilot_fixture(path, jet_scenario="fixed")
|
||||
with unittest.TestCase().assertRaisesRegex(ValueError, "CV observation mode"):
|
||||
r.verify_pilot(path, old, "MRT", "window", "fixed", True)
|
||||
for relative in ("plan.json", "uniform/summary.json", "refined/summary.json",
|
||||
"uniform/ready.json", "refined/ready.json"):
|
||||
file = path / relative
|
||||
record = json.loads(file.read_text())
|
||||
record.update(observe_cv=True, cv_samples=250)
|
||||
if relative == "plan.json":
|
||||
record["source_manifest"] = manifest
|
||||
file.write_text(json.dumps(record))
|
||||
assert r.verify_pilot(path, manifest, "MRT", "window", "fixed", True) == 3606
|
||||
|
||||
|
||||
def test_compact_geometry_and_cv_stencils():
|
||||
from CelerisLab.lbm.refinement import ACTIVE
|
||||
from CelerisLab.lbm.descriptors import SOLID
|
||||
|
||||
_, pair, _, host = r.host_geometry(r.DEFAULT_SOURCE, h, "MRT", "fixed", "compact")
|
||||
assert pair.fine_shape == (168, 168) and tuple(pair.fine_origin) == (46, 46)
|
||||
fluid = (pair.fine_kind == ACTIVE) & ((host.flags.reshape(pair.fine_shape) & SOLID) == 0)
|
||||
rho, ux, uy = np.ones(pair.fine_shape), np.full(pair.fine_shape, .01), np.zeros(pair.fine_shape)
|
||||
record = r.cv_snapshot(rho, ux, uy, fluid, pair.fine_origin, True, 1160000, 80, 40, [1, 2], "compact")
|
||||
np.testing.assert_allclose(record["outward_flux"], 0, atol=1e-12)
|
||||
stencil = np.zeros(pair.fine_shape, bool)
|
||||
for rect in r.cv_contours(True, 40, "compact"):
|
||||
x0, y0, x1, y1 = np.rint((rect - np.tile(pair.fine_origin, 2))/.5).astype(int)
|
||||
stencil[y0-2:y1+2, x0-2:x0+2] = stencil[y0-2:y1+2, x1-2:x1+2] = True
|
||||
stencil[y0-2:y0+2, x0-2:x1+2] = stencil[y1-2:y1+2, x0-2:x1+2] = True
|
||||
assert fluid[stencil].all() and not stencil.ravel()[host.transfer_fine_cells].any()
|
||||
|
||||
|
||||
def test_d160_config_and_single_case_receipts():
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
cfg, _, p = r.config(h, 160, "MRT", "fixed")
|
||||
assert (p["nx"], p["ny"], p["center"], p["radius"]) == (3521, 658, [320, 320.5], 80)
|
||||
assert p["viscosity"] == .016 and p["mean_speed"] == .01 and not cfg["method"]["ddf_shifting"]
|
||||
assert r.uniform_origin(160) == (.375, .75)
|
||||
np.testing.assert_array_equal(r.cv_contours(False, 160), r.CV_CONTOURS + [.125, 0, .125, 0])
|
||||
for scenario, expected_exit in (("fixed", 0), ("zero", 2)):
|
||||
with TemporaryDirectory() as tmp:
|
||||
path = Path(tmp)
|
||||
manifest = pilot_fixture(path, jet_scenario=scenario, case_selection="uniform", uniform_diameter=160)
|
||||
assert r.verify_pilot(path, manifest, "MRT", "window", scenario, False, "uniform", 160) == 1803
|
||||
for selection, diameter, placement in (("both", 160, "wide"), ("uniform", 80, "wide"), ("uniform", 160, "compact")):
|
||||
with unittest.TestCase().assertRaises(ValueError):
|
||||
r.verify_pilot(path, manifest, "MRT", "window", scenario, False, selection, diameter, placement)
|
||||
# Synthetic dispatch/result check: no CUDA imports or execution.
|
||||
output = path / "full"
|
||||
argv = ["runner", "--collision", "MRT", "--exchange-window", "--jet-scenario", scenario,
|
||||
"--case", "uniform", "--uniform-diameter", "160", "--mode", "full",
|
||||
"--pilot-results", str(path), "--output", str(output), "--max-wall-seconds", "10", "--gpu-gates-approved"]
|
||||
result = dict(case="uniform", healthy=True, periodic=scenario == "zero", jet_diagnostic_windows=[],
|
||||
status="driven_diagnostics_complete" if scenario == "fixed" else "periodic",
|
||||
reference_comparison=dict(reference_agreement=False))
|
||||
with patch.object(sys, "argv", argv), patch.dict(r.os.environ, CUDA_VISIBLE_DEVICES="mock-no-gpu"), \
|
||||
patch.object(r, "run_case", return_value=result) as run:
|
||||
assert r.main() == expected_exit and run.call_count == 1
|
||||
comparison = json.loads((output / "comparison.json").read_text())
|
||||
assert comparison["matched_case_pass"] is False and "refined_vs_uniform" not in comparison
|
||||
assert comparison["status"].startswith("single_case_")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
suite = unittest.TestSuite(
|
||||
unittest.FunctionTestCase(fn)
|
||||
|
||||
@@ -22,13 +22,16 @@ class RefinementMRTHost(unittest.TestCase):
|
||||
|
||||
def test_srt_statements_and_guards_preserved(self):
|
||||
"""MRT is an additive compile-time branch; SRT statements and other guards remain."""
|
||||
blocks = (KERNELS / "blocks.cu").read_text()
|
||||
blocks = (KERNELS / "blocks.cu").read_text() + (KERNELS / "step/block_step.cu").read_text()
|
||||
channel = (KERNELS / "step/block_channel.cu").read_text()
|
||||
refinement = (KERNELS / "refinement.cu").read_text()
|
||||
block_collision = (KERNELS / "operators/block_collision.cuh").read_text()
|
||||
self.assertIn('#include "operators/block_collision.cuh"', blocks)
|
||||
self.assertEqual(blocks.count("block_collide(f);"), 1)
|
||||
refinement = (KERNELS / "refinement.cu").read_text() + (KERNELS / "step/patch_step.cu").read_text()
|
||||
for text in ("compute_feq_from_momentum(drho, jx, jy, ux, uy, feq);",
|
||||
"compute_feq(rho, ux, uy, feq);",
|
||||
"collide_srt_no_force(f, feq, d_params.omega);"):
|
||||
self.assertIn(text, blocks)
|
||||
self.assertIn(text, block_collision)
|
||||
self.assertIn("collide_srt(f, feq, forcing, omega);", channel)
|
||||
self.assertIn("collide_srt_no_force(f, feq, d_params.omega);", refinement)
|
||||
for source in (blocks, refinement):
|
||||
@@ -37,8 +40,14 @@ class RefinementMRTHost(unittest.TestCase):
|
||||
self.assertIn("STORE_PRECISION != 0", source)
|
||||
self.assertIn('#if COLLISION_MODEL == 2\n#include "operators/collision_mrt.cuh"\n#endif', source)
|
||||
self.assertIn("USE_DDF_SHIFTING ||", refinement) # dense/mixed shifted stays rejected
|
||||
for source in (blocks, channel, refinement):
|
||||
self.assertEqual(source.count("collide_mrt_no_force("), 1)
|
||||
# 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, refinement):
|
||||
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")
|
||||
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
|
||||
@@ -173,8 +173,10 @@ def bodies_and_jets(speed=.002):
|
||||
return bodies,jets
|
||||
|
||||
|
||||
KW=dict(base_block=(4,4),fine_block=(4,4),child_block=(4,4),
|
||||
child_rectangles_in_parent=CHILDREN)
|
||||
def levels(parents=PARENTS,children=CHILDREN,blocks=((4,4),)*3,objects=((),(),()),jets=((),(),())):
|
||||
"""Level-list constructor keywords for the L0 set, L1 set and L2 set used here."""
|
||||
return dict(refinements=[parents,children],level_blocks=list(blocks),
|
||||
level_objects=list(objects),level_jets=list(jets))
|
||||
|
||||
|
||||
def mock_backend(*, children=CHILDREN, bodies=False, fail_compile=False, fail_upload=False):
|
||||
@@ -201,9 +203,8 @@ def mock_backend(*, children=CHILDREN, bodies=False, fail_compile=False, fail_up
|
||||
module.cuda.memcpy_htod.side_effect=upload
|
||||
objects,jets=bodies_and_jets() if bodies else ((),())
|
||||
try:
|
||||
run=module.FixedPatchStepper(CFG,PARENTS,context=context,
|
||||
child_objects=objects,child_jets=jets,
|
||||
**dict(KW,child_rectangles_in_parent=children))
|
||||
run=module.FixedPatchStepper(CFG,context=context,
|
||||
**levels(children=children,objects=((),(),objects),jets=((),(),jets)))
|
||||
except RuntimeError:
|
||||
for allocation in allocations:
|
||||
allocation.free.assert_called_once_with()
|
||||
@@ -214,28 +215,29 @@ def mock_backend(*, children=CHILDREN, bodies=False, fail_compile=False, fail_up
|
||||
class ConstructorAndSchedule(unittest.TestCase):
|
||||
def test_invalid_inputs_fail_before_context_or_allocations(self):
|
||||
context=Mock()
|
||||
cases=[dict(child=(8,8,16,16)),dict(child_rectangles_in_parent=[]),
|
||||
dict(child_rectangles_in_parent=[(16,16,32)]),
|
||||
dict(child_rectangles_in_parent=[(16.5,16,32,40)]),
|
||||
dict(child_rectangles_in_parent=[(16,16,32,24),(16,27,32,55)]),
|
||||
dict(child_rectangles_in_parent=[(40,16,48,40)]),
|
||||
dict(child_rectangles_in_parent=[(8,16,32,40)]),
|
||||
dict(child_block=None),dict(fine_block=None),dict(base_block=None),
|
||||
dict(child_block=(5,4)),dict(fine_block=(5,4)),
|
||||
dict(child_block=(4,)),dict(child_rectangles_in_parent=(16,16,32,40))]
|
||||
b=(4,4)
|
||||
cases=[dict(children=(8,8,16,16)),dict(children=[]),
|
||||
dict(children=[(16,16,32)]),
|
||||
dict(children=[(16.5,16,32,40)]),
|
||||
dict(children=[(16,16,32,24),(16,27,32,55)]),
|
||||
dict(children=[(40,16,48,40)]),
|
||||
dict(children=[(8,16,32,40)]),
|
||||
dict(blocks=(b,b,None)),dict(blocks=(b,None,b)),dict(blocks=(None,b,b)),
|
||||
dict(blocks=(b,b,(5,4))),dict(blocks=(b,(5,4),b)),
|
||||
dict(blocks=(b,b,(4,))),dict(children=(16,16,32,40))]
|
||||
for changes in cases:
|
||||
with self.subTest(changes=changes),self.assertRaises((ValueError,TypeError)):
|
||||
host.FixedPatchStepper(CFG,PARENTS,context=context,**dict(KW,**changes))
|
||||
host.FixedPatchStepper(CFG,context=context,**levels(**changes))
|
||||
with self.assertRaisesRegex(ValueError,'margin'):
|
||||
host.FixedPatchStepper(CFG,[(1,4,20,28),(23,4,44,28)],context=context,**KW)
|
||||
host.FixedPatchStepper(CFG,context=context,**levels(parents=[(1,4,20,28),(23,4,44,28)]))
|
||||
objects,jets=bodies_and_jets()
|
||||
with self.assertRaisesRegex(ValueError,'only on L2'):
|
||||
host.FixedPatchStepper(CFG,PARENTS,context=context,fine_objects=objects,**KW)
|
||||
with self.assertRaisesRegex(ValueError,'only on the finest level'):
|
||||
host.FixedPatchStepper(CFG,context=context,**levels(objects=((),objects,())))
|
||||
from CelerisLab.body.jets import Jet,Tube,Parallel
|
||||
spanning=Jet(Tube((10.5,14.),0.,25.,.3),Parallel(0.),amplitude=0.)
|
||||
with self.assertRaisesRegex(ValueError,'one jet'):
|
||||
host.FixedPatchStepper(CFG,PARENTS,context=context,child_objects=objects,
|
||||
child_jets=[spanning],**KW)
|
||||
host.FixedPatchStepper(CFG,context=context,
|
||||
**levels(objects=((),(),objects),jets=((),(),[spanning])))
|
||||
context.activate.assert_not_called()
|
||||
host.cuda.mem_alloc.assert_not_called()
|
||||
|
||||
@@ -313,21 +315,23 @@ class ConstructorAndSchedule(unittest.TestCase):
|
||||
|
||||
|
||||
|
||||
class InitialLevels(unittest.TestCase):
|
||||
"""initialize() entry adapter: positional per-level arrays or the legacy names, never both."""
|
||||
class RemovedLegacyAPI(unittest.TestCase):
|
||||
"""The pre-level-list constructor/initialize forms are gone and fail loudly."""
|
||||
|
||||
def test_forms(self):
|
||||
a, b, c = object(), object(), object()
|
||||
f = host._initial_levels
|
||||
self.assertEqual(f((a, b), None, None, None), (a, b))
|
||||
self.assertEqual(f((a, b, c), None, None, None), (a, b, c))
|
||||
self.assertEqual(f((a, b), None, None, c), (a, b, c)) # old initialize(c, f, child_g=g)
|
||||
self.assertEqual(f((), a, b, None), (a, b))
|
||||
self.assertEqual(f((), a, b, c), (a, b, c))
|
||||
for args in (((a,), None, None, c), ((a, b, c), None, None, c), ((a,), a, b, None),
|
||||
((), a, None, None), ((), None, b, c)):
|
||||
with self.subTest(args=args), self.assertRaises(TypeError):
|
||||
f(*args)
|
||||
def test_removed_constructor_forms_raise_type_error(self):
|
||||
import inspect
|
||||
context = Mock()
|
||||
with self.assertRaises(TypeError): # positional bounds
|
||||
host.FixedPatchStepper(CFG, PARENTS, context=context)
|
||||
removed = dict(bounds=PARENTS, fine_block=(4, 4), base_block=(4, 4), fine_objects=(),
|
||||
jets=(), child=(8, 8, 16, 16), child_block=(4, 4), child_objects=(),
|
||||
child_jets=(), child_rectangles_in_parent=CHILDREN)
|
||||
for name, value in removed.items():
|
||||
with self.subTest(keyword=name), self.assertRaises(TypeError):
|
||||
host.FixedPatchStepper(CFG, context=context, **levels(), **{name: value})
|
||||
context.activate.assert_not_called()
|
||||
self.assertEqual(list(inspect.signature(host.FixedPatchStepper.initialize).parameters),
|
||||
["self", "level_g"]) # no coarse_g/fine_g/child_g
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
|
||||
@@ -54,14 +54,13 @@ class ShiftedJets(unittest.TestCase):
|
||||
(Jet(Tube((10.,14.),0.,26.,.3),Parallel(0.)),))]
|
||||
for objects,jets in cases:
|
||||
with self.subTest(objects=len(objects),jets=len(jets)),self.assertRaises(ValueError):
|
||||
module.FixedPatchStepper(CFG,backend.PARENTS,context=None,
|
||||
base_block=(4,4),fine_block=(4,4),child_block=(4,4),
|
||||
child_rectangles_in_parent=A,child_objects=objects,child_jets=jets)
|
||||
module.FixedPatchStepper(CFG,context=None,refinements=[backend.PARENTS,A],
|
||||
level_blocks=[(4,4)]*3,level_objects=[(),(),objects],level_jets=[(),(),jets])
|
||||
for blocks in ((None,None,None),((4,4),None,(4,4))):
|
||||
with self.assertRaises(ValueError):
|
||||
module.FixedPatchStepper(CFG,backend.PARENTS,context=None,
|
||||
base_block=blocks[0],fine_block=blocks[1],child_block=blocks[2],
|
||||
child_rectangles_in_parent=A,child_objects=(body(),),child_jets=(jet(),))
|
||||
module.FixedPatchStepper(CFG,context=None,refinements=[backend.PARENTS,A],
|
||||
level_blocks=list(blocks),level_objects=[(),(),(body(),)],
|
||||
level_jets=[(),(),(jet(),)])
|
||||
module.cuda.mem_alloc.assert_not_called()
|
||||
module.compiler.compile_kernel.assert_not_called()
|
||||
|
||||
|
||||
@@ -78,13 +78,15 @@ class ShiftedStorage(unittest.TestCase):
|
||||
|
||||
def test_shifted_density_and_tag_validation(self):
|
||||
old,new = topology(A),topology(B)
|
||||
a = tuple(np.where(m[None], np.float32(-.001), np.nan).repeat(9,axis=0) for m in old.leaf_masks)
|
||||
a = tuple(np.where(m[None], np.float32(-.001), np.float32(np.nan)).repeat(9,axis=0)
|
||||
for m in old.leaf_masks) # FP32 under NumPy 1 and 2 promotion rules
|
||||
remap(old,new,a)
|
||||
with self.assertRaisesRegex(ValueError,'representation'): remap(old,new,a,False)
|
||||
with self.assertRaisesRegex(ValueError,'FP32'): remap(old,new,tuple(x.astype(np.float64) for x in a))
|
||||
a[0][:,old.leaf_masks[0]] = -1
|
||||
with self.assertRaisesRegex(ValueError,'positive density'): remap(old,new,a)
|
||||
rest = tuple(np.where(m[None],np.float32(0),np.nan).repeat(9,axis=0) for m in old.leaf_masks)
|
||||
rest = tuple(np.where(m[None],np.float32(0),np.float32(np.nan)).repeat(9,axis=0)
|
||||
for m in old.leaf_masks)
|
||||
remap(old,new,rest)
|
||||
with self.assertRaisesRegex(ValueError,'positive density'):
|
||||
remap_synchronized(old,new,rest,base_count=7,level_steps=(7,14,28),phase='postcollision_g')
|
||||
@@ -129,9 +131,10 @@ class ShiftedStorage(unittest.TestCase):
|
||||
def test_dense_or_mixed_shifted_rejected_before_compilation(self):
|
||||
module=backend.host_module()
|
||||
cfg=replace(backend.CFG,ddf_shifting=True)
|
||||
for kwargs in ({},dict(base_block=(4,4)),dict(fine_block=(4,4))):
|
||||
with self.assertRaisesRegex(ValueError,'all-blocked|base_block requires fine_block'):
|
||||
module.FixedPatchStepper(cfg,(4,4,20,28),context=None,**kwargs)
|
||||
for blocks in ([None,None],[(4,4),None],[None,(4,4)]):
|
||||
with self.assertRaisesRegex(ValueError,'all-blocked|blocked refined levels below'):
|
||||
module.FixedPatchStepper(cfg,context=None,refinements=[(4,4,20,28)],
|
||||
level_blocks=blocks)
|
||||
module.compiler.compile_kernel.assert_not_called()
|
||||
|
||||
def test_uniform_block_codec_and_raw_snapshot(self):
|
||||
|
||||
@@ -238,6 +238,81 @@ def test_overlapping_force_regions_rejected(device):
|
||||
bodies.build_compact_lists(bodies.build_flags(field.flag))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("second_x,error", [(14., "unique cut-link writers"),
|
||||
(17., "active fluid receivers")])
|
||||
def test_passive_overlap_rejected_before_publishing_lists(second_x, error):
|
||||
cfg = LBMConfig(nx=32, ny=24)
|
||||
cfg.validate()
|
||||
bodies = ObjectManager(cfg.nx, cfg.ny, cfg.nz, cfg.nq, cfg)
|
||||
for x in (14., second_x):
|
||||
bodies.add(obj(x=x, y=12., r=4.3))
|
||||
bodies.add(obj(x=4., y=4., r=2., is_sensor=True))
|
||||
counts = np.full(bodies.count, 91, np.int32)
|
||||
hits = []
|
||||
flags = bodies.build_flags(np.full(cfg.nx * cfg.ny, FLUID, np.uint16))
|
||||
for domain_flags in (flags, None):
|
||||
with pytest.raises(ValueError, match=error):
|
||||
bodies.build_compact_lists_for(bodies.objects, domain_flags,
|
||||
sensor_cell_counts_out=counts, hits_out=hits)
|
||||
np.testing.assert_array_equal(counts, 91)
|
||||
assert hits == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("centers", [((10., 12.), (22., 12.)),
|
||||
((10.5, 12.), (19.5, 12.))])
|
||||
def test_separated_passive_bodies_keep_valid_writers(centers):
|
||||
# The second case has a subcell physical gap with a resolved fluid column.
|
||||
cfg = LBMConfig(nx=32, ny=24)
|
||||
cfg.validate()
|
||||
bodies = ObjectManager(cfg.nx, cfg.ny, cfg.nz, cfg.nq, cfg)
|
||||
for x, y in centers:
|
||||
bodies.add(obj(x=x, y=y, r=4.3))
|
||||
flags = bodies.build_flags(np.full(cfg.nx * cfg.ny, FLUID, np.uint16))
|
||||
indices, directions, _, ids, *_ = bodies.build_compact_lists(flags)
|
||||
assert set(ids) == {0, 1}
|
||||
assert np.all(flags[indices] & FLUID) and not np.any(flags[indices] & SOLID)
|
||||
assert len(np.unique(indices.astype(np.uint64) * cfg.nq + directions)) == len(indices)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entry", ["sync", "initialize", "runtime_edit"])
|
||||
@pytest.mark.parametrize("second_x", [5., 6.])
|
||||
def test_passive_overlap_failure_keeps_live_state(device, entry, second_x):
|
||||
from CelerisLab.simulation import Simulation
|
||||
field = make_field(device)
|
||||
bodies = manager(field, [obj(), obj(x=9., y=3., r=1., is_sensor=True)])
|
||||
bodies.sync_to_gpu(field)
|
||||
overlapping = obj(x=second_x)
|
||||
if entry == "runtime_edit":
|
||||
bodies.stage_add(overlapping)
|
||||
else:
|
||||
bodies.add(overlapping)
|
||||
objects = bodies.objects
|
||||
flags, curved, counts = field.flag.copy(), field.curved.fluid_idx.copy(), bodies.sensor_cell_counts.copy()
|
||||
action = bodies.action.copy()
|
||||
buffers = [(block, bytes(block.data), block.freed) for block in device.blocks]
|
||||
writes = device.writes
|
||||
sim = SimpleNamespace(bodies=bodies, field=field, lbm_cfg=field.cfg,
|
||||
_initialized=entry == "runtime_edit",
|
||||
_assert_allocation_contract=lambda: None,
|
||||
stream=SimpleNamespace(synchronize=device.synchronize))
|
||||
with pytest.raises(ValueError, match="active fluid receivers|unique cut-link writers"):
|
||||
if entry == "sync":
|
||||
bodies.sync_to_gpu(field)
|
||||
elif entry == "initialize":
|
||||
Simulation.initialize(sim)
|
||||
else:
|
||||
Simulation.sync_bodies(sim)
|
||||
assert bodies.objects == objects
|
||||
assert bodies.has_pending_edit() == (entry == "runtime_edit")
|
||||
assert sim._initialized == (entry == "runtime_edit")
|
||||
assert device.writes == writes and len(device.blocks) == len(buffers)
|
||||
assert all(bytes(block.data) == data and block.freed == freed for block, data, freed in buffers)
|
||||
np.testing.assert_array_equal(field.flag, flags)
|
||||
np.testing.assert_array_equal(field.curved.fluid_idx, curved)
|
||||
np.testing.assert_array_equal(bodies.sensor_cell_counts, counts)
|
||||
np.testing.assert_array_equal(bodies.action, action)
|
||||
|
||||
|
||||
def test_action_upload_and_scratch_are_pinned(device):
|
||||
field = make_field(device, streaming="esopull")
|
||||
bodies = manager(field, [obj(is_force_region=True)])
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Host contract of FixedPatchStepper.step(n): one stream wait per call (mocked driver).
|
||||
|
||||
All n base intervals are enqueued in order on the backend stream and the host
|
||||
waits once before step returns. Launch order and buffer arguments must equal n
|
||||
step(1) calls; completion metadata, raw-exchange and window ledgers, and failure
|
||||
invalidation are published only after that wait.
|
||||
"""
|
||||
from contextlib import nullcontext
|
||||
from unittest.mock import Mock
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from CelerisLab.config import LBMConfig
|
||||
from tests.unit.test_raw_exchange import body
|
||||
from tests.unit.test_refinement_three_level_multipatch import host_module
|
||||
|
||||
BODY_LEVEL, HOPS = 3, 8 # body on L3: 2**3 hops per base interval
|
||||
|
||||
|
||||
def backend(window=False):
|
||||
"""Four-level host backend; every launch, memset and stream wait goes to one log."""
|
||||
host = host_module()
|
||||
log, allocations = [], []
|
||||
|
||||
def allocate(size):
|
||||
allocations.append(Mock(size=size))
|
||||
return allocations[-1]
|
||||
|
||||
module = Mock()
|
||||
module.get_global.return_value = (Mock(), 20)
|
||||
module.get_function.side_effect = lambda name: Mock(
|
||||
side_effect=lambda *a, **k: log.append((name, a, k)))
|
||||
host.compiler.load_module.return_value = module
|
||||
host.cuda.mem_alloc.side_effect = allocate
|
||||
host.cuda.memcpy_dtoh.side_effect = lambda array, pointer: array.fill(0)
|
||||
host.cuda.memset_d8_async.side_effect = lambda *a: log.append(("memset", a, {}))
|
||||
run = host.FixedPatchStepper(
|
||||
LBMConfig(nx=48, ny=48, viscosity=.02), context=Mock(activate=nullcontext, sm_arch="sm_70"),
|
||||
refinements=[[(6, 6, 26, 26)], [(20, 20, 44, 44)], [(56, 56, 72, 72)]],
|
||||
level_blocks=[(4, 4)] * 4, level_objects=[(), (), (), (body(),)],
|
||||
observe_raw_exchange=True, observe_window_exchange=window)
|
||||
run._stream.synchronize.side_effect = lambda: log.append(("sync", (), {}))
|
||||
run.initialize(*[np.full(level.shape, 1 / 9, np.float32) for level in run._levels])
|
||||
log.clear()
|
||||
return run, log, allocations
|
||||
|
||||
|
||||
def signature(log, allocations):
|
||||
"""Event names with device-pointer arguments replaced by allocation index."""
|
||||
index = {id(a): i for i, a in enumerate(allocations)}
|
||||
return [(name, tuple(index.get(id(a), "-") for a in args)) for name, args, _ in log]
|
||||
|
||||
|
||||
def names(log):
|
||||
return [name for name, _, _ in log]
|
||||
|
||||
|
||||
class StepSync(unittest.TestCase):
|
||||
def test_step1_waits_once_after_all_launches(self):
|
||||
run, log, _ = backend()
|
||||
run.step(1)
|
||||
self.assertEqual(names(log).count("sync"), 1)
|
||||
self.assertEqual(names(log)[-1], "sync")
|
||||
self.assertEqual(names(log)[0], "memset")
|
||||
self.assertEqual(names(log).count("BlockStep"), 1 + 2 + 4 + 8)
|
||||
self.assertEqual(run.step_count, 1)
|
||||
run.close()
|
||||
|
||||
def test_step_n_equals_n_step1_launches_with_one_wait(self):
|
||||
single, single_log, single_alloc = backend()
|
||||
for _ in range(3):
|
||||
single.step(1)
|
||||
batched, batched_log, batched_alloc = backend()
|
||||
batched.step(3)
|
||||
expected = [e for e in signature(single_log, single_alloc) if e[0] != "sync"]
|
||||
got = signature(batched_log, batched_alloc)
|
||||
self.assertEqual(got[-1][0], "sync")
|
||||
self.assertEqual(names(batched_log).count("sync"), 1)
|
||||
self.assertEqual(got[:-1], expected) # same order, same ping-pong buffers
|
||||
self.assertEqual(names(batched_log).count("memset"), 3)
|
||||
self.assertEqual(batched.step_count, single.step_count)
|
||||
index = [{id(a): i for i, a in enumerate(alloc)} for alloc in (single_alloc, batched_alloc)]
|
||||
self.assertEqual(index[1][id(batched._levels[0].current)],
|
||||
index[0][id(single._levels[0].current)])
|
||||
single.close()
|
||||
batched.close()
|
||||
|
||||
def test_step0_launches_and_waits_for_nothing(self):
|
||||
run, log, _ = backend()
|
||||
run.step(2)
|
||||
log.clear()
|
||||
run.step(0)
|
||||
self.assertEqual(log, [])
|
||||
self.assertEqual(run.step_count, 2)
|
||||
value = run.read_raw_exchange(0, level=BODY_LEVEL) # step(0) keeps the ledger valid
|
||||
self.assertEqual((value["time_start"], value["time_end"]), (1, 2))
|
||||
run.close()
|
||||
|
||||
def test_completion_metadata_covers_all_intervals(self):
|
||||
run, _, _ = backend()
|
||||
with self.assertRaisesRegex(RuntimeError, "completed"):
|
||||
run.read_raw_exchange(0, level=BODY_LEVEL)
|
||||
run.step(3)
|
||||
value = run.read_raw_exchange(0, level=BODY_LEVEL)
|
||||
self.assertEqual((value["time_start"], value["time_end"], value["sample_count"]),
|
||||
(2, 3, HOPS)) # step(n) retains only the final base interval
|
||||
run.step(2)
|
||||
self.assertEqual(run.read_raw_exchange(0, level=BODY_LEVEL)["time_end"], 5)
|
||||
run.close()
|
||||
|
||||
def test_window_accounting_across_batched_calls(self):
|
||||
windows = []
|
||||
for calls in ((1, 3), (4,), (1, 1, 1, 1)):
|
||||
run, log, _ = backend(window=True)
|
||||
run.step(1)
|
||||
run.start_exchange_window()
|
||||
log.clear()
|
||||
for n in calls:
|
||||
run.step(n)
|
||||
self.assertEqual(names(log).count("BlockAccumulateExchange"), 2 * 4 * HOPS)
|
||||
self.assertEqual(names(log).count("sync"), len(calls))
|
||||
out = run.read_exchange_window(0, level=BODY_LEVEL)
|
||||
windows.append((out["time_start"], out["time_end"], out["sample_count"]))
|
||||
run.close()
|
||||
self.assertEqual(windows, [(1., 5, 4 * HOPS)] * 3)
|
||||
|
||||
def test_failed_wait_publishes_nothing(self):
|
||||
run, _, _ = backend(window=True)
|
||||
run.step(2)
|
||||
run.start_exchange_window()
|
||||
run._stream.synchronize.side_effect = RuntimeError("device failure")
|
||||
with self.assertRaisesRegex(RuntimeError, "device failure"):
|
||||
run.step(3)
|
||||
self.assertEqual(run.step_count, 2) # value at entry to the failing call
|
||||
self.assertFalse(run._exchange_valid)
|
||||
self.assertFalse(run._window_valid)
|
||||
for read in (lambda: run.read_raw_exchange(0, level=BODY_LEVEL),
|
||||
lambda: run.read_exchange_window(0, level=BODY_LEVEL), run.download,
|
||||
lambda: run.step(1)):
|
||||
with self.assertRaisesRegex(RuntimeError, "failed"):
|
||||
read()
|
||||
run._stream.synchronize.side_effect = None
|
||||
run.close()
|
||||
|
||||
def test_launch_error_mid_batch_skips_wait_and_fails(self):
|
||||
run, log, _ = backend()
|
||||
run.step(1)
|
||||
log.clear()
|
||||
hop, calls = run._hop, []
|
||||
|
||||
def failing(index, *args, **kw):
|
||||
if index == 0:
|
||||
calls.append(1)
|
||||
if len(calls) == 2:
|
||||
raise RuntimeError("launch failure")
|
||||
return hop(index, *args, **kw)
|
||||
run._hop = failing
|
||||
with self.assertRaisesRegex(RuntimeError, "launch failure"):
|
||||
run.step(3)
|
||||
self.assertNotIn("sync", names(log))
|
||||
self.assertEqual(run.step_count, 1)
|
||||
self.assertFalse(run._exchange_valid)
|
||||
with self.assertRaisesRegex(RuntimeError, "failed"):
|
||||
run.read_raw_exchange(0, level=BODY_LEVEL)
|
||||
run.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -12,13 +12,15 @@ from source_provenance import checkout_provenance
|
||||
import re100_helpers
|
||||
import run_jet_circle_cv
|
||||
|
||||
sys.path.insert(0, str(ROOT / "tests/unit"))
|
||||
from historical_tree import historical_tree, tracked
|
||||
|
||||
ORIGIN = "47b05bc8abfc957fe876b0c63bdb386e3c6cbaa5"
|
||||
|
||||
|
||||
def test_manifests_cover_current_source_and_preserve_origin():
|
||||
names = subprocess.check_output(
|
||||
["git", "-C", str(ROOT), "ls-files", "src"], text=True
|
||||
).splitlines()
|
||||
def test_manifests_cover_current_source_and_preserve_origin(tmp_path):
|
||||
assert re100_helpers.PIN == run_jet_circle_cv.PIN == ORIGIN
|
||||
manifests = {}
|
||||
for group, hashes_key, revision_key in (
|
||||
("dfg_re100", "production_sha256", "commit"),
|
||||
("jet_circle_cv", "files", "revision"),
|
||||
@@ -29,18 +31,22 @@ def test_manifests_cover_current_source_and_preserve_origin():
|
||||
).read_text()
|
||||
)
|
||||
assert manifest[revision_key] == ORIGIN
|
||||
assert set(manifest[hashes_key]) == set(names)
|
||||
assert manifest["source_origin_commits"][-1] == ORIGIN
|
||||
assert (
|
||||
manifest["historical_checkpoints"]["production"]
|
||||
== "d79e3f6f0a424f5bcc3a1a6d610313e49daf97a2"
|
||||
)
|
||||
assert "not inherited from d79" in manifest["experiment"]
|
||||
for name, expected in manifest[hashes_key].items():
|
||||
manifests[group] = manifest[hashes_key]
|
||||
# History-dependent checks last, so a missing commit masks nothing above.
|
||||
names = tracked(ORIGIN)
|
||||
origin = historical_tree(ORIGIN, tmp_path)
|
||||
for group, hashes in manifests.items():
|
||||
assert set(hashes) == set(names), group
|
||||
for name, expected in hashes.items():
|
||||
assert (
|
||||
hashlib.sha256((ROOT / name).read_bytes()).hexdigest() == expected
|
||||
hashlib.sha256((origin / name).read_bytes()).hexdigest() == expected
|
||||
), name
|
||||
assert re100_helpers.PIN == run_jet_circle_cv.PIN == ORIGIN
|
||||
|
||||
|
||||
def test_runtime_checkout_identity_is_separate_from_source_origin(tmp_path):
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"base_commit": "a135252ae78b47b57d7e77d19001700b3bedac8b",
|
||||
"production_origin_commit": "a135252ae78b47b57d7e77d19001700b3bedac8b",
|
||||
"source_experiment": "Explicit a135252 uniform SRT LES regression. Sources pin the committed corrected tree; baseline is a synthetic isolated reverse-header control, not the committed a135252 source. Historical profiles and receipts remain unchanged.",
|
||||
"sources": {
|
||||
"src/CelerisLab/__init__.py": "21cc469bd6e970a1f9ff5970c8eea4602065e41fc881854759ee565ee55c10c6",
|
||||
"src/CelerisLab/body/__init__.py": "e4e0e8f0769a94428d5b02fac790443997556cfc5515a5f1ed1595d2c694d69d",
|
||||
"src/CelerisLab/body/action_smoother.py": "b1a23ee6bbd4850b172cb8738c5c3546ee465a54189e6af9d8044f023d4687dc",
|
||||
"src/CelerisLab/body/coupling/__init__.py": "c931f7eb318d48408c3e2febcc6917c88c8dcc1697cbf4329b1f340d94c35763",
|
||||
"src/CelerisLab/body/coupling/soa_packer.py": "8ec26fd55ef74801bbd5ab6bd3d0a7dabc750cbd498aaac2ca4c61a0108ad3c9",
|
||||
"src/CelerisLab/body/ddf_patch.py": "60fcfb7441e73bf290bcff41ca3a83da3e7ddad765d7247464e8f02e3043b4e2",
|
||||
"src/CelerisLab/body/geometry/__init__.py": "ada88980ab672959aca9aa3a4c4aade3219524b2ab68110cd2a2f41e3e0ca90a",
|
||||
"src/CelerisLab/body/geometry/base.py": "cceca14361ca2f34d34a3e43730e1a79b8ca81be00f0dd62e884db32b479419f",
|
||||
"src/CelerisLab/body/geometry/circle.py": "7605ca7c21103f1d801ac73ab8dd18dffbdac425b2dee7b8858b283628a9e436",
|
||||
"src/CelerisLab/body/jets.py": "4104c24af4651f4bb4f8d8fd36fb6d88f41c6e4469822f36e22e74c9ede31dac",
|
||||
"src/CelerisLab/body/manager.py": "01b653d25bd9b4333379c8bfd12383596b686d2029f2e91b1c91b618540ec6b3",
|
||||
"src/CelerisLab/body/objects.py": "8e63cee8faa9cc6243bcff428be628dfad73a02a9a3b8ece7d78c0a63406a08d",
|
||||
"src/CelerisLab/body/preprocess/__init__.py": "dfd9d58bc07f626ab23a70053e31b88a6d9165d2e4c76a4e9f48abe674d3d2ec",
|
||||
"src/CelerisLab/body/preprocess/flag_overlay.py": "831fc9e9736b758847759a085ff133eb49ec91318ace4fda76f85960138ee300",
|
||||
"src/CelerisLab/body/registry.py": "fbb7327780adbdc6da15ebdb799980daf32df22ee974af0893d7409f177bbb68",
|
||||
"src/CelerisLab/body/sync_plan.py": "3a15bfe9ebeb91482dbfc68850761ef39c872cc12d2efc795babd0c01e392323",
|
||||
"src/CelerisLab/common/__init__.py": "d4dbde78bc97fccc9eabac9abae8f0f15c112c10ff8e9b45bd9401d4723df1ef",
|
||||
"src/CelerisLab/common/_types.py": "aad2ff1a9033d4fd68783ad7123afb47201ddc58785cb6dc0246947037072a5f",
|
||||
"src/CelerisLab/common/checkpoint.py": "b2f960aecd5ac27a3a7130b809ffad4709c44d522edef0359ab952654625410b",
|
||||
"src/CelerisLab/common/pathline.py": "a1b6efcc0a076d0f1dc8d49a595779426d7d5626b00072a59bac710ff02ad046",
|
||||
"src/CelerisLab/common/preprocess.py": "7fc78a08857cf8e98519500b094a1d896af189b6b15658ff9b388ed51bed4515",
|
||||
"src/CelerisLab/common/render.py": "0e055476257eb2bcd11e733766e52195de02f8fad228f570a3511a7c831bdc29",
|
||||
"src/CelerisLab/common/streakline/__init__.py": "13ff1cfa416fdd721a08a659e76b5a5a99b8d3201add10a83b7b0bc654bc1eec",
|
||||
"src/CelerisLab/common/streakline/_config.py": "a9185ef68e9c7b5d5a1dc0b04a28341e2ad91d13c145a7c106636a878cf0a605",
|
||||
"src/CelerisLab/common/streakline/_integrate.py": "3fbd4368b2c8d0e7fda40dc65aac5df02b085008b4c68acd83cb7ae45d33a13b",
|
||||
"src/CelerisLab/common/streakline/_render.py": "236cd637414ee318c4d66f9aed32110d8dc42a100d65fde06172f7cd0ba62c65",
|
||||
"src/CelerisLab/common/streakline/_streakline.py": "1a3e08daa706b5f83cdeb337e9a945a36ddf7583c40af1d0bb9d825ac954df37",
|
||||
"src/CelerisLab/config.py": "2f1bba7515f5bc08cb3a8d140bcafe7fa0689e01b3b06494697bb2956a71530e",
|
||||
"src/CelerisLab/configs/CONFIG.md": "41cafdbe525366c08aa9d0416ea026ccf0026ba7123b8a9b65f968a80d928e3a",
|
||||
"src/CelerisLab/configs/config_body.json": "055f261f0828ca1d4f2bb694bd15b72bbcabe31d552326208f28e37f761adab1",
|
||||
"src/CelerisLab/configs/config_lbm.json": "17e4311f1f250c6ece59177a8b6ff856b69a016c7c10267e774b030a0a390865",
|
||||
"src/CelerisLab/configs/config_lbm_flume_mrt_zouhe.json": "8f593321db05a563a42931603a228d61cbcba06dfbe113262d5cd0ad93c0fe79",
|
||||
"src/CelerisLab/configs/config_lbm_flume_trt_regularized.json": "f4bba8e6a4339fc3c4eb14f6e2f57c4b22993c73703c773d853edfbe34736e56",
|
||||
"src/CelerisLab/configs/config_lbm_three_cylinder_triangle.json": "eeb8e7149dbe4aa6b3a9562802735aa950750dfbe2d972a0727e788573afa817",
|
||||
"src/CelerisLab/cuda/__init__.py": "0d01dff9f0399c8baba822aab5ca0122a135f011008ed8a499ebd9f7e5b06aea",
|
||||
"src/CelerisLab/cuda/compiler_v2.py": "c4cc3ec4445c8d75e6a253dd87963877117a722bc73b9d4811806fc6045d08c1",
|
||||
"src/CelerisLab/cuda/context.py": "013dd4ec6d01d2fbd11afdfb103eac6a273bd9f798d663d761b851eeb27885e9",
|
||||
"src/CelerisLab/cuda/obs_layout.py": "5ffb2c3aeeaaaaeb64f2cd8febfbcf4ad9919e5414020ad753cfade0683b21c2",
|
||||
"src/CelerisLab/lbm/__init__.py": "7f60fb3aaae81501b510d08c2fedff44fcb05657260eb10b6f5cc91e7204306b",
|
||||
"src/CelerisLab/lbm/block_stepper.py": "00b313daddc6e63d8ab8423c9190ca7e92ad291de20a2f6b80eb53e3f2d85ec7",
|
||||
"src/CelerisLab/lbm/blocks.py": "1e644c5719993f418185ad4a10b6950a30636fc912befd9eab41ff48b4cf85cd",
|
||||
"src/CelerisLab/lbm/channel.py": "40d0354e5dc97bcdb7e7b5c1e7f9303a109b7134d8a3af88b8ff4cd75ab5a095",
|
||||
"src/CelerisLab/lbm/curved_links.py": "b6957fa13d13f864dba4f7364e93a39905d062dbb3ee9b3f5191add81fff0732",
|
||||
"src/CelerisLab/lbm/descriptors.py": "c9f025dfc14da43b989047b84cc22bd05d5cc92c9ec695c1da68fdfb788e3f2b",
|
||||
"src/CelerisLab/lbm/equilibrium.py": "2fb2f2bb7ff326d23abce80fc2a4c0907b17740ea3e29fe6ae079c5044783826",
|
||||
"src/CelerisLab/lbm/field.py": "0e727eacc3706add4d8d15c418e9b631050871f831698d3ac689dd5f2c701b9b",
|
||||
"src/CelerisLab/lbm/hierarchy.py": "f234975074f6b6d289d573ff6ea6832feae0e27cb04f80b37fe81792b13a12e7",
|
||||
"src/CelerisLab/lbm/initializers.py": "dacf9586963e862d6d0a98ef1a066036be6f91e9d423720661d8ef2c1fb2df6a",
|
||||
"src/CelerisLab/lbm/kernels/blocks.cu": "668e5cf14e88bdcfa281c2fa6c75c0f8f8cdbbfb8ba2ca03064ae02ef7ad0057",
|
||||
"src/CelerisLab/lbm/kernels/boundary/bounce_back.cuh": "aba3748c564ed91b161d268ef8b737b7c7be96a823063d201f3da684dd845233",
|
||||
"src/CelerisLab/lbm/kernels/boundary/curved_boundary.cuh": "aeb16bfd483514fb03e15a2fed437d673c3b8b2fe68ab83edf4244ef593878ea",
|
||||
"src/CelerisLab/lbm/kernels/boundary/esopull_curved_helpers.cuh": "b9b474bc4edaff05681c5443235472d04072d0d775fc0eb9c5967faef52b0258",
|
||||
"src/CelerisLab/lbm/kernels/boundary/ibm_kernels.cuh": "e6909b547dc8d64cdd8a76a90660243a8f5fee535caa161b52b4dd8b7c29934b",
|
||||
"src/CelerisLab/lbm/kernels/boundary/inlet/channel_stabilized.cuh": "efe077afcbb6b731256a987c2fd79a1dbc470c902f87070d60421ea6cb13d4e2",
|
||||
"src/CelerisLab/lbm/kernels/boundary/inlet/common.cuh": "59b38a89439878e00de7de16b9c7b03feff443fcc5a421593f7d2536dcbb7ab8",
|
||||
"src/CelerisLab/lbm/kernels/boundary/inlet/equilibrium.cuh": "f0964c811b2c41a5aaa1c6d99c490b06e850c634eb8749f1f05ef61f93c2de1d",
|
||||
"src/CelerisLab/lbm/kernels/boundary/inlet/regularized.cuh": "943d4a571818bacb89c70bccfaac202691121e3e385803ce6309123a0a03ddaf",
|
||||
"src/CelerisLab/lbm/kernels/boundary/inlet/zou_he_local.cuh": "e73e66b853b1e4e0e30d8f4185d832aa414ce670a1813ce2b5459f2887f71f89",
|
||||
"src/CelerisLab/lbm/kernels/boundary/inlet_outlet.cuh": "de8b213a6da42c2e5e9f8966199fc66ea4541b85144a90f3f5dbad3c6ab63b97",
|
||||
"src/CelerisLab/lbm/kernels/boundary/outlet/pressure_neq.cuh": "c8b2adc66d12fe602076ddc9693c5c208711fcaddb58aa9a31401a269f51c4c4",
|
||||
"src/CelerisLab/lbm/kernels/boundary/planar_velocity.cuh": "501b2759d0e2e4280cb6fdd127d66a50fa89f6de48fc2cae9a7eafb11ae90bed",
|
||||
"src/CelerisLab/lbm/kernels/config.h": "7d1124125e0b354558297934460e64c760fb68cdd7bf6d01a1238bb2eaab8c37",
|
||||
"src/CelerisLab/lbm/kernels/config/config_grid.h": "1db3e030ebdc116cd7f02362d1fd077daac55e8f0b4329f6285948c224d3ca5d",
|
||||
"src/CelerisLab/lbm/kernels/config/config_method.h": "4374e04a130f90f97d17cc20c40d2f9ea0ed0dd8603817d07f215d02266e247c",
|
||||
"src/CelerisLab/lbm/kernels/config/config_objects.h": "db793af052c756561a190a3ff2cb93ee5fe28462ee4f97c6af80882b4dc8b072",
|
||||
"src/CelerisLab/lbm/kernels/config/config_obs.h": "c11e2249359145b5c4cbaaf0487506fa77c1f4b82c7c169ddc574aa0babcd727",
|
||||
"src/CelerisLab/lbm/kernels/config/config_physics.h": "8ac0990b168e1058588676cc05c80df8634cfd671dbc92a89246c3c372d83a4e",
|
||||
"src/CelerisLab/lbm/kernels/core/descriptors.cuh": "8440bc14857da9007982650f828a23608eec9f7cc4bf772e8c8dc1239cb45356",
|
||||
"src/CelerisLab/lbm/kernels/core/flags.cuh": "0025705f24710330d9d9195f713879bfb650a85a3449baef0e196b831af104ab",
|
||||
"src/CelerisLab/lbm/kernels/core/layout.cuh": "1fbd820021c89ac3efcde24145996cb0147769e607e498e61f308ed3e0b13706",
|
||||
"src/CelerisLab/lbm/kernels/core/obs.cuh": "b742b77c9f61e1f020fe9922c3838ae9d855e17b381154958b58f10a22938922",
|
||||
"src/CelerisLab/lbm/kernels/core/params.cuh": "6e18bf730d936d940e3d85452f0714a2eaae31bf41002f9fd520ec5b372878dd",
|
||||
"src/CelerisLab/lbm/kernels/core/precision.cuh": "849df14b4cce50af52399e4b8f3e997196f3ae5f1163f68e28df3e6b2507a8c4",
|
||||
"src/CelerisLab/lbm/kernels/kernel_v2.cu": "c7bd3fb705b77261ba2441c52a24a7e6a7865c165cba088a1b75201c4d29c408",
|
||||
"src/CelerisLab/lbm/kernels/operators/collision_mrt.cuh": "aee444eb60c47a3fd518dca975d1af05425a0818a0ec6c3fb3f6150f60c2cb44",
|
||||
"src/CelerisLab/lbm/kernels/operators/collision_srt.cuh": "6b7ff842f392c8f853d2d1a97cc69bfe50f21283f73e659404da07cf6c95f5e1",
|
||||
"src/CelerisLab/lbm/kernels/operators/collision_trt.cuh": "9725b9840b9ad8b4ec7be61ff5d06d3151e86d1a87585c13f2fbf934d0d2c5f6",
|
||||
"src/CelerisLab/lbm/kernels/operators/forcing_guo.cuh": "969a430c1af03f32e3bd0df95d805e64f56334717c44dd4ba208ba503062a24b",
|
||||
"src/CelerisLab/lbm/kernels/operators/helpers.cuh": "0b3a4d95d7d19f5fec09e5423b8f4cc99f8a226b541967425967330da5a051ec",
|
||||
"src/CelerisLab/lbm/kernels/operators/macro.cuh": "2be20eb2b7d7f5522daaad7f4b57b1ff8cdf883efe4a67846a96783b2fe846a3",
|
||||
"src/CelerisLab/lbm/kernels/operators/turbulence_smag.cuh": "a3db46c2e7d27530b2d1436e65cdbed0b72cc7e047bfbeef2a640877271b51aa",
|
||||
"src/CelerisLab/lbm/kernels/refinement.cu": "b45aad9c022cecb64f7e968ec3cae2d8385a3cc430ee409700c404a3f4dd8cf8",
|
||||
"src/CelerisLab/lbm/kernels/step/aux_kernels.cu": "578a5b20a157241ee678547059abb1372dd5a589ad06bb2c15b987eb01ca1013",
|
||||
"src/CelerisLab/lbm/kernels/step/block_channel.cu": "ee8a31398b8e5233d977c816e6b8e060c1eb2ec71320e594226dbca55593c0d0",
|
||||
"src/CelerisLab/lbm/kernels/step/curved_double.cu": "bfa7a1df1e939020314d49755045f7a85fe4a19cc1c68dbe62653c66c5e7c2b3",
|
||||
"src/CelerisLab/lbm/kernels/step/esopull_macro.cu": "c73116020ca217ede1e4de27c8269c31f9d91e33433ac8252a21e9b14187c50f",
|
||||
"src/CelerisLab/lbm/kernels/step/esopull_sync.cu": "c0ff3e72900c3ab89a05dd03684e96d3b740cf1672d565727fc2af165933d087",
|
||||
"src/CelerisLab/lbm/kernels/step/init_flow.cu": "305be3bb12fc0d039acec1701baabcf0e8a35e683c49c34f2af8ca9867d2b29c",
|
||||
"src/CelerisLab/lbm/kernels/step/one_step_double.cu": "f9f8242b38e8e0cf8425b75e2b0215bc15363c62eaa0fb43c761b8e0931bfdc9",
|
||||
"src/CelerisLab/lbm/kernels/step/one_step_esopull.cu": "aa223b2744ae6c9125673df311a96ce588032c1ba58bebb11fd33ecb990f8ed4",
|
||||
"src/CelerisLab/lbm/kernels/streaming/esopull_semantic_helpers.cuh": "dd5a9091628b079efd97e53fd49e52d09dcee88529235db77a5648e02937af6e",
|
||||
"src/CelerisLab/lbm/kernels/streaming/esopull_single_buffer.cuh": "77ed4ab9a0ebc5ff62a91a7c39066ef395391ae36e9040a69fb2bd8b1626dc79",
|
||||
"src/CelerisLab/lbm/kernels/streaming/pull_double_buffer.cuh": "b65d741ed78b7b62f99531ba0c0ce1936663d8dc9640e7d7d16c707f1baf6549",
|
||||
"src/CelerisLab/lbm/population_storage.py": "a6a86c29c52c892edfe4802fb773c387212e3f4cc86708d80e154986ddf88044",
|
||||
"src/CelerisLab/lbm/raw_exchange.py": "9031a196006a0f74dee4028e6f3bb673bc19ef7959f9f04dde9e01f93293aeb3",
|
||||
"src/CelerisLab/lbm/refinement.py": "100d5e46ac894f6008b61364cdad5d37c2ca7ed949d8d71405572d6366526417",
|
||||
"src/CelerisLab/lbm/refinement_stepper.py": "536cd1d3482591e4ff57c4ae8e888c19e31701687d287aa509129df5b2a43be3",
|
||||
"src/CelerisLab/lbm/refinement_tables.py": "0cbc0f29aabd0ab4ba1f912bd4d0ac2b430246584cb6ddf13dd28e10582accf3",
|
||||
"src/CelerisLab/lbm/stepper.py": "0dcb7cd7ae87e33e3dc67a38b6aa43042fbe673e37d9ad165342ee1d695933ce",
|
||||
"src/CelerisLab/lbm/synchronized_remap.py": "7400fc4beaffd4649d6a9c524d38ce5fd6fa7eeb32eb5332e4030a4b6648f197",
|
||||
"src/CelerisLab/simulation.py": "0a03904dc6de88667d7af699005a289adf8b09f0976cb0d099b041f4830a8f4c"
|
||||
},
|
||||
"candidate_header_sha256": "a3db46c2e7d27530b2d1436e65cdbed0b72cc7e047bfbeef2a640877271b51aa",
|
||||
"baseline_header_sha256": "ff7f000297ee0817e255ad9fcb6a13b647e3ddd5b206518d35f38d2895a9f501"
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
"""Bounded exact-source CUDA plumbing gate; never a flow-accuracy certificate.
|
||||
|
||||
Run this driver externally against exact dev with --mode baseline first, then
|
||||
against the experimental source with --mode candidate --baseline <receipt>.
|
||||
Only scalar receipts/hashes and actual compiler sidecars are retained. Whole-step
|
||||
mass/momentum are diagnostics: no verified FP32 conservation bound is claimed.
|
||||
"""
|
||||
import argparse
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import replace
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
BASE = 'ed9d8c937d446b854d6963bd92d718bb4fb98b96'
|
||||
PRODUCTION = 'a135252ae78b47b57d7e77d19001700b3bedac8b'
|
||||
STOPS = (0, 1, 2, 8)
|
||||
OPERATOR_ATOL, OPERATOR_RTOL = 6e-8, 2e-7
|
||||
REST_ATOL, REST_RTOL = 3e-7, 1e-6
|
||||
REPLAY_ATOL = 3e-6
|
||||
E = np.array([(0,0),(1,0),(-1,0),(0,1),(0,-1),(1,1),(-1,-1),(1,-1),(-1,1)])
|
||||
W = np.array([4/9]+[1/9]*4+[1/36]*4)
|
||||
|
||||
|
||||
def digest(array):
|
||||
"""All finite bits (including signed zero) and exact NaN mask; no NaN payloads."""
|
||||
array = np.asarray(array)
|
||||
if np.isinf(array).any():
|
||||
raise AssertionError('infinite population')
|
||||
finite = np.isfinite(array)
|
||||
h = hashlib.sha256()
|
||||
h.update(str((array.shape, array.dtype.str)).encode())
|
||||
h.update(np.ascontiguousarray(finite).tobytes())
|
||||
h.update(np.ascontiguousarray(array[finite]).tobytes())
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def independent_explosion(coarse, pair, layout, kind):
|
||||
"""Coordinate oracle; never reads the production neighbor/offset tables."""
|
||||
ny, nx = pair.coarse_shape
|
||||
legal = np.zeros((ny,nx), bool)
|
||||
legal.ravel()[pair.ghost_coarse] = True
|
||||
legal &= layout.owned & ~pair.covered & (kind == 0)
|
||||
if not legal.ravel()[pair.ghost_coarse].all():
|
||||
raise AssertionError('illegal center donor')
|
||||
out = np.empty((9,len(pair.ghost_fine)))
|
||||
flat = np.asarray(coarse, np.float64).reshape(9,-1)
|
||||
for row, (target, donor) in enumerate(zip(pair.ghost_fine,pair.ghost_coarse)):
|
||||
y,x = divmod(int(donor),nx)
|
||||
gradient = np.zeros((9,2))
|
||||
for axis, (dx,dy) in enumerate(((1,0),(0,1))):
|
||||
ends = [(x-dx,y-dy),(x+dx,y+dy)]
|
||||
if not layout.periodic[axis] and any(not (0<=xx<nx and 0<=yy<ny) for xx,yy in ends):
|
||||
continue
|
||||
ends = [(xx%nx,yy%ny) for xx,yy in ends]
|
||||
if all(legal[yy,xx] for xx,yy in ends):
|
||||
(xm,ym),(xp,yp) = ends
|
||||
gradient[:,axis] = (flat[:,yp*nx+xp]-flat[:,ym*nx+xm])/2
|
||||
for q in range(1,9):
|
||||
gradient[q] -= E[q]*np.dot(E[q],gradient[q])/np.dot(E[q],E[q])
|
||||
fy,fx = divmod(int(target),pair.fine_shape[1])
|
||||
offset = np.array([pair.fine_origin[0]+(fx+.5)/2-(x+.5),
|
||||
pair.fine_origin[1]+(fy+.5)/2-(y+.5)])
|
||||
out[:,row] = flat[:,int(donor)] + gradient@offset
|
||||
return out
|
||||
|
||||
|
||||
def default_options(selector):
|
||||
# Exact dev has no explosion keyword. Baseline and omitted paths pass none.
|
||||
return {} if selector == 'omitted' else {'explosion':selector}
|
||||
|
||||
|
||||
def snapshot(run, raw):
|
||||
from CelerisLab.lbm.refinement import ACTIVE, OUTER_GHOST
|
||||
stored, public = raw(run), run.download()
|
||||
hashes, totals = [], np.zeros(3)
|
||||
for level,(values,visible,kind) in enumerate(zip(stored,public,run.hierarchy.kinds)):
|
||||
active = kind == ACTIVE
|
||||
if not np.isfinite(visible[:,active]).all() or not np.isnan(visible[:,~active]).all():
|
||||
raise AssertionError('public ACTIVE/invalid mask mismatch')
|
||||
if not np.isnan(values[:,kind==OUTER_GHOST]).all():
|
||||
raise AssertionError('OUTER owner core is not poisoned')
|
||||
population = visible[:,active].sum(axis=1,dtype=np.float64)*(.25**level)
|
||||
totals += np.r_[population.sum(), E.T@population]
|
||||
hashes.append(dict(raw=digest(values), public=digest(visible), active=int(active.sum())))
|
||||
if not np.isfinite(totals).all():
|
||||
raise AssertionError('nonfinite mass/momentum diagnostic')
|
||||
return dict(step=run.step_count, levels=hashes, mass_momentum=totals.tolist())
|
||||
|
||||
|
||||
def initialize(run, rest=False):
|
||||
arrays, origin, spacing = [], np.zeros(2), 1.
|
||||
for index,level in enumerate(run._levels):
|
||||
if index:
|
||||
origin += spacing*np.asarray(run.hierarchy.pairs[index-1].fine_origin)
|
||||
spacing /= 2
|
||||
y,x = np.indices(level.shape[1:],dtype=np.float64)
|
||||
x,y = origin[0]+spacing*(x+.5),origin[1]+spacing*(y+.5)
|
||||
q = np.arange(9)[:,None,None]
|
||||
values = np.broadcast_to(W[:,None,None],level.shape).copy()
|
||||
if not rest:
|
||||
# q-distinct smooth non-equilibrium G; same physical frame on all levels.
|
||||
values += 1e-4*np.sin(.17*x+.23*y+q)
|
||||
arrays.append(values)
|
||||
run.initialize(*arrays)
|
||||
|
||||
|
||||
def operator_case(run, index):
|
||||
import pycuda.driver as cuda
|
||||
parent,child = run._levels[index:index+2]
|
||||
geometry,pair = run.hierarchy.pairs[index],run._pairs[index]
|
||||
c = np.random.default_rng(19+index).normal(0,.001,(9,*geometry.coarse_shape))
|
||||
if not run._ddf_shifting:
|
||||
c += W[:,None,None]
|
||||
c = c.astype(np.float32)
|
||||
legal = np.zeros(geometry.coarse_shape,bool)
|
||||
legal.ravel()[geometry.ghost_coarse] = True
|
||||
c[:,~legal] = np.nan
|
||||
packed = parent.layout.to_blocked(c)
|
||||
output = np.full(child.layout.blocked_shape,np.nan,np.float32)
|
||||
cuda.memcpy_htod(parent.current,packed)
|
||||
cuda.memcpy_htod(child.current,output)
|
||||
calls, hop = [],run._hop
|
||||
try:
|
||||
run._hop = lambda *a,**kw: calls.append(a)
|
||||
run._advance(index+1,parent.current,block=run._block,stream=run._stream)
|
||||
run._stream.synchronize()
|
||||
finally:
|
||||
run._hop = hop
|
||||
if calls != [(index+1,child.current,child.spare),(index+1,child.spare,child.current)]:
|
||||
raise AssertionError('explosion changed two-hop launch contract')
|
||||
cuda.memcpy_dtoh(output,child.current)
|
||||
target = child.layout.owner.ravel()[geometry.ghost_fine]
|
||||
slots = np.array([child.layout.slot(target,q) for q in range(9)])
|
||||
actual = output.ravel()[slots]
|
||||
expected = independent_explosion(c,geometry,parent.layout,run.hierarchy.kinds[index])
|
||||
constant = c.reshape(9,-1)[:,geometry.ghost_coarse]
|
||||
if not np.isfinite(actual).all() or np.max(abs(expected-constant)) <= 1e-5:
|
||||
raise AssertionError('operator must exercise finite nonzero projected slopes')
|
||||
np.testing.assert_allclose(actual,expected,atol=OPERATOR_ATOL,rtol=OPERATOR_RTOL)
|
||||
untouched = np.ones(output.size,bool); untouched[slots.ravel()] = False
|
||||
if not np.isnan(output.ravel()[untouched]).all():
|
||||
raise AssertionError('explosion wrote outside its targets')
|
||||
return dict(pair=index,rows=len(target),parent_stride=pair.parent_cells,
|
||||
child_stride=int(np.prod(child.layout.local_shape)),
|
||||
max_error_by_q=np.max(abs(actual-expected),axis=1).tolist(),
|
||||
nonzero_projection=True,poisoned_nondonors=True,untouched_preserved=True)
|
||||
|
||||
|
||||
def replay_two_hops(values, pair, layout, kind):
|
||||
"""Existing FP64 two-hop algebra, using the independent coordinate oracle."""
|
||||
from tests.unit.test_projected_explosion import collide
|
||||
coarse,fine = (a.copy() for a in values)
|
||||
fine.reshape(9,-1)[:,pair.ghost_fine] = independent_explosion(coarse,pair,layout,kind)
|
||||
for _ in range(2):
|
||||
fine = np.array([np.roll(fine[q],(ey,ex),(0,1)) for q,(ex,ey) in enumerate(E)])
|
||||
fine[:,pair.fine_kind==2] = np.nan
|
||||
active = pair.fine_kind==0
|
||||
fine[:,active] = collide(fine[:,active],pair.tau_fine)
|
||||
out = np.array([np.roll(coarse[q],(ey,ex),(0,1)) for q,(ex,ey) in enumerate(E)])
|
||||
out.reshape(-1)[pair.coalesce_coarse] = fine.reshape(-1)[pair.coalesce_fine].mean(axis=1)
|
||||
out[:,~pair.covered] = collide(out[:,~pair.covered],pair.tau_coarse)
|
||||
out[:,pair.covered] = np.nan
|
||||
return out,fine
|
||||
|
||||
|
||||
def run_case(ctx, case, shifted, selector, raw, projected=False, replay=False):
|
||||
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
|
||||
cfg,refs,blocks = case
|
||||
cfg = replace(cfg,velocity=0,ddf_shifting=shifted)
|
||||
with FixedPatchStepper(cfg,context=ctx,refinements=refs,level_blocks=blocks,
|
||||
**default_options(selector)) as run:
|
||||
operator = [operator_case(run,i) for i in range(len(run._pairs))] if projected else []
|
||||
counts = [0]*len(run._levels)
|
||||
for index,level in enumerate(run._levels):
|
||||
def counted(*a, index=index, kernel=level.kernel, **kw):
|
||||
counts[index] += 1
|
||||
return kernel(*a,**kw)
|
||||
level.kernel = counted
|
||||
def check_counts(stop):
|
||||
if counts != [stop*2**index for index in range(len(counts))]:
|
||||
raise AssertionError('actual level hop counts differ from acoustic schedule')
|
||||
rest = []
|
||||
if projected:
|
||||
initialize(run,rest=True)
|
||||
for stop in STOPS:
|
||||
run.step(stop-run.step_count)
|
||||
check_counts(stop)
|
||||
snapshot(run,raw) # actual public and raw invalid masks at every stop
|
||||
maximum = 0.
|
||||
for values,kind in zip(run.download(),run.hierarchy.kinds):
|
||||
active = values[:,kind==0]
|
||||
np.testing.assert_allclose(active,np.broadcast_to(W[:,None],active.shape),
|
||||
atol=REST_ATOL,rtol=REST_RTOL)
|
||||
maximum = max(maximum,float(np.max(abs(active-W[:,None]))))
|
||||
rest.append(dict(step=stop,max_absolute_error=maximum))
|
||||
counts[:] = [0]*len(counts)
|
||||
initialize(run)
|
||||
reference = tuple(a.astype(np.float64) for a in run.download()) if replay else None
|
||||
snapshots,replay_errors,previous = [],[],0
|
||||
for stop in STOPS:
|
||||
run.step(stop-run.step_count)
|
||||
if replay:
|
||||
for _ in range(stop-previous):
|
||||
reference = replay_two_hops(reference,run.hierarchy.pairs[0],
|
||||
run._levels[0].layout,run.hierarchy.kinds[0])
|
||||
errors = []
|
||||
for actual,expected,kind in zip(run.download(),reference,run.hierarchy.kinds):
|
||||
np.testing.assert_allclose(actual[:,kind==0],expected[:,kind==0],
|
||||
atol=REPLAY_ATOL,rtol=0)
|
||||
errors.append(np.max(abs(actual[:,kind==0]-expected[:,kind==0]),axis=1).tolist())
|
||||
replay_errors.append(dict(step=stop,max_error_by_level_q=errors))
|
||||
check_counts(stop)
|
||||
snapshots.append(dict(snapshot(run,raw),observed_level_hops=counts.copy())); previous = stop
|
||||
change = (np.array(snapshots[-1]['mass_momentum'])-
|
||||
np.array(snapshots[0]['mass_momentum'])).tolist()
|
||||
return dict(operator=operator,snapshots=snapshots,rest_fixed_point=rest,
|
||||
replay_max_errors=replay_errors,mass_momentum_change_diagnostic=change,
|
||||
conservation_status='diagnostic_only_no_FP32_bound')
|
||||
|
||||
|
||||
def compare_snapshots(actual,expected):
|
||||
if len(actual) != len(expected):
|
||||
raise AssertionError('snapshot count differs')
|
||||
for got,want in zip(actual,expected):
|
||||
if (got['step'] != want['step'] or got['levels'] != want['levels']
|
||||
or got.get('observed_level_hops') != want.get('observed_level_hops')):
|
||||
raise AssertionError('owner bits, public values or masks differ')
|
||||
|
||||
|
||||
def cases():
|
||||
from CelerisLab.config import LBMConfig
|
||||
from tests.unit.test_hierarchy import CFG, FOUR
|
||||
scalar = (LBMConfig(nx=16,ny=16,viscosity=.02),
|
||||
[(4,4,12,12),(8,8,16,16),(8,8,16,16)],
|
||||
[(4,4),(4,8),(6,4),(4,6)])
|
||||
four = (CFG,FOUR,[(6,8),(8,6),(12,8),(16,12)])
|
||||
concave = (LBMConfig(nx=30,ny=30,viscosity=.1),
|
||||
[[(4,4,5,7),(4,5,7,7)]],[(6,5),(4,6)])
|
||||
return dict(scalar=scalar,four=four,concave=concave)
|
||||
|
||||
|
||||
def baseline_receipt(receipt, driver_hash, keys):
|
||||
if (receipt.get('completed') is not True or receipt.get('dry_run') is not False
|
||||
or receipt.get('mode') != 'baseline' or receipt.get('production_revision') != BASE
|
||||
or receipt.get('source',{}).get('revision') != BASE
|
||||
or receipt.get('source',{}).get('dirty') is not False
|
||||
or receipt.get('driver_sha256') != driver_hash
|
||||
or receipt.get('stops') != list(STOPS)
|
||||
or receipt.get('operator_tolerance') != [OPERATOR_ATOL,OPERATOR_RTOL]
|
||||
or receipt.get('rest_tolerance') != [REST_ATOL,REST_RTOL]
|
||||
or receipt.get('replay_tolerance') != [REPLAY_ATOL,0]
|
||||
or set(receipt.get('default_snapshots',{})) != set(keys)
|
||||
or not receipt.get('actual_build_sidecars')):
|
||||
raise ValueError('baseline receipt does not match the frozen exact-dev gate')
|
||||
return receipt
|
||||
|
||||
|
||||
def remap_case(ctx, shifted, raw):
|
||||
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
|
||||
cfg,refs,blocks = cases()['four']
|
||||
with FixedPatchStepper(replace(cfg,velocity=0,ddf_shifting=shifted),context=ctx,
|
||||
refinements=refs[:2],level_blocks=blocks[:3],explosion='projected_linear') as run:
|
||||
initialize(run); run.step(1)
|
||||
before = snapshot(run,raw)
|
||||
with run.remap_child([(22,22,42,42),(72,68,76,76)]) as clone:
|
||||
if clone._explosion != 'projected_linear' or any(p.neighbors is None for p in clone._pairs):
|
||||
raise AssertionError('remap lost projected selection')
|
||||
if clone.step_count != 1: raise AssertionError('remap lost synchronized count')
|
||||
initial = snapshot(clone,raw)
|
||||
clone.step(1)
|
||||
after = snapshot(clone,raw)
|
||||
if clone.step_count != 2: raise AssertionError('remap did not continue count')
|
||||
compare_snapshots([snapshot(run,raw)],[before])
|
||||
return dict(shifted=shifted,source_unchanged=True,initial=initial,continued=after)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def capture_builds(report, save, baseline=None):
|
||||
from CelerisLab.cuda import compiler_v2 as compiler
|
||||
original = compiler.load_module
|
||||
def load(path):
|
||||
path = Path(path)
|
||||
metadata = json.loads(Path(str(path)+'.json').read_text())
|
||||
if metadata['ptx_sha256'] != hashlib.sha256(path.read_bytes()).hexdigest():
|
||||
raise AssertionError('actual PTX and build sidecar disagree')
|
||||
if baseline and (metadata['arch'],metadata['flags']) not in [
|
||||
(m['arch'],m['flags']) for m in baseline['actual_build_sidecars']]:
|
||||
raise AssertionError('baseline build architecture or flags differ')
|
||||
report['actual_build_sidecars'].append(metadata)
|
||||
save() # capture AMR metadata before production cleanup removes scratch
|
||||
return original(str(path))
|
||||
compiler.load_module = load
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
compiler.load_module = original
|
||||
|
||||
|
||||
def preflight(repo, revision, mode):
|
||||
git = lambda *a: subprocess.check_output(['git','-C',str(repo),*a],text=True).strip()
|
||||
if git('rev-parse','HEAD') != revision or git('status','--porcelain'):
|
||||
raise ValueError('requires the exact expected clean checkout')
|
||||
source = BASE if mode == 'baseline' else PRODUCTION
|
||||
if mode == 'baseline' and revision != BASE:
|
||||
raise ValueError('baseline must be exact ed9d8c9')
|
||||
if subprocess.call(['git','-C',str(repo),'diff','--quiet',source,'--','src']):
|
||||
raise ValueError('production source differs from the frozen source revision')
|
||||
return source
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--repo',type=Path,required=True)
|
||||
parser.add_argument('--expected-revision',required=True)
|
||||
parser.add_argument('--mode',choices=('baseline','candidate'),required=True)
|
||||
parser.add_argument('--baseline',type=Path)
|
||||
parser.add_argument('--output',type=Path,required=True)
|
||||
parser.add_argument('--run-gpu',action='store_true')
|
||||
parser.add_argument('--dry-run',action='store_true')
|
||||
args = parser.parse_args()
|
||||
if args.output.exists(): parser.error('output already exists')
|
||||
selected = os.environ.get('CUDA_VISIBLE_DEVICES','')
|
||||
if not args.dry_run and (not args.run_gpu or not selected or ',' in selected or selected=='-1'):
|
||||
parser.error('--run-gpu and exactly one explicit CUDA_VISIBLE_DEVICES are required')
|
||||
repo = args.repo.resolve()
|
||||
source = preflight(repo,args.expected_revision,args.mode)
|
||||
baseline = None
|
||||
if args.mode == 'candidate':
|
||||
if args.baseline is None: parser.error('candidate requires --baseline receipt')
|
||||
baseline = json.loads(args.baseline.read_text())
|
||||
sys.path[:0] = [str(repo/'src'),str(repo),str(repo/'tests/validation/multigrid'),
|
||||
str(repo/'tests/validation/taylor_green')]
|
||||
import CelerisLab
|
||||
if Path(CelerisLab.__file__).resolve() != repo/'src/CelerisLab/__init__.py':
|
||||
raise RuntimeError('wrong imported checkout')
|
||||
from run_case import provenance
|
||||
driver_hash = hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
|
||||
keys = [f'{name}:shifted={shifted}' for name in cases() for shifted in (False,True)]
|
||||
if baseline is not None: baseline_receipt(baseline,driver_hash,keys)
|
||||
report = dict(mode=args.mode,production_revision=source,source=provenance(repo),
|
||||
driver_sha256=driver_hash,
|
||||
device_selection=selected,completed=False,dry_run=args.dry_run,
|
||||
operator_tolerance=[OPERATOR_ATOL,OPERATOR_RTOL],
|
||||
rest_tolerance=[REST_ATOL,REST_RTOL],replay_tolerance=[REPLAY_ATOL,0],stops=STOPS,
|
||||
conservation_status='diagnostic_only_no_FP32_bound',
|
||||
actual_build_sidecars=[],default_snapshots={},default_equivalence=[],
|
||||
projected_operator=[],union_multilevel_smoke=[],changed_remap=[])
|
||||
args.output.parent.mkdir(parents=True,exist_ok=True)
|
||||
def save():
|
||||
text = json.dumps(report,indent=2,allow_nan=False)+'\n'
|
||||
if len(text.encode()) > 8*1024*1024: raise RuntimeError('receipt exceeds 8 MiB cap')
|
||||
temporary = args.output.with_suffix(args.output.suffix+'.tmp')
|
||||
temporary.write_text(text); os.replace(temporary,args.output)
|
||||
save()
|
||||
if args.dry_run:
|
||||
print('Source preflight only; no CUDA imports or execution.'); return
|
||||
try:
|
||||
from CelerisLab.cuda.context import CudaContext
|
||||
from test_refinement_blocked_base import raw
|
||||
with CudaContext(0) as ctx, capture_builds(report,save,baseline):
|
||||
import pycuda.driver as cuda
|
||||
report['device'] = dict(name=ctx.device.name(),pci_bus_id=ctx.device.pci_bus_id(),
|
||||
capability=list(ctx.device.compute_capability()),
|
||||
driver=cuda.get_driver_version(),runtime=list(cuda.get_version()))
|
||||
nvcc = shutil.which('nvcc') or '/usr/local/cuda/bin/nvcc'
|
||||
report['nvcc_version'] = subprocess.check_output([nvcc,'--version'],text=True).strip()
|
||||
if baseline and (report['device'] != baseline.get('device')
|
||||
or report['nvcc_version'] != baseline.get('nvcc_version')
|
||||
or selected != baseline.get('device_selection')):
|
||||
raise ValueError('baseline GPU/device selection or toolchain differs')
|
||||
for name,case in cases().items():
|
||||
for shifted in (False,True):
|
||||
key = f'{name}:shifted={shifted}'
|
||||
report['active_case'] = f'default:{key}'
|
||||
result = run_case(ctx,case,shifted,'omitted',raw)
|
||||
report['default_snapshots'][key] = result['snapshots']
|
||||
if baseline:
|
||||
compare_snapshots(result['snapshots'],baseline['default_snapshots'][key])
|
||||
explicit = run_case(ctx,case,shifted,'constant',raw)
|
||||
compare_snapshots(explicit['snapshots'],result['snapshots'])
|
||||
report['default_equivalence'].append(dict(case=key,passed=True))
|
||||
save()
|
||||
if baseline:
|
||||
for name,case in cases().items():
|
||||
for shifted in (False,True):
|
||||
report['active_case'] = f'projected:{name}:shifted={shifted}'
|
||||
result = run_case(ctx,case,shifted,'projected_linear',raw,True,name=='concave')
|
||||
cfg,refs,blocks = case
|
||||
if name == 'four':
|
||||
variant_refs = [list(reversed(rects)) for rects in refs]
|
||||
elif name == 'concave':
|
||||
variant_refs = [[(4,5,7,7),(4,4,5,5)]] # same union, disjoint decomposition
|
||||
else:
|
||||
variant_refs = refs
|
||||
variant = (cfg,variant_refs,[(2,2)]*len(blocks))
|
||||
alternate = run_case(ctx,variant,shifted,'projected_linear',raw,True)
|
||||
compare_snapshots(alternate['snapshots'],result['snapshots'])
|
||||
report['projected_operator'].append(dict(case=name,shifted=shifted,rows=result['operator']))
|
||||
report['union_multilevel_smoke'].append(dict(case=name,shifted=shifted,result=result,
|
||||
alternate_bits_identical=True))
|
||||
save()
|
||||
for shifted in (False,True):
|
||||
report['active_case'] = f'changed-remap:shifted={shifted}'
|
||||
report['changed_remap'].append(remap_case(ctx,shifted,raw)); save()
|
||||
expected_builds = {(s['arch'],tuple(s['flags'])) for s in baseline['actual_build_sidecars']}
|
||||
actual_builds = {(s['arch'],tuple(s['flags'])) for s in report['actual_build_sidecars']}
|
||||
if actual_builds != expected_builds:
|
||||
raise AssertionError('baseline build architecture or compiler flags differ')
|
||||
report['completed'] = True
|
||||
save()
|
||||
except Exception as error:
|
||||
report['error'] = f'{type(error).__name__}: {error}'
|
||||
save(); raise
|
||||
print(json.dumps(dict(completed=True,mode=args.mode,receipt=str(args.output))))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,263 +0,0 @@
|
||||
"""Four opt-in shifted GPU controls: U64/U128/U256 and refined base128/fine256."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import resource
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import run_spacetime_gpu_n64 as old
|
||||
|
||||
gpu,ref,tg,ROOT = old.gpu,old.ref,old.tg,old.ROOT
|
||||
BASE='26d0d3853c9d4d3cd6ae3f27653dced56f8c5fec'
|
||||
REFERENCE_SHA='9d154c989b087bf288f86d413db0f3067b73767b618c329d4b9e53c4eee00032'
|
||||
CASES=(('uniform64',64,False),('uniform128',128,False),('uniform256',256,False),('refined128',128,True))
|
||||
LIMITS=old.LIMITS
|
||||
SECONDS,RSS_MIB=600.,512.
|
||||
|
||||
|
||||
def setup(n, refined):
|
||||
return dict(base_n=n,finest_n=2*n if refined else n,normalized_domain=[0.,1.,0.,1.],
|
||||
re_L=100,U=.03,mach=.03*np.sqrt(3),viscosity=.0003*n,
|
||||
tau=[.5+.0009*n]+([.5+.0018*n] if refined else []),
|
||||
bounds=[n//8,n//8,7*n//8,3*n//4] if refined else None,
|
||||
normalized_patch=[.125,.125,.875,.75] if refined else None,
|
||||
intervals=16*n,sample_steps=[4*n,8*n,12*n,16*n],sample_times=list(old.TIMES),
|
||||
early_steps=list(range(0,4*n+1,n//16)),early_times=(np.arange(65)*.001875).tolist(),
|
||||
phase='arrival_f',storage='shifted_FP32',collision='SRT',
|
||||
initialization='common analytic FP64 equilibrium at actual cell centers, production shifted codec')
|
||||
|
||||
|
||||
def pressure_projection(arrays,masks,t,n,viscosity):
|
||||
"""One global gauge and a weighted Gram fit, including unequal leaf areas."""
|
||||
rows=[]
|
||||
for level,(a,mask) in enumerate(zip(arrays,masks)):
|
||||
rho=np.asarray(a[:,mask],np.float64).sum(axis=0)
|
||||
x,y=tg.centers(mask.shape,level); x,y=x[mask],y[mask]
|
||||
exact=tg.exact(x,y,t,n,viscosity)[2]
|
||||
angle=4*np.pi/n
|
||||
basis=np.column_stack((np.cos(angle*x),np.sin(angle*x),np.cos(angle*y),np.sin(angle*y)))
|
||||
rows.append(((rho-1)/3,exact,np.full(len(rho),.25**level),basis))
|
||||
p,exact,w,b=[np.concatenate(x) for x in zip(*rows)]
|
||||
volume=w.sum(); p-=w@p/volume; exact-=w@exact/volume; e=p-exact
|
||||
b-=w@b/volume
|
||||
gram=b.T@(w[:,None]*b); rhs=b.T@(w*e)
|
||||
coefficients=np.linalg.solve(gram,rhs); residual=e-b@coefficients
|
||||
norm=float(w@(e*e)); exact_norm=float(w@(exact*exact)); remaining=float(w@(residual*residual))
|
||||
projected=float(coefficients@gram@coefficients)
|
||||
return dict(step=t,tstar=t*.03/n,absolute_rms=np.sqrt(norm/volume),relative_rms=np.sqrt(norm/exact_norm),
|
||||
coefficients=coefficients.tolist(),basis=['cos_4pi_x','sin_4pi_x','cos_4pi_y','sin_4pi_y'],
|
||||
residual_rms=np.sqrt(remaining/volume),residual_relative=np.sqrt(remaining/exact_norm),
|
||||
gram_condition=float(np.linalg.cond(gram)),
|
||||
orthogonality=(b.T@(w*residual)/volume).tolist(),energy_closure=(norm-projected-remaining)/volume)
|
||||
|
||||
|
||||
def load_reference(path):
|
||||
if old.sha(path)!=REFERENCE_SHA:
|
||||
raise ValueError('verified shifted N64 receipt differs')
|
||||
data=json.loads(Path(path).read_text())
|
||||
if (not data['completed'] or not data['passed'] or data['source']['commit']!=BASE
|
||||
or data['setup']['storage']!='shifted_FP32' or data['setup']['bounds']!=[8,8,56,48]
|
||||
or data['setup']['interval_count']!=1024 or len(data['rows'])!=4):
|
||||
raise ValueError('N64 reference identity differs')
|
||||
if tuple(row['tstar'] for row in data['rows'])!=old.TIMES:
|
||||
raise ValueError('N64 reference sample times differ')
|
||||
return data
|
||||
|
||||
|
||||
def source_identity(reference):
|
||||
git=lambda *args:subprocess.check_output(['git',*args],cwd=ROOT,text=True).strip()
|
||||
if git('status','--porcelain'):
|
||||
raise RuntimeError('clean committed checkout required')
|
||||
unchanged=['src','tests/validation/taylor_green/oracle.py']+[f'tests/validation/multigrid/{name}' for name in
|
||||
('spacetime_gpu.cu','spacetime_reference.py','cpu_reference.py','run_spacetime_gpu_n64.py','test_spacetime_gpu.py')]
|
||||
subprocess.check_call(['git','diff','--exit-code',BASE,'--',*unchanged],cwd=ROOT)
|
||||
previous=subprocess.check_output(['git','show',f'{BASE}:tests/validation/multigrid/spacetime_gpu.py'],cwd=ROOT,text=True)
|
||||
# The raw interval schedule remains byte-for-byte unchanged despite the new owner.
|
||||
method=inspect.getsource(gpu.SpaceTimeGPU.step)
|
||||
if method not in previous or np.__version__!=reference['source']['numpy']:
|
||||
raise RuntimeError('raw schedule or NumPy identity changed')
|
||||
files=[Path(__file__),Path(gpu.__file__),Path(old.table_guard.__file__),Path(__file__).with_name('test_spacetime_gpu_ladder.py')]
|
||||
return dict(commit=git('rev-parse','HEAD'),dirty=False,numpy=np.__version__,production_tree=git('rev-parse','HEAD:src'),
|
||||
hashes={p.name:old.sha(p) for p in files},cuda_sha256=old.sha(Path(gpu.__file__).with_suffix('.cu')),
|
||||
raw_schedule_sha256=hashlib.sha256(method.encode()).hexdigest(),unchanged_against=BASE,
|
||||
reference_hashes=gpu.PINS)
|
||||
|
||||
|
||||
def directional(arrays,masks):
|
||||
return sum(.25**level*np.asarray(a[:,m],np.float64).sum(axis=1) for level,(a,m) in enumerate(zip(arrays,masks)))
|
||||
|
||||
|
||||
def populations(run,refined,*,raw=False):
|
||||
state=run._download_stored() if raw else run.download()
|
||||
if not refined:
|
||||
return (state,)
|
||||
if state.phase!='arrival_f' or state.tick!=2*run.step_count:
|
||||
raise AssertionError('refined sample is not synchronized arrival F')
|
||||
return state.coarse_f,state.fine_f
|
||||
|
||||
|
||||
def assess(rows,health):
|
||||
checks={key:all(np.isfinite(row[key]) and 0<=row[key]<=limit for row in rows) for key,limit in LIMITS.items()}
|
||||
checks['population_health']=not any(health)
|
||||
checks['sampled_population_positive']=all(row['minimum_population']>0 for row in rows)
|
||||
return dict(worst={key:max(row[key] for row in rows) for key in LIMITS},campaign_checks=checks,
|
||||
campaign_pass=all(checks.values()),accuracy_comparison_status='descriptive_only_no_new_threshold')
|
||||
|
||||
|
||||
def ratios(a,b,*,trend=False):
|
||||
out=dict(ratio=a/b if b>0 else None,ratio_status='defined' if b>0 else 'zero_denominator')
|
||||
if trend:
|
||||
out.update(two_point_slope=np.log2(a/b) if a>0 and b>0 else None,
|
||||
slope_status='defined' if a>0 and b>0 else 'zero_error_log_undefined')
|
||||
return out
|
||||
|
||||
|
||||
def comparisons(runs,reference):
|
||||
data={r['name']:r for r in runs}; data['refined64']=reference
|
||||
out=[]
|
||||
for refined,base,fine in (('refined64','uniform64','uniform128'),('refined128','uniform128','uniform256')):
|
||||
for control in (base,fine):
|
||||
out.append(dict(candidate=refined,control=control,control_spacing='base' if control==base else 'finest',
|
||||
samples=[dict(tstar=a['tstar'],**{key:dict(candidate=a[key],control=b[key],**ratios(a[key],b[key]))
|
||||
for key in ('velocity_l2','pressure_l2','energy_relative')})
|
||||
for a,b in zip(data[refined]['rows'],data[control]['rows'])]))
|
||||
trends=[]
|
||||
for coarse,fine in (('uniform64','uniform128'),('uniform128','uniform256'),('refined64','refined128')):
|
||||
trends.append(dict(coarse=coarse,fine=fine,samples=[dict(tstar=a['tstar'],**{
|
||||
key:ratios(a[key],b[key],trend=True)
|
||||
for key in ('velocity_l2','pressure_l2','energy_relative')})
|
||||
for a,b in zip(data[coarse]['rows'],data[fine]['rows'])]))
|
||||
return dict(controls=out,trends=trends,caveat='norm differences are not interface error; fixed Mach and two refined sizes do not certify convergence')
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--gpu-admitted',action='store_true'); p.add_argument('--reference-n64',type=Path,required=True)
|
||||
p.add_argument('--output',type=Path,required=True); args=p.parse_args()
|
||||
if not args.gpu_admitted or not os.environ.get('CUDA_VISIBLE_DEVICES'):
|
||||
p.error('explicit GPU admission and CUDA_VISIBLE_DEVICES required')
|
||||
if args.output.exists() or args.output.with_suffix(args.output.suffix+'.tmp').exists(): p.error('output or partial receipt exists; preserve it')
|
||||
reference=load_reference(args.reference_n64); source=source_identity(reference); gpu.check_sources()
|
||||
started=time.monotonic()
|
||||
report=dict(completed=False,passed=False,source=source,runs=[],limits=dict(seconds=SECONDS,peak_rss_mib=RSS_MIB),
|
||||
campaign_limits=LIMITS,reference=dict(sha256=REFERENCE_SHA,source=reference['source'],
|
||||
coverage='verified refined base64/fine128 four-time rows only; early pressure trace unavailable'),
|
||||
historical_pressure_gate=dict(scope='reused_refined64_only',**reference['historical_pressure_gate']),
|
||||
n64_experimental_screen=dict(scope='reused_refined64_only',checks=reference['experimental_checks']),
|
||||
frozen_cases=[setup(n,r) for _,n,r in CASES],early_trace_scope='65 predeclared rows per new run, no field snapshots',
|
||||
cuda_visible_devices=os.environ['CUDA_VISIBLE_DEVICES'])
|
||||
run=context=ledger=None
|
||||
case_started=None
|
||||
def save():
|
||||
report['resources']=dict(seconds=time.monotonic()-started,peak_rss_mib=resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/1024,
|
||||
rss_scope='Python process peak; external wrapper monitors process group and wall time')
|
||||
report['resources']['within_limits']=report['resources']['seconds']<=SECONDS and report['resources']['peak_rss_mib']<=RSS_MIB
|
||||
if report['completed'] and not report['resources']['within_limits']: report['passed']=False
|
||||
if run is not None:
|
||||
report['runs'][-1].update(completed_intervals=run.step_count,health_counts=run.health_counts.tolist(),
|
||||
seconds=time.monotonic()-case_started)
|
||||
if ledger is not None:
|
||||
report['runs'][-1].update(ledger=ledger.snapshot(),audited_intervals=ledger.steps)
|
||||
temporary=args.output.with_suffix(args.output.suffix+'.tmp')
|
||||
ref.write_receipt(temporary,report); os.replace(temporary,args.output)
|
||||
def cap():
|
||||
if time.monotonic()-started>SECONDS or resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/1024>RSS_MIB:
|
||||
raise RuntimeError('frozen aggregate resource cap exceeded')
|
||||
save()
|
||||
try:
|
||||
from CelerisLab.cuda.context import CudaContext
|
||||
context=CudaContext(0)
|
||||
if context.name!=reference['device'] or context.sm_arch!=reference['sm_arch']:
|
||||
raise RuntimeError('matched reference device identity differs')
|
||||
report.update(device=context.name,sm_arch=context.sm_arch)
|
||||
for name,n,refined in CASES:
|
||||
cap(); ledger=None; case_started=time.monotonic(); cfg=old.LBMConfig(nx=n,ny=n,viscosity=.0003*n,velocity=0.,ddf_shifting=True)
|
||||
case=dict(name=name,setup=setup(n,refined),completed=False,phase='setup',rows=[],early_pressure=[],prefixes=[],health_counts=[0]*6,
|
||||
ledger_scope='all intervals; fine/transfer components are mathematically absent for uniform runs')
|
||||
report['runs'].append(case); save()
|
||||
run=gpu.SpaceTimeGPU(cfg,tuple(case['setup']['bounds']),context=context,allow_n128=True) if refined else gpu.UniformGPU(cfg,context=context)
|
||||
case.update(build_metadata=run.build_metadata,device_allocations_bytes=run.device_bytes,
|
||||
device_memory_scope='owner allocations; excludes driver/context/compiler')
|
||||
expected_modules=None
|
||||
if not refined and n in (64,128): expected_modules=[reference['build_metadata'][0 if n==64 else 1]]
|
||||
if refined: expected_modules=[report['runs'][1]['build_metadata'][0],report['runs'][2]['build_metadata'][0]]
|
||||
if expected_modules is not None:
|
||||
if run.build_metadata!=expected_modules: raise RuntimeError('matched source/config/PTX metadata differs')
|
||||
case['matched_build_metadata']=True
|
||||
if any(m['flags']!=reference['build_metadata'][0]['flags'] for m in run.build_metadata):
|
||||
raise RuntimeError('compiler flags differ from reference')
|
||||
if refined:
|
||||
case['tables']=old.table_guard.validate_tables(run.plan,run.tables,allow_n128=True)
|
||||
masks=(run.plan.maps.coarse_active,run.plan.maps.fine_active)
|
||||
else: masks=(np.ones((n,n),bool),)
|
||||
analytic=tuple(tg.initial(m.shape,l,n,cfg.viscosity) for l,m in enumerate(masks))
|
||||
initial=ref.cpu.State(*analytic) if refined else analytic[0]
|
||||
physical=lambda:populations(run,refined)
|
||||
stored=lambda:populations(run,refined,raw=True)
|
||||
run.initialize(initial); raw0=stored(); direction0=directional(raw0,masks)
|
||||
case['phase']='warm_reset'; warm_started=time.monotonic()
|
||||
for _ in range(32): cap(); run.step()
|
||||
case['warmup_seconds']=time.monotonic()-warm_started
|
||||
run.initialize(initial)
|
||||
for a,b in zip(raw0,stored()): np.testing.assert_array_equal(a.view(np.uint32),b.view(np.uint32))
|
||||
if run.step_count or run.health_counts.any(): raise AssertionError('warm reset failed')
|
||||
case['raw_reset_verified']=True; arrays=physical()
|
||||
first=tg.metrics(arrays,masks,0,n,cfg.viscosity); mass0=first['mass']; moment0=ref.cpu.MOMENTS.T@directional(arrays,masks)
|
||||
case['initial']=dict(mass=mass0,momentum=moment0[1:].tolist(),minimum_population=min(float(a[:,m].min()) for a,m in zip(arrays,masks)))
|
||||
case['initial_quantization']=[float(abs(a[:,m]-b[:,m]).max()) for a,b,m in zip(arrays,analytic,masks)]
|
||||
case['early_pressure'].append(pressure_projection(arrays,masks,0,n,cfg.viscosity))
|
||||
ledger=old.LedgerTotals(); case['phase']='trajectory'; trajectory_started=time.monotonic()
|
||||
case['all_interval_mass']=dict(maximum_relative=0.,step=0,signed_change_at_maximum=0.,limit=LIMITS['mass_relative'],passed=True)
|
||||
for step in range(1,16*n+1):
|
||||
cap(); measured=run.step(); ledger.add(measured)
|
||||
if run.step_count!=step: raise AssertionError('interval count differs')
|
||||
mass_change=float(ledger.sums[0].sum()); drift=abs(mass_change)/mass0
|
||||
if drift>case['all_interval_mass']['maximum_relative']:
|
||||
case['all_interval_mass'].update(maximum_relative=drift,step=step,signed_change_at_maximum=mass_change,passed=drift<=LIMITS['mass_relative'])
|
||||
if step in case['setup']['early_steps'] or step in case['setup']['sample_steps']:
|
||||
arrays=physical()
|
||||
if step in case['setup']['early_steps']:
|
||||
case['early_pressure'].append(pressure_projection(arrays,masks,step,n,cfg.viscosity)); save()
|
||||
if step in case['setup']['sample_steps']:
|
||||
row=tg.metrics(arrays,masks,step,n,cfg.viscosity,mass0)
|
||||
row.update(step=step,momentum_change=(ref.cpu.MOMENTS.T@directional(arrays,masks)-moment0)[1:].tolist(),
|
||||
mass_change=row['mass']-mass0,minimum_population=min(float(a[:,m].min()) for a,m in zip(arrays,masks)))
|
||||
if not all(np.isfinite(v).all() for v in row.values()): raise FloatingPointError('nonfinite sampled metric')
|
||||
case['rows'].append(row); prefix=ledger.snapshot(directional(stored(),masks)-direction0)
|
||||
prefix['physical_minus_raw_endpoint_moments']=(ref.cpu.MOMENTS.T@directional(arrays,masks)-moment0-ref.cpu.MOMENTS.T@np.array(prefix['endpoint_directional_change'])).tolist()
|
||||
case['prefixes'].append(prefix)
|
||||
if not prefix['closure_pass'] or not prefix['component_split_pass']: raise AssertionError('cumulative budget failed')
|
||||
save(); print(json.dumps(dict(case=name,**row),allow_nan=False),flush=True)
|
||||
case.update(assess(case['rows'],run.health_counts))
|
||||
cap(); case.update(completed=True,phase='completed',health_counts=run.health_counts.tolist(),audited_intervals=ledger.steps,
|
||||
ledger=ledger.snapshot(),trajectory_seconds=time.monotonic()-trajectory_started,seconds=time.monotonic()-case_started)
|
||||
case['passed']=case['campaign_pass'] and case['all_interval_mass']['passed'] and ledger.steps==16*n and len(case['early_pressure'])==65
|
||||
run.close(); run=None; save()
|
||||
cap(); report.update(completed=True,passed=all(c['passed'] for c in report['runs']),comparisons=comparisons(report['runs'],reference))
|
||||
except Exception as exc:
|
||||
report['failure']=repr(exc)
|
||||
raise
|
||||
finally:
|
||||
if run is not None and ledger is not None:
|
||||
current=report['runs'][-1]
|
||||
current['failure_endpoint_status']='unavailable_owner_not_ready'
|
||||
if run.status=='ready':
|
||||
try:
|
||||
current['failure_endpoint']=ledger.snapshot(directional(populations(run,refined,raw=True),masks)-direction0)
|
||||
current['failure_endpoint_status']='measured'
|
||||
except Exception as exc: current['failure_endpoint_status']=repr(exc)
|
||||
for owner in (run,context):
|
||||
if owner is not None:
|
||||
try: owner.close()
|
||||
except Exception as exc: report.setdefault('cleanup_errors',[]).append(repr(exc)); report['passed']=False
|
||||
save()
|
||||
return 0 if report['passed'] else 1
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -1,392 +0,0 @@
|
||||
"""Opt-in, frozen N64 space-time GPU trajectory; no convergence certification."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import resource
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT/'src'))
|
||||
sys.path.insert(0, str(Path(__file__).parents[1]/'taylor_green'))
|
||||
from CelerisLab.config import LBMConfig
|
||||
import oracle as tg
|
||||
import spacetime_gpu as gpu
|
||||
import spacetime_table_guard as table_guard
|
||||
ref = gpu.ref
|
||||
|
||||
CPU_SHA = '180eef90a1c3f43113fe18cc54981ee4723cfc0cc2724334907ff188022fa770'
|
||||
UNIFORM_SHA = '01e7415ae39456f2de7f0c3d331afb971c49d19353b43038650e32e80737f1e7'
|
||||
CUDA_SHA = 'afea11ea4a1c2c3800b2f6a7a8e81f5568989fc9e1cc02fb52d7e1ea3864e003'
|
||||
AUDIT_SHA = 'fba340b1533bec308b666ee84666df0ac9d08e2872be31ee379e2d1447565158'
|
||||
STEPS = (256, 512, 768, 1024)
|
||||
TIMES = (.12, .24, .36, .48)
|
||||
FACTORS = dict(velocity_l2=.8, pressure_l2=1., energy_relative=1.)
|
||||
LIMITS = dict(velocity_l2=.05, pressure_l2=.15, energy_relative=.05, mass_relative=1e-5)
|
||||
HEALTH = ('boundary_pre_nonfinite', 'boundary_pre_nonpositive', 'boundary_stored_nonfinite',
|
||||
'boundary_stored_nonpositive', 'physical_population_nonfinite', 'physical_population_nonpositive')
|
||||
|
||||
|
||||
def sha(path):
|
||||
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def load_references(cpu_path, uniform_path):
|
||||
for path, expected in ((cpu_path, CPU_SHA), (uniform_path, UNIFORM_SHA)):
|
||||
if sha(path) != expected:
|
||||
raise ValueError(f'pinned reference receipt differs: {path}')
|
||||
cpu, uniform = [json.loads(Path(p).read_text()) for p in (cpu_path, uniform_path)]
|
||||
assert cpu['completed'] and cpu['experimental_pass'] and not cpu['source']['dirty']
|
||||
assert cpu['source']['commit'] == '07879bc52bfef8d95503146c55d3823c8b6f8d0a'
|
||||
assert [r['mode'] for r in cpu['runs']] == ['constant_ivp', 'spacetime']
|
||||
assert all(tuple(row['tstar'] for row in r['rows']) == TIMES for r in cpu['runs'])
|
||||
assert uniform['completed'] and not uniform['source']['dirty']
|
||||
assert uniform['source']['revision'] == '9805383f9c50d867e8555471baf5dbda8333a652'
|
||||
assert (uniform['n'], uniform['depth'], uniform['viscosity']) == (64, 1, .0192)
|
||||
assert uniform['limits'] == tg.LIMITS == LIMITS
|
||||
return cpu, uniform
|
||||
|
||||
|
||||
def total(state, maps):
|
||||
arrays = [a.astype(np.float64) for a in (state.coarse_f, state.fine_f)]
|
||||
return ref.cpu.MOMENTS.T @ ref.directional(ref.cpu.State(*arrays), maps)
|
||||
|
||||
|
||||
def differing_fields(actual, expected):
|
||||
"""Compare serialized sample numerics exactly, including the sign of zero."""
|
||||
if actual.keys() != expected.keys():
|
||||
return ['field_set']
|
||||
return [key for key in actual if np.asarray(actual[key]).dtype != np.asarray(expected[key]).dtype
|
||||
or np.asarray(actual[key]).shape != np.asarray(expected[key]).shape
|
||||
or np.asarray(actual[key]).tobytes() != np.asarray(expected[key]).tobytes()]
|
||||
|
||||
|
||||
def sample_difference(actual, expected):
|
||||
"""Descriptive comparison only; never an equivalence gate for different codecs."""
|
||||
return {key:dict(reference=value,signed=(np.asarray(actual[key])-value).tolist(),
|
||||
absolute=np.abs(np.asarray(actual[key])-value).tolist()) for key,value in expected.items()}
|
||||
|
||||
|
||||
class LedgerTotals:
|
||||
"""Compensated directional sums; retain absolute step sums to expose cancellation."""
|
||||
names = ('net_change','physical_collision','coarse_collision','fine0_collision','fine1_collision',
|
||||
'child_rounding','child_interpolation','child_cast','register_rounding','receiver_rounding','unexplained')
|
||||
|
||||
def __init__(self):
|
||||
self.sums = np.zeros((len(self.names),9)); self.correction = self.sums.copy()
|
||||
self.absolute = self.sums.copy(); self.absolute_moments = np.zeros((len(self.names),3))
|
||||
self.maxima = self.sums.copy(); self.max_steps = np.zeros(self.sums.shape,np.int64)
|
||||
self.cells = np.zeros((3,4)); self.cell_correction = self.cells.copy()
|
||||
self.steps = 0; self.bound = 0.; self.maximum = 0.
|
||||
|
||||
def add(self, ledger):
|
||||
values = np.array([ledger[name] for name in self.names],np.float64)
|
||||
cells = np.asarray(ledger['collision_cell_mass'],np.float64)
|
||||
if (values.shape != self.sums.shape or not np.isfinite(values).all()
|
||||
or cells.shape != (3,4) or not np.isfinite(cells).all()
|
||||
or not np.isfinite(ledger['unexplained_bound']) or ledger['unexplained_bound'] <= 0):
|
||||
raise AssertionError('invalid diagnostic ledger')
|
||||
delta = values-self.correction
|
||||
updated = self.sums+delta
|
||||
self.correction = (updated-self.sums)-delta
|
||||
self.sums = updated
|
||||
self.absolute += abs(values)
|
||||
self.absolute_moments += abs(values @ ref.cpu.MOMENTS)
|
||||
larger = abs(values) > self.maxima
|
||||
self.max_steps[larger] = self.steps+1; self.maxima = np.maximum(self.maxima,abs(values))
|
||||
delta = cells-self.cell_correction
|
||||
updated = self.cells+delta
|
||||
self.cell_correction = (updated-self.cells)-delta; self.cells = updated
|
||||
self.steps += 1; self.bound += ledger['unexplained_bound']
|
||||
self.maximum = max(self.maximum,float(abs(values[-1]).max()))
|
||||
|
||||
def snapshot(self, endpoint_change=None):
|
||||
values = dict(zip(self.names,self.sums))
|
||||
accounted = sum(values[key] for key in ('physical_collision','child_rounding','register_rounding','receiver_rounding'))
|
||||
closure = None if endpoint_change is None else endpoint_change-accounted
|
||||
telescoping = None if endpoint_change is None else endpoint_change-values['net_change']
|
||||
closure_pass = None if closure is None else max(float(abs(closure).max()),float(abs(telescoping).max())) <= self.bound
|
||||
collision_split = values['physical_collision']-sum(values[key] for key in ('coarse_collision','fine0_collision','fine1_collision'))
|
||||
child_split = values['child_rounding']-values['child_interpolation']-values['child_cast']
|
||||
split_pass = max(float(abs(collision_split).max()),float(abs(child_split).max())) <= self.bound
|
||||
return dict(intervals=self.steps, summed_per_step_bound=self.bound, max_step_unexplained=self.maximum,
|
||||
closure_pass=closure_pass,component_split_pass=split_pass,
|
||||
endpoint_status='not_measured' if endpoint_change is None else 'measured',
|
||||
endpoint_directional_change=None if endpoint_change is None else endpoint_change.tolist(),
|
||||
closure_residual=None if closure is None else closure.tolist(),
|
||||
telescoping_residual=None if telescoping is None else telescoping.tolist(),
|
||||
closure_mass_momentum=None if closure is None else (ref.cpu.MOMENTS.T@closure).tolist(),
|
||||
collision_split_residual=collision_split.tolist(),child_split_residual=child_split.tolist(),
|
||||
collision_cell_mass=dict(levels=['coarse','fine0','fine1'],columns=['net','positive','negative','l1'],
|
||||
applied_volume_weights=[1.,.25,.25],values=self.cells.tolist()),
|
||||
components={key:dict(directional=self.sums[i].tolist(),mass_momentum=(self.sums[i]@ref.cpu.MOMENTS).tolist(),
|
||||
absolute_step_directional_sum=self.absolute[i].tolist(),
|
||||
absolute_step_mass_momentum_sum=self.absolute_moments[i].tolist(),
|
||||
max_absolute_interval=self.maxima[i].tolist(),max_absolute_interval_step=self.max_steps[i].tolist())
|
||||
for i,key in enumerate(self.names)})
|
||||
|
||||
|
||||
def warm_and_reset(run, initial, check_cap, *, audit=False):
|
||||
"""Time 32 synchronized intervals, then restore exact uploaded arrival F."""
|
||||
run.initialize(initial)
|
||||
before = run.download()
|
||||
started = time.monotonic()
|
||||
for _ in range(32):
|
||||
check_cap()
|
||||
run.step(audit=audit)
|
||||
seconds = time.monotonic()-started
|
||||
warm_health = run.health_counts.tolist()
|
||||
run.initialize(initial)
|
||||
after = run.download()
|
||||
for a, b in zip((before.coarse_f, before.fine_f), (after.coarse_f, after.fine_f)):
|
||||
np.testing.assert_array_equal(a, b)
|
||||
if run.step_count != 0 or after.tick != 0 or run.health_counts.any():
|
||||
raise AssertionError('warm-up reset did not restore tick/counters')
|
||||
return after, dict(intervals=32, seconds=seconds, estimated_1024_seconds=32*seconds,
|
||||
health_counts=warm_health, reset_verified=True, ledger_audit=audit)
|
||||
|
||||
|
||||
def assess(rows, cpu, uniform, health_counts):
|
||||
if tuple(row['tstar'] for row in rows) != TIMES:
|
||||
raise ValueError('four frozen sample times required')
|
||||
worst = {key: max(row[key] for row in rows) for key in LIMITS}
|
||||
campaign = {key: all(np.isfinite(row[key]) and 0 <= row[key] <= limit for row in rows)
|
||||
for key, limit in LIMITS.items()}
|
||||
campaign['population_health'] = not any(health_counts)
|
||||
campaign['sampled_positive_finite'] = all(
|
||||
np.isfinite(row[key]) and row[key] > 0 for row in rows
|
||||
for key in ('minimum_population', 'minimum_boundary_pre', 'minimum_boundary_stored'))
|
||||
baseline, candidate = cpu['runs']
|
||||
thresholds = {key: factor*baseline['worst'][key] for key, factor in FACTORS.items()}
|
||||
experimental = {key: all(np.isfinite(row[key]) and row[key] >= 0 for row in rows)
|
||||
and worst[key] <= threshold for key, threshold in thresholds.items()}
|
||||
differences = []
|
||||
for actual, expected in zip(rows, candidate['rows']):
|
||||
delta = {}
|
||||
for key, value in expected.items():
|
||||
d = np.asarray(actual[key])-np.asarray(value)
|
||||
delta[key] = dict(cpu=value, signed=d.tolist(), absolute=np.abs(d).tolist())
|
||||
differences.append(dict(tstar=actual['tstar'], fields=delta))
|
||||
historical_worst = max(row['pressure_l2'] for row in uniform['rows'] if row['tstar'] > 0)
|
||||
historical_limit = max(2*historical_worst, .02)
|
||||
assert historical_limit == .024506347612699403
|
||||
return dict(worst=worst, campaign_checks=campaign, campaign_pass=all(campaign.values()),
|
||||
experimental_thresholds=thresholds, experimental_checks=experimental,
|
||||
experimental_pass=all(experimental.values()),
|
||||
gpu_minus_cpu=dict(status='descriptive_only_no_equivalence_tolerance', rows=differences),
|
||||
historical_pressure_gate=dict(status='historical_shifted_FP32_uniform_vs_unshifted_FP32_candidate',
|
||||
matched_storage=False, uniform_worst=historical_worst, threshold=historical_limit,
|
||||
candidate_worst=worst['pressure_l2'], passed=worst['pressure_l2'] <= historical_limit))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--gpu-admitted', action='store_true')
|
||||
parser.add_argument('--cpu-receipt', type=Path, required=True)
|
||||
parser.add_argument('--uniform-receipt', type=Path, required=True)
|
||||
parser.add_argument('--audit-against', type=Path, help='audit every interval and compare exact saved 1779cf2 samples')
|
||||
parser.add_argument('--shifted', action='store_true', help='opt-in shifted storage with all interval ledgers')
|
||||
parser.add_argument('--unshifted-receipt', type=Path, help='pinned1779 comparison, descriptive only')
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
if not args.gpu_admitted or not os.environ.get('CUDA_VISIBLE_DEVICES'):
|
||||
parser.error('explicit --gpu-admitted and CUDA_VISIBLE_DEVICES required')
|
||||
if args.output.exists():
|
||||
parser.error('output exists; preserve evidence and choose a new path')
|
||||
if args.shifted != (args.unshifted_receipt is not None) or (args.shifted and args.audit_against):
|
||||
parser.error('--shifted requires --unshifted-receipt and cannot use exact --audit-against')
|
||||
exact_repeat = args.audit_against is not None
|
||||
cpu, uniform = load_references(args.cpu_receipt, args.uniform_receipt)
|
||||
previous = None
|
||||
comparison_path = args.audit_against or args.unshifted_receipt
|
||||
if comparison_path is not None:
|
||||
if sha(comparison_path) != AUDIT_SHA:
|
||||
raise ValueError('pinned GPU trajectory receipt differs')
|
||||
previous = json.loads(comparison_path.read_text())
|
||||
if previous['source']['numpy'] != np.__version__:
|
||||
raise RuntimeError('audited repeat requires unchanged NumPy environment')
|
||||
gpu.check_sources()
|
||||
if sha(Path(gpu.__file__).with_suffix('.cu')) != CUDA_SHA:
|
||||
raise RuntimeError('reviewed CUDA source changed')
|
||||
source = dict(commit=subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip(),
|
||||
dirty=bool(subprocess.check_output(['git', 'status', '--porcelain'], cwd=ROOT, text=True)),
|
||||
hashes={p.name: sha(p) for p in (Path(__file__), Path(gpu.__file__), Path(gpu.__file__).with_suffix('.cu'),
|
||||
Path(__file__).with_name('test_spacetime_gpu.py'), Path(table_guard.__file__))},
|
||||
reference_hashes=gpu.PINS, numpy=np.__version__, checkout=str(ROOT))
|
||||
if source['dirty']:
|
||||
raise RuntimeError('clean committed checkout required')
|
||||
started = time.monotonic()
|
||||
report = dict(scope='N64 experimental space-time GPU; no grid-convergence qualification',
|
||||
completed=False, passed=False, source=source, rows=[], health_counts=[0]*6, health_labels=HEALTH,
|
||||
setup=dict(n=64, bounds=[8,8,56,48], re_L=100, U=.03, viscosity=.0192, tau=[.5576,.6152],
|
||||
phase='arrival_f', storage='shifted_FP32' if args.shifted else 'unshifted_FP32', collision='SRT', interval_count=1024,
|
||||
initialization='common analytic FP64 field, encoded using production FP64 weights before FP32 upload' if args.shifted
|
||||
else 'same physical FP64 equilibrium as CPU, rounded to FP32 on upload',
|
||||
cpu_storage='FP64', sample_steps=STEPS, sample_times=TIMES),
|
||||
references=dict(cpu_receipt_sha256=CPU_SHA, cpu_source=cpu['source'],
|
||||
cpu_archive_drive_id='197F9nwDOGf_YROK1a46YpVvNrSldnH_w',
|
||||
cpu_archive_sha256='850ddce3f5203ef38e76e5153e718a9b92e137d1fc388ba7cd12f5d47eab83b4',
|
||||
uniform_receipt_sha256=UNIFORM_SHA, uniform_source=uniform['source'],
|
||||
uniform_storage='historical_shifted_FP32'),
|
||||
frozen_screen=dict(campaign_limits=LIMITS, experimental_factors=FACTORS,
|
||||
gpu_cpu_equivalence_tolerance=None, historical_pressure_threshold=.024506347612699403),
|
||||
boundary_budget_status='pending_measurement' if previous else 'not_measured', max_directional_residual=None,
|
||||
minima_scope='initial and four sampled physical arrival states; boundary minima at four samples',
|
||||
limits=dict(seconds=180., peak_rss_mib=512.), cuda_visible_devices=os.environ['CUDA_VISIBLE_DEVICES'])
|
||||
run = context = None
|
||||
ledger_totals = LedgerTotals() if previous else None
|
||||
if previous:
|
||||
report['diagnostic'] = dict(completed=False, passed=False, reference_sha256=AUDIT_SHA,
|
||||
reference_source=previous['source']['commit'], sample_checks=[], prefixes=[],
|
||||
scope='net drift attribution; exact saved diagnostics only, not full-field trajectory certification' if exact_repeat
|
||||
else 'raw stored-value ledger; unshifted and CPU comparisons descriptive, initial codecs differ')
|
||||
phase = 'setup'
|
||||
|
||||
def save():
|
||||
report['resources'] = dict(seconds=time.monotonic()-started,
|
||||
peak_rss_mib=resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/1024,
|
||||
device_allocations_bytes=None if run is None else run.device_bytes,
|
||||
device_memory_scope='owner allocations; excludes compiler, driver and context')
|
||||
if run is not None:
|
||||
report['health_counts'] = run.health_counts.tolist()
|
||||
report['completed_intervals'] = run.step_count
|
||||
if ledger_totals is not None:
|
||||
report['audited_intervals'] = ledger_totals.steps
|
||||
if ledger_totals.steps:
|
||||
report['boundary_budget_status'] = 'measured' if ledger_totals.steps==1024 else 'measured_prefix'
|
||||
report['diagnostic']['accumulated'] = ledger_totals.snapshot()
|
||||
ref.write_receipt(args.output, report)
|
||||
|
||||
def check_cap():
|
||||
if time.monotonic()-started > 180 or resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/1024 > 512:
|
||||
raise RuntimeError('frozen resource cap exceeded')
|
||||
|
||||
save()
|
||||
try:
|
||||
from CelerisLab.cuda.context import CudaContext
|
||||
context = CudaContext(0)
|
||||
config = LBMConfig(nx=64, ny=64, viscosity=.0192, velocity=0., ddf_shifting=args.shifted)
|
||||
run = gpu.SpaceTimeGPU(config, (8,8,56,48), context=context)
|
||||
report.update(device=context.name, sm_arch=context.sm_arch, build_metadata=run.build_metadata,
|
||||
table_sha256=gpu.table_hash(run.tables))
|
||||
report['table_validation'] = table_guard.validate_tables(run.plan,run.tables)
|
||||
if previous:
|
||||
for key in ('device','sm_arch','table_sha256') + (('build_metadata',) if exact_repeat else ()):
|
||||
if report[key] != previous[key]:
|
||||
raise AssertionError(f'audited repeat identity differs: {key}')
|
||||
analytic = ref.cpu.State(tg.initial((64,64),0,64,.0192), tg.initial((128,128),1,64,.0192))
|
||||
initial = analytic
|
||||
phase = 'warm_reset'
|
||||
initial, report['warmup'] = warm_and_reset(run, initial, check_cap, audit=previous is not None)
|
||||
maps = run.plan.maps
|
||||
masks = (maps.coarse_active, maps.fine_active)
|
||||
arrays = (initial.coarse_f, initial.fine_f)
|
||||
mass0 = tg.metrics(arrays, masks, 0, 64, .0192)['mass']
|
||||
moment0 = total(initial, maps)
|
||||
report['initial'] = dict(mass=mass0, momentum=moment0[1:].tolist(),
|
||||
minimum_population=min(float(a[:,m].min()) for a,m in zip(arrays,masks)))
|
||||
if exact_repeat and differing_fields(report['initial'],previous['initial']):
|
||||
raise AssertionError('audited repeat initial state diagnostics differ')
|
||||
raw_initial = run._download_stored() if args.shifted else initial
|
||||
direction0 = ref.directional(ref.cpu.State(*(a.astype(np.float64) for a in
|
||||
(raw_initial.coarse_f,raw_initial.fine_f))),maps)
|
||||
if args.shifted:
|
||||
errors=[]; unshifted_errors=[]
|
||||
for raw,actual,wanted,mask in zip((raw_initial.coarse_f,raw_initial.fine_f),arrays,
|
||||
(analytic.coarse_f,analytic.fine_f),masks):
|
||||
encoded=gpu._encode_physical(wanted,True)
|
||||
np.testing.assert_array_equal(raw[:,mask].view(np.uint32),encoded[:,mask].view(np.uint32))
|
||||
errors.append(float(abs(actual[:,mask]-wanted[:,mask]).max()))
|
||||
unshifted_errors.append(float(abs(gpu._encode_physical(wanted,False)[:,mask]-wanted[:,mask]).max()))
|
||||
report['warmup']['raw_reset_verified']=True
|
||||
report['initial_quantization']=dict(maximum_population_error_by_level=errors,
|
||||
unshifted_codec_error_by_level=unshifted_errors,
|
||||
moment_change_vs_analytic=(moment0-total(analytic,maps)).tolist(),
|
||||
cpu_start='pinned analytic FP64 trajectory, not reinitialized to decoded GPU upload',
|
||||
matched_cpu_initial_bits=False)
|
||||
report['unshifted_comparison']=dict(status='descriptive_only_codecs_have_different_initial_bits',
|
||||
reference_sha256=AUDIT_SHA,initial=sample_difference(report['initial'],previous['initial']),rows=[])
|
||||
save()
|
||||
check_cap()
|
||||
phase = 'trajectory'
|
||||
trajectory_started = time.monotonic()
|
||||
for step in range(1,1025):
|
||||
check_cap()
|
||||
ledger = run.step(audit=previous is not None)
|
||||
if ledger_totals is not None:
|
||||
ledger_totals.add(ledger)
|
||||
report['max_directional_residual'] = ledger_totals.maximum
|
||||
if step in STEPS:
|
||||
state = run.download()
|
||||
if state.tick != 2*step:
|
||||
raise AssertionError('sample is not synchronized arrival state')
|
||||
arrays = (state.coarse_f, state.fine_f)
|
||||
row = tg.metrics(arrays, masks, step, 64, .0192, mass0)
|
||||
row.update(step=step, momentum_change=(total(state,maps)-moment0)[1:].tolist(),
|
||||
mass_change=row['mass']-mass0,
|
||||
minimum_population=min(float(a[:,m].min()) for a,m in zip(arrays,masks)))
|
||||
if ledger_totals is not None:
|
||||
row['minimum_boundary_pre'] = ledger['minimum_pre']
|
||||
row['minimum_boundary_stored'] = ledger['minimum_stored']
|
||||
else:
|
||||
with context.activate():
|
||||
row['minimum_boundary_pre'] = float(run.read(run.pre,(len(run.plan.groups),4),np.float64).min())
|
||||
row['minimum_boundary_stored'] = float(run.read(run.h,(len(run.plan.groups),4)).min())
|
||||
if not all(np.isfinite(value).all() for value in row.values()):
|
||||
report['failed_sample'] = {key: repr(value) for key,value in row.items()}
|
||||
raise FloatingPointError('nonfinite sample diagnostic')
|
||||
report['rows'].append(row)
|
||||
if previous:
|
||||
expected=previous['rows'][len(report['rows'])-1]
|
||||
changed = differing_fields(row,expected) if exact_repeat else []
|
||||
report['diagnostic']['sample_checks'].append(dict(step=step,
|
||||
status='exact_repeat' if exact_repeat else 'descriptive_only',differing_fields=changed if exact_repeat else None))
|
||||
if args.shifted:
|
||||
report['unshifted_comparison']['rows'].append(dict(step=step,fields=sample_difference(row,expected)))
|
||||
raw_state=run._download_stored() if args.shifted else state
|
||||
endpoint = ref.directional(ref.cpu.State(*(a.astype(np.float64) for a in
|
||||
(raw_state.coarse_f,raw_state.fine_f))),maps)-direction0
|
||||
prefix = ledger_totals.snapshot(endpoint)
|
||||
if args.shifted:
|
||||
prefix['physical_minus_raw_endpoint_moments'] = (
|
||||
total(state,maps)-moment0-ref.cpu.MOMENTS.T@endpoint).tolist()
|
||||
report['diagnostic']['prefixes'].append(prefix)
|
||||
if not prefix['closure_pass'] or not prefix['component_split_pass']:
|
||||
raise AssertionError('cumulative ledger does not close within summed existing per-step bounds')
|
||||
if changed:
|
||||
raise AssertionError(f'audited repeat sample differs: {changed}')
|
||||
save()
|
||||
print(json.dumps(row, allow_nan=False), flush=True)
|
||||
report['trajectory_seconds'] = time.monotonic()-trajectory_started
|
||||
check_cap()
|
||||
report.update(assess(report['rows'], cpu, uniform, run.health_counts))
|
||||
if args.shifted:
|
||||
report['historical_pressure_gate'].update(status='historical_shifted_FP32_uniform_vs_shifted_FP32_candidate',
|
||||
matched_storage=True,matched_control=False)
|
||||
report['completed'] = True
|
||||
report['passed'] = report['campaign_pass'] and report['experimental_pass']
|
||||
if previous:
|
||||
report['diagnostic'].update(completed=True,passed=ledger_totals.steps==1024 and len(report['rows'])==4)
|
||||
except Exception as exc:
|
||||
report['failure'] = dict(phase=phase, error=repr(exc))
|
||||
raise
|
||||
finally:
|
||||
# Preserve the primary failure and every completed sample even if cleanup fails.
|
||||
for owner in (run, context):
|
||||
if owner is not None:
|
||||
try:
|
||||
owner.close()
|
||||
except Exception as exc:
|
||||
report.setdefault('cleanup_errors', []).append(repr(exc))
|
||||
report['passed'] = False
|
||||
save()
|
||||
return 0 if report['passed'] else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -1,165 +0,0 @@
|
||||
// Test-only arrival-f space-time coupling. No production scheduler changes.
|
||||
#include <stdint.h>
|
||||
#include <math.h>
|
||||
#include "config.h"
|
||||
#include "core/precision.cuh"
|
||||
#include "core/descriptors.cuh"
|
||||
#include "core/layout.cuh"
|
||||
#include "operators/macro.cuh"
|
||||
#include "operators/collision_srt.cuh"
|
||||
#include "streaming/pull_double_buffer.cuh"
|
||||
|
||||
#if DIM != 2 || NQ != 9 || COLLISION_MODEL != 0 || STORE_PRECISION != 0 || USE_LES || STREAMING_MODEL != 0
|
||||
#error "Space-time experiment requires FP32 D2Q9 SRT double buffers without LES"
|
||||
#endif
|
||||
|
||||
__device__ __forceinline__ void collide(float* f, float omega) {
|
||||
float rho, ux, uy, feq[NQ];
|
||||
compute_rho_u(f, rho, ux, uy);
|
||||
#if USE_DDF_SHIFTING
|
||||
// Same perturbation-preserving SRT branch as production BlockStep.
|
||||
const float drho = f[0]+f[1]+f[2]+f[3]+f[4]+f[5]+f[6]+f[7]+f[8];
|
||||
const float jx = f[1]-f[2]+f[5]-f[6]+f[7]-f[8];
|
||||
const float jy = f[3]-f[4]+f[5]-f[6]-f[7]+f[8];
|
||||
compute_feq_from_momentum(drho, jx, jy, ux, uy, feq);
|
||||
#else
|
||||
compute_feq(rho, ux, uy, feq);
|
||||
#endif
|
||||
collide_srt_no_force(f, feq, omega);
|
||||
}
|
||||
|
||||
__device__ __forceinline__ double physical(double value, unsigned int q) {
|
||||
#if USE_DDF_SHIFTING
|
||||
// Match population_storage.py's FP64 weights, not promoted FP32 d_w.
|
||||
return value + (q == 0 ? 4.0/9.0 : (q < 5 ? 1.0/9.0 : 1.0/36.0));
|
||||
#else
|
||||
return value;
|
||||
#endif
|
||||
}
|
||||
|
||||
__device__ __forceinline__ void health(float stored, unsigned int q, unsigned int* counts) {
|
||||
const double value = physical((double)stored, q);
|
||||
if (!isfinite(value)) atomicAdd(counts + 4, 1u);
|
||||
if (value <= 0.0f) atomicAdd(counts + 5, 1u);
|
||||
}
|
||||
|
||||
extern "C" __global__ void CoarseStart(
|
||||
const float* coarse, const float* fine, float* out, float omega,
|
||||
unsigned int x0, unsigned int y0, unsigned int x1, unsigned int y1,
|
||||
unsigned int* counts)
|
||||
{
|
||||
unsigned int x, y; unsigned long k;
|
||||
index_from_thread(x, y, k);
|
||||
if (x >= NX || y >= NY) return;
|
||||
const bool covered = x >= x0 && x < x1 && y >= y0 && y < y1;
|
||||
float f[NQ];
|
||||
for (unsigned int q = 0; q < NQ; ++q) {
|
||||
const unsigned long s = q * 4ul * TOTAL_CELLS + 2ul * y * (2u * NX) + 2u * x;
|
||||
f[q] = covered ? 0.25f * (fine[s] + fine[s+1] + fine[s+2u*NX] + fine[s+2u*NX+1])
|
||||
: coarse[index_f(k, q)];
|
||||
}
|
||||
collide(f, omega);
|
||||
for (int q = 0; q < NQ; ++q) if (!covered) health(f[q], q, counts);
|
||||
stream_pull_store(k, f, out);
|
||||
}
|
||||
|
||||
extern "C" __global__ void Collide(
|
||||
const float* in, float* out, const unsigned char* active, float omega,
|
||||
unsigned int* counts)
|
||||
{
|
||||
unsigned int x, y; unsigned long k;
|
||||
index_from_thread(x, y, k);
|
||||
if (x >= NX || y >= NY) return;
|
||||
float f[NQ];
|
||||
if (active == nullptr || active[k]) {
|
||||
for (int q = 0; q < NQ; ++q) f[q] = in[index_f(k, q)];
|
||||
collide(f, omega);
|
||||
if (active != nullptr) for (int q = 0; q < NQ; ++q) health(f[q], q, counts);
|
||||
} else {
|
||||
for (int q = 0; q < NQ; ++q) f[q] = NAN;
|
||||
}
|
||||
stream_pull_store(k, f, out);
|
||||
}
|
||||
|
||||
extern "C" __global__ void Pull(
|
||||
const float* in, float* out, const unsigned char* active, unsigned int* counts)
|
||||
{
|
||||
unsigned int x, y; unsigned long k;
|
||||
index_from_thread(x, y, k);
|
||||
if (x >= NX || y >= NY) return;
|
||||
float f[NQ];
|
||||
if (active == nullptr || active[k]) {
|
||||
unsigned long j[NQ];
|
||||
compute_neighbors(x, y, j);
|
||||
stream_pull_load(k, f, in, j);
|
||||
if (active != nullptr) for (int q = 0; q < NQ; ++q) health(f[q], q, counts);
|
||||
} else {
|
||||
for (int q = 0; q < NQ; ++q) f[q] = NAN;
|
||||
}
|
||||
stream_pull_store(k, f, out);
|
||||
}
|
||||
|
||||
extern "C" __global__ void Interpolate(
|
||||
const float* g0, const float* gp, const uint32_t* samples,
|
||||
const uint32_t* origins, const int* sizes, const double* weights,
|
||||
double* pre, float* h, unsigned int groups, unsigned int frozen,
|
||||
unsigned int* counts)
|
||||
{
|
||||
unsigned int group = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (group >= groups) return;
|
||||
const int size = sizes[group];
|
||||
const unsigned int q = origins[group] / TOTAL_CELLS;
|
||||
for (int child = 0; child < 4; ++child) {
|
||||
double value = frozen ? (double)g0[origins[group]] : 0.0;
|
||||
if (!frozen) for (int j = 0; j < size; ++j) {
|
||||
const float* source = j < size/2 ? g0 : gp;
|
||||
value += weights[(4u*group+child)*10u+j] * (double)source[samples[10u*group+j]];
|
||||
}
|
||||
const float stored = __double2float_rn(value);
|
||||
pre[4u*group+child] = value;
|
||||
h[4u*group+child] = stored;
|
||||
if (!isfinite(physical(value, q))) atomicAdd(counts, 1u);
|
||||
if (physical(value, q) <= 0.0) atomicAdd(counts+1, 1u);
|
||||
if (!isfinite(physical((double)stored, q))) atomicAdd(counts+2, 1u);
|
||||
if (physical((double)stored, q) <= 0.0) atomicAdd(counts+3, 1u);
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" __global__ void Inject(
|
||||
float* fine, const float* h, const uint32_t* rows, unsigned int count,
|
||||
unsigned int omit_first)
|
||||
{
|
||||
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (row >= count || (omit_first && row == 0)) return;
|
||||
fine[rows[2u*row]] = h[rows[2u*row+1]];
|
||||
}
|
||||
|
||||
extern "C" __global__ void Register(
|
||||
const float* predicted, const float* fine, const float* h,
|
||||
const uint32_t* targets, const uint32_t* sources, const int* times,
|
||||
double* registers, unsigned int count, int substep, int mutation)
|
||||
{
|
||||
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (row >= count) return;
|
||||
double value = substep == 0 ? -(double)predicted[targets[row]] : registers[row];
|
||||
for (int j = 0; j < 4; ++j) {
|
||||
const unsigned int slot = 4u*row+j;
|
||||
const int when = times[slot];
|
||||
if (when != substep && !(substep == 0 && when == -1)) continue;
|
||||
if ((mutation == 1 && when == -1) || (mutation == 2 && when == 0)
|
||||
|| (mutation == 3 && when == 1)) continue;
|
||||
value += 0.25 * (double)(when == -1 ? h[sources[slot]] : fine[sources[slot]]);
|
||||
}
|
||||
registers[row] = value;
|
||||
}
|
||||
|
||||
extern "C" __global__ void ApplyRegisters(
|
||||
float* predicted, const double* registers, const uint32_t* targets,
|
||||
unsigned int count, unsigned int* counts)
|
||||
{
|
||||
unsigned int row = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (row >= count) return;
|
||||
const unsigned int slot = targets[row];
|
||||
predicted[slot] = __double2float_rn((double)predicted[slot] + registers[row]);
|
||||
health(predicted[slot], slot / TOTAL_CELLS, counts);
|
||||
}
|
||||
@@ -1,472 +0,0 @@
|
||||
"""Test-only arrival-f GPU owner; CUDA compilation/execution requires admission."""
|
||||
from dataclasses import replace
|
||||
import hashlib
|
||||
from numbers import Integral
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import spacetime_reference as ref
|
||||
from CelerisLab.lbm.population_storage import _encode_physical, _decode_stored, _WEIGHTS
|
||||
|
||||
PINS = {"spacetime_reference.py": "1fe13242b9a41062a1227db4a6f9b695165aefab7f4c0b3b2adff0c9023552bf",
|
||||
"cpu_reference.py": "bba2a06a9f6517b32079ffff15e4060d8c3b7ed35f16ca134e1877226c9e4e1e",
|
||||
"../taylor_green/oracle.py": "83e4d7425d45538278e9fb181af450a36358bd9a5ed6be57ff611dd9ae5eb35b"}
|
||||
|
||||
|
||||
def check_sources():
|
||||
for name, expected in PINS.items():
|
||||
if hashlib.sha256((Path(__file__).parent/name).read_bytes()).hexdigest() != expected:
|
||||
raise RuntimeError(f"reference source changed: {name}")
|
||||
|
||||
|
||||
def validate_config(config, *, uniform=False, allow_n128=False):
|
||||
if type(uniform) is not bool or type(allow_n128) is not bool:
|
||||
raise ValueError("admission flags must be bool")
|
||||
cfg = replace(config)
|
||||
cfg.validate()
|
||||
if (cfg.nx != cfg.ny or not 8 <= cfg.nx <= (256 if uniform else (128 if allow_n128 else 64)) or cfg.nz != 1
|
||||
or cfg.collision != "SRT" or not cfg.is_d2q9
|
||||
or cfg.streaming != "double_buffer" or cfg.store_precision != "FP32"
|
||||
or cfg.data_type != "FP32" or type(cfg.ddf_shifting) is not bool or cfg.les_enabled
|
||||
or cfg.velocity != 0 or cfg.wall_slot_width or cfg.wall_normal_velocity):
|
||||
raise ValueError("experiment requires periodic unforced square FP32 D2Q9/SRT within admitted size")
|
||||
if uniform:
|
||||
return (cfg,)
|
||||
fine = replace(cfg, nx=2*cfg.nx, ny=2*cfg.ny, viscosity=2*cfg.viscosity)
|
||||
fine.validate()
|
||||
return cfg, fine
|
||||
|
||||
|
||||
def pack_plan(plan):
|
||||
"""Pack reviewed coordinate events; no production transfer builder imports."""
|
||||
n, nf = plan.n, plan.nf
|
||||
groups, receivers = plan.groups, plan.receivers
|
||||
data = dict(samples=np.zeros((len(groups), 10), np.uint32),
|
||||
origins=np.empty(len(groups), np.uint32), sizes=np.empty(len(groups), np.int32),
|
||||
weights=np.zeros((len(groups), 4, 10), np.float64),
|
||||
targets=(receivers[:, 0]*n*n+receivers[:, 1]).astype(np.uint32))
|
||||
lookup = {tuple(row): i for i, row in enumerate(receivers)}
|
||||
packets = [[] for _ in receivers]; incoming = [[], []]
|
||||
for i, g in enumerate(groups):
|
||||
size = len(g.samples)
|
||||
if size not in (6, 10):
|
||||
raise ValueError("unexpected interpolation stencil")
|
||||
data['samples'][i, :size] = g.q*n*n+g.samples
|
||||
data['origins'][i] = g.q*n*n+g.source
|
||||
data['sizes'][i] = size
|
||||
data['weights'][i, :, :size] = g.weights
|
||||
for k, event in enumerate(g.events):
|
||||
if event is None:
|
||||
packets[lookup[(g.q, g.receiver)]].append((-1, 4*i+k))
|
||||
else:
|
||||
m, slot = event
|
||||
incoming[m].append((g.q*nf*nf+slot, 4*i+k))
|
||||
for m, outgoing in enumerate(plan.outgoing):
|
||||
for q, source, target in outgoing:
|
||||
packets[lookup[(int(q), int(target))]].append((m, int(q*nf*nf+source)))
|
||||
if any(len(row) != 4 or len(set(row)) != 4 for row in packets):
|
||||
raise ValueError("each receiver needs four distinct quarter-packets")
|
||||
if len({packet for row in packets for packet in row}) != 4*len(receivers):
|
||||
raise ValueError("packet has multiple receiver owners")
|
||||
data['times'] = np.array([[t for t, _ in row] for row in packets], np.int32)
|
||||
data['sources'] = np.array([[s for _, s in row] for row in packets], np.uint32)
|
||||
for m, rows in enumerate(incoming):
|
||||
data[f'incoming{m}'] = np.array(rows, np.uint32)
|
||||
if len({slot for slot, _ in rows}) != len(rows):
|
||||
raise ValueError("duplicate incoming writer")
|
||||
return {key: np.ascontiguousarray(value) for key, value in data.items()}
|
||||
|
||||
|
||||
def table_hash(data):
|
||||
digest = hashlib.sha256()
|
||||
for key, value in sorted(data.items()):
|
||||
digest.update(f"{key}:{value.dtype}:{value.shape}".encode()+value.tobytes())
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def rounding_ledger(plan, tables, before, after, g0, predicted, fine_phases, pre, h, registers, *, shifted=False):
|
||||
"""Audit actual stored FP32 phases; never call a missing packet 'rounding'."""
|
||||
maps, n = plan.maps, plan.n
|
||||
f64 = lambda a: np.asarray(a, dtype=np.float64)
|
||||
difference = f64(g0[:, maps.coarse_active])-f64(before.coarse_f[:, maps.coarse_active])
|
||||
coarse_collision = difference.sum(axis=1)
|
||||
cell_mass = [difference.sum(axis=0)]
|
||||
collision = coarse_collision.copy(); fine_collision = []
|
||||
for f, g in fine_phases:
|
||||
difference = f64(g[:, maps.fine_active])-f64(f[:, maps.fine_active])
|
||||
fine_collision.append(.25*difference.sum(axis=1))
|
||||
cell_mass.append(.25*difference.sum(axis=0))
|
||||
collision += fine_collision[-1]
|
||||
np.testing.assert_array_equal(h, pre.astype(np.float32))
|
||||
origins = f64(g0).ravel()[tables['origins']]
|
||||
np.testing.assert_allclose(pre.mean(axis=1), origins, rtol=0, atol=2e-14)
|
||||
group = np.bincount(tables['origins']//(n*n), weights=f64(h).mean(axis=1)-origins, minlength=9)
|
||||
interpolation = np.bincount(tables['origins']//(n*n), weights=pre.mean(axis=1)-origins, minlength=9)
|
||||
child_cast = np.bincount(tables['origins']//(n*n), weights=f64(h).mean(axis=1)-pre.mean(axis=1), minlength=9)
|
||||
packet = np.empty(tables['times'].shape, np.float64)
|
||||
for when, source in ((-1, h), (0, fine_phases[0][1]), (1, fine_phases[1][1])):
|
||||
mask = tables['times'] == when
|
||||
packet[mask] = f64(source).ravel()[tables['sources'][mask]]
|
||||
p = f64(predicted).ravel()[tables['targets']]
|
||||
ideal = -p + .25*packet.sum(axis=1)
|
||||
reg_error = registers-ideal
|
||||
bound = 16*np.finfo(float).eps*(abs(p)+.25*abs(packet).sum(axis=1))
|
||||
if np.any(abs(reg_error) > bound):
|
||||
raise AssertionError("receiver packet register mismatch (not roundoff)")
|
||||
actual = after.coarse_f.ravel()[tables['targets']]
|
||||
np.testing.assert_array_equal(actual, (p+registers).astype(np.float32))
|
||||
q = tables['targets']//(n*n)
|
||||
store_error = np.bincount(q, weights=f64(actual)-(p+registers), minlength=9)
|
||||
register_error = np.bincount(q, weights=reg_error, minlength=9)
|
||||
# directional() must sum converted arrays, not accumulate in FP32.
|
||||
total = lambda s: ref.directional(ref.cpu.State(f64(s.coarse_f), f64(s.fine_f)), maps)
|
||||
net = total(after)-total(before)
|
||||
raw = net-collision
|
||||
unexplained = raw-group-store_error-register_error
|
||||
limit = float(64*np.finfo(float).eps*(total(before).sum()+int(shifted)*n*n))
|
||||
np.testing.assert_allclose(unexplained, 0, rtol=0, atol=limit)
|
||||
offset = _WEIGHTS.ravel()[tables['origins']//(n*n),None] if shifted else 0.
|
||||
return dict(raw=raw.tolist(), net_change=net.tolist(), physical_collision=collision.tolist(),
|
||||
coarse_collision=coarse_collision.tolist(), fine0_collision=fine_collision[0].tolist(),
|
||||
fine1_collision=fine_collision[1].tolist(), child_interpolation=interpolation.tolist(),
|
||||
child_cast=child_cast.tolist(),
|
||||
collision_cell_mass=[[float(a.sum()),float(a[a>0].sum()),float(a[a<0].sum()),float(abs(a).sum())]
|
||||
for a in cell_mass],
|
||||
child_rounding=group.tolist(), receiver_rounding=store_error.tolist(),
|
||||
register_rounding=register_error.tolist(), unexplained=unexplained.tolist(),
|
||||
unexplained_bound=limit, minimum_pre=float((pre+offset).min()),
|
||||
minimum_stored=float((f64(h)+offset).min()))
|
||||
|
||||
|
||||
class SpaceTimeGPU:
|
||||
"""Caller-owned context, one stream, explicit synchronized arrival state."""
|
||||
def __init__(self, config, bounds, *, context, mode='spacetime', identity=False, allow_n128=False):
|
||||
check_sources()
|
||||
self.configs = validate_config(config,allow_n128=allow_n128)
|
||||
if mode not in ('spacetime', 'frozen') or type(identity) is not bool:
|
||||
raise ValueError("unsupported interpolation or debug collision mode")
|
||||
self.bounds = tuple(bounds)
|
||||
if len(self.bounds) != 4 or any(isinstance(v, bool) or not isinstance(v, Integral) for v in self.bounds):
|
||||
raise ValueError("rectangle bounds must be four integers")
|
||||
self.plan = ref.Plan(config.nx, self.bounds)
|
||||
self.tables = pack_plan(self.plan)
|
||||
self.ctx, self.mode, self.identity = context, mode, identity
|
||||
self.allocations, self.modules, self.build_metadata = [], [], []
|
||||
self.status, self.step_count, self.device_bytes = 'new', 0, 0
|
||||
self.health_counts = np.zeros(6, np.uint64)
|
||||
import pycuda.driver as cuda
|
||||
from CelerisLab.cuda import compiler_v2 as compiler
|
||||
root = Path(__file__).resolve().parents[3]
|
||||
if Path(compiler._KERNEL_DIR).resolve() != root/'src/CelerisLab/lbm/kernels':
|
||||
raise RuntimeError("wrong production kernel checkout")
|
||||
self.cuda = cuda
|
||||
with context.activate():
|
||||
self.stream = cuda.Stream()
|
||||
try:
|
||||
source = Path(__file__).with_suffix('.cu')
|
||||
for cfg in self.configs:
|
||||
build = compiler.generate_config(cfg)
|
||||
try:
|
||||
Path(build, source.name).write_bytes(source.read_bytes())
|
||||
ptx = compiler.compile_kernel(context.sm_arch, source=source.name, build_dir=build)
|
||||
self.modules.append(compiler.load_module(ptx))
|
||||
import json
|
||||
metadata = json.loads(Path(ptx+'.json').read_text())
|
||||
metadata['test_entry_sha256'] = hashlib.sha256(source.read_bytes()).hexdigest()
|
||||
self.build_metadata.append(metadata)
|
||||
finally:
|
||||
compiler.cleanup_build_dir(build)
|
||||
self.device_tables = {key: self.upload(value) for key, value in self.tables.items()}
|
||||
self.active = self.upload(self.plan.maps.fine_active.astype(np.uint8))
|
||||
self.coarse = [self.allocate(9*config.nx**2*4) for _ in range(4)]
|
||||
self.fine = [self.allocate(9*(2*config.nx)**2*4) for _ in range(3)]
|
||||
self.pre = self.allocate(len(self.plan.groups)*4*8)
|
||||
self.h = self.allocate(len(self.plan.groups)*4*4)
|
||||
self.registers = self.allocate(len(self.plan.receivers)*8)
|
||||
self.counts = self.allocate(6*4)
|
||||
except BaseException:
|
||||
try:
|
||||
self.close()
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
def allocate(self, size):
|
||||
pointer = self.cuda.mem_alloc(size)
|
||||
self.allocations.append(pointer); self.device_bytes += size
|
||||
return pointer
|
||||
|
||||
def upload(self, array):
|
||||
pointer = self.allocate(array.nbytes)
|
||||
self.cuda.memcpy_htod(pointer, array)
|
||||
return pointer
|
||||
|
||||
def read(self, pointer, shape, dtype=np.float32):
|
||||
self.stream.synchronize()
|
||||
out = np.empty(shape, dtype)
|
||||
self.cuda.memcpy_dtoh(out, pointer)
|
||||
return out
|
||||
|
||||
def launch(self, level, name, *args, count=None):
|
||||
cfg = self.configs[level]
|
||||
nt = cfg.threads_per_block
|
||||
grid = ((cfg.nx+nt-1)//nt, cfg.ny) if count is None else ((count+nt-1)//nt, 1)
|
||||
self.modules[level].get_function(name)(*args, block=(nt,1,1), grid=grid, stream=self.stream)
|
||||
|
||||
def initialize(self, state):
|
||||
if (self.status not in ('new', 'ready') or state.phase != 'arrival_f'
|
||||
or isinstance(state.tick, bool) or not isinstance(state.tick, Integral) or state.tick != 0):
|
||||
raise ValueError("initial synchronized arrival state with tick=0 required")
|
||||
shifted = self.configs[0].ddf_shifting
|
||||
arrays = []
|
||||
for values, mask in zip((state.coarse_f,state.fine_f),
|
||||
(self.plan.maps.coarse_active,self.plan.maps.fine_active)):
|
||||
if np.shape(values) != (9,*mask.shape):
|
||||
raise ValueError("positive finite leaf populations and exact shapes required")
|
||||
a = _encode_physical(values,shifted)
|
||||
physical = _decode_stored(a,shifted)
|
||||
if not np.isfinite(physical[:,mask]).all() or (physical[:,mask] <= 0).any():
|
||||
raise ValueError("positive finite leaf populations and exact shapes required")
|
||||
a[:,~mask] = np.nan
|
||||
arrays.append(a)
|
||||
with self.ctx.activate():
|
||||
try:
|
||||
self.cuda.memcpy_htod(self.coarse[0], arrays[0])
|
||||
self.cuda.memcpy_htod(self.fine[0], arrays[1])
|
||||
except BaseException:
|
||||
self.status = 'failed'
|
||||
raise
|
||||
self.status, self.step_count = 'ready', 0
|
||||
self.health_counts.fill(0)
|
||||
|
||||
def _download_stored(self):
|
||||
"""Private audit/reset snapshot; values remain in their storage representation."""
|
||||
if self.status != 'ready':
|
||||
raise RuntimeError(f"GPU owner is {self.status}")
|
||||
try:
|
||||
with self.ctx.activate():
|
||||
arrays = [self.read(self.coarse[0], (9,self.plan.n,self.plan.n)),
|
||||
self.read(self.fine[0], (9,self.plan.nf,self.plan.nf))]
|
||||
except BaseException:
|
||||
self.status = 'failed'
|
||||
raise
|
||||
for a, mask in zip(arrays, (self.plan.maps.coarse_active,self.plan.maps.fine_active)):
|
||||
a[:,~mask] = np.nan
|
||||
return ref.cpu.State(*arrays, tick=2*self.step_count)
|
||||
|
||||
def download(self):
|
||||
"""Physical arrival populations; shifted values are reconstructed in FP64."""
|
||||
stored = self._download_stored()
|
||||
return ref.cpu.State(*(_decode_stored(a,self.configs[0].ddf_shifting)
|
||||
for a in (stored.coarse_f,stored.fine_f)),tick=stored.tick)
|
||||
|
||||
def step(self, *, mutation=0, audit=True):
|
||||
"""One interval; audit=False skips only host ledger snapshots, never health checks."""
|
||||
if (self.status != 'ready' or type(mutation) is not int or mutation not in range(5)
|
||||
or type(audit) is not bool or (mutation and not audit)):
|
||||
raise RuntimeError("ready owner and known test mutation required")
|
||||
before = self._download_stored() if audit else None
|
||||
n, nf, t = self.plan.n, self.plan.nf, self.device_tables
|
||||
ng, nr = len(self.plan.groups), len(self.plan.receivers)
|
||||
u32, null = np.uint32, np.uintp(0)
|
||||
omega = [np.float32(0 if self.identity else cfg.omega) for cfg in self.configs]
|
||||
with self.ctx.activate():
|
||||
try:
|
||||
self.status = 'advancing'
|
||||
self.cuda.memset_d32_async(self.counts, 0, 6, self.stream)
|
||||
fc, g0, predicted, gp = self.coarse
|
||||
self.launch(0, 'CoarseStart', fc,self.fine[0],g0,omega[0],
|
||||
*(u32(v) for v in self.bounds),self.counts)
|
||||
self.launch(0, 'Pull', g0,predicted,null,self.counts)
|
||||
self.launch(0, 'Collide', predicted,gp,null,omega[0],self.counts)
|
||||
self.launch(0, 'Interpolate', g0,gp,t['samples'],t['origins'],t['sizes'],t['weights'],
|
||||
self.pre,self.h,u32(ng),u32(self.mode=='frozen'),self.counts,count=ng)
|
||||
if audit:
|
||||
coarse_g = self.read(g0,(9,n,n)); prediction = self.read(predicted,(9,n,n))
|
||||
fine_phases = []
|
||||
for m in (0,1):
|
||||
f, out, g = self.fine
|
||||
if audit:
|
||||
old_f = self.read(f,(9,nf,nf))
|
||||
self.launch(1, 'Collide', f,g,self.active,omega[1],self.counts)
|
||||
if audit:
|
||||
fine_phases.append((old_f,self.read(g,(9,nf,nf))))
|
||||
self.launch(0, 'Register', predicted,g,self.h,t['targets'],t['sources'],t['times'],
|
||||
self.registers,u32(nr),np.int32(m),np.int32(mutation),count=nr)
|
||||
rows = self.tables[f'incoming{m}']
|
||||
self.launch(1, 'Inject', g,self.h,t[f'incoming{m}'],u32(len(rows)),u32(mutation==4),count=len(rows))
|
||||
self.launch(1, 'Pull', g,out,self.active,self.counts)
|
||||
self.fine[0], self.fine[1] = out, f
|
||||
self.launch(0, 'ApplyRegisters', predicted,self.registers,t['targets'],u32(nr),self.counts,count=nr)
|
||||
counts = self.read(self.counts,(6,),np.uint32)
|
||||
self.health_counts += counts.astype(np.uint64)
|
||||
if counts.any():
|
||||
raise AssertionError(f"boundary/leaf health failed: {counts.tolist()}")
|
||||
ledger = dict(boundary_budget_status='not_measured',unexplained=None)
|
||||
if audit:
|
||||
coarse_after = self.read(predicted,(9,n,n))
|
||||
coarse_after[:,~self.plan.maps.coarse_active] = np.nan
|
||||
after = ref.cpu.State(coarse_after,self.read(self.fine[0],(9,nf,nf)),tick=2*(self.step_count+1))
|
||||
ledger = rounding_ledger(self.plan,self.tables,before,after,coarse_g,prediction,fine_phases,
|
||||
self.read(self.pre,(ng,4),np.float64),self.read(self.h,(ng,4)),
|
||||
self.read(self.registers,(nr,),np.float64),
|
||||
shifted=self.configs[0].ddf_shifting)
|
||||
ledger['boundary_budget_status'] = 'measured'
|
||||
ledger['health_counts'] = counts.tolist()
|
||||
self.coarse[0], self.coarse[2] = predicted, fc
|
||||
self.step_count += 1
|
||||
self.status = 'ready'
|
||||
return ledger
|
||||
except BaseException:
|
||||
self.status = 'failed'
|
||||
raise
|
||||
|
||||
def close(self):
|
||||
if self.status == 'closed':
|
||||
return
|
||||
with self.ctx.activate():
|
||||
error = None
|
||||
try:
|
||||
self.stream.synchronize()
|
||||
except Exception as exc:
|
||||
error = exc
|
||||
failed = []
|
||||
for pointer in self.allocations:
|
||||
try:
|
||||
pointer.free()
|
||||
except Exception as exc:
|
||||
error = error or exc
|
||||
failed.append(pointer)
|
||||
self.allocations = failed
|
||||
self.status = 'cleanup_failed' if failed else 'closed'
|
||||
if not failed:
|
||||
self.modules.clear()
|
||||
if error:
|
||||
raise error
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, kind, value, traceback):
|
||||
try:
|
||||
self.close()
|
||||
except Exception:
|
||||
if kind is None:
|
||||
raise
|
||||
|
||||
|
||||
def uniform_ledger(before, after, collided, *, shifted):
|
||||
"""Periodic streaming permutes each direction; only collision changes its sum."""
|
||||
a,b,g = [np.asarray(x,np.float64) for x in (before,after,collided)]
|
||||
collision=(g-a).sum(axis=(1,2)); net=b.sum(axis=(1,2))-a.sum(axis=(1,2))
|
||||
residual=net-collision
|
||||
bound=float(64*np.finfo(float).eps*(a.sum()+int(shifted)*a.shape[1]*a.shape[2]))
|
||||
np.testing.assert_allclose(residual,0,rtol=0,atol=bound)
|
||||
cells=(g-a).sum(axis=0)
|
||||
out={key:[0.]*9 for key in ('fine0_collision','fine1_collision','child_rounding',
|
||||
'child_interpolation','child_cast','register_rounding','receiver_rounding')}
|
||||
out.update(net_change=net.tolist(),physical_collision=collision.tolist(),coarse_collision=collision.tolist(),
|
||||
unexplained=residual.tolist(),unexplained_bound=bound,
|
||||
collision_cell_mass=[[float(cells.sum()),float(cells[cells>0].sum()),
|
||||
float(cells[cells<0].sum()),float(abs(cells).sum())],[0.]*4,[0.]*4],
|
||||
boundary_budget_status='not_applicable_uniform',inactive_components='fine, child, register, receiver')
|
||||
return out
|
||||
|
||||
|
||||
class UniformGPU(SpaceTimeGPU):
|
||||
"""One explicit arrival-F level; reuse the reviewed kernels and owner helpers."""
|
||||
def __init__(self, config, *, context, identity=False):
|
||||
check_sources()
|
||||
self.configs=validate_config(config,uniform=True)
|
||||
if type(identity) is not bool:
|
||||
raise ValueError('identity must be bool')
|
||||
self.ctx,self.identity=context,identity
|
||||
self.allocations,self.modules,self.build_metadata=[],[],[]
|
||||
self.status,self.step_count,self.device_bytes='new',0,0
|
||||
self.health_counts=np.zeros(6,np.uint64)
|
||||
import pycuda.driver as cuda
|
||||
from CelerisLab.cuda import compiler_v2 as compiler
|
||||
if Path(compiler._KERNEL_DIR).resolve()!=Path(__file__).resolve().parents[3]/'src/CelerisLab/lbm/kernels':
|
||||
raise RuntimeError('wrong production kernel checkout')
|
||||
self.cuda=cuda
|
||||
with context.activate():
|
||||
self.stream=cuda.Stream()
|
||||
try:
|
||||
source=Path(__file__).with_suffix('.cu')
|
||||
build=compiler.generate_config(self.configs[0])
|
||||
try:
|
||||
Path(build,source.name).write_bytes(source.read_bytes())
|
||||
ptx=compiler.compile_kernel(context.sm_arch,source=source.name,build_dir=build)
|
||||
self.modules.append(compiler.load_module(ptx))
|
||||
import json
|
||||
metadata=json.loads(Path(ptx+'.json').read_text())
|
||||
metadata['test_entry_sha256']=hashlib.sha256(source.read_bytes()).hexdigest()
|
||||
self.build_metadata.append(metadata)
|
||||
finally:
|
||||
compiler.cleanup_build_dir(build)
|
||||
n=config.nx
|
||||
self.buffers=[self.allocate(9*n*n*4) for _ in range(3)]
|
||||
self.active=self.upload(np.ones((n,n),np.uint8))
|
||||
self.counts=self.allocate(6*4)
|
||||
except BaseException:
|
||||
try:
|
||||
self.close()
|
||||
except BaseException:
|
||||
pass
|
||||
raise
|
||||
|
||||
def initialize(self, physical):
|
||||
cfg=self.configs[0]
|
||||
if self.status not in ('new','ready') or np.shape(physical)!=(9,cfg.ny,cfg.nx):
|
||||
raise ValueError('ready owner and exact uniform population shape required')
|
||||
stored=_encode_physical(physical,cfg.ddf_shifting)
|
||||
decoded=_decode_stored(stored,cfg.ddf_shifting)
|
||||
if not np.isfinite(decoded).all() or (decoded<=0).any():
|
||||
raise ValueError('positive finite physical populations required')
|
||||
try:
|
||||
with self.ctx.activate():
|
||||
self.cuda.memcpy_htod(self.buffers[0],stored)
|
||||
self.health_counts[:]=0; self.step_count=0; self.status='ready'
|
||||
except BaseException:
|
||||
self.status='failed'
|
||||
raise
|
||||
|
||||
def _download_stored(self):
|
||||
if self.status!='ready':
|
||||
raise RuntimeError('uniform owner is not ready')
|
||||
try:
|
||||
with self.ctx.activate():
|
||||
n=self.configs[0].nx
|
||||
return self.read(self.buffers[0],(9,n,n))
|
||||
except BaseException:
|
||||
self.status='failed'
|
||||
raise
|
||||
|
||||
def download(self):
|
||||
return _decode_stored(self._download_stored(),self.configs[0].ddf_shifting)
|
||||
|
||||
def step(self, *, audit=True):
|
||||
if self.status!='ready' or type(audit) is not bool:
|
||||
raise RuntimeError('ready owner and bool audit required')
|
||||
cfg=self.configs[0]; shape=(9,cfg.ny,cfg.nx)
|
||||
before=self._download_stored() if audit else None
|
||||
with self.ctx.activate():
|
||||
try:
|
||||
self.status='advancing'
|
||||
self.cuda.memset_d32_async(self.counts,0,6,self.stream)
|
||||
f,g,out=self.buffers
|
||||
self.launch(0,'Collide',f,g,self.active,np.float32(0 if self.identity else cfg.omega),self.counts)
|
||||
self.launch(0,'Pull',g,out,self.active,self.counts)
|
||||
counts=self.read(self.counts,(6,),np.uint32)
|
||||
self.health_counts+=counts.astype(np.uint64)
|
||||
if counts.any():
|
||||
raise AssertionError(f'uniform population health failed: {counts.tolist()}')
|
||||
ledger=uniform_ledger(before,self.read(out,shape),self.read(g,shape),shifted=cfg.ddf_shifting) if audit else dict(
|
||||
boundary_budget_status='not_measured',unexplained=None)
|
||||
ledger['health_counts']=counts.tolist()
|
||||
self.buffers[0],self.buffers[2]=out,f
|
||||
self.step_count+=1; self.status='ready'
|
||||
return ledger
|
||||
except BaseException:
|
||||
self.status='failed'
|
||||
raise
|
||||
@@ -1,334 +0,0 @@
|
||||
"""Experimental FP64 BVP coupling: one periodic rectangle, D2Q9, ratio two.
|
||||
|
||||
Guzik et al., https://www.osti.gov/servlets/purl/1226215, sections 3.2--3.5.
|
||||
Coordinate-generated stream events; CPUREF collision only, no production imports.
|
||||
This is a mathematical prototype, not a certified accuracy implementation.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import resource
|
||||
import sys
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import cpu_reference as cpu
|
||||
|
||||
|
||||
class Spec(cpu.Spec):
|
||||
@property
|
||||
def n_fine(self):
|
||||
return 2 * self.n_coarse
|
||||
|
||||
|
||||
def geometry(n, bounds):
|
||||
x0, y0, x1, y1 = bounds
|
||||
if not (2 <= x0 < x1 <= n - 2 and 2 <= y0 < y1 <= n - 2):
|
||||
raise ValueError("one interior rectangle with two coarse cells of clearance required")
|
||||
y, x = np.indices((n, n))
|
||||
covered = (x >= x0) & (x < x1) & (y >= y0) & (y < y1)
|
||||
y, x = np.indices((2*n, 2*n))
|
||||
active = (x >= 2*x0) & (x < 2*x1) & (y >= 2*y0) & (y < 2*y1)
|
||||
inner = (x >= 2*x0-2) & (x < 2*x1+2) & (y >= 2*y0-2) & (y < 2*y1+2) & ~active
|
||||
rows, links = [], []
|
||||
for ky, kx in zip(*np.nonzero(~covered)):
|
||||
for q, (cy, cx) in enumerate(zip(cpu.CY, cpu.CX)):
|
||||
if any(active[2*ky+dy-cy, 2*kx+dx-cx]
|
||||
for dy in (0, 1) for dx in (0, 1)
|
||||
if 0 <= 2*ky+dy-cy < 2*n and 0 <= 2*kx+dx-cx < 2*n):
|
||||
rows.append((q, ky, kx))
|
||||
sy, sx = (ky-cy) % n, (kx-cx) % n
|
||||
if not covered[sy, sx]:
|
||||
links.append(cpu.CornerLink(f"{kx}_{ky}_{q}", q, (sx, sy), (kx, ky)))
|
||||
kind = np.where(active, 0, np.where(inner, 1, 2))
|
||||
return cpu.Maps(~covered, covered, active, inner, kind == 2, kind,
|
||||
np.arange(2*n)//2, (np.arange(2*n)+.5)/2,
|
||||
np.array(rows, dtype=int), tuple(links))
|
||||
|
||||
|
||||
@lru_cache(None)
|
||||
def interpolation(kind, points):
|
||||
"""Return data coordinates, evaluation matrix, and constrained coefficient map."""
|
||||
if kind == "face":
|
||||
samples = np.array([(s, t) for t in (0., 1.) for s in (-1., 0., 1.)])
|
||||
a = np.array([[1, s, t, s*t] for s, t in samples])
|
||||
e = np.array([[1, s, t, s*t] for s, t in points])
|
||||
b = e.mean(axis=0, keepdims=True)
|
||||
d = np.zeros((1, 6)); d[0, 1] = 1
|
||||
else:
|
||||
cross = ((0, 0), (-1, 0), (1, 0), (0, -1), (0, 1))
|
||||
samples = np.array([(x, y, t) for t in (0., 1.) for x, y in cross])
|
||||
a = np.array([[1, x, y, t] for x, y, t in samples])
|
||||
e = np.array([[1, x, y, t] for x, y, t in points])
|
||||
b = np.stack((e.mean(axis=0), [0, 0, 0, 1]))
|
||||
d = np.zeros((2, 10)); d[0, 0] = 1; d[1, 0] = -1; d[1, 5] = 1
|
||||
kkt = np.block([[a.T @ a, b.T], [b, np.zeros((len(b), len(b)))]])
|
||||
coeff = np.linalg.solve(kkt, np.vstack((a.T, d)))[:4]
|
||||
if not np.allclose(b @ coeff, d, rtol=0, atol=2e-14):
|
||||
raise AssertionError("invalid constrained interpolation matrix")
|
||||
return samples, a, e, coeff
|
||||
|
||||
|
||||
@dataclass
|
||||
class Group:
|
||||
q: int
|
||||
source: int
|
||||
receiver: int
|
||||
kind: str
|
||||
points: tuple
|
||||
samples: np.ndarray
|
||||
weights: np.ndarray
|
||||
events: tuple # (substep, fine ghost slot), or None for a leftover
|
||||
|
||||
|
||||
class Plan:
|
||||
def __init__(self, n, bounds):
|
||||
self.n, self.nf = n, 2*n
|
||||
self.maps = geometry(n, bounds)
|
||||
active = self.maps.fine_active
|
||||
lo, hi = np.array(bounds[1::-1]), np.array(bounds[3:1:-1])
|
||||
self.groups = []
|
||||
for iy, ix in zip(*np.nonzero(self.maps.coarse_active)):
|
||||
origin = np.array((iy, ix))
|
||||
for q in range(1, 9):
|
||||
c = np.array((cpu.CY[q], cpu.CX[q]))
|
||||
starts = [2*origin + (dy, dx) for dy in (0, 1) for dx in (0, 1)]
|
||||
events = []
|
||||
for p in starts:
|
||||
event = None
|
||||
for m in (0, 1):
|
||||
old, new = p+m*c, p+(m+1)*c
|
||||
inside = lambda z: bool(np.all(z >= 0) and np.all(z < 2*n) and active[tuple(z)])
|
||||
if not inside(old) and inside(new):
|
||||
event = (m, int(old[0]*2*n+old[1]))
|
||||
break
|
||||
events.append(event)
|
||||
if all(event is None for event in events):
|
||||
continue
|
||||
axes = [j for j in range(2) if origin[j] < lo[j] or origin[j] >= hi[j]]
|
||||
kind = "corner" if len(axes) == 2 else "face"
|
||||
points = []
|
||||
for p in starts:
|
||||
x = (p+.5)/2
|
||||
times = [(float(lo[j] if c[j] > 0 else hi[j])-x[j])/c[j] for j in axes]
|
||||
time = max(times)
|
||||
offset = x+c*time-(origin+.5+c*.5)
|
||||
points.append((float(offset[1]), float(offset[0]), float(time-.5))
|
||||
if kind == "corner" else (float(offset[1-axes[0]]), float(time-.5)))
|
||||
points = tuple(points)
|
||||
samples, _, e, coeff = interpolation(kind, points)
|
||||
indices = []
|
||||
for row in samples:
|
||||
shift = np.array((row[1], row[0]), dtype=int) if kind == "corner" else np.eye(2, dtype=int)[1-axes[0]]*int(row[0])
|
||||
pos = (origin+shift) % n
|
||||
indices.append(int(pos[0]*n+pos[1]))
|
||||
target = (origin+c) % n
|
||||
self.groups.append(Group(q, int(iy*n+ix), int(target[0]*n+target[1]), kind,
|
||||
points, np.array(indices), e @ coeff, tuple(events)))
|
||||
self.outgoing = []
|
||||
# m is zero-based: a substep-0 exit travels two fine spacings by
|
||||
# synchronization; a substep-1 exit travels one.
|
||||
for m in (0, 1):
|
||||
records = []
|
||||
for q in range(1, 9):
|
||||
c = np.array((cpu.CY[q], cpu.CX[q]))
|
||||
for y, x in zip(*np.nonzero(active)):
|
||||
p = np.array((y, x)); endpoint = p+c
|
||||
if not active[tuple(endpoint)]:
|
||||
target = (p+(2-m)*c)//2
|
||||
assert self.maps.coarse_active[tuple(target)]
|
||||
records.append((q, int(y*2*n+x), int(target[0]*n+target[1])))
|
||||
self.outgoing.append(np.array(records, dtype=int))
|
||||
incoming = [(m, g.q, slot) for g in self.groups for event in g.events
|
||||
if event is not None for m, slot in [event]]
|
||||
assert len(incoming) == len(set(incoming)), "duplicate incoming writer"
|
||||
expected = {(m, int(q), int((y-cpu.CY[q])*2*n+x-cpu.CX[q]))
|
||||
for m in (0, 1) for q in range(1, 9)
|
||||
for y, x in zip(*np.nonzero(active)) if not active[y-cpu.CY[q], x-cpu.CX[q]]}
|
||||
assert set(incoming) == expected, "incoming coverage mismatch"
|
||||
receivers = {(int(q), int(target)) for out in self.outgoing for q, _, target in out}
|
||||
receivers |= {(g.q, g.receiver) for g in self.groups if None in g.events}
|
||||
assert receivers == {(int(q), int(y*n+x)) for q, y, x in self.maps.coalesce}
|
||||
self.receivers = np.array(sorted(receivers), dtype=int)
|
||||
|
||||
|
||||
def directional(state, maps):
|
||||
return state.coarse_f[:, maps.coarse_active].sum(axis=1) + .25*state.fine_f[:, maps.fine_active].sum(axis=1)
|
||||
|
||||
|
||||
def advance(spec, plan, state, mode="spacetime", mutation=None):
|
||||
"""One arrival-to-arrival interval. 'frozen' disables space AND time variation."""
|
||||
if mode not in ("frozen", "spacetime"):
|
||||
raise ValueError(mode)
|
||||
n, nf, maps = plan.n, plan.nf, plan.maps
|
||||
if state.phase != "arrival_f" or state.tick % 2:
|
||||
raise ValueError("synchronized arrival state required")
|
||||
fc, ff = state.coarse_f.copy(), state.fine_f.copy()
|
||||
covered_mean = ff.reshape(9, n, 2, n, 2).mean(axis=(2, 4))
|
||||
fc[:, maps.coarse_covered] = covered_mean[:, maps.coarse_covered]
|
||||
all_cells = np.ones((n, n), dtype=bool)
|
||||
def collide(f, mask, tau):
|
||||
return cpu._collide(spec, f, np.isfinite(f), tau, mask, np.zeros(mask.shape, dtype=int))
|
||||
gc = collide(fc, all_cells, spec.tau_coarse)
|
||||
collision = (gc[:, maps.coarse_active]-fc[:, maps.coarse_active]).sum(axis=1)
|
||||
predicted = cpu._stream_periodic(gc)
|
||||
gp = collide(predicted, all_cells, spec.tau_coarse) if mode == "spacetime" else gc
|
||||
registers = np.zeros((9, n*n))
|
||||
q, target = plan.receivers.T
|
||||
registers[q, target] = -predicted.reshape(9, -1)[q, target]
|
||||
incoming = [[], []]
|
||||
group_error = 0.
|
||||
min_boundary = float("inf")
|
||||
negative_boundary = nonfinite_boundary = 0
|
||||
dropped = False
|
||||
for group in plan.groups:
|
||||
q = group.q
|
||||
count = len(group.samples)//2
|
||||
data = np.concatenate((gc.reshape(9, -1)[q, group.samples[:count]], gp.reshape(9, -1)[q, group.samples[count:]]))
|
||||
value = (group.weights @ data if mode == "spacetime"
|
||||
else np.full(4, gc.reshape(9, -1)[q, group.source]))
|
||||
min_boundary = min(min_boundary, float(value.min()))
|
||||
negative_boundary += int(np.count_nonzero(value < 0))
|
||||
nonfinite_boundary += int(np.count_nonzero(~np.isfinite(value)))
|
||||
group_error = max(group_error, abs(value.mean()-gc.reshape(9, -1)[q, group.source]))
|
||||
for k, event in enumerate(group.events):
|
||||
if event is None:
|
||||
if mutation == "omit_leftover" and not dropped:
|
||||
dropped = True
|
||||
else:
|
||||
registers[q, group.receiver] += .25*value[k]
|
||||
else:
|
||||
m, slot = event
|
||||
incoming[m].append((q, slot, value[k]))
|
||||
for m in (0, 1):
|
||||
gf = collide(ff, maps.fine_active, spec.tau_fine)
|
||||
collision += .25*(gf[:, maps.fine_active]-ff[:, maps.fine_active]).sum(axis=1)
|
||||
out = plan.outgoing[m]
|
||||
if mutation != f"omit_outgoing_{m}":
|
||||
np.add.at(registers, (out[:, 0], out[:, 2]), .25*gf.reshape(9, -1)[out[:, 0], out[:, 1]])
|
||||
for q, slot, value in incoming[m]:
|
||||
gf.reshape(9, -1)[q, slot] = value
|
||||
fresh = np.full_like(ff, np.nan)
|
||||
for q in range(9):
|
||||
moved = cpu._shift(gf[q], int(cpu.CY[q]), int(cpu.CX[q]), np.nan)
|
||||
fresh[q, maps.fine_active] = moved[maps.fine_active]
|
||||
if not np.isfinite(fresh[:, maps.fine_active]).all():
|
||||
raise AssertionError("invalid fine incoming population")
|
||||
ff = fresh
|
||||
final_c = predicted + registers.reshape(9, n, n)
|
||||
final_c[:, maps.coarse_covered] = np.nan
|
||||
final = cpu.State(final_c, ff, tick=state.tick+2)
|
||||
residual = directional(final, maps)-directional(state, maps)-collision
|
||||
minimum = min(float(final_c[:, maps.coarse_active].min()), float(ff[:, maps.fine_active].min()))
|
||||
return final, dict(directional_residual=residual, collision=collision,
|
||||
group_error=group_error, min_boundary=min_boundary, min_population=minimum,
|
||||
negative_boundary_count=negative_boundary, nonfinite_boundary_count=nonfinite_boundary)
|
||||
|
||||
|
||||
def write_receipt(path, result):
|
||||
"""Normalize NumPy scalars at the JSON boundary; reject other unknown types."""
|
||||
def scalar(value):
|
||||
if isinstance(value, np.generic):
|
||||
return value.item()
|
||||
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
|
||||
path.write_text(json.dumps(result, indent=2, allow_nan=False, default=scalar)+"\n")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--n64", action="store_true", help="explicitly run the frozen 1024-interval experiment")
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--max-seconds",type=float,default=180.,help="wall-time cap, including plan generation")
|
||||
parser.add_argument("--max-rss-mib",type=float,default=256.,help="Linux peak-RSS screening cap")
|
||||
args = parser.parse_args()
|
||||
if not args.n64:
|
||||
parser.error("full trajectory requires --n64")
|
||||
if args.output.exists():
|
||||
parser.error("output exists; preserve prior evidence and choose a new path")
|
||||
if not (np.isfinite(args.max_seconds) and args.max_seconds>0 and np.isfinite(args.max_rss_mib) and args.max_rss_mib>0):
|
||||
parser.error("positive finite resource caps required")
|
||||
started = time.monotonic()
|
||||
sys.path.insert(0, str(Path(__file__).parents[1]/"taylor_green"))
|
||||
import oracle as tg
|
||||
plan = Plan(64, (8, 8, 56, 48))
|
||||
spec = Spec(n_coarse=64, patch_lo=8, halo=16, tau_coarse=.5576, tau_fine=.6152, collision="srt")
|
||||
arrays = [tg.initial((64,64), 0, 64, .0192), tg.initial((128,128), 1, 64, .0192)]
|
||||
for a, mask in zip(arrays, (plan.maps.coarse_active, plan.maps.fine_active)):
|
||||
a[:, ~mask] = np.nan
|
||||
initial = cpu.State(*arrays)
|
||||
mass0 = tg.metrics(arrays, (plan.maps.coarse_active, plan.maps.fine_active), 0, 64, .0192)["mass"]
|
||||
metrics = ("velocity_l2", "pressure_l2", "energy_relative")
|
||||
frozen_baseline = dict(zip(metrics, (.01131134574353476, .03592961319244149, .012514764591008386)))
|
||||
budget_bound = float(64*np.finfo(float).eps*mass0)
|
||||
root = Path(__file__).resolve().parents[3]
|
||||
source = dict(commit=subprocess.check_output(["git", "rev-parse", "HEAD"],cwd=root,text=True).strip(),
|
||||
dirty=bool(subprocess.check_output(["git","status","--porcelain"],cwd=root,text=True)),
|
||||
hashes={p.name:hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
for p in (Path(__file__),Path(cpu.__file__),Path(tg.__file__))},numpy=np.__version__)
|
||||
result = dict(scope="experimental CPU FP64; no GPU/accuracy certification", source=source,
|
||||
screen=dict(velocity_factor=.8, pressure_factor=1., energy_factor=1.,
|
||||
directional_step_bound=budget_bound, mass_relative=1e-11,
|
||||
momentum_absolute=1e-9, baseline_metric_atol=1e-10),
|
||||
limits=dict(seconds=args.max_seconds,rss_mib=args.max_rss_mib),runs=[],completed=False)
|
||||
def save():
|
||||
result["resources"] = dict(seconds=time.monotonic()-started,peak_rss_mib=resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/1024)
|
||||
write_receipt(args.output, result)
|
||||
save()
|
||||
try:
|
||||
for mode in ("constant_ivp", "spacetime"):
|
||||
measured = mode == "spacetime"
|
||||
state = initial; record = dict(mode=mode,rows=[],
|
||||
boundary_budget_status="measured" if measured else "not_measured",
|
||||
max_directional_residual=0. if measured else None,
|
||||
minimum_boundary=None,negative_boundary_count=0 if measured else None,
|
||||
nonfinite_boundary_count=0 if measured else None)
|
||||
result["runs"].append(record)
|
||||
for k in range(1, 1025):
|
||||
if time.monotonic()-started>args.max_seconds or resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/1024>args.max_rss_mib:
|
||||
raise RuntimeError("experiment resource cap exceeded")
|
||||
if mode == "constant_ivp":
|
||||
state,_ = cpu.advance_coarse_once(spec,plan.maps,state)
|
||||
else:
|
||||
state,diagnostic = advance(spec,plan,state)
|
||||
error = float(np.max(np.abs(diagnostic["directional_residual"])))
|
||||
record["max_directional_residual"] = max(record["max_directional_residual"],error)
|
||||
if np.isfinite(diagnostic["min_boundary"]):
|
||||
record["minimum_boundary"] = (diagnostic["min_boundary"] if record["minimum_boundary"] is None else min(record["minimum_boundary"],diagnostic["min_boundary"]))
|
||||
for name in ("negative_boundary_count","nonfinite_boundary_count"):
|
||||
record[name] += diagnostic[name]
|
||||
if (not np.isfinite(error) or error>budget_bound or diagnostic["group_error"]>2e-14
|
||||
or diagnostic["negative_boundary_count"] or diagnostic["nonfinite_boundary_count"]
|
||||
or not np.isfinite(diagnostic["min_boundary"]) or diagnostic["min_boundary"]<=0
|
||||
or not np.isfinite(diagnostic["min_population"]) or diagnostic["min_population"]<=0):
|
||||
record["failed_diagnostic"] = {name: repr(value) for name,value in diagnostic.items()}
|
||||
raise AssertionError("candidate directional/health invariant failed")
|
||||
if k in (256,512,768,1024):
|
||||
row = tg.metrics((state.coarse_f,state.fine_f),(plan.maps.coarse_active,plan.maps.fine_active),k,64,.0192,mass0)
|
||||
row["momentum_change"] = (cpu.MOMENTS.T@(directional(state,plan.maps)-directional(initial,plan.maps)))[1:].tolist()
|
||||
record["rows"].append(row)
|
||||
save(); print(mode,k,json.dumps(row),flush=True)
|
||||
record["worst"] = {key:max(row[key] for row in record["rows"]) for key in metrics}
|
||||
if mode == "constant_ivp":
|
||||
for key,value in frozen_baseline.items():
|
||||
if abs(record["worst"][key]-value)>1e-10:
|
||||
raise AssertionError(f"frozen baseline mismatch: {key}")
|
||||
baseline,candidate = result["runs"]
|
||||
result["checks"] = {key:candidate["worst"][key]<=factor*baseline["worst"][key]
|
||||
for key,factor in zip(metrics,(.8,1.,1.))}
|
||||
result["checks"]["mass_momentum"] = all(row["mass_relative"]<=1e-11 and max(map(abs,row["momentum_change"]))<=1e-9 for row in candidate["rows"])
|
||||
result["completed"] = True
|
||||
result["experimental_pass"] = all(result["checks"].values())
|
||||
save()
|
||||
return 0 if result["experimental_pass"] else 1
|
||||
except Exception as exc:
|
||||
result["failure"] = dict(mode=mode,step=k,error=repr(exc))
|
||||
save()
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,92 +0,0 @@
|
||||
"""N64/N128 admission check: exact topology and independently rational interpolation.
|
||||
|
||||
Raw FP64 bytes are diagnostic: different LAPACK builds need not produce them
|
||||
identically. The 2e-14 ceiling is the existing interpolation constraint policy,
|
||||
not a universal LAPACK error theorem. This module never changes runtime weights.
|
||||
"""
|
||||
from fractions import Fraction as F
|
||||
from functools import lru_cache
|
||||
|
||||
import numpy as np
|
||||
from spacetime_gpu import table_hash
|
||||
|
||||
STRUCTURE_SHA = 'cd1842952c4dd4df82da8015454c25da9be12b983270236cc65b4044fa38e6e4'
|
||||
STRUCTURES = {64: (STRUCTURE_SHA, 532),
|
||||
128: ('ee4ecb645674ae04eb87220a5217851405ccc0f45fbb0fe210499dcea5cd93b1', 1060)}
|
||||
ATOL = 2e-14
|
||||
|
||||
|
||||
def _transpose(a):
|
||||
return list(map(list, zip(*a)))
|
||||
|
||||
|
||||
def _multiply(a, b):
|
||||
return [[sum(x*y for x,y in zip(row,column)) for column in zip(*b)] for row in a]
|
||||
|
||||
|
||||
@lru_cache(None)
|
||||
def exact_weights(kind, points):
|
||||
"""Solve the small documented constrained fit using exact rational arithmetic."""
|
||||
if kind == 'face':
|
||||
a = [[F(1),F(s),F(t),F(s*t)] for t in (0,1) for s in (-1,0,1)]
|
||||
e = [[F(1),F(s),F(t),F(s)*F(t)] for s,t in points]
|
||||
b = [[sum(column)/4 for column in zip(*e)]]
|
||||
d = [[F(int(j==1)) for j in range(6)]]
|
||||
elif kind == 'corner':
|
||||
a = [[F(1),F(x),F(y),F(t)] for t in (0,1) for x,y in ((0,0),(-1,0),(1,0),(0,-1),(0,1))]
|
||||
e = [[F(1),F(x),F(y),F(t)] for x,y,t in points]
|
||||
b = [[sum(column)/4 for column in zip(*e)], [F(0),F(0),F(0),F(1)]]
|
||||
d = [[F(int(j==0)) for j in range(10)], [F(int(j==5)-int(j==0)) for j in range(10)]]
|
||||
else:
|
||||
raise ValueError('unsupported interpolation kind')
|
||||
kkt = [r+s for r,s in zip(_multiply(_transpose(a),a),_transpose(b))]
|
||||
kkt += [row+[F(0)]*len(b) for row in b]
|
||||
rhs = _transpose(a)+d
|
||||
n = len(kkt)
|
||||
augmented = [row+data for row,data in zip(kkt,rhs)]
|
||||
for j in range(n):
|
||||
pivot = next(i for i in range(j,n) if augmented[i][j])
|
||||
augmented[j],augmented[pivot] = augmented[pivot],augmented[j]
|
||||
divisor = augmented[j][j]
|
||||
augmented[j] = [v/divisor for v in augmented[j]]
|
||||
for i in range(n):
|
||||
if i != j:
|
||||
factor = augmented[i][j]
|
||||
augmented[i] = [x-factor*y for x,y in zip(augmented[i],augmented[j])]
|
||||
solution = [row[n:] for row in augmented]
|
||||
if _multiply(kkt,solution) != rhs:
|
||||
raise AssertionError('exact interpolation solve failed')
|
||||
return np.array(_multiply(e,solution[:4]), dtype=np.float64)
|
||||
|
||||
|
||||
def validate_tables(plan, tables, *, allow_n128=False):
|
||||
"""Reject topology, layout, padding, operator, or constraint changes separately."""
|
||||
structure = table_hash({key:value for key,value in tables.items() if key != 'weights'})
|
||||
admitted = (64,128) if allow_n128 is True else (64,)
|
||||
if plan.n not in admitted or structure != STRUCTURES[plan.n][0]:
|
||||
raise ValueError('frozen integer topology changed')
|
||||
w = tables['weights']
|
||||
if (w.dtype != np.dtype(np.float64) or w.shape != (STRUCTURES[plan.n][1],4,10) or len(plan.groups) != w.shape[0]
|
||||
or not all(a.flags.c_contiguous for a in tables.values()) or not np.isfinite(w).all()):
|
||||
raise ValueError('weight dtype/shape/layout/finiteness changed')
|
||||
errors = dict(coefficient=0., constant=0., mean=0., temporal=0.)
|
||||
for i,g in enumerate(plan.groups):
|
||||
size = len(g.samples)
|
||||
if size != tables['sizes'][i] or size != {'face':6,'corner':10}.get(g.kind) or len(g.points) != 4:
|
||||
raise ValueError('interpolation group coverage/size changed')
|
||||
if w[i,:,size:].tobytes() != np.zeros((4,10-size),np.float64).tobytes():
|
||||
raise ValueError('weight padding is not exact positive zero')
|
||||
actual = w[i,:,:size]
|
||||
errors['coefficient'] = max(errors['coefficient'],float(abs(actual-exact_weights(g.kind,g.points)).max()))
|
||||
errors['constant'] = max(errors['constant'],float(abs(actual.sum(axis=1)-1).max()))
|
||||
central = np.zeros(size); central[1 if g.kind=='face' else 0] = 1
|
||||
errors['mean'] = max(errors['mean'],float(abs(actual.mean(axis=0)-central).max()))
|
||||
if g.kind == 'corner':
|
||||
early,late = (g.points.index((0.,0.,t)) for t in (-.25,.25))
|
||||
slope = np.zeros(size); slope[0],slope[5] = -1,1
|
||||
errors['temporal'] = max(errors['temporal'],float(abs(2*(actual[late]-actual[early])-slope).max()))
|
||||
if any(error > ATOL for error in errors.values()):
|
||||
raise ValueError(f'exact interpolation/constraint check failed: {errors}; bound={ATOL}')
|
||||
return dict(status='passed', structure_sha256=structure, raw_table_sha256=table_hash(tables),
|
||||
coefficient_oracle='exact rational KKT, rounded once to FP64', atol=ATOL, rtol=0.,
|
||||
maximum_absolute_errors=errors, positive_zero_padding=True)
|
||||
@@ -1,239 +0,0 @@
|
||||
"""Host-only experimental union event contract; no solver or runtime admission.
|
||||
|
||||
Consumes existing level-wide union geometry, never its legacy transfer tables.
|
||||
The frozen 707afe6 rectangle oracle remains independent and unchanged. Topology
|
||||
acceptance proves packet accounting, not interpolation accuracy or GPU support.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import spacetime_reference as ref
|
||||
|
||||
|
||||
class UnresolvedTopology(ValueError):
|
||||
"""An experimental coupling limitation, not a production geometry rejection."""
|
||||
def __init__(self, code, **detail):
|
||||
self.code, self.detail = code, detail
|
||||
super().__init__(f"experimental space-time topology unresolved: {code}: {detail}")
|
||||
|
||||
|
||||
def owned_slots(layout, directions, cells):
|
||||
"""Map dense logical reads/writes to existing owner-core storage, not halos."""
|
||||
cells = np.asarray(cells)
|
||||
if not np.issubdtype(cells.dtype, np.integer) or np.any((cells < 0) | (cells >= np.prod(layout.shape))):
|
||||
raise ValueError('invalid dense cell index')
|
||||
owner = layout.owner.ravel()[cells]
|
||||
if np.any(owner == layout.hole):
|
||||
raise UnresolvedTopology('missing_block_owner', cells=cells[owner == layout.hole].tolist())
|
||||
return layout.slot(owner, directions)
|
||||
|
||||
|
||||
def _first_entry(covered, origin, child, c):
|
||||
"""First continuous union entry of one characteristic, in exact quarter times.
|
||||
|
||||
Probe the cells immediately before/after each crossed grid line using integer
|
||||
eighth-cell coordinates. Return only actual exterior faces of the UNION.
|
||||
"""
|
||||
start4 = 4*origin + 2*child + 1
|
||||
for t4 in (1, 3):
|
||||
before = (2*start4 + c*(2*t4-1))//8
|
||||
after = (2*start4 + c*(2*t4+1))//8
|
||||
if not covered[tuple(before)] and covered[tuple(after)]:
|
||||
axes = []
|
||||
for j in range(2):
|
||||
if before[j] != after[j]:
|
||||
neighbor = after.copy(); neighbor[j] = before[j]
|
||||
if not covered[tuple(neighbor)]: axes.append(j)
|
||||
return t4, axes
|
||||
return None
|
||||
|
||||
|
||||
def _crossing_time(covered, origin, position, c, axes):
|
||||
"""Fig.11 convex T uses the last plane; Fig.13 concave T uses the first.
|
||||
|
||||
Both use the same KKT formulation on different boundary geometry.
|
||||
DOI/primary PDF: https://www.osti.gov/servlets/purl/1226215, §3.5.1.
|
||||
"""
|
||||
times=[(origin[j]+int(c[j]>0)-position[j])/c[j] for j in axes]
|
||||
if len(axes)==1: return times[0]
|
||||
sides=[bool(covered[tuple(origin+np.eye(2,dtype=int)[j]*c[j])]) for j in axes]
|
||||
if sides[0]!=sides[1]:
|
||||
raise UnresolvedTopology('mixed_corner_support',source_yx=origin.tolist())
|
||||
return min(times) if all(sides) else max(times)
|
||||
|
||||
|
||||
class UnionPlan:
|
||||
"""Two-level, level-wide square geometry; all coordinates are level-local.
|
||||
|
||||
Assumes a complete parent domain; does not validate nested parent coverage.
|
||||
``pair`` is existing FixedPatchSet2D geometry, even for one rectangle. Scalar
|
||||
window origins and nested state coverage need their own contract; they are
|
||||
explicitly unadmitted here. No global rectangle-gap rule is imposed.
|
||||
"""
|
||||
phase = 'arrival_f'
|
||||
runtime_supported = False
|
||||
interpolation_accuracy = 'unqualified_for_new_unions'
|
||||
|
||||
def __init__(self, pair):
|
||||
ny, nx = pair.coarse_shape
|
||||
self.origin = tuple(pair.fine_origin)
|
||||
if ny != nx or pair.fine_shape != (2*ny, 2*nx) or self.origin != (0., 0.):
|
||||
raise UnresolvedTopology('requires_level_wide_square_pair',
|
||||
coarse_shape=pair.coarse_shape, fine_shape=pair.fine_shape, origin=self.origin)
|
||||
self.n, self.nf = nx, 2*nx
|
||||
covered = np.array(pair.covered, dtype=bool, copy=True)
|
||||
active = np.kron(covered, np.ones((2, 2), bool))
|
||||
if not np.array_equal(active, pair.fine_kind == 0):
|
||||
raise UnresolvedTopology('non_pair_active_coverage')
|
||||
self.maps = SimpleNamespace(coarse_active=~covered, coarse_covered=covered, fine_active=active)
|
||||
self.outgoing, transit = [[], []], []
|
||||
# Guzik et al. §3.5.4, Fig.19 (https://www.osti.gov/servlets/purl/1226215):
|
||||
# retain substep-0 outgoing G at source, then fill the substep-1 ghost.
|
||||
# Rows are (q, fine source, next ghost); no covered coarse receiver credit.
|
||||
for q in range(1, 9):
|
||||
c = np.array((ref.cpu.CY[q], ref.cpu.CX[q]))
|
||||
boundary = active & ~np.roll(active, tuple(-c), axis=(0, 1))
|
||||
for y, x in zip(*np.nonzero(boundary)):
|
||||
p = np.array((y, x))
|
||||
for m in (0, 1):
|
||||
target = (p+(2-m)*c)//2
|
||||
if covered[tuple(target)]:
|
||||
if m != 0 or not active[tuple(p+2*c)]:
|
||||
raise UnresolvedTopology('unsupported_transit', q=q, substep=m, source_yx=p.tolist())
|
||||
ghost=p+c
|
||||
transit.append((q,int(y*self.nf+x),int(ghost[0]*self.nf+ghost[1])))
|
||||
else:
|
||||
self.outgoing[m].append((q, int(y*self.nf+x), int(target[0]*nx+target[1])))
|
||||
self.outgoing = [np.array(x, dtype=np.int64).reshape(-1, 3) for x in self.outgoing]
|
||||
self.transit = np.array(transit,dtype=np.int64).reshape(-1,3)
|
||||
self.groups = []
|
||||
for iy, ix in zip(*np.nonzero(~covered)):
|
||||
origin = np.array((iy, ix))
|
||||
# Two-cell production margins make all nearby probes in bounds.
|
||||
if not covered[max(0,iy-1):iy+2,max(0,ix-1):ix+2].any(): continue
|
||||
for q in range(1, 9):
|
||||
c = np.array((ref.cpu.CY[q], ref.cpu.CX[q]))
|
||||
children = [np.array((dy, dx)) for dy in (0, 1) for dx in (0, 1)]
|
||||
starts = [2*origin+child for child in children]
|
||||
events = []
|
||||
for p in starts:
|
||||
event = None
|
||||
for m in (0, 1):
|
||||
old, new = p+m*c, p+(m+1)*c
|
||||
if not active[tuple(old)] and active[tuple(new)]:
|
||||
event = (m, int(old[0]*self.nf+old[1])); break
|
||||
events.append(event)
|
||||
if all(e is None for e in events): continue
|
||||
entries = [_first_entry(covered, origin, child, c) for child in children]
|
||||
axes = sorted({j for entry in entries if entry is not None for j in entry[1]})
|
||||
if not axes:
|
||||
raise UnresolvedTopology('missing_exterior_support', source_yx=origin.tolist(), q=q)
|
||||
points = []
|
||||
for p, entry, event in zip(starts, entries, events):
|
||||
position = (p+.5)/2
|
||||
time = _crossing_time(covered,origin,position,c,axes)
|
||||
if entry is not None and (4*time != entry[0] or event is None):
|
||||
raise UnresolvedTopology('nonconvex_entry_support', source_yx=origin.tolist(), q=q,
|
||||
quarter_time=entry[0], support_quarter_time=float(4*time))
|
||||
offset = position+c*time-(origin+.5+c*.5)
|
||||
points.append((float(offset[1]), float(offset[0]), float(time-.5)) if len(axes)==2
|
||||
else (float(offset[1-axes[0]]), float(time-.5)))
|
||||
kind = 'corner' if len(axes)==2 else 'face'; points = tuple(points)
|
||||
samples, _, evaluation, coefficients = ref.interpolation(kind, points)
|
||||
indices = []
|
||||
for row in samples:
|
||||
shift = np.array((row[1],row[0]), int) if kind=='corner' else np.eye(2,dtype=int)[1-axes[0]]*int(row[0])
|
||||
pos = (origin+shift) % nx; indices.append(int(pos[0]*nx+pos[1]))
|
||||
target = (origin+c) % nx
|
||||
self.groups.append(ref.Group(q,int(iy*nx+ix),int(target[0]*nx+target[1]),kind,
|
||||
points,np.array(indices),evaluation@coefficients,tuple(events)))
|
||||
receivers = {(int(q),int(target)) for rows in self.outgoing for q,_,target in rows}
|
||||
receivers |= {(g.q,g.receiver) for g in self.groups if None in g.events}
|
||||
self.receivers = np.array(sorted(receivers),dtype=np.int64).reshape(-1,2)
|
||||
self.proof = validate_ownership(self)
|
||||
|
||||
|
||||
def validate_ownership(plan):
|
||||
"""Independent mask-based event coverage and four-quarter receiver accounting."""
|
||||
n, nf, active = plan.n, plan.nf, plan.maps.fine_active
|
||||
actual = [(m,g.q,slot) for g in plan.groups for e in g.events if e is not None for m,slot in [e]]
|
||||
expected_transit={(q,int(y*nf+x),int((y+ref.cpu.CY[q])*nf+x+ref.cpu.CX[q]))
|
||||
for q in range(1,9) for y,x in zip(*np.nonzero(active))
|
||||
if not active[y+ref.cpu.CY[q],x+ref.cpu.CX[q]]
|
||||
and active[y+2*ref.cpu.CY[q],x+2*ref.cpu.CX[q]]}
|
||||
routes=[tuple(map(int,row)) for row in plan.transit]
|
||||
if len(routes)!=len(set(routes)) or set(routes)!=expected_transit:
|
||||
raise ValueError('transit provenance/uniqueness/coverage failed')
|
||||
actual += [(1,q,ghost) for q,source,ghost in routes]
|
||||
expected = {(m,q,int((y-ref.cpu.CY[q])*nf+x-ref.cpu.CX[q]))
|
||||
for m in (0,1) for q in range(1,9) for y,x in zip(*np.nonzero(active))
|
||||
if not active[y-ref.cpu.CY[q],x-ref.cpu.CX[q]]}
|
||||
if len(actual)!=len(set(actual)) or set(actual)!=expected:
|
||||
raise ValueError('incoming writer uniqueness/coverage failed')
|
||||
packets = {tuple(map(int,row)):[] for row in plan.receivers}
|
||||
if len(packets)!=len(plan.receivers): raise ValueError('duplicate coarse receiver')
|
||||
anchors = set()
|
||||
for i,g in enumerate(plan.groups):
|
||||
if not 1<=g.q<9 or not 0<=g.source<n*n or not plan.maps.coarse_active.ravel()[g.source]:
|
||||
raise ValueError('invalid coarse source anchor')
|
||||
if (g.q,g.source) in anchors: raise ValueError('duplicate coarse source group')
|
||||
anchors.add((g.q,g.source))
|
||||
origin=np.array(divmod(g.source,n)); c=np.array((ref.cpu.CY[g.q],ref.cpu.CX[g.q]))
|
||||
target=(origin+c)%n
|
||||
if g.receiver!=target[0]*n+target[1]: raise ValueError('group receiver provenance failed')
|
||||
ordered=[]; entries=[]
|
||||
for dy,dx in ((0,0),(0,1),(1,0),(1,1)):
|
||||
p=2*origin+(dy,dx); event=None
|
||||
for m in (0,1):
|
||||
old,new=p+m*c,p+(m+1)*c
|
||||
if not active[tuple(old)] and active[tuple(new)]:
|
||||
event=(m,int(old[0]*nf+old[1])); break
|
||||
ordered.append(event)
|
||||
entries.append(_first_entry(plan.maps.coarse_covered,origin,np.array((dy,dx)),c))
|
||||
if tuple(ordered)!=g.events or all(e is None for e in ordered):
|
||||
raise ValueError('ordered child characteristic provenance failed')
|
||||
axes=sorted({j for e in entries if e is not None for j in e[1]})
|
||||
if not axes or g.kind!=('corner' if len(axes)==2 else 'face'):
|
||||
raise ValueError('group supporting axes differ')
|
||||
samples,_,evaluation,coefficients=ref.interpolation(g.kind,g.points)
|
||||
indices=[]
|
||||
for row in samples:
|
||||
shift=np.array((row[1],row[0]),int) if g.kind=='corner' else np.eye(2,dtype=int)[1-axes[0]]*int(row[0])
|
||||
pos=(origin+shift)%n; indices.append(int(pos[0]*n+pos[1]))
|
||||
if not np.array_equal(g.samples,indices): raise ValueError('sample anchor provenance failed')
|
||||
for k,(dy,dx) in enumerate(((0,0),(0,1),(1,0),(1,1))):
|
||||
position=origin+(np.array((dy,dx))+.5)/2
|
||||
time=_crossing_time(plan.maps.coarse_covered,origin,position,c,axes)
|
||||
offset=position+c*time-(origin+.5+c*.5)
|
||||
point=(float(offset[1]),float(offset[0]),float(time-.5)) if g.kind=='corner' else (float(offset[1-axes[0]]),float(time-.5))
|
||||
if g.points[k]!=point: raise ValueError('evaluation point provenance failed')
|
||||
source=np.zeros(len(g.samples)); source[0 if g.kind=='corner' else 1]=1
|
||||
if (not np.allclose(g.weights,evaluation@coefficients,rtol=0,atol=2e-14)
|
||||
or not np.allclose(g.weights.mean(axis=0),source,rtol=0,atol=2e-14)):
|
||||
raise ValueError('group coefficients/directional budget failed')
|
||||
for k,event in enumerate(g.events):
|
||||
if event is None:
|
||||
if (g.q,g.receiver) not in packets: raise ValueError('missing leftover receiver')
|
||||
packets[(g.q,g.receiver)].append(('leftover',i,k))
|
||||
for m,rows in enumerate(plan.outgoing):
|
||||
expected_out = {(q,int(y*nf+x),int(((y+(2-m)*ref.cpu.CY[q])//2)*n+(x+(2-m)*ref.cpu.CX[q])//2))
|
||||
for q in range(1,9) for y,x in zip(*np.nonzero(active))
|
||||
if not active[y+ref.cpu.CY[q],x+ref.cpu.CX[q]]
|
||||
and plan.maps.coarse_active[(y+(2-m)*ref.cpu.CY[q])//2,(x+(2-m)*ref.cpu.CX[q])//2]}
|
||||
actual_out = [tuple(map(int,row)) for row in rows]
|
||||
if len(actual_out)!=len(set(actual_out)) or set(actual_out)!=expected_out:
|
||||
raise ValueError('outgoing uniqueness/coverage failed')
|
||||
for q,slot,target in actual_out: packets[(q,target)].append(('outgoing',m,q,slot))
|
||||
expected_receivers = {(q,int(y*n+x)) for y,x in zip(*np.nonzero(plan.maps.coarse_active))
|
||||
for q in range(1,9) if any(active[2*y+dy-ref.cpu.CY[q],2*x+dx-ref.cpu.CX[q]]
|
||||
for dy in (0,1) for dx in (0,1)
|
||||
if 0<=2*y+dy-ref.cpu.CY[q]<nf and 0<=2*x+dx-ref.cpu.CX[q]<nf)}
|
||||
if set(packets)!=expected_receivers: raise ValueError('coarse receiver coverage failed')
|
||||
tokens=[token for row in packets.values() for token in row]
|
||||
if any(len(row)!=4 for row in packets.values()) or len(tokens)!=len(set(tokens)):
|
||||
raise ValueError('receiver needs four distinct quarter-packets')
|
||||
return dict(topology='supported',incoming_events=len(actual),outgoing_events=sum(map(len,plan.outgoing)),
|
||||
transit_events=len(routes),
|
||||
groups=len(plan.groups),receivers=len(packets),receiver_quarter_packets=len(tokens),
|
||||
interpolation_accuracy=plan.interpolation_accuracy,runtime_supported=False,
|
||||
scope='pair-local ownership/coefficients; complete parent domain assumed, nested coverage unvalidated')
|
||||
@@ -192,6 +192,7 @@ class CpuContracts(unittest.TestCase):
|
||||
backend._lock = RLock()
|
||||
backend._status = "ready"
|
||||
backend._step_count = 0
|
||||
backend._ddf_shifting = False # unshifted storage, as for the default config
|
||||
backend._stream = Mock()
|
||||
backend._module = Mock()
|
||||
backend._copy = backend._step = Mock()
|
||||
|
||||
@@ -27,7 +27,7 @@ def state(ny, nx):
|
||||
|
||||
def compile_module(cfg, context, source):
|
||||
cfg.validate()
|
||||
build = compiler.generate_config(cfg)
|
||||
build = compiler.generate_config(cfg, block_root_bc=2 if source == "blocks.cu" else 0)
|
||||
try:
|
||||
module = compiler.load_module(compiler.compile_kernel(context.sm_arch, source=source, build_dir=build))
|
||||
finally:
|
||||
@@ -106,9 +106,10 @@ class ChannelRootGPU(unittest.TestCase):
|
||||
cfg = LBMConfig(nx=48, ny=32, viscosity=.1, velocity=.015, ddf_shifting=shifted)
|
||||
reference = None
|
||||
for block in ((32,48), (8,8), (4,1)):
|
||||
with FixedPatchStepper(cfg, (8,6,40,26), context=context,
|
||||
coarse_boundary="channel", base_block=block, fine_block=(8,8),
|
||||
fine_objects=(body,) if bodies else (), jets=(jet,) if bodies else ()) as backend:
|
||||
with FixedPatchStepper(cfg, context=context, coarse_boundary="channel",
|
||||
refinements=[(8,6,40,26)], level_blocks=[block, (8,8)],
|
||||
level_objects=[(), (body,) if bodies else ()],
|
||||
level_jets=[(), (jet,) if bodies else ()]) as backend:
|
||||
arrays = [state(*level.shape[1:]) for level in backend._levels]
|
||||
backend.initialize(*arrays)
|
||||
backend.step(4)
|
||||
|
||||
@@ -25,8 +25,8 @@ EPS = np.finfo(np.float64).eps
|
||||
|
||||
|
||||
def build(m, window=True, raw=True):
|
||||
return FixedPatchStepper(config("periodic"), BOUNDS, context=m["context"], base_block=(3, 4),
|
||||
fine_block=(16, 20), fine_objects=[circle()],
|
||||
return FixedPatchStepper(config("periodic"), context=m["context"], refinements=[BOUNDS],
|
||||
level_blocks=[(3, 4), (16, 20)], level_objects=[(), (circle(),)],
|
||||
observe_raw_exchange=raw, observe_window_exchange=window)
|
||||
|
||||
|
||||
@@ -109,10 +109,11 @@ class ExchangeWindowGPU(unittest.TestCase):
|
||||
from test_raw_exchange_gpu import BOUNDS as RB, CFG, circle as rc, jet, state
|
||||
from CelerisLab.cuda.context import CudaContext
|
||||
with CudaContext(0) as ctx:
|
||||
kw = dict(context=ctx, fine_block=(16, 20), fine_objects=[rc()], jets=[jet()],
|
||||
kw = dict(context=ctx, refinements=[RB], level_blocks=[None, (16, 20)],
|
||||
level_objects=[(), (rc(),)], level_jets=[(), (jet(),)],
|
||||
observe_raw_exchange=True)
|
||||
with FixedPatchStepper(CFG, RB, observe_window_exchange=True, **kw) as windowed, \
|
||||
FixedPatchStepper(CFG, RB, **kw) as plain:
|
||||
with FixedPatchStepper(CFG, observe_window_exchange=True, **kw) as windowed, \
|
||||
FixedPatchStepper(CFG, **kw) as plain:
|
||||
arrays = [state(level.shape) for level in windowed._levels]
|
||||
windowed.initialize(*arrays)
|
||||
plain.initialize(*arrays)
|
||||
@@ -200,11 +201,11 @@ class ExchangeWindowShiftedMRTGPU(unittest.TestCase):
|
||||
from CelerisLab.cuda.context import CudaContext
|
||||
cfg = LBMConfig(nx=32, ny=24, viscosity=.1, collision="MRT", ddf_shifting=True)
|
||||
with CudaContext(0) as ctx:
|
||||
kw = dict(context=ctx, base_block=(12, 8), fine_block=(16, 20), fine_objects=[rc()],
|
||||
observe_raw_exchange=True)
|
||||
with FixedPatchStepper(cfg, RB, observe_window_exchange=True, **kw) as windowed, \
|
||||
FixedPatchStepper(cfg, RB, observe_window_exchange=True, **kw) as split, \
|
||||
FixedPatchStepper(cfg, RB, **kw) as plain:
|
||||
kw = dict(context=ctx, refinements=[RB], level_blocks=[(12, 8), (16, 20)],
|
||||
level_objects=[(), (rc(),)], observe_raw_exchange=True)
|
||||
with FixedPatchStepper(cfg, observe_window_exchange=True, **kw) as windowed, \
|
||||
FixedPatchStepper(cfg, observe_window_exchange=True, **kw) as split, \
|
||||
FixedPatchStepper(cfg, **kw) as plain:
|
||||
arrays = [state(level.shape) for level in windowed._levels]
|
||||
for run in (windowed, split, plain):
|
||||
run.initialize(*arrays)
|
||||
|
||||
@@ -140,7 +140,8 @@ def run_patch(speed, *, audit=True):
|
||||
build = (patch.object(compiler, "compile_kernel", audit_compiler(compiler.compile_kernel))
|
||||
if audit else nullcontext())
|
||||
with build:
|
||||
backend = implementation.FixedPatchStepper(cfg, BOUNDS, context=context, fine_objects=[source])
|
||||
backend = implementation.FixedPatchStepper(cfg, context=context, refinements=[BOUNDS],
|
||||
level_objects=[(), (source,)])
|
||||
with backend, context.activate():
|
||||
c, f = backend._levels
|
||||
cl = backend._fine_curved
|
||||
@@ -281,7 +282,8 @@ def test_fine_surface_four_hops(speed):
|
||||
@pytest.mark.parametrize("missing", ["surface_velocity_gpu", "link_exchange_gpu"])
|
||||
def test_missing_surface_device_buffer_rejects_before_launch(missing):
|
||||
backend = implementation.FixedPatchStepper.__new__(implementation.FixedPatchStepper)
|
||||
backend.fine_boundary = SimpleNamespace(objects=[object_spec(.002)])
|
||||
backend._boundary_hosts = [None, SimpleNamespace(objects=[object_spec(.002)])]
|
||||
backend._level_jets = [(), ()]
|
||||
backend._fine_curved = SimpleNamespace(fluid_idx_gpu=object(),
|
||||
surface_velocity_gpu=object(), link_exchange_gpu=object())
|
||||
setattr(backend._fine_curved, missing, None)
|
||||
@@ -325,6 +327,7 @@ def test_cross_interface_donor_rejects_before_cuda():
|
||||
context.activate.side_effect = AssertionError("must reject before CUDA activation")
|
||||
with patch.object(cuda, "mem_alloc") as allocate:
|
||||
with pytest.raises(ValueError, match="donor crosses"):
|
||||
implementation.FixedPatchStepper(cfg, BOUNDS, context=context, fine_objects=[source])
|
||||
implementation.FixedPatchStepper(cfg, context=context, refinements=[BOUNDS],
|
||||
level_objects=[(), (source,)])
|
||||
context.activate.assert_not_called()
|
||||
allocate.assert_not_called()
|
||||
|
||||
@@ -48,9 +48,8 @@ def make(context):
|
||||
for (x, y), amplitude in zip(centers, (.003, -.002))
|
||||
)
|
||||
run = FixedPatchStepper(
|
||||
CFG, PARENTS, context=context, base_block=(4, 4), fine_block=(4, 4),
|
||||
child_block=(4, 4), child_rectangles_in_parent=START,
|
||||
child_objects=objects, child_jets=jets,
|
||||
CFG, context=context, refinements=[PARENTS, START], level_blocks=[(4, 4)] * 3,
|
||||
level_objects=[(), (), objects], level_jets=[(), (), jets],
|
||||
)
|
||||
return run, objects
|
||||
|
||||
|
||||
@@ -25,9 +25,8 @@ B = ((16,16,32,40), (60,16,76,40))
|
||||
def make(ctx):
|
||||
body = SimObject(71, geometry=CircleGeometry(12.,14.,.8),center=(12.,14.),radius=.8)
|
||||
jet = Jet(Tube((10.5,14.),0.,3.,.3),Parallel(0.),'parabolic',.003)
|
||||
run = FixedPatchStepper(CFG,PARENTS,context=ctx,base_block=(4,4),fine_block=(4,4),
|
||||
child_block=(4,4),child_rectangles_in_parent=A,
|
||||
child_objects=(body,),child_jets=(jet,))
|
||||
run = FixedPatchStepper(CFG,context=ctx,refinements=[PARENTS,A],level_blocks=[(4,4)]*3,
|
||||
level_objects=[(),(),(body,)],level_jets=[(),(),(jet,)])
|
||||
return run,(body,)
|
||||
|
||||
|
||||
|
||||
@@ -90,14 +90,18 @@ def census(run, index, buffer):
|
||||
return totals(dense, leaf), external, independent
|
||||
|
||||
|
||||
|
||||
def fine(block):
|
||||
"""Two-level circle+jet construction on BOUNDS; block None keeps the dense fine level."""
|
||||
return dict(refinements=[BOUNDS], level_blocks=[None, block],
|
||||
level_objects=[(), (circle(),)], level_jets=[(), (jet(),)])
|
||||
|
||||
class RawExchangeGPU(unittest.TestCase):
|
||||
def test_row_ledger_interval_jet_switch_and_no_population_effect(self):
|
||||
with CudaContext(0) as ctx:
|
||||
for block in ((32, 40), (16, 20), (8, 10)):
|
||||
with FixedPatchStepper(CFG, BOUNDS, context=ctx, fine_block=block,
|
||||
fine_objects=[circle()], jets=[jet()], observe_raw_exchange=True) as measured, \
|
||||
FixedPatchStepper(CFG, BOUNDS, context=ctx, fine_block=block,
|
||||
fine_objects=[circle()], jets=[jet()]) as plain:
|
||||
with FixedPatchStepper(CFG, context=ctx, **fine(block), observe_raw_exchange=True) as measured, \
|
||||
FixedPatchStepper(CFG, context=ctx, **fine(block)) as plain:
|
||||
arrays = [state(l.shape) for l in measured._levels]
|
||||
measured.initialize(*arrays)
|
||||
plain.initialize(*arrays)
|
||||
@@ -146,9 +150,8 @@ class RawExchangeGPU(unittest.TestCase):
|
||||
"""Same fine resolution/initial data with dense storage: a numerical
|
||||
reference for cut-link rows, not a uniform-whole-domain convergence study."""
|
||||
with CudaContext(0) as ctx:
|
||||
with FixedPatchStepper(CFG, BOUNDS, context=ctx, fine_objects=[circle()], jets=[jet()]) as dense, \
|
||||
FixedPatchStepper(CFG, BOUNDS, context=ctx, fine_block=(16, 20),
|
||||
fine_objects=[circle()], jets=[jet()], observe_raw_exchange=True) as blocked:
|
||||
with FixedPatchStepper(CFG, context=ctx, **fine(None)) as dense, \
|
||||
FixedPatchStepper(CFG, context=ctx, **fine((16, 20)), observe_raw_exchange=True) as blocked:
|
||||
arrays = [state(l.shape) for l in blocked._levels]
|
||||
dense.initialize(*arrays)
|
||||
blocked.initialize(*arrays)
|
||||
@@ -229,8 +232,7 @@ class RawExchangeGPU(unittest.TestCase):
|
||||
layout = BlockLayout2D.build(48, 64, 8, 8)
|
||||
with CudaContext(0) as ctx:
|
||||
with PeriodicBlockStepper(fine_cfg, layout, context=ctx, objects=[obj]) as uniform, \
|
||||
FixedPatchStepper(CFG, BOUNDS, context=ctx, fine_block=(16, 20),
|
||||
fine_objects=[circle()], jets=[jet()], observe_raw_exchange=True) as amr:
|
||||
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._level_boundaries[1]
|
||||
|
||||
@@ -83,11 +83,10 @@ def mocked_backend(mode="periodic", fail_alloc=None):
|
||||
class BlockedBaseCPU(unittest.TestCase):
|
||||
def test_reject_before_cuda(self):
|
||||
context = Mock(activate=Mock(side_effect=AssertionError("CUDA reached")))
|
||||
for kwargs in ({"base_block": (12, 8)},
|
||||
{"base_block": (7, 8), "fine_block": FINE_BLOCK},
|
||||
{"base_block": (12,), "fine_block": FINE_BLOCK}):
|
||||
with self.subTest(kwargs=kwargs), self.assertRaises(ValueError):
|
||||
FixedPatchStepper(config("periodic"), BOUNDS, context=context, **kwargs)
|
||||
for blocks in ([(12, 8), None], [(7, 8), FINE_BLOCK], [(12,), FINE_BLOCK]):
|
||||
with self.subTest(blocks=blocks), self.assertRaises(ValueError):
|
||||
FixedPatchStepper(config("periodic"), context=context, refinements=[BOUNDS],
|
||||
level_blocks=blocks)
|
||||
context.activate.assert_not_called()
|
||||
|
||||
def test_all_arrival_rows_donors_and_kind_masks(self):
|
||||
@@ -193,9 +192,10 @@ class BlockedBaseCPU(unittest.TestCase):
|
||||
|
||||
def test_launch_order_stream_wall_constants_and_buffer_swaps(self):
|
||||
with mocked_backend("slot_reservoir") as m:
|
||||
backend = FixedPatchStepper(config("slot_reservoir"), BOUNDS, context=m["context"],
|
||||
coarse_boundary="slot_reservoir", fine_block=FINE_BLOCK,
|
||||
base_block=(3, 4), fine_objects=[circle(actuation(.002))])
|
||||
backend = FixedPatchStepper(config("slot_reservoir"), context=m["context"],
|
||||
coarse_boundary="slot_reservoir", refinements=[BOUNDS],
|
||||
level_blocks=[(3, 4), FINE_BLOCK],
|
||||
level_objects=[(), (circle(actuation(.002)),)])
|
||||
try:
|
||||
backend.initialize(state(NY, NX, 1), state(*backend.patch.fine_shape, 2))
|
||||
coarse, fine = backend._levels
|
||||
@@ -223,6 +223,8 @@ class BlockedBaseCPU(unittest.TestCase):
|
||||
self.assertIs(collide[4], coarse.kind)
|
||||
walls = [a for ptr, a in m["uploads"] if isinstance(ptr, str) and ptr == "d_block_wall"]
|
||||
self.assertEqual(len(walls), 1)
|
||||
self.assertEqual([call.kwargs["block_root_bc"] for call in
|
||||
compiler.generate_config.call_args_list], [1, 0])
|
||||
width, height, slot, speed = struct.unpack("=IIIf", walls[0])
|
||||
self.assertEqual((width, height, slot), (NX, NY, 8))
|
||||
self.assertAlmostEqual(speed, .001)
|
||||
@@ -236,16 +238,16 @@ class BlockedBaseCPU(unittest.TestCase):
|
||||
for fail_at in (1, 6, 10):
|
||||
with self.subTest(fail_at=fail_at), mocked_backend(fail_alloc=fail_at) as m:
|
||||
with self.assertRaisesRegex(MemoryError, "injected allocation"):
|
||||
FixedPatchStepper(config("periodic"), BOUNDS, context=m["context"],
|
||||
fine_block=FINE_BLOCK, base_block=(3, 4))
|
||||
FixedPatchStepper(config("periodic"), context=m["context"], refinements=[BOUNDS],
|
||||
level_blocks=[(3, 4), FINE_BLOCK])
|
||||
self.assertEqual(len(m["allocations"]), fail_at)
|
||||
for allocation in m["allocations"]:
|
||||
allocation.free.assert_called_once_with()
|
||||
|
||||
def test_failed_parent_stage_cannot_publish_or_resume(self):
|
||||
with mocked_backend() as m:
|
||||
backend = FixedPatchStepper(config("periodic"), BOUNDS, context=m["context"],
|
||||
fine_block=FINE_BLOCK, base_block=(3, 4))
|
||||
backend = FixedPatchStepper(config("periodic"), context=m["context"], refinements=[BOUNDS],
|
||||
level_blocks=[(3, 4), FINE_BLOCK])
|
||||
backend.initialize(state(NY, NX, 1), state(*backend.patch.fine_shape, 2))
|
||||
m["functions"]["BlockCoalesce"].side_effect = RuntimeError("injected parent stage failure")
|
||||
current = backend._levels[0].current
|
||||
@@ -289,9 +291,10 @@ class BlockedBaseGPU(unittest.TestCase):
|
||||
for base_block in (None, *layouts):
|
||||
label = f"{mode} width={width} speed={speed} base={base_block}"
|
||||
snapshots = []
|
||||
with FixedPatchStepper(cfg, BOUNDS, context=context, coarse_boundary=mode,
|
||||
fine_block=FINE_BLOCK, base_block=base_block,
|
||||
fine_objects=() if speed is None else [circle(actuation(speed))]) as backend:
|
||||
with FixedPatchStepper(cfg, context=context, coarse_boundary=mode,
|
||||
refinements=[BOUNDS], level_blocks=[base_block, FINE_BLOCK],
|
||||
level_objects=[(), () if speed is None
|
||||
else (circle(actuation(speed)),)]) as backend:
|
||||
backend.initialize(*arrays)
|
||||
for step in (0, 1, 2, 8):
|
||||
if step == 1 and base_block is not None:
|
||||
|
||||
@@ -105,9 +105,10 @@ class BlockedFineCPU(unittest.TestCase):
|
||||
return Mock(activate=Mock(side_effect=AssertionError("CUDA reached")), sm_arch="sm_70")
|
||||
|
||||
def test_rejections_before_cuda(self):
|
||||
for kw in ({"fine_block": (7, 7)}, {"fine_block": (16,)}):
|
||||
for kw in ({"level_blocks": [None, (7, 7)]}, {"level_blocks": [None, (16,)]}):
|
||||
with self.subTest(kw=kw), self.assertRaises(ValueError):
|
||||
FixedPatchStepper(config("periodic"), BOUNDS, context=self.forbidden_context(), **kw)
|
||||
FixedPatchStepper(config("periodic"), context=self.forbidden_context(),
|
||||
refinements=[BOUNDS], **kw)
|
||||
|
||||
def test_single_block_layout_allocates_no_empty_halo_tables(self):
|
||||
# A 1x1 non-periodic fine layout has zero halo pairs; CUDA rejects mem_alloc(0).
|
||||
@@ -125,8 +126,8 @@ class BlockedFineCPU(unittest.TestCase):
|
||||
patch.object(compiler, "compile_kernel"), patch.object(compiler, "cleanup_build_dir"), \
|
||||
patch.object(compiler, "load_module", return_value=module), \
|
||||
patch.object(cuda, "mem_alloc", side_effect=alloc), patch.object(cuda, "memcpy_htod"):
|
||||
backend = FixedPatchStepper(config("periodic"), BOUNDS, context=context,
|
||||
fine_block=FINE_BLOCKS[0])
|
||||
backend = FixedPatchStepper(config("periodic"), context=context, refinements=[BOUNDS],
|
||||
level_blocks=[None, FINE_BLOCKS[0]])
|
||||
self.assertEqual(len(backend.fine_layout.halo_dst), 0)
|
||||
self.assertIsNone(backend._levels[1].halo_dst)
|
||||
backend._gather_halo(backend._levels[1], Mock()) # no launch without pairs
|
||||
@@ -177,8 +178,9 @@ class BlockedFineCPU(unittest.TestCase):
|
||||
patch.object(cuda, "mem_alloc", side_effect=lambda size: Mock()), \
|
||||
patch.object(cuda, "memcpy_htod"), \
|
||||
patch.object(curved_links, "CurvedLinkSoA", side_effect=AssertionError("dense SoA built")):
|
||||
backend = FixedPatchStepper(config("periodic"), BOUNDS, context=context,
|
||||
fine_objects=objects, fine_block=FINE_BLOCKS[1])
|
||||
backend = FixedPatchStepper(config("periodic"), context=context, refinements=[BOUNDS],
|
||||
level_blocks=[None, FINE_BLOCKS[1]],
|
||||
level_objects=[(), tuple(objects)])
|
||||
backend.initialize(state(NY, NX, 1), state(*backend.patch.fine_shape, 2))
|
||||
return backend, calls
|
||||
|
||||
@@ -289,8 +291,9 @@ class BlockedFineGPU(unittest.TestCase):
|
||||
results = []
|
||||
with CudaContext(0) as context:
|
||||
for fine_block in (None, *FINE_BLOCKS):
|
||||
with FixedPatchStepper(cfg, BOUNDS, context=context, coarse_boundary=mode,
|
||||
fine_block=fine_block) as backend:
|
||||
with FixedPatchStepper(cfg, context=context, coarse_boundary=mode,
|
||||
refinements=[BOUNDS],
|
||||
level_blocks=[None, fine_block]) as backend:
|
||||
backend.initialize(g_coarse, g_fine)
|
||||
backend.step(STEPS)
|
||||
self.assertEqual(backend.step_count, STEPS)
|
||||
@@ -318,8 +321,9 @@ class BlockedFineGPU(unittest.TestCase):
|
||||
results = []
|
||||
with CudaContext(0) as context:
|
||||
for fine_block in (None, *FINE_BLOCKS):
|
||||
with FixedPatchStepper(cfg, BOUNDS, context=context, coarse_boundary=mode,
|
||||
fine_objects=[circle()], fine_block=fine_block) as backend:
|
||||
with FixedPatchStepper(cfg, context=context, coarse_boundary=mode,
|
||||
refinements=[BOUNDS], level_blocks=[None, fine_block],
|
||||
level_objects=[(), (circle(),)]) as backend:
|
||||
backend.initialize(g_coarse, g_fine)
|
||||
backend.step(STEPS)
|
||||
results.append((backend.download(), raw(backend)))
|
||||
@@ -346,9 +350,9 @@ class BlockedFineGPU(unittest.TestCase):
|
||||
results = []
|
||||
with CudaContext(0) as context:
|
||||
for fine_block in (None, *FINE_BLOCKS):
|
||||
with FixedPatchStepper(cfg, BOUNDS, context=context, coarse_boundary=mode,
|
||||
fine_objects=[circle(actuation(speed))],
|
||||
fine_block=fine_block) as backend:
|
||||
with FixedPatchStepper(cfg, context=context, coarse_boundary=mode,
|
||||
refinements=[BOUNDS], level_blocks=[None, fine_block],
|
||||
level_objects=[(), (circle(actuation(speed)),)]) as backend:
|
||||
backend.initialize(g_coarse, g_fine)
|
||||
backend.step(STEPS)
|
||||
results.append((backend.download(), raw(backend)))
|
||||
|
||||
@@ -94,7 +94,8 @@ class FaultGuardsCPU(unittest.TestCase):
|
||||
("slot_reservoir", (12, 4, 20, 21))):
|
||||
with self.subTest(mode=mode, bounds=bounds):
|
||||
with self.assertRaises(ValueError):
|
||||
implementation.FixedPatchStepper(cfg, bounds, context=context, coarse_boundary=mode)
|
||||
implementation.FixedPatchStepper(cfg, context=context, coarse_boundary=mode,
|
||||
refinements=[bounds])
|
||||
context.activate.assert_not_called()
|
||||
# Both exact channel limits and the original periodic two-cell margin
|
||||
# must reach context activation, with no allocation or device required.
|
||||
@@ -102,11 +103,14 @@ class FaultGuardsCPU(unittest.TestCase):
|
||||
("periodic", (2, 2, 6, 5))):
|
||||
with self.subTest(mode=mode, bounds=bounds):
|
||||
with self.assertRaisesRegex(RuntimeError, "validated before CUDA"):
|
||||
implementation.FixedPatchStepper(cfg, bounds, context=context, coarse_boundary=mode)
|
||||
implementation.FixedPatchStepper(cfg, context=context, coarse_boundary=mode,
|
||||
refinements=[bounds])
|
||||
|
||||
def fake_backend(self):
|
||||
backend = PeriodicPatchStepper.__new__(PeriodicPatchStepper)
|
||||
backend.fine_layout = None # dense fine storage
|
||||
backend._layouts = [None, None] # dense storage on both levels
|
||||
backend._ddf_shifting = False # unshifted on every level, as _check_storage expects
|
||||
backend._level_cfgs = [LBMConfig(nx=16, ny=16, viscosity=.1)] * 2 # ddf_shifting off
|
||||
backend.ctx = Mock(activate=nullcontext)
|
||||
backend._lock = RLock()
|
||||
backend._status = "ready"
|
||||
@@ -154,12 +158,23 @@ class FaultGuardsCPU(unittest.TestCase):
|
||||
context = Mock(activate=nullcontext, sm_arch="sm_70")
|
||||
with patch.object(implementation, "HALO", 2):
|
||||
with self.assertRaisesRegex(RuntimeError, "four-cell"):
|
||||
PeriodicPatchStepper(cfg, (4, 4, 12, 12), context=context)
|
||||
PeriodicPatchStepper(cfg, context=context, refinements=[(4, 4, 12, 12)])
|
||||
p = implementation.FixedPatch2D.from_config(cfg, (4, 4, 12, 12))
|
||||
with patch.object(cuda, "Stream"), patch.object(implementation.FixedPatch2D, "from_config",
|
||||
return_value=replace(p, tau_coarse=.9)):
|
||||
with self.assertRaisesRegex(RuntimeError, "omega/tau"):
|
||||
PeriodicPatchStepper(cfg, p.bounds, context=context)
|
||||
from CelerisLab.lbm import hierarchy
|
||||
bad = patch.object(implementation.FixedPatch2D, "from_config",
|
||||
return_value=replace(p, tau_coarse=.9))
|
||||
# A pair tau inconsistent with its level config is rejected twice over, both before
|
||||
# any device allocation or upload: first by the hierarchy chain check, and, if that
|
||||
# is bypassed, by the per-level omega/tau check in _build_level.
|
||||
with patch.object(cuda, "Stream"), bad, patch.object(cuda, "mem_alloc") as alloc, \
|
||||
patch.object(cuda, "memcpy_htod") as upload:
|
||||
with self.assertRaisesRegex(RuntimeError, "config chain is inconsistent"):
|
||||
PeriodicPatchStepper(cfg, context=context, refinements=[p.bounds])
|
||||
with patch.object(hierarchy, "_check_chain"), \
|
||||
self.assertRaisesRegex(RuntimeError, "omega/tau"):
|
||||
PeriodicPatchStepper(cfg, context=context, refinements=[p.bounds])
|
||||
alloc.assert_not_called()
|
||||
upload.assert_not_called()
|
||||
module = Mock()
|
||||
module.get_global.return_value = (Mock(), 16)
|
||||
with patch.object(cuda, "Stream"), patch.object(compiler, "generate_config"), \
|
||||
@@ -167,7 +182,7 @@ class FaultGuardsCPU(unittest.TestCase):
|
||||
patch.object(compiler, "load_module", return_value=module), \
|
||||
patch.object(cuda, "memcpy_htod") as upload:
|
||||
with self.assertRaisesRegex(RuntimeError, "layout mismatch"):
|
||||
PeriodicPatchStepper(cfg, p.bounds, context=context)
|
||||
PeriodicPatchStepper(cfg, context=context, refinements=[p.bounds])
|
||||
upload.assert_not_called()
|
||||
|
||||
|
||||
@@ -241,7 +256,7 @@ class NormalBuildGPU(unittest.TestCase):
|
||||
max_error = max_bc_error = 0.0
|
||||
with CudaContext(0) as context:
|
||||
with patch.object(compiler, "generate_config", side_effect=record_config):
|
||||
backend = implementation.FixedPatchStepper(cfg, bounds, context=context,
|
||||
backend = implementation.FixedPatchStepper(cfg, context=context, refinements=[bounds],
|
||||
coarse_boundary="slot_reservoir")
|
||||
with backend:
|
||||
self.assertEqual(len(generated), 2)
|
||||
@@ -311,7 +326,8 @@ class NormalBuildGPU(unittest.TestCase):
|
||||
for a in (initial.coarse_f, initial.fine_f)))
|
||||
max_pop = max_drift = max_moment = 0.0
|
||||
with CudaContext(0) as context, PeriodicPatchStepper(
|
||||
LBMConfig(nx=16, ny=16, viscosity=.1), (4, 4, 12, 12), context=context) as backend:
|
||||
LBMConfig(nx=16, ny=16, viscosity=.1), context=context,
|
||||
refinements=[(4, 4, 12, 12)]) as backend:
|
||||
backend.initialize(*postcollision(state, spec, maps))
|
||||
before = totals(backend.download(), maps)
|
||||
for step in range(1, 9):
|
||||
@@ -342,7 +358,7 @@ class NormalBuildGPU(unittest.TestCase):
|
||||
fine_mask[4:-4, 4:-4] = True
|
||||
initial[1][:, ~fine_mask] = np.nan
|
||||
expected = scalar_interval(*[a.astype(np.float64) for a in initial], bounds)
|
||||
with CudaContext(0) as context, PeriodicPatchStepper(cfg, bounds, context=context) as backend:
|
||||
with CudaContext(0) as context, PeriodicPatchStepper(cfg, context=context, refinements=[bounds]) as backend:
|
||||
self.assertEqual(backend.patch.fine_shape, (14, 16))
|
||||
p = backend.patch
|
||||
allocated = 2 * 9 * (17*13+16*14) * 4 + sum(a.nbytes for a in
|
||||
|
||||
@@ -84,13 +84,13 @@ class CoupledGPU(unittest.TestCase):
|
||||
|
||||
cases = (("dense_unshifted", False, {}),
|
||||
("blocked_shifted", True,
|
||||
dict(base_block=(4, 4), fine_block=(6, 6))))
|
||||
dict(level_blocks=[(4, 4), (6, 6)])))
|
||||
with CudaContext(0) as context:
|
||||
for label, shifted, storage in cases:
|
||||
with self.subTest(storage=label):
|
||||
cfg = LBMConfig(nx=16, ny=16, viscosity=.0192,
|
||||
ddf_shifting=shifted)
|
||||
with FixedPatchStepper(cfg, (4, 4, 12, 12), context=context,
|
||||
with FixedPatchStepper(cfg, context=context, refinements=[(4, 4, 12, 12)],
|
||||
**storage) as backend:
|
||||
self.assertAlmostEqual(backend.patch.tau_coarse, spec.tau_coarse)
|
||||
self.assertAlmostEqual(backend.patch.tau_fine, spec.tau_fine)
|
||||
@@ -148,7 +148,7 @@ class CoupledGPU(unittest.TestCase):
|
||||
maps = ref.build_maps(spec)
|
||||
with CudaContext(0) as context:
|
||||
with PeriodicPatchStepper(LBMConfig(nx=16, ny=16, viscosity=.1),
|
||||
(4, 4, 12, 12), context=context) as backend:
|
||||
context=context, refinements=[(4, 4, 12, 12)]) as backend:
|
||||
backend.initialize(*postcollision(ref.make_state(spec), spec, maps))
|
||||
coarse, fine = backend._levels
|
||||
|
||||
@@ -187,7 +187,7 @@ class CoupledGPU(unittest.TestCase):
|
||||
|
||||
with CudaContext(0) as context:
|
||||
with patch.object(compiler, "compile_kernel", audit_compile):
|
||||
backend = PeriodicPatchStepper(cfg, (4, 4, 12, 12), context=context)
|
||||
backend = PeriodicPatchStepper(cfg, context=context, refinements=[(4, 4, 12, 12)])
|
||||
with backend:
|
||||
# Production allocations: four DDF arrays and four compact tables.
|
||||
device_bytes = 2 * 9 * (16**2 + 24**2) * 4
|
||||
@@ -270,7 +270,7 @@ class CoupledGPU(unittest.TestCase):
|
||||
backend.download()
|
||||
|
||||
# Compile and execute the actual non-audited entry as well: one step.
|
||||
with PeriodicPatchStepper(cfg, (4, 4, 12, 12), context=context) as plain:
|
||||
with PeriodicPatchStepper(cfg, context=context, refinements=[(4, 4, 12, 12)]) as plain:
|
||||
rest = postcollision(ref.make_state(spec), spec, maps)
|
||||
plain.initialize(*rest)
|
||||
plain.step()
|
||||
|
||||
@@ -41,8 +41,9 @@ def mocked(objects, jets, fine_block):
|
||||
patch.object(compiler, "load_module", return_value=module), \
|
||||
patch.object(cuda, "mem_alloc", side_effect=lambda size: Mock()), \
|
||||
patch.object(cuda, "memcpy_htod", htod):
|
||||
backend = FixedPatchStepper(config("periodic"), BOUNDS, context=context,
|
||||
fine_objects=objects, fine_block=fine_block, jets=jets)
|
||||
backend = FixedPatchStepper(config("periodic"), context=context, refinements=[BOUNDS],
|
||||
level_blocks=[None, fine_block], level_objects=[(), tuple(objects)],
|
||||
level_jets=[(), tuple(jets)])
|
||||
return backend, htod
|
||||
|
||||
|
||||
@@ -149,8 +150,9 @@ class JetStepperCPU(unittest.TestCase):
|
||||
def test_jet_overlapping_legacy_aperture_rejected_before_cuda(self):
|
||||
with patch.object(cuda, "Stream", side_effect=AssertionError("CUDA touched")):
|
||||
with self.assertRaisesRegex(ValueError, "SurfaceActuation"):
|
||||
FixedPatchStepper(config("periodic"), BOUNDS, context=Mock(),
|
||||
fine_objects=[circle(actuation(SPEED))], jets=[jet()])
|
||||
FixedPatchStepper(config("periodic"), context=Mock(), refinements=[BOUNDS],
|
||||
level_objects=[(), (circle(actuation(SPEED)),)],
|
||||
level_jets=[(), (jet(),)])
|
||||
|
||||
|
||||
def run(mode, fine_block, *, objects, jets, switch=None):
|
||||
@@ -158,8 +160,9 @@ def run(mode, fine_block, *, objects, jets, switch=None):
|
||||
cfg = config(mode)
|
||||
patch_ = FixedPatch2D.from_config(cfg, BOUNDS)
|
||||
with CudaContext(0) as context, FixedPatchStepper(
|
||||
cfg, BOUNDS, context=context, coarse_boundary=mode, fine_objects=objects,
|
||||
fine_block=fine_block, jets=jets) as backend:
|
||||
cfg, context=context, coarse_boundary=mode, refinements=[BOUNDS],
|
||||
level_blocks=[None, fine_block], level_objects=[(), tuple(objects)],
|
||||
level_jets=[(), tuple(jets)]) as backend:
|
||||
backend.initialize(state(NY, NX, 1), state(*patch_.fine_shape, 2))
|
||||
if switch is None:
|
||||
backend.step(STEPS)
|
||||
|
||||
@@ -42,8 +42,9 @@ class RefinementMRTGPU(unittest.TestCase):
|
||||
collision="MRT", ddf_shifting=shifted)
|
||||
reference = None
|
||||
for block in ((32, 48), (8, 8), (4, 1)):
|
||||
with FixedPatchStepper(cfg, (8, 6, 40, 26), context=context, coarse_boundary=boundary,
|
||||
base_block=block, fine_block=(8, 8), fine_objects=(body,)) as backend:
|
||||
with FixedPatchStepper(cfg, context=context, coarse_boundary=boundary,
|
||||
refinements=[(8, 6, 40, 26)], level_blocks=[block, (8, 8)],
|
||||
level_objects=[(), (body,)]) as backend:
|
||||
backend.initialize(*[state(*level.shape[1:]) for level in backend._levels])
|
||||
backend.step(8)
|
||||
raw = backend._download_raw()
|
||||
@@ -63,8 +64,8 @@ class RefinementMRTGPU(unittest.TestCase):
|
||||
results = {}
|
||||
layouts = ((None, None), *((base, FINE_BLOCK) for base in (None, *BASE_BLOCKS)))
|
||||
for base_block, fine_block in layouts:
|
||||
with FixedPatchStepper(cfg, BOUNDS, context=context, fine_block=fine_block,
|
||||
base_block=base_block) as backend:
|
||||
with FixedPatchStepper(cfg, context=context, refinements=[BOUNDS],
|
||||
level_blocks=[base_block, fine_block]) as backend:
|
||||
backend.initialize(*[fixture.state(*level.shape[1:], seed) for seed, level in enumerate(backend._levels)])
|
||||
backend.step(4)
|
||||
results[base_block, fine_block] = raw(backend)
|
||||
@@ -75,9 +76,9 @@ class RefinementMRTGPU(unittest.TestCase):
|
||||
def test_shifted_all_blocked_three_level_mrt_smoke(self):
|
||||
"""Per-level shear rate omega_l = 1/(3*2**l*nu + 1/2); finite ACTIVE leaves."""
|
||||
cfg = LBMConfig(nx=16, ny=16, viscosity=.1, collision="MRT", ddf_shifting=True)
|
||||
kw = dict(fine_block=(4, 4), base_block=(4, 4), child=(8, 8, 16, 16), child_block=(4, 4))
|
||||
kw = dict(refinements=[(4, 4, 12, 12), (8, 8, 16, 16)], level_blocks=[(4, 4)] * 3)
|
||||
with CudaContext(0) as context:
|
||||
with FixedPatchStepper(cfg, (4, 4, 12, 12), context=context, **kw) as backend:
|
||||
with FixedPatchStepper(cfg, context=context, **kw) as backend:
|
||||
self.assertEqual(len(backend._level_cfgs), 3)
|
||||
for level, level_cfg in enumerate(backend._level_cfgs):
|
||||
checked = replace(level_cfg)
|
||||
|
||||
@@ -26,6 +26,12 @@ FACE = ((4, 4, 8, 12), (12, 4, 16, 12))
|
||||
S1 = ((4, 4, 12, 12), (20, 4, 28, 12))
|
||||
|
||||
|
||||
def levels(bounds, blocks=(None, None), objects=(), jets=(), **rest):
|
||||
"""Two-level constructor keywords: one L1 rectangle or set, per-level blocks, L1 bodies/Jets."""
|
||||
return dict(refinements=[bounds], level_blocks=list(blocks),
|
||||
level_objects=[(), tuple(objects)], level_jets=[(), tuple(jets)], **rest)
|
||||
|
||||
|
||||
def level_fine(patch_set, windows):
|
||||
"""Level-wide fine array with each patch window filled from `windows`."""
|
||||
out = np.full((9, *patch_set.fine_shape), np.nan, np.float32)
|
||||
@@ -45,21 +51,21 @@ class MultipatchCPU(unittest.TestCase):
|
||||
def test_rejections_before_cuda(self):
|
||||
cfg = config("periodic")
|
||||
context = object() # never activated: every case must fail first
|
||||
for kw in (dict(), dict(fine_block=(8, 4)), dict(base_block=(4, 4)),
|
||||
dict(fine_block=(8, 4), base_block=(4, 4), fine_objects=[circle()]),
|
||||
dict(fine_block=(5, 4), base_block=(4, 4))):
|
||||
for kw in (dict(), dict(blocks=(None, (8, 4))), dict(blocks=((4, 4), None)),
|
||||
dict(blocks=((4, 4), (8, 4)), objects=[circle()]),
|
||||
dict(blocks=((4, 4), (5, 4)))):
|
||||
with self.subTest(kw=kw), self.assertRaises(ValueError):
|
||||
FixedPatchStepper(cfg, ((4, 4, 8, 12), (14, 4, 18, 12)), context=context, **kw)
|
||||
FixedPatchStepper(cfg, context=context, **levels(((4, 4, 8, 12), (14, 4, 18, 12)), **kw))
|
||||
with self.assertRaisesRegex(ValueError, "margin"):
|
||||
FixedPatchStepper(cfg, ((1, 4, 8, 12), (10, 4, 14, 12)), context=context,
|
||||
fine_block=(8, 4), base_block=(4, 4))
|
||||
FixedPatchStepper(cfg, context=context,
|
||||
**levels(((1, 4, 8, 12), (10, 4, 14, 12)), blocks=((4, 4), (8, 4))))
|
||||
from CelerisLab.body.jets import Jet, Parallel, Tube
|
||||
cfg32 = LBMConfig(nx=32, ny=16, viscosity=.1)
|
||||
span = Jet(Tube((5., 8.), 0., 22., .6), Parallel(0.), amplitude=0.)
|
||||
with self.assertRaisesRegex(ValueError, "one jet"): # same obj_id cannot bypass it
|
||||
FixedPatchStepper(cfg32, ((2, 2, 14, 14), (18, 2, 30, 14)), context=context,
|
||||
fine_block=(4, 4), base_block=(4, 4),
|
||||
fine_objects=[body(8., 8.), body(24., 8.)], jets=[span])
|
||||
FixedPatchStepper(cfg32, context=context,
|
||||
**levels(((2, 2, 14, 14), (18, 2, 30, 14)), blocks=((4, 4), (4, 4)),
|
||||
objects=[body(8., 8.), body(24., 8.)], jets=[span]))
|
||||
|
||||
|
||||
class MultipatchGPU(unittest.TestCase):
|
||||
@@ -67,7 +73,7 @@ class MultipatchGPU(unittest.TestCase):
|
||||
|
||||
def run_steps(self, context, cfg, bounds, coarse, fine, **kw):
|
||||
out = []
|
||||
with FixedPatchStepper(cfg, bounds, context=context, **kw) as backend:
|
||||
with FixedPatchStepper(cfg, context=context, **levels(bounds, **kw)) as backend:
|
||||
backend.initialize(coarse, fine)
|
||||
for step in self.STEPS:
|
||||
backend.step(step - backend.step_count)
|
||||
@@ -82,7 +88,7 @@ class MultipatchGPU(unittest.TestCase):
|
||||
p = FixedPatch2D.from_config(cfg, BOUNDS)
|
||||
s = FixedPatchSet2D.from_config(cfg, (BOUNDS,))
|
||||
coarse, fine = state(NY, NX, 1), state(*p.fine_shape, 2)
|
||||
kw = dict(coarse_boundary=mode, fine_block=(8, 4), base_block=(3, 4))
|
||||
kw = dict(coarse_boundary=mode, blocks=((3, 4), (8, 4)))
|
||||
old = self.run_steps(context, cfg, BOUNDS, coarse, fine, **kw)
|
||||
new = self.run_steps(context, cfg, (BOUNDS,), coarse, level_fine(s, [fine]), **kw)
|
||||
for step, a, b in zip(self.STEPS, old, new):
|
||||
@@ -99,7 +105,7 @@ class MultipatchGPU(unittest.TestCase):
|
||||
one = FixedPatchSet2D.from_config(cfg16, (S1[0],))
|
||||
two = FixedPatchSet2D.from_config(cfg32, S1)
|
||||
coarse, fine = state(16, 16, 3), state(*one.patches[0].fine_shape, 4)
|
||||
kw = dict(fine_block=(4, 4), base_block=(4, 4))
|
||||
kw = dict(blocks=((4, 4), (4, 4)))
|
||||
with CudaContext(0) as context:
|
||||
ref = self.run_steps(context, cfg16, (S1[0],), coarse, level_fine(one, [fine]), **kw)
|
||||
tiled = np.concatenate([coarse, coarse], axis=2)
|
||||
@@ -118,7 +124,7 @@ class MultipatchGPU(unittest.TestCase):
|
||||
s = FixedPatchSet2D.from_config(cfg, FACE)
|
||||
coarse = state(16, 24, 5)
|
||||
fine = level_fine(s, [state(*p.fine_shape, 6 + i) for i, p in enumerate(s.patches)])
|
||||
kw = dict(fine_block=(4, 4), base_block=(4, 4))
|
||||
kw = dict(blocks=((4, 4), (4, 4)))
|
||||
leaf = s.fine_kind == ACTIVE
|
||||
support = s.fine_layout((4, 4)).owned
|
||||
outer = support & (s.fine_kind == OUTER_GHOST)
|
||||
@@ -164,7 +170,7 @@ class MultipatchBodiesGPU(unittest.TestCase):
|
||||
|
||||
def run_steps(self, context, cfg, bounds, coarse, fine, switch=None, **kw):
|
||||
out = []
|
||||
with FixedPatchStepper(cfg, bounds, context=context, **kw) as backend:
|
||||
with FixedPatchStepper(cfg, context=context, **levels(bounds, **kw)) as backend:
|
||||
backend.initialize(coarse, fine)
|
||||
for step in self.STEPS:
|
||||
if switch is not None and step == 8:
|
||||
@@ -182,8 +188,7 @@ class MultipatchBodiesGPU(unittest.TestCase):
|
||||
p = FixedPatch2D.from_config(cfg, BOUNDS)
|
||||
s = FixedPatchSet2D.from_config(cfg, (BOUNDS,))
|
||||
coarse, fine = state(NY, NX, 1), state(*p.fine_shape, 2)
|
||||
kw = dict(coarse_boundary=mode, fine_block=(8, 4), base_block=(3, 4),
|
||||
fine_objects=[circle()])
|
||||
kw = dict(coarse_boundary=mode, blocks=((3, 4), (8, 4)), objects=[circle()])
|
||||
old = self.run_steps(context, cfg, BOUNDS, coarse, fine, **kw)
|
||||
new = self.run_steps(context, cfg, (BOUNDS,), coarse, level_fine(s, [fine]), **kw)
|
||||
for step, a, b in zip(self.STEPS, old, new):
|
||||
@@ -202,17 +207,17 @@ class MultipatchBodiesGPU(unittest.TestCase):
|
||||
two = FixedPatchSet2D.from_config(cfg32, self.PATCHES)
|
||||
coarse, fine = state(16, 16, 3), state(*one.patches[0].fine_shape, 4)
|
||||
jet = lambda x: Jet(Tube((x, 8.), 0., 6., .6), Parallel(0.), amplitude=.002)
|
||||
kw = dict(fine_block=(4, 4), base_block=(4, 4))
|
||||
kw = dict(blocks=((4, 4), (4, 4)))
|
||||
with CudaContext(0) as context:
|
||||
for jets in ((), (jet(5.),)):
|
||||
ref = self.run_steps(context, cfg16, self.PATCHES[:1], coarse, level_fine(one, [fine]),
|
||||
switch=[(0, -.001)] if jets else None,
|
||||
fine_objects=[body(8., 8.)], jets=jets, **kw)
|
||||
objects=[body(8., 8.)], jets=jets, **kw)
|
||||
both = jets and (jet(5.), jet(21.))
|
||||
got = self.run_steps(context, cfg32, self.PATCHES, np.concatenate([coarse, coarse], 2),
|
||||
level_fine(two, [fine, fine]),
|
||||
switch=[(0, -.001), (1, -.001)] if jets else None,
|
||||
fine_objects=[body(8., 8., 5), body(24., 8., 5)],
|
||||
objects=[body(8., 8., 5), body(24., 8., 5)],
|
||||
jets=both or (), **kw)
|
||||
for step, a, b in zip(self.STEPS, ref, got):
|
||||
label = f"jets={len(jets)} step {step}"
|
||||
|
||||
@@ -27,7 +27,16 @@ from test_refinement_blocked_base import raw
|
||||
|
||||
CFG = LBMConfig(nx=16, ny=16, viscosity=.1)
|
||||
L1, L2 = (4, 4, 12, 12), (8, 8, 16, 16)
|
||||
KW = dict(fine_block=(4, 4), base_block=(4, 4), child=L2, child_block=(4, 4))
|
||||
B = (4, 4)
|
||||
|
||||
|
||||
def levels(l1=L1, l2=L2, blocks=(B, B, B), objects=((), (), ()), jets=((), (), ())):
|
||||
"""Level-list keywords for the scalar L1 patch with one scalar L2 child."""
|
||||
return dict(refinements=[l1, l2], level_blocks=list(blocks),
|
||||
level_objects=list(objects), level_jets=list(jets))
|
||||
|
||||
|
||||
KW = levels()
|
||||
|
||||
|
||||
def child_body(spec=None):
|
||||
@@ -52,26 +61,29 @@ def mocked_child():
|
||||
patch.object(compiler, "load_module", return_value=module), \
|
||||
patch.object(cuda, "mem_alloc", side_effect=lambda size: Mock()), \
|
||||
patch.object(cuda, "memcpy_htod"):
|
||||
return FixedPatchStepper(CFG, L1, context=context, child_objects=[child_body()],
|
||||
child_jets=[child_jet()], **KW)
|
||||
return FixedPatchStepper(CFG, context=context,
|
||||
**levels(objects=((), (), (child_body(),)), jets=((), (), (child_jet(),))))
|
||||
|
||||
|
||||
class ThreeLevelCPU(unittest.TestCase):
|
||||
def test_rejections_before_cuda(self):
|
||||
context = object() # never activated: every case must fail first
|
||||
bad = (dict(KW, child_block=None), dict(KW, fine_block=None),
|
||||
dict(KW, fine_objects=[circle()]), dict(KW, child=(4, 8, 16, 16)),
|
||||
dict(KW, child_block=(5, 4)))
|
||||
bad = (levels(blocks=(B, B, None)), levels(blocks=(B, None, B)),
|
||||
levels(objects=((), (circle(),), ())), levels(l2=(4, 8, 16, 16)),
|
||||
levels(blocks=(B, B, (5, 4))))
|
||||
for kw in bad:
|
||||
with self.subTest(kw=kw), self.assertRaises(ValueError):
|
||||
FixedPatchStepper(CFG, L1, context=context, **kw)
|
||||
for kw in (dict(child_objects=[child_body()]), dict(child_jets=[child_jet()]),
|
||||
dict(KW, child_jets=[child_jet()])):
|
||||
FixedPatchStepper(CFG, context=context, **kw)
|
||||
# L2 bodies/Jets without an L2 level (one entry too many), and an L2 Jet without a body.
|
||||
two_level = dict(refinements=[L1], level_blocks=[None, None])
|
||||
for kw in (dict(two_level, level_objects=[(), (), (child_body(),)]),
|
||||
dict(two_level, level_jets=[(), (), (child_jet(),)]),
|
||||
levels(jets=((), (), (child_jet(),)))):
|
||||
with self.assertRaises(ValueError):
|
||||
FixedPatchStepper(CFG, L1, context=context, **kw)
|
||||
with self.assertRaises(ValueError): # several L1 patches + child: not yet
|
||||
FixedPatchStepper(LBMConfig(nx=32, ny=16, viscosity=.1), ((4, 4, 12, 12), (20, 4, 28, 12)),
|
||||
context=context, **KW)
|
||||
FixedPatchStepper(CFG, context=context, **kw)
|
||||
with self.assertRaises(ValueError): # several L1 patches + scalar child: not yet
|
||||
FixedPatchStepper(LBMConfig(nx=32, ny=16, viscosity=.1), context=context,
|
||||
**levels(l1=((4, 4, 12, 12), (20, 4, 28, 12))))
|
||||
|
||||
|
||||
def test_four_halo_bfl_steps_and_fresh_middle_feeds(self):
|
||||
@@ -80,11 +92,12 @@ class ThreeLevelCPU(unittest.TestCase):
|
||||
f, g = run._levels[1:]
|
||||
b = g.boundary
|
||||
events, feeds = [], []
|
||||
advance, gather = run._advance_child, run._gather_halo
|
||||
def record_feed(buffer, **kw):
|
||||
self.assertIs(buffer, (f.current, f.spare)[len(feeds)])
|
||||
feeds.append(buffer)
|
||||
advance(buffer, **kw)
|
||||
advance, gather = run._advance, run._gather_halo
|
||||
def record_feed(index, buffer, **kw): # index 2: L1 hop feeds L2
|
||||
if index == 2:
|
||||
self.assertIs(buffer, (f.current, f.spare)[len(feeds)])
|
||||
feeds.append(buffer)
|
||||
advance(index, buffer, **kw)
|
||||
def record_halo(level, buffer, **kw):
|
||||
if level is g:
|
||||
events.append(("halo", buffer))
|
||||
@@ -95,7 +108,7 @@ class ThreeLevelCPU(unittest.TestCase):
|
||||
self.assertIs(kind, g.kind)
|
||||
events.append(("step", src))
|
||||
g.kernel.side_effect = record_step
|
||||
with patch.object(run, "_advance_child", side_effect=record_feed), \
|
||||
with patch.object(run, "_advance", side_effect=record_feed), \
|
||||
patch.object(run, "_gather_halo", side_effect=record_halo):
|
||||
run.step()
|
||||
self.assertEqual(feeds, [f.current, f.spare])
|
||||
@@ -105,8 +118,8 @@ class ThreeLevelCPU(unittest.TestCase):
|
||||
self.assertGreater(len(np.unique(b.tables["block"])), 1)
|
||||
# Negative control: reusing L1's old source at the midpoint must fail.
|
||||
feeds.clear()
|
||||
with patch.object(run, "_advance_child", side_effect=lambda buffer, **kw:
|
||||
record_feed(f.current, **kw)):
|
||||
with patch.object(run, "_advance", side_effect=lambda index, buffer, **kw:
|
||||
record_feed(index, f.current if index == 2 else buffer, **kw)):
|
||||
with self.assertRaises(AssertionError):
|
||||
run.step()
|
||||
run.close()
|
||||
@@ -155,7 +168,7 @@ class ThreeLevelGPU(unittest.TestCase):
|
||||
def test_equilibrium_is_kept_on_every_level(self):
|
||||
from CelerisLab.cuda.context import CudaContext
|
||||
feq = np.asarray(compute_feq_d2q9(1.0, .03, -.02), np.float32)[:, None, None]
|
||||
with CudaContext(0) as ctx, FixedPatchStepper(CFG, L1, context=ctx, **KW) as run:
|
||||
with CudaContext(0) as ctx, FixedPatchStepper(CFG, context=ctx, **KW) as run:
|
||||
shapes = ((16, 16), run.patch.fine_shape, run.child_patch.fine_shape)
|
||||
run.initialize(*(np.broadcast_to(feq, (9, *s)).copy() for s in shapes))
|
||||
run.step(8)
|
||||
@@ -168,15 +181,16 @@ class ThreeLevelGPU(unittest.TestCase):
|
||||
|
||||
def test_nonequilibrium_masks_and_fresh_middle_feeds(self):
|
||||
from CelerisLab.cuda.context import CudaContext
|
||||
with CudaContext(0) as ctx, FixedPatchStepper(CFG, L1, context=ctx, **KW) as run:
|
||||
with CudaContext(0) as ctx, FixedPatchStepper(CFG, context=ctx, **KW) as run:
|
||||
p1, p2 = run.patch, run.child_patch
|
||||
run.initialize(state(16, 16, 1), state(*p1.fine_shape, 2), state(*p2.fine_shape, 3))
|
||||
f = run._levels[1]
|
||||
feeds, original = [], run._advance_child
|
||||
def record(buffer, **kw):
|
||||
feeds.append(int(buffer))
|
||||
original(buffer, **kw)
|
||||
with patch.object(run, "_advance_child", side_effect=record):
|
||||
feeds, original = [], run._advance
|
||||
def record(index, buffer, **kw): # index 2: L1 hop feeds L2
|
||||
if index == 2:
|
||||
feeds.append(int(buffer))
|
||||
original(index, buffer, **kw)
|
||||
with patch.object(run, "_advance", side_effect=record):
|
||||
cur, spare = int(f.current), int(f.spare)
|
||||
run.step(1)
|
||||
self.assertEqual(feeds, [cur, spare]) # current G, then the new G of hop 1
|
||||
@@ -203,8 +217,9 @@ class ThreeLevelBodyGPU(unittest.TestCase):
|
||||
CFG, velocity=0., wall_normal_velocity=.001, wall_slot_width=4,
|
||||
y_wall_bc="on_site_velocity", top_wall_bc="reservoir")
|
||||
with CudaContext(0) as ctx, FixedPatchStepper(
|
||||
cfg, L1, context=ctx, coarse_boundary=mode, child_objects=[child_body(spec)],
|
||||
child_jets=jets, **dict(KW, child_block=block)) as run:
|
||||
cfg, context=ctx, coarse_boundary=mode,
|
||||
**levels(blocks=(B, B, block), objects=((), (), (child_body(spec),)),
|
||||
jets=((), (), tuple(jets)))) as run:
|
||||
run.initialize(*(state(*level.shape[1:], i + 1) for i, level in enumerate(run._levels)))
|
||||
snapshots = []
|
||||
for stop in (0, 1, 2, 4, 8):
|
||||
|
||||
@@ -78,10 +78,10 @@ class ThreeLevelMultipatchGPU(unittest.TestCase):
|
||||
def run_case(self, ctx, cfg=CFG, parents=PARENTS, children=CHILDREN,
|
||||
mode='periodic', blocks=((4, 4), (4, 4)), objects=(), drive=(), switch=False,
|
||||
scalar_child=None):
|
||||
kwargs = dict(child_rectangles_in_parent=children) if scalar_child is None else dict(child=scalar_child)
|
||||
with FixedPatchStepper(config(cfg, mode), parents, context=ctx, coarse_boundary=mode,
|
||||
base_block=(4, 4), fine_block=blocks[0], child_block=blocks[1],
|
||||
child_objects=objects, child_jets=drive, **kwargs) as run:
|
||||
child = children if scalar_child is None else scalar_child
|
||||
with FixedPatchStepper(config(cfg, mode), context=ctx, coarse_boundary=mode,
|
||||
refinements=[parents, child], level_blocks=[(4, 4), blocks[0], blocks[1]],
|
||||
level_objects=[(), (), objects], level_jets=[(), (), drive]) as run:
|
||||
frame = frames(run)
|
||||
run.initialize(*(physical_state(level.shape[1:], *f)
|
||||
for level, f in zip(run._levels, frame)))
|
||||
|
||||
@@ -26,9 +26,8 @@ FP64_ATOL = 3e-6
|
||||
|
||||
|
||||
def make(ctx):
|
||||
return FixedPatchStepper(CFG, PARENTS, context=ctx, base_block=(4, 4),
|
||||
fine_block=(4, 4), child_block=(4, 4),
|
||||
child_rectangles_in_parent=A)
|
||||
return FixedPatchStepper(CFG, context=ctx, refinements=[PARENTS, A],
|
||||
level_blocks=[(4, 4)] * 3)
|
||||
|
||||
|
||||
def initial(run, uniform):
|
||||
|
||||
@@ -136,8 +136,8 @@ class ShiftedJetsGPU(unittest.TestCase):
|
||||
Parallel(0.),amplitude=.013)
|
||||
for rho in (.99,1.01):
|
||||
with self.subTest(rho=rho),CudaContext(0) as ctx:
|
||||
with FixedPatchStepper(cfg,(8,6,24,18),context=ctx,base_block=(4,4),fine_block=(4,4),
|
||||
fine_objects=(obj,),jets=(whole,),observe_raw_exchange=True) as run:
|
||||
with FixedPatchStepper(cfg,context=ctx,refinements=[(8,6,24,18)],level_blocks=[(4,4)]*2,
|
||||
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._level_boundaries[1].tables['aperture'].all())
|
||||
@@ -153,7 +153,8 @@ class ShiftedJetsGPU(unittest.TestCase):
|
||||
obj=circle((16.,12.),2.15);j=drive((16.,12.),2.15)
|
||||
for rho in (.99,1.01):
|
||||
with self.subTest(rho=rho),CudaContext(0) as ctx,ExitStack() as owned:
|
||||
settings=dict(bounds=(8,6,24,18),context=ctx,base_block=(4,4),fine_block=(4,4),fine_objects=(obj,),jets=(j,))
|
||||
settings=dict(context=ctx,refinements=[(8,6,24,18)],level_blocks=[(4,4)]*2,
|
||||
level_objects=[(),(obj,)],level_jets=[(),(j,)])
|
||||
measured=owned.enter_context(FixedPatchStepper(cfg,observe_raw_exchange=True,**settings))
|
||||
plain=owned.enter_context(FixedPatchStepper(cfg,**settings))
|
||||
measured._initialize_raw(initial(measured,rho));plain._initialize_raw(initial(plain,rho))
|
||||
@@ -186,8 +187,9 @@ class ShiftedJetsGPU(unittest.TestCase):
|
||||
parents=((4,4,20,28),(24,4,44,28));a=((16,16,32,40),(56,16,72,40));b=(a[0],(60,16,76,40))
|
||||
obj=circle((12.,14.),.8);j=drive((12.,14.),.8)
|
||||
with CudaContext(0) as ctx,ExitStack() as owned:
|
||||
old=owned.enter_context(FixedPatchStepper(cfg,parents,context=ctx,base_block=(4,4),fine_block=(4,4),
|
||||
child_block=(4,4),child_rectangles_in_parent=a,child_objects=(obj,),child_jets=(j,),observe_raw_exchange=True))
|
||||
old=owned.enter_context(FixedPatchStepper(cfg,context=ctx,refinements=[parents,a],
|
||||
level_blocks=[(4,4)]*3,level_objects=[(),(),(obj,)],level_jets=[(),(),(j,)],
|
||||
observe_raw_exchange=True))
|
||||
old._initialize_raw(initial(old,.99));ledger=Ledger(self,old,2)
|
||||
for speed in (.002,-.001):old.set_jet_amplitude(0,speed,level=2);ledger.step()
|
||||
before=old._download_raw();count=old.step_count
|
||||
|
||||
@@ -22,9 +22,8 @@ B=((20,16,36,40),)
|
||||
|
||||
|
||||
def make(ctx):
|
||||
return FixedPatchStepper(CFG,PARENTS,context=ctx,base_block=(4,4),
|
||||
fine_block=(4,4),child_block=(4,4),
|
||||
child_rectangles_in_parent=A)
|
||||
return FixedPatchStepper(CFG,context=ctx,refinements=[PARENTS,A],
|
||||
level_blocks=[(4,4)]*3)
|
||||
|
||||
|
||||
def initial(run):
|
||||
|
||||
@@ -1,497 +0,0 @@
|
||||
"""Explicit host-only checks or admitted tiny GPU audit; never a long trajectory."""
|
||||
import argparse
|
||||
from contextlib import nullcontext
|
||||
from dataclasses import replace
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
import numpy as np
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT/'src'))
|
||||
from CelerisLab.config import LBMConfig
|
||||
import spacetime_gpu as gpu
|
||||
import run_spacetime_gpu_n64 as trajectory
|
||||
ref = gpu.ref
|
||||
RECORDS = []
|
||||
REPORT = None
|
||||
OUTPUT = None
|
||||
GPU_ADMITTED = False
|
||||
|
||||
|
||||
def record(value):
|
||||
RECORDS.append(value)
|
||||
if OUTPUT is not None:
|
||||
ref.write_receipt(OUTPUT,REPORT)
|
||||
|
||||
|
||||
def config(n, shifted=False):
|
||||
return LBMConfig(nx=n,ny=n,viscosity=.0192,velocity=0.,ddf_shifting=shifted)
|
||||
|
||||
|
||||
def initial(plan, tagged=True):
|
||||
arrays = []
|
||||
for n, mask in ((plan.n,plan.maps.coarse_active),(plan.nf,plan.maps.fine_active)):
|
||||
a = np.broadcast_to(ref.cpu.W[:,None,None], (9,n,n)).copy()
|
||||
if tagged:
|
||||
a *= 1+1e-3*np.random.default_rng(n).standard_normal(a.shape)
|
||||
a[:,~mask] = np.nan
|
||||
arrays.append(a)
|
||||
return ref.cpu.State(*arrays)
|
||||
|
||||
|
||||
class Host(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.plan = ref.Plan(12,(3,3,9,8))
|
||||
|
||||
def test_pins_and_supported_config(self):
|
||||
gpu.check_sources()
|
||||
for n in (12,16,64):
|
||||
c,f = gpu.validate_config(config(n))
|
||||
self.assertAlmostEqual(c.omega,1/.5576)
|
||||
self.assertAlmostEqual(f.omega,1/.6152)
|
||||
for change in (dict(ddf_shifting=1),dict(collision='TRT'),dict(nx=128,ny=128),
|
||||
dict(ny=16),dict(les_enabled=True),dict(velocity=.03)):
|
||||
with self.subTest(change=change), self.assertRaises(ValueError):
|
||||
gpu.validate_config(replace(config(12),**change))
|
||||
|
||||
def test_shifted_codec_and_uploaded_physical_validation(self):
|
||||
for n in (12,64):
|
||||
self.assertTrue(gpu.validate_config(config(n,True))[1].ddf_shifting)
|
||||
physical=gpu._WEIGHTS+2.**-35
|
||||
stored=gpu._encode_physical(physical,True)
|
||||
np.testing.assert_array_equal(stored,np.full((9,1,1),2.**-35,np.float32))
|
||||
np.testing.assert_array_equal(gpu._decode_stored(stored,True),physical)
|
||||
run=gpu.SpaceTimeGPU.__new__(gpu.SpaceTimeGPU)
|
||||
run.status='new'; run.configs=gpu.validate_config(config(12,True)); run.plan=self.plan
|
||||
run.ctx=SimpleNamespace(activate=nullcontext); run.cuda=Mock()
|
||||
run.coarse=['coarse']; run.fine=['fine']; run.health_counts=np.zeros(6,np.uint64)
|
||||
state=initial(self.plan,False)
|
||||
run.initialize(state)
|
||||
for call in run.cuda.memcpy_htod.call_args_list:
|
||||
self.assertTrue((call.args[1][np.isfinite(call.args[1])]==0).all())
|
||||
state.coarse_f[:]-=.01; state.fine_f[:]-=.01
|
||||
run.initialize(state) # Valid negative stored values.
|
||||
with self.assertRaisesRegex(ValueError,'exact shapes'):
|
||||
run.initialize(ref.cpu.State(state.coarse_f[:1],state.fine_f))
|
||||
for invalid in (-.01,np.nan):
|
||||
bad=ref.cpu.State(state.coarse_f.copy(),state.fine_f.copy())
|
||||
bad.coarse_f[0,0,0]=invalid
|
||||
with self.assertRaisesRegex(ValueError,'positive finite'):
|
||||
run.initialize(bad)
|
||||
|
||||
def test_shifted_transfer_translation_and_raw_ledger(self):
|
||||
p=self.plan; t=gpu.pack_plan(p)
|
||||
rng=np.random.default_rng(19)
|
||||
for g in p.groups:
|
||||
values=rng.uniform(.02,.45,len(g.samples)); weight=gpu._WEIGHTS[g.q,0,0]
|
||||
np.testing.assert_allclose(g.weights@(values-weight),g.weights@values-weight,rtol=0,atol=2e-14)
|
||||
# Uniform nonzero stored packets expose register omissions; exact rest would not.
|
||||
arrays=[np.full((9,n,n),-.01,np.float32) for n in (p.n,p.nf)]
|
||||
for a,m in zip(arrays,(p.maps.coarse_active,p.maps.fine_active)):
|
||||
a[:,~m]=np.nan
|
||||
state=ref.cpu.State(*arrays); g0=np.full((9,p.n,p.n),-.01,np.float32)
|
||||
pre=np.repeat(g0.ravel()[t['origins'],None].astype(float),4,axis=1)
|
||||
h=pre.astype(np.float32); registers=np.zeros(len(p.receivers))
|
||||
args=(p,t,state,state,g0,g0,[(state.fine_f,state.fine_f)]*2,pre,h,registers)
|
||||
ledger=gpu.rounding_ledger(*args,shifted=True)
|
||||
self.assertEqual(max(map(abs,ledger['unexplained'])),0.)
|
||||
self.assertGreater(ledger['unexplained_bound'],0.)
|
||||
self.assertGreater(ledger['minimum_stored'],0.)
|
||||
expected_mass=p.n*p.n*(1.+9*float(np.float32(-.01)))
|
||||
self.assertEqual(ledger['unexplained_bound'],64*np.finfo(float).eps*expected_mass)
|
||||
decoded=ref.cpu.State(*(gpu._decode_stored(a,True) for a in arrays))
|
||||
with self.assertRaises(AssertionError):
|
||||
gpu.rounding_ledger(p,t,decoded,*args[3:],shifted=True)
|
||||
registers[0]+=.25*float(np.float32(-.01))
|
||||
with self.assertRaisesRegex(AssertionError,'register mismatch'):
|
||||
gpu.rounding_ledger(*args,shifted=True)
|
||||
|
||||
def test_packed_weights_and_packet_ownership(self):
|
||||
for plan in (self.plan,ref.Plan(16,(4,5,11,12))):
|
||||
t = gpu.pack_plan(plan)
|
||||
self.assertEqual(t['sources'].shape,(len(plan.receivers),4))
|
||||
self.assertEqual(np.count_nonzero(t['times']==-1),24)
|
||||
self.assertEqual(gpu.table_hash(t),gpu.table_hash(gpu.pack_plan(plan)))
|
||||
for i,g in enumerate(plan.groups):
|
||||
np.testing.assert_array_equal(t['weights'][i,:,:len(g.samples)],g.weights)
|
||||
np.testing.assert_allclose(t['weights'][i].sum(axis=1),1,rtol=0,atol=2e-14)
|
||||
np.testing.assert_array_equal(t['samples'][i,:len(g.samples)],g.q*plan.n**2+g.samples)
|
||||
for m in (0,1):
|
||||
self.assertEqual(len(t[f'incoming{m}']),len(plan.outgoing[m]))
|
||||
|
||||
def test_duplicate_writer_is_rejected(self):
|
||||
plan = self.plan
|
||||
groups = list(plan.groups)
|
||||
a,b = next((i,k) for i,g in enumerate(groups) for k,e in enumerate(g.events) if e is not None)
|
||||
event = groups[a].events[b]
|
||||
other = next(i for i,g in enumerate(groups) if i!=a and g.q==groups[a].q and event[0] in [e[0] for e in g.events if e])
|
||||
events = list(groups[other].events)
|
||||
k = next(k for k,e in enumerate(events) if e is not None and e[0]==event[0])
|
||||
events[k] = event; groups[other] = replace(groups[other],events=tuple(events))
|
||||
fake = SimpleNamespace(n=plan.n,nf=plan.nf,groups=groups,receivers=plan.receivers,outgoing=plan.outgoing)
|
||||
with self.assertRaisesRegex(ValueError,'duplicate incoming'):
|
||||
gpu.pack_plan(fake)
|
||||
|
||||
def test_rest_ledger_and_missing_packet_discrimination(self):
|
||||
p,t = self.plan,gpu.pack_plan(self.plan)
|
||||
state = initial(p,False)
|
||||
state = ref.cpu.State(state.coarse_f.astype(np.float32),state.fine_f.astype(np.float32))
|
||||
g0 = np.broadcast_to(ref.cpu.W.astype(np.float32)[:,None,None],(9,p.n,p.n)).copy()
|
||||
pre = np.repeat(g0.ravel()[t['origins'],None].astype(float),4,axis=1)
|
||||
h = pre.astype(np.float32); registers = np.zeros(len(p.receivers))
|
||||
phases = [(state.fine_f,state.fine_f)]*2
|
||||
args = (p,t,state,state,g0,g0,phases,pre,h)
|
||||
ledger = gpu.rounding_ledger(*args,registers)
|
||||
self.assertEqual(max(map(abs,ledger['unexplained'])),0)
|
||||
np.testing.assert_array_equal(ledger['physical_collision'],np.array(ledger['coarse_collision'])
|
||||
+ledger['fine0_collision']+ledger['fine1_collision'])
|
||||
np.testing.assert_array_equal(ledger['child_rounding'],np.array(ledger['child_cast'])+ledger['child_interpolation'])
|
||||
np.testing.assert_array_equal(ledger['collision_cell_mass'],np.zeros((3,4)))
|
||||
registers[0] = .01
|
||||
with self.assertRaisesRegex(AssertionError,'register mismatch'):
|
||||
gpu.rounding_ledger(*args,registers)
|
||||
|
||||
def test_second_substep_characteristic_donor(self):
|
||||
p = ref.Plan(16,(4,4,12,12)); t = gpu.pack_plan(p)
|
||||
row = t['incoming1'][t['incoming1'][:,0]==5*32*32+10*32+7][0]
|
||||
self.assertEqual(t['origins'][row[1]//4],5*16*16+4*16+3)
|
||||
|
||||
def test_failed_free_remains_retryable(self):
|
||||
run=gpu.SpaceTimeGPU.__new__(gpu.SpaceTimeGPU)
|
||||
run.ctx=SimpleNamespace(activate=nullcontext); run.stream=Mock()
|
||||
first,second=Mock(),Mock(); first.free.side_effect=[RuntimeError('free failed'),None]
|
||||
run.allocations=[first,second]; run.modules=[]; run.status='ready'
|
||||
with self.assertRaisesRegex(RuntimeError,'free failed'):
|
||||
run.close()
|
||||
self.assertEqual(run.allocations,[first]); self.assertEqual(run.status,'cleanup_failed')
|
||||
run.close()
|
||||
self.assertEqual(run.status,'closed'); self.assertEqual(run.allocations,[])
|
||||
second.free.assert_called_once()
|
||||
|
||||
def test_unmeasured_schedule_and_health_failure(self):
|
||||
run=gpu.SpaceTimeGPU.__new__(gpu.SpaceTimeGPU)
|
||||
run.status='ready'; run.step_count=0; run.identity=False; run.mode='spacetime'
|
||||
run.ctx=SimpleNamespace(activate=nullcontext); run.cuda=Mock(); run.stream=Mock()
|
||||
run.plan=self.plan; run.bounds=(3,3,9,8)
|
||||
run.configs=gpu.validate_config(config(12)); run.coarse=list('abcd'); run.fine=list('efg')
|
||||
run.tables=gpu.pack_plan(self.plan); run.device_tables={key:key for key in run.tables}
|
||||
run.active='active'; run.pre='pre'; run.h='h'; run.registers='registers'; run.counts='counts'
|
||||
run.health_counts=np.zeros(6,np.uint64)
|
||||
run.launch=Mock(); run.read=Mock(return_value=np.zeros(6,np.uint32)); run.download=Mock()
|
||||
names=['CoarseStart','Pull','Collide','Interpolate','Collide','Register','Inject','Pull',
|
||||
'Collide','Register','Inject','Pull','ApplyRegisters']
|
||||
for _ in range(2):
|
||||
result=run.step(audit=False)
|
||||
self.assertEqual(result['boundary_budget_status'],'not_measured')
|
||||
self.assertIsNone(result['unexplained'])
|
||||
self.assertEqual([call.args[1] for call in run.launch.call_args_list],names*2)
|
||||
self.assertEqual(run.read.call_count,2); run.download.assert_not_called()
|
||||
self.assertEqual(run.step_count,2); self.assertEqual(run.coarse,list('abcd'))
|
||||
run.read.return_value=np.array([0,0,0,0,1,0],np.uint32)
|
||||
with self.assertRaisesRegex(AssertionError,'health failed'):
|
||||
run.step(audit=False)
|
||||
self.assertEqual(run.status,'failed'); self.assertEqual(run.step_count,2)
|
||||
self.assertEqual(run.health_counts.tolist(),[0,0,0,0,1,0])
|
||||
|
||||
def test_warmup_resets_arrivals_and_counters(self):
|
||||
audits=[]
|
||||
class Fake:
|
||||
def initialize(self,state):
|
||||
self.state=ref.cpu.State(state.coarse_f.copy(),state.fine_f.copy())
|
||||
self.step_count=0; self.health_counts=np.zeros(6,np.uint64)
|
||||
def download(self):
|
||||
return ref.cpu.State(self.state.coarse_f.copy(),self.state.fine_f.copy(),tick=2*self.step_count)
|
||||
def step(self,*,audit):
|
||||
audits.append(audit)
|
||||
self.step_count+=1; self.state.coarse_f[:]+=1e-4
|
||||
fake=Fake(); check=Mock(); state=initial(self.plan)
|
||||
actual,warm=trajectory.warm_and_reset(fake,state,check)
|
||||
np.testing.assert_array_equal(actual.coarse_f,state.coarse_f)
|
||||
self.assertEqual(check.call_count,32); self.assertEqual(fake.step_count,0)
|
||||
self.assertTrue(warm['reset_verified']); self.assertEqual(warm['intervals'],32)
|
||||
trajectory.warm_and_reset(fake,state,check,audit=True)
|
||||
self.assertEqual(audits,[False]*32+[True]*32)
|
||||
|
||||
def test_accumulated_drift_ledger_and_exact_samples(self):
|
||||
totals=trajectory.LedgerTotals()
|
||||
for sign in (1.,-1.):
|
||||
ledger={name:np.zeros(9) for name in totals.names}
|
||||
ledger['coarse_collision'][0]=sign*2.**-20
|
||||
ledger['physical_collision']=ledger['coarse_collision'].copy()
|
||||
ledger['child_cast'][5]=sign*2.**-30
|
||||
ledger['child_interpolation'][5]=sign*2.**-50
|
||||
ledger['child_rounding']=ledger['child_cast']+ledger['child_interpolation']
|
||||
ledger['receiver_rounding'][5]=-sign*2.**-32
|
||||
ledger['net_change']=sum(ledger[k] for k in ('physical_collision','child_rounding','receiver_rounding'))
|
||||
mass=ledger['coarse_collision'].sum()
|
||||
ledger.update(unexplained_bound=1e-12,collision_cell_mass=[[mass,max(mass,0.),min(mass,0.),abs(mass)],[0.]*4,[0.]*4])
|
||||
totals.add(ledger)
|
||||
result=totals.snapshot(np.zeros(9))
|
||||
self.assertEqual(result['intervals'],2)
|
||||
self.assertEqual(result['components']['physical_collision']['directional'],[0.]*9)
|
||||
self.assertGreater(result['components']['physical_collision']['absolute_step_mass_momentum_sum'][0],0)
|
||||
self.assertEqual(result['components']['physical_collision']['max_absolute_interval_step'][0],1)
|
||||
self.assertEqual(totals.snapshot()['endpoint_status'],'not_measured')
|
||||
self.assertFalse(totals.snapshot(np.ones(9)*1e-8)['closure_pass'])
|
||||
self.assertEqual(trajectory.differing_fields(dict(a=1.,b=[0.,2.]),dict(a=1.,b=[0.,2.])),[])
|
||||
self.assertEqual(trajectory.differing_fields(dict(a=0.),dict(a=-0.)),['a'])
|
||||
self.assertEqual(trajectory.differing_fields(dict(a=1.),dict(a=np.nextafter(1.,2.))),['a'])
|
||||
|
||||
def test_trajectory_screens_differences_and_json(self):
|
||||
rows=[dict(tstar=t,velocity_l2=np.float64(.01),pressure_l2=.03,energy_relative=.01,
|
||||
mass_relative=1e-7,momentum_change=[1e-6,-2e-6],minimum_population=.02,
|
||||
minimum_boundary_pre=.02,minimum_boundary_stored=.02) for t in trajectory.TIMES]
|
||||
cpu=dict(runs=[dict(worst=dict(velocity_l2=.02,pressure_l2=.04,energy_relative=.02)),
|
||||
dict(rows=[dict(tstar=t,velocity_l2=.009,momentum_change=[0.,0.]) for t in trajectory.TIMES])])
|
||||
uniform=dict(rows=[dict(tstar=.12,pressure_l2=.012253173806349701)])
|
||||
result=trajectory.assess(rows,cpu,uniform,[0]*6)
|
||||
self.assertTrue(result['campaign_pass']); self.assertTrue(result['experimental_pass'])
|
||||
self.assertFalse(result['historical_pressure_gate']['passed'])
|
||||
self.assertFalse(result['historical_pressure_gate']['matched_storage'])
|
||||
self.assertAlmostEqual(result['gpu_minus_cpu']['rows'][0]['fields']['velocity_l2']['signed'],.001)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path=Path(directory)/'receipt.json'
|
||||
ref.write_receipt(path,dict(completed=True,**result))
|
||||
self.assertTrue(json.loads(path.read_text())['completed'])
|
||||
rows[0]['velocity_l2']=.06
|
||||
failed=trajectory.assess(rows,cpu,uniform,[0,1,0,0,0,0])
|
||||
ref.write_receipt(path,dict(completed=True,**failed))
|
||||
saved=json.loads(path.read_text())
|
||||
self.assertFalse(saved['campaign_pass']); self.assertFalse(saved['experimental_pass'])
|
||||
for invalid in (float('nan'),-.01):
|
||||
rows[0]['velocity_l2']=.01; rows[-1]['velocity_l2']=invalid
|
||||
failed=trajectory.assess(rows,cpu,uniform,[0]*6)
|
||||
self.assertFalse(failed['campaign_pass']); self.assertFalse(failed['experimental_pass'])
|
||||
path.write_text('changed reference')
|
||||
with self.assertRaisesRegex(ValueError,'pinned reference'):
|
||||
trajectory.load_references(path,path)
|
||||
|
||||
def test_shifted_descriptive_comparison_and_runner_pin(self):
|
||||
expected=dict(mass=4096.,momentum=[0.,-1e-4])
|
||||
actual=dict(mass=4096.+2.**-20,momentum=[-1e-6,-2e-4])
|
||||
difference=trajectory.sample_difference(actual,expected)
|
||||
self.assertEqual(difference['mass']['signed'],2.**-20)
|
||||
self.assertEqual(difference['momentum']['absolute'],[1e-6,1e-4])
|
||||
self.assertEqual(trajectory.sha(Path(gpu.__file__).with_suffix('.cu')),trajectory.CUDA_SHA)
|
||||
self.assertEqual(trajectory.LIMITS,dict(velocity_l2=.05,pressure_l2=.15,energy_relative=.05,mass_relative=1e-5))
|
||||
self.assertEqual(trajectory.FACTORS,dict(velocity_l2=.8,pressure_l2=1.,energy_relative=1.))
|
||||
|
||||
def test_n64_table_guard_and_balanced_mutation(self):
|
||||
guard=trajectory.table_guard
|
||||
plan=ref.Plan(64,(8,8,56,48)); tables=gpu.pack_plan(plan)
|
||||
self.assertEqual(guard.validate_tables(plan,tables)['status'],'passed')
|
||||
face=next(i for i,g in enumerate(plan.groups) if g.kind=='face')
|
||||
changed={key:value.copy() for key,value in tables.items()}
|
||||
changed['weights'][face,0,0]=np.nextafter(changed['weights'][face,0,0],np.inf)
|
||||
self.assertEqual(guard.validate_tables(plan,changed)['status'],'passed')
|
||||
for mutation in ('coordinate','order','dtype','shape','layout','padding','negative_zero','nan','inf','balanced'):
|
||||
changed={key:value.copy() for key,value in tables.items()}
|
||||
w=changed['weights']
|
||||
if mutation=='coordinate': changed['incoming1'][0,0]^=1
|
||||
elif mutation=='order': changed['origins']=changed['origins'][::-1].copy()
|
||||
elif mutation=='dtype': changed['weights']=w.astype(np.float32)
|
||||
elif mutation=='shape': changed['weights']=w.reshape(532,10,4)
|
||||
elif mutation=='layout': changed['weights']=np.asfortranarray(w)
|
||||
elif mutation=='padding': w[face,0,9]=1e-30
|
||||
elif mutation=='negative_zero': w[face,0,9]=-0.
|
||||
elif mutation=='nan': w[face,0,0]=np.nan
|
||||
elif mutation=='inf': w[face,0,0]=np.inf
|
||||
else:
|
||||
w[face,:2,:2] += 2.**-30*np.array([[1.,-1.],[-1.,1.]])
|
||||
np.testing.assert_array_equal(w[face].sum(axis=1),tables['weights'][face].sum(axis=1))
|
||||
np.testing.assert_array_equal(w[face].mean(axis=0),tables['weights'][face].mean(axis=0))
|
||||
with self.subTest(mutation=mutation), self.assertRaises(ValueError):
|
||||
guard.validate_tables(plan,changed)
|
||||
changed={key:value.copy() for key,value in tables.items()}
|
||||
changed['weights'][-1,0,0]+=1.
|
||||
with self.assertRaises(ValueError):
|
||||
guard.validate_tables(SimpleNamespace(n=64,groups=plan.groups[:-1]),changed)
|
||||
groups=list(plan.groups); groups[0]=replace(groups[0],samples=groups[0].samples[:-1])
|
||||
with self.assertRaisesRegex(ValueError,'coverage/size'):
|
||||
guard.validate_tables(SimpleNamespace(n=64,groups=groups),tables)
|
||||
|
||||
|
||||
class Device(unittest.TestCase):
|
||||
shifted = False
|
||||
|
||||
def config(self,n):
|
||||
return config(n,self.shifted)
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if not GPU_ADMITTED:
|
||||
raise unittest.SkipTest('CUDA compilation/execution requires explicit tiny-job admission')
|
||||
from CelerisLab.cuda.context import CudaContext
|
||||
cls.context = CudaContext(0)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.context.close()
|
||||
|
||||
def test_tiny_equivalence_budgets_and_phase(self):
|
||||
for mode,identity in (('spacetime',False),('spacetime',True),('frozen',False),('frozen',True)):
|
||||
with self.subTest(mode=mode,identity=identity), gpu.SpaceTimeGPU(
|
||||
self.config(12),(3,3,9,8),context=self.context,mode=mode,identity=identity) as run:
|
||||
spec = ref.Spec(n_coarse=12,patch_lo=1,halo=2,tau_coarse=.5576,tau_fine=.6152,
|
||||
collision='identity' if identity else 'srt')
|
||||
for tagged,steps in ((False,2),(True,8)):
|
||||
expected = initial(run.plan,tagged); run.initialize(expected)
|
||||
before = run.download()
|
||||
if self.shifted:
|
||||
expected = before
|
||||
for k in range(1,steps+1):
|
||||
expected,_ = ref.advance(spec,run.plan,expected,mode)
|
||||
ledger = run.step(); actual = run.download()
|
||||
self.assertEqual(actual.tick,2*k); self.assertEqual(run.step_count,k)
|
||||
for a,b in zip((actual.coarse_f,actual.fine_f),(expected.coarse_f,expected.fine_f)):
|
||||
np.testing.assert_array_equal(np.isnan(a),np.isnan(b))
|
||||
np.testing.assert_allclose(a,b,rtol=0,atol=3e-6,equal_nan=True)
|
||||
total = lambda s: ref.cpu.MOMENTS.T @ ref.directional(
|
||||
ref.cpu.State(s.coarse_f.astype(float),s.fine_f.astype(float)),run.plan.maps)
|
||||
np.testing.assert_allclose(total(actual),total(before),rtol=0,atol=2e-4)
|
||||
if tagged and not identity and k==1:
|
||||
for a,b,tau,mask in zip((actual.coarse_f,actual.fine_f),
|
||||
(expected.coarse_f,expected.fine_f),(.5576,.6152),
|
||||
(run.plan.maps.coarse_active,run.plan.maps.fine_active)):
|
||||
wrong = ref.cpu._collide(spec,b,np.isfinite(b),tau,mask,np.zeros(mask.shape,int))
|
||||
self.assertGreater(float(np.nanmax(abs(a-wrong))),3e-6)
|
||||
if k in (1,2,8):
|
||||
record(dict(case='tiny',shifted=self.shifted,mode=mode,identity=identity,tagged=tagged,
|
||||
step=k,device_bytes=run.device_bytes,ledger=ledger))
|
||||
|
||||
def test_omitted_packets_and_ghosts_fail(self):
|
||||
for mutation in (1,2,3,4):
|
||||
with self.subTest(mutation=mutation), gpu.SpaceTimeGPU(
|
||||
self.config(12),(3,3,9,8),context=self.context,identity=self.shifted) as run:
|
||||
state=initial(run.plan,not self.shifted)
|
||||
if self.shifted:
|
||||
state.coarse_f[:]+=1e-4; state.fine_f[:]+=1e-4
|
||||
for a,mask in zip((state.coarse_f,state.fine_f),(run.plan.maps.coarse_active,run.plan.maps.fine_active)):
|
||||
self.assertTrue((gpu._encode_physical(a,True)[:,mask]>0).all())
|
||||
run.initialize(state)
|
||||
with self.assertRaises(AssertionError):
|
||||
run.step(mutation=mutation)
|
||||
self.assertEqual(run.step_count,0)
|
||||
self.assertEqual(run.status,'failed')
|
||||
with self.assertRaises(RuntimeError):
|
||||
run.download()
|
||||
|
||||
def test_frozen_donor_trap(self):
|
||||
with gpu.SpaceTimeGPU(self.config(16),(4,4,12,12),context=self.context,mode='frozen',identity=True) as run:
|
||||
state = initial(run.plan,False)
|
||||
state.coarse_f[5,4,3]=1.; state.coarse_f[5,5,3]=2.
|
||||
run.initialize(state); donor=run.download().coarse_f[5,4,3]; run.step()
|
||||
self.assertEqual(run.download().fine_f[5,11,8],donor)
|
||||
# Negative stored values can be physical in shifted mode; never clip either case.
|
||||
with self.context.activate():
|
||||
for physical_negative in ((False,True) if self.shifted else (True,)):
|
||||
data=np.full((9,16,16),-.01,np.float32)
|
||||
if self.shifted and physical_negative:
|
||||
data=np.broadcast_to(-gpu._WEIGHTS-.01,(9,16,16)).astype(np.float32).copy()
|
||||
run.cuda.memcpy_htod(run.coarse[1],data); run.cuda.memcpy_htod(run.coarse[3],data)
|
||||
run.cuda.memset_d32_async(run.counts,0,6,run.stream)
|
||||
t=run.device_tables; ng=len(run.plan.groups)
|
||||
run.launch(0,'Interpolate',run.coarse[1],run.coarse[3],t['samples'],t['origins'],
|
||||
t['sizes'],t['weights'],run.pre,run.h,np.uint32(ng),np.uint32(0),run.counts,count=ng)
|
||||
counts=run.read(run.counts,(6,),np.uint32)
|
||||
self.assertEqual(counts[1],4*ng*physical_negative)
|
||||
self.assertEqual(counts[3],4*ng*physical_negative)
|
||||
self.assertTrue((run.read(run.h,(ng,4))<0).all())
|
||||
|
||||
def test_n64_one_interval_only(self):
|
||||
sys.path.insert(0,str(Path(__file__).parents[1]/'taylor_green'))
|
||||
import oracle as tg
|
||||
with gpu.SpaceTimeGPU(self.config(64),(8,8,56,48),context=self.context) as run:
|
||||
arrays = [tg.initial((64,64),0,64,.0192),tg.initial((128,128),1,64,.0192)]
|
||||
for a,mask in zip(arrays,(run.plan.maps.coarse_active,run.plan.maps.fine_active)):
|
||||
a[:,~mask]=np.nan
|
||||
state = ref.cpu.State(*arrays); run.initialize(state)
|
||||
spec = ref.Spec(n_coarse=64,patch_lo=8,halo=16,tau_coarse=.5576,tau_fine=.6152,collision='srt')
|
||||
expected,_ = ref.advance(spec,run.plan,run.download() if self.shifted else state)
|
||||
started=time.monotonic(); ledger=run.step(); seconds=time.monotonic()-started
|
||||
actual=run.download()
|
||||
errors=[]
|
||||
for a,b in zip((actual.coarse_f,actual.fine_f),(expected.coarse_f,expected.fine_f)):
|
||||
np.testing.assert_array_equal(np.isnan(a),np.isnan(b))
|
||||
np.testing.assert_allclose(a,b,rtol=0,atol=3e-6,equal_nan=True)
|
||||
errors.append(float(np.nanmax(abs(a-b))))
|
||||
record(dict(case='N64_one_interval',shifted=self.shifted,errors=errors,ledger=ledger,seconds=seconds,
|
||||
device_bytes=run.device_bytes,table_sha256=gpu.table_hash(run.tables),
|
||||
build_metadata=run.build_metadata,device=self.context.name))
|
||||
|
||||
def test_unmeasured_path_and_warm_reset(self):
|
||||
with gpu.SpaceTimeGPU(self.config(12),(3,3,9,8),context=self.context) as run:
|
||||
state=initial(run.plan)
|
||||
run.initialize(state)
|
||||
raw_initial=run._download_stored()
|
||||
for _ in range(2):
|
||||
run.step()
|
||||
expected=run._download_stored()
|
||||
run.initialize(state)
|
||||
for _ in range(2):
|
||||
result=run.step(audit=False)
|
||||
self.assertEqual(result['boundary_budget_status'],'not_measured')
|
||||
actual=run._download_stored()
|
||||
for a,b in zip((actual.coarse_f,actual.fine_f),(expected.coarse_f,expected.fine_f)):
|
||||
np.testing.assert_array_equal(a.view(np.uint32),b.view(np.uint32))
|
||||
reset,warm=trajectory.warm_and_reset(run,state,lambda:None)
|
||||
raw_reset=run._download_stored()
|
||||
for a,b in zip((raw_reset.coarse_f,raw_reset.fine_f),(raw_initial.coarse_f,raw_initial.fine_f)):
|
||||
np.testing.assert_array_equal(a.view(np.uint32),b.view(np.uint32))
|
||||
record(dict(case='unmeasured_bitwise_and_reset',shifted=self.shifted,warmup=warm,health_counts=run.health_counts.tolist()))
|
||||
|
||||
|
||||
class ShiftedDevice(Device):
|
||||
shifted = True
|
||||
|
||||
|
||||
def main():
|
||||
global REPORT,OUTPUT,GPU_ADMITTED
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
mode=parser.add_mutually_exclusive_group(required=True)
|
||||
mode.add_argument('--host-only',action='store_true')
|
||||
mode.add_argument('--gpu-admitted',action='store_true')
|
||||
parser.add_argument('--output',type=Path)
|
||||
args=parser.parse_args()
|
||||
GPU_ADMITTED=args.gpu_admitted
|
||||
if args.gpu_admitted and (not os.environ.get('CUDA_VISIBLE_DEVICES') or args.output is None):
|
||||
parser.error('GPU job requires explicit CUDA_VISIBLE_DEVICES and --output')
|
||||
if args.output is not None and args.output.exists():
|
||||
parser.error('output exists; retain evidence and choose a new path')
|
||||
started=time.monotonic()
|
||||
report=dict(scope='tiny GPU equivalence and rounding audit; no long trajectory',completed=False,
|
||||
requested_mode='admitted_tiny_gpu' if args.gpu_admitted else 'host_only',
|
||||
commit=subprocess.check_output(['git','rev-parse','HEAD'],cwd=ROOT,text=True).strip(),
|
||||
dirty=bool(subprocess.check_output(['git','status','--porcelain'],cwd=ROOT,text=True)),
|
||||
hashes={p.name:hashlib.sha256(p.read_bytes()).hexdigest() for p in
|
||||
(Path(__file__),Path(gpu.__file__),Path(gpu.__file__).with_suffix('.cu'))},
|
||||
reference_hashes=gpu.PINS,cuda_visible_devices=os.environ.get('CUDA_VISIBLE_DEVICES'),records=RECORDS)
|
||||
REPORT,OUTPUT=report,args.output
|
||||
if args.output:
|
||||
ref.write_receipt(args.output,report)
|
||||
suite=unittest.TestLoader().loadTestsFromTestCase(Host)
|
||||
if args.gpu_admitted:
|
||||
suite.addTests(unittest.TestLoader().loadTestsFromTestCase(Device))
|
||||
suite.addTests(unittest.TestLoader().loadTestsFromTestCase(ShiftedDevice))
|
||||
result=unittest.TextTestRunner(verbosity=2).run(suite)
|
||||
report.update(completed=True,passed=result.wasSuccessful(),tests=result.testsRun,
|
||||
failures=[dict(test=str(test),error=error) for test,error in result.failures+result.errors],
|
||||
seconds=time.monotonic()-started)
|
||||
if args.output:
|
||||
ref.write_receipt(args.output,report)
|
||||
return 0 if result.wasSuccessful() else 1
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -1,205 +0,0 @@
|
||||
"""Host checks or separately admitted tiny GPU ladder capability; no trajectories."""
|
||||
import argparse
|
||||
from contextlib import nullcontext
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
import numpy as np
|
||||
import run_spacetime_gpu_ladder as ladder
|
||||
|
||||
gpu,ref,tg=ladder.gpu,ladder.ref,ladder.tg
|
||||
RECORDS=[]
|
||||
ADMITTED=False
|
||||
|
||||
|
||||
def config(n):
|
||||
return ladder.old.LBMConfig(nx=n,ny=n,viscosity=.0003*n,velocity=0.,ddf_shifting=True)
|
||||
|
||||
|
||||
class Host(unittest.TestCase):
|
||||
def test_scaling_and_admission(self):
|
||||
for _,n,refined in ladder.CASES:
|
||||
s=ladder.setup(n,refined)
|
||||
self.assertEqual(s['finest_n'],n*(2 if refined else 1))
|
||||
self.assertEqual(len(s['early_steps']),65)
|
||||
np.testing.assert_allclose(np.array(s['early_steps'])*.03/n,s['early_times'],rtol=0,atol=3e-17)
|
||||
self.assertEqual(s['intervals']*.03/n,.48)
|
||||
gpu.validate_config(config(n),uniform=not refined,allow_n128=refined)
|
||||
with self.assertRaises(ValueError): gpu.validate_config(config(128))
|
||||
with self.assertRaises(ValueError): gpu.validate_config(config(256),allow_n128=True)
|
||||
with self.assertRaises(ValueError): gpu.validate_config(config(512),uniform=True)
|
||||
|
||||
def test_n128_tables_and_mutations(self):
|
||||
plan=ref.Plan(128,(16,16,112,96)); tables=gpu.pack_plan(plan)
|
||||
out=ladder.old.table_guard.validate_tables(plan,tables,allow_n128=True)
|
||||
self.assertEqual(out['structure_sha256'],'ee4ecb645674ae04eb87220a5217851405ccc0f45fbb0fe210499dcea5cd93b1')
|
||||
self.assertEqual(len(plan.groups),1060)
|
||||
with self.assertRaises(ValueError): ladder.old.table_guard.validate_tables(plan,tables)
|
||||
for kind in ('topology','balanced_weights'):
|
||||
bad={k:v.copy() for k,v in tables.items()}
|
||||
if kind=='topology': bad['sources'][0,0]+=1
|
||||
else: bad['weights'][0,:2,:2]+=1e-5*np.array([[1,-1],[-1,1]])
|
||||
with self.assertRaises(ValueError): ladder.old.table_guard.validate_tables(plan,bad,allow_n128=True)
|
||||
|
||||
def test_projection_manufactured_nonuniform_leaves(self):
|
||||
n=16; plan=ref.Plan(n,(3,3,13,12)); masks=(plan.maps.coarse_active,plan.maps.fine_active)
|
||||
coords=[tg.centers(m.shape,l) for l,m in enumerate(masks)]
|
||||
x=np.concatenate([v[0][m] for v,m in zip(coords,masks)])
|
||||
y=np.concatenate([v[1][m] for v,m in zip(coords,masks)])
|
||||
w=np.concatenate([np.full(m.sum(),.25**l) for l,m in enumerate(masks)])
|
||||
b=np.column_stack((np.cos(4*np.pi*x/n),np.sin(4*np.pi*x/n),np.cos(4*np.pi*y/n),np.sin(4*np.pi*y/n)))
|
||||
b-=w@b/w.sum(); gram=b.T@(w[:,None]*b)
|
||||
residual=np.cos(6*np.pi*x/n)+np.sin(6*np.pi*y/n); residual-=w@residual/w.sum()
|
||||
residual-=b@np.linalg.solve(gram,b.T@(w*residual)); residual*=1e-5
|
||||
coefficients=np.array([2,-3,4,-1])*1e-5
|
||||
pressure=tg.exact(x,y,0,n,.0003*n)[2]+b@coefficients+residual+.017
|
||||
arrays=[]; offset=0
|
||||
for m in masks:
|
||||
a=np.full((9,*m.shape),np.nan); count=m.sum()
|
||||
a[:,m]=ref.cpu.W[:,None]*(1+3*pressure[offset:offset+count]); arrays.append(a); offset+=count
|
||||
result=ladder.pressure_projection(arrays,masks,0,n,.0003*n)
|
||||
np.testing.assert_allclose(result['coefficients'],coefficients,rtol=0,atol=3e-17)
|
||||
self.assertAlmostEqual(result['residual_rms'],np.sqrt(w@(residual*residual)/w.sum()),places=15)
|
||||
np.testing.assert_allclose(result['orthogonality'],0,rtol=0,atol=1e-18)
|
||||
self.assertLess(abs(result['energy_closure']),1e-20)
|
||||
expected=tg.metrics(arrays,masks,0,n,.0003*n)['pressure_l2']
|
||||
self.assertAlmostEqual(result['relative_rms'],expected,places=12)
|
||||
|
||||
def test_uniform_budget_and_negative_control(self):
|
||||
rng=np.random.default_rng(12); a=rng.normal(0,.001,(9,12,12)).astype(np.float32)
|
||||
g=(a*.99).astype(np.float32)
|
||||
b=np.stack([np.roll(g[q],(int(ref.cpu.CY[q]),int(ref.cpu.CX[q])),axis=(0,1)) for q in range(9)])
|
||||
ledger=gpu.uniform_ledger(a,b,g,shifted=True)
|
||||
self.assertLess(max(abs(np.array(ledger['unexplained']))),ledger['unexplained_bound'])
|
||||
b[1,0,0]+=1e-4
|
||||
with self.assertRaises(AssertionError): gpu.uniform_ledger(a,b,g,shifted=True)
|
||||
|
||||
def test_uniform_failed_io_poisoning(self):
|
||||
run=object.__new__(gpu.UniformGPU); run.configs=(config(12),); run.status='ready'
|
||||
run.ctx=SimpleNamespace(activate=nullcontext); run.buffers=['f']; run.cuda=SimpleNamespace(memcpy_htod=Mock(side_effect=RuntimeError('copy')))
|
||||
with self.assertRaisesRegex(RuntimeError,'copy'): run.initialize(np.broadcast_to(ref.cpu.W[:,None,None],(9,12,12)))
|
||||
self.assertEqual(run.status,'failed'); run.status='ready'; run.read=Mock(side_effect=RuntimeError('read'))
|
||||
with self.assertRaisesRegex(RuntimeError,'read'): run._download_stored()
|
||||
self.assertEqual(run.status,'failed')
|
||||
|
||||
def test_uniform_health_mask_reaches_both_kernels(self):
|
||||
run=object.__new__(gpu.UniformGPU); run.configs=(config(16),); run.status='ready'; run.identity=False
|
||||
run.ctx=SimpleNamespace(activate=nullcontext); run.buffers=['f','g','out']; run.active=object(); run.counts='counts'
|
||||
run.stream=object(); run.cuda=SimpleNamespace(memset_d32_async=Mock()); run.launch=Mock()
|
||||
run.read=Mock(return_value=np.zeros(6,np.uint32)); run.health_counts=np.zeros(6,np.uint64); run.step_count=0
|
||||
run.step(audit=False)
|
||||
self.assertEqual([call.args[1] for call in run.launch.call_args_list],['Collide','Pull'])
|
||||
self.assertTrue(all(call.args[4] is run.active for call in run.launch.call_args_list))
|
||||
|
||||
def test_zero_error_comparisons(self):
|
||||
rows=[dict(tstar=t,velocity_l2=0.,pressure_l2=0.,energy_relative=0.) for t in ladder.old.TIMES]
|
||||
result=ladder.comparisons([dict(name=name,rows=rows) for name,_,_ in ladder.CASES],dict(rows=rows))
|
||||
self.assertIsNone(result['trends'][0]['samples'][0]['pressure_l2']['ratio'])
|
||||
self.assertNotIn('two_point_slope',result['controls'][0]['samples'][0]['pressure_l2'])
|
||||
self.assertIn('two_point_slope',result['trends'][0]['samples'][0]['pressure_l2'])
|
||||
json.dumps(result,allow_nan=False)
|
||||
|
||||
def test_frozen_screen_and_json_receipt(self):
|
||||
rows=[dict(tstar=t,minimum_population=.02,**{key:np.float64(limit*.5) for key,limit in ladder.LIMITS.items()}) for t in ladder.old.TIMES]
|
||||
self.assertTrue(ladder.assess(rows,[0]*6)['campaign_pass'])
|
||||
rows[0]['mass_relative']=2e-5
|
||||
failed=ladder.assess(rows,[0]*6); self.assertFalse(failed['campaign_pass'])
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path=Path(tmp)/'receipt.json'; ref.write_receipt(path,dict(completed=True,passed=np.bool_(False),assessment=failed))
|
||||
saved=json.loads(path.read_text()); self.assertFalse(saved['passed']); self.assertTrue(saved['completed'])
|
||||
|
||||
|
||||
class Device(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if not ADMITTED: raise unittest.SkipTest('explicit tiny GPU admission required')
|
||||
from CelerisLab.cuda.context import CudaContext
|
||||
cls.context=CudaContext(0)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls): cls.context.close()
|
||||
|
||||
def test_uniform_actual_upload_oracle_and_reset(self):
|
||||
for n in (16,64,128,256):
|
||||
with gpu.UniformGPU(config(n),context=self.context) as run:
|
||||
analytic=tg.initial((n,n),0,n,.0003*n); run.initialize(analytic); start=run._download_stored()
|
||||
actual_start=run.download(); mask=np.ones((n,n),bool)
|
||||
spec=ref.Spec(n_coarse=n,patch_lo=1,halo=2,tau_coarse=.5+.0009*n,tau_fine=.5+.0018*n,collision='srt')
|
||||
collided=ref.cpu._collide(spec,actual_start,np.isfinite(actual_start),spec.tau_coarse,mask,np.zeros((n,n),int))
|
||||
expected=np.stack([np.roll(collided[q],(int(ref.cpu.CY[q]),int(ref.cpu.CX[q])),axis=(0,1)) for q in range(9)])
|
||||
ledger=run.step(); actual=run.download()
|
||||
np.testing.assert_allclose(actual,expected,rtol=0,atol=3e-6)
|
||||
audited=run._download_stored(); run.initialize(analytic); run.step(audit=False)
|
||||
np.testing.assert_array_equal(audited.view(np.uint32),run._download_stored().view(np.uint32))
|
||||
run.initialize(analytic); np.testing.assert_array_equal(start.view(np.uint32),run._download_stored().view(np.uint32))
|
||||
self.assertEqual(run.step_count,0); self.assertFalse(run.health_counts.any())
|
||||
RECORDS.append(dict(case=f'uniform{n}_one_interval',error=float(abs(actual-expected).max()),ledger=ledger,
|
||||
build_metadata=run.build_metadata,device_bytes=run.device_bytes,raw_reset_verified=True))
|
||||
|
||||
def test_uniform_tagged_phase_and_health_controls(self):
|
||||
n=16; rng=np.random.default_rng(n)
|
||||
analytic=np.broadcast_to(ref.cpu.W[:,None,None],(9,n,n))*(1+1e-3*rng.standard_normal((9,n,n)))
|
||||
for identity in (True,False):
|
||||
with gpu.UniformGPU(config(n),context=self.context,identity=identity) as run:
|
||||
run.initialize(analytic); start=run.download(); raw_start=run._download_stored(); mask=np.ones((n,n),bool)
|
||||
spec=ref.Spec(tau_coarse=.5+.0009*n,collision='identity' if identity else 'srt')
|
||||
collided=ref.cpu._collide(spec,start,np.isfinite(start),spec.tau_coarse,mask,np.zeros((n,n),int))
|
||||
expected=np.stack([np.roll(collided[q],(int(ref.cpu.CY[q]),int(ref.cpu.CX[q])),axis=(0,1)) for q in range(9)])
|
||||
ledger=run.step(); actual=run.download(); np.testing.assert_allclose(actual,expected,rtol=0,atol=3e-6)
|
||||
if identity:
|
||||
rolled=np.stack([np.roll(raw_start[q],(int(ref.cpu.CY[q]),int(ref.cpu.CX[q])),axis=(0,1)) for q in range(9)])
|
||||
np.testing.assert_array_equal(run._download_stored().view(np.uint32),rolled.view(np.uint32))
|
||||
if not identity:
|
||||
wrong=ref.cpu._collide(spec,expected,np.isfinite(expected),spec.tau_coarse,mask,np.zeros((n,n),int))
|
||||
self.assertGreater(float(abs(actual-wrong).max()),3e-6)
|
||||
RECORDS.append(dict(case='uniform_tagged',identity=identity,error=float(abs(actual-expected).max()),ledger=ledger))
|
||||
with gpu.UniformGPU(config(n),context=self.context) as run:
|
||||
run.initialize(analytic)
|
||||
with self.context.activate():
|
||||
bad=np.broadcast_to(-gpu._WEIGHTS-.01,(9,n,n)).astype(np.float32).copy()
|
||||
run.cuda.memcpy_htod(run.buffers[0],bad)
|
||||
with self.assertRaises(AssertionError): run.step()
|
||||
self.assertTrue(run.health_counts.any()); self.assertEqual(run.status,'failed')
|
||||
RECORDS.append(dict(case='uniform_health_negative_control',health_counts=run.health_counts.tolist()))
|
||||
|
||||
def test_refined_n128_actual_upload_oracle(self):
|
||||
with gpu.SpaceTimeGPU(config(128),(16,16,112,96),context=self.context,allow_n128=True) as run:
|
||||
tables=ladder.old.table_guard.validate_tables(run.plan,run.tables,allow_n128=True)
|
||||
run.initialize(ref.cpu.State(tg.initial((128,128),0,128,.0384),tg.initial((256,256),1,128,.0384)))
|
||||
spec=ref.Spec(n_coarse=128,patch_lo=16,halo=32,tau_coarse=.6152,tau_fine=.7304,collision='srt')
|
||||
expected,_=ref.advance(spec,run.plan,run.download()); ledger=run.step(); actual=run.download(); errors=[]
|
||||
for a,b in zip((actual.coarse_f,actual.fine_f),(expected.coarse_f,expected.fine_f)):
|
||||
np.testing.assert_allclose(a,b,rtol=0,atol=3e-6,equal_nan=True); errors.append(float(np.nanmax(abs(a-b))))
|
||||
RECORDS.append(dict(case='refined128_one_interval',errors=errors,ledger=ledger,tables=tables,
|
||||
build_metadata=run.build_metadata,device_bytes=run.device_bytes))
|
||||
|
||||
|
||||
def main():
|
||||
global ADMITTED
|
||||
p=argparse.ArgumentParser(description=__doc__); modes=p.add_mutually_exclusive_group(required=True)
|
||||
modes.add_argument('--host-only',action='store_true'); modes.add_argument('--gpu-admitted',action='store_true')
|
||||
p.add_argument('--output',type=Path,required=True); args=p.parse_args(); ADMITTED=args.gpu_admitted
|
||||
if args.output.exists(): p.error('output exists; preserve evidence')
|
||||
if ADMITTED and not os.environ.get('CUDA_VISIBLE_DEVICES'): p.error('explicit CUDA_VISIBLE_DEVICES required')
|
||||
paths=(Path(__file__),Path(ladder.__file__),Path(gpu.__file__),Path(gpu.__file__).with_suffix('.cu'),Path(ladder.old.table_guard.__file__))
|
||||
report=dict(completed=False,passed=False,mode='tiny_gpu' if ADMITTED else 'host_only',records=RECORDS,
|
||||
source=dict(commit=subprocess.check_output(['git','rev-parse','HEAD'],cwd=ladder.ROOT,text=True).strip(),
|
||||
dirty=bool(subprocess.check_output(['git','status','--porcelain'],cwd=ladder.ROOT,text=True)),
|
||||
hashes={p.name:ladder.old.sha(p) for p in paths}))
|
||||
ref.write_receipt(args.output,report); started=time.monotonic()
|
||||
suite=unittest.TestLoader().loadTestsFromTestCase(Host)
|
||||
if ADMITTED: suite.addTests(unittest.TestLoader().loadTestsFromTestCase(Device))
|
||||
result=unittest.TextTestRunner(verbosity=2).run(suite)
|
||||
report.update(completed=True,passed=result.wasSuccessful(),tests=result.testsRun,seconds=time.monotonic()-started,
|
||||
failures=[dict(test=str(t),error=e) for t,e in result.errors+result.failures])
|
||||
ref.write_receipt(args.output,report); return 0 if report['passed'] else 1
|
||||
|
||||
|
||||
if __name__=='__main__': raise SystemExit(main())
|
||||
@@ -1,155 +0,0 @@
|
||||
"""Tiny CPU algebra/event tests; no full trajectory or GPU execution."""
|
||||
from dataclasses import replace
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
import numpy as np
|
||||
import spacetime_reference as st
|
||||
|
||||
|
||||
class SpaceTime(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.plan = st.Plan(12, (3, 3, 9, 8))
|
||||
cls.spec = st.Spec(n_coarse=12, patch_lo=1, halo=2,
|
||||
tau_coarse=.5576, tau_fine=.6152, collision="srt")
|
||||
|
||||
def state(self, tagged=False):
|
||||
arrays = []
|
||||
for n, mask in ((12,self.plan.maps.coarse_active),(24,self.plan.maps.fine_active)):
|
||||
q, y, x = np.indices((9,n,n))
|
||||
a = np.broadcast_to(st.cpu.W[:,None,None], (9,n,n)).copy()
|
||||
if tagged:
|
||||
a *= 1+1e-3*np.random.default_rng(n).normal(size=a.shape)
|
||||
a[:,~mask] = np.nan
|
||||
arrays.append(a)
|
||||
return st.cpu.State(*arrays)
|
||||
|
||||
def test_events_and_corner_leftovers(self):
|
||||
groups = [g for g in self.plan.groups if None in g.events]
|
||||
self.assertEqual(len(groups), 8)
|
||||
self.assertTrue(all(g.events.count(None)==3 for g in groups))
|
||||
for m in (0,1):
|
||||
events = list(map(tuple,self.plan.outgoing[m]))
|
||||
self.assertEqual(len(events),len(set(events)))
|
||||
st.Plan(16,(4,5,11,12))
|
||||
|
||||
def test_interpolation(self):
|
||||
for g in self.plan.groups:
|
||||
samples, a, e, coeff = st.interpolation(g.kind,g.points)
|
||||
np.testing.assert_allclose(g.weights@np.ones(len(samples)),1,rtol=0,atol=2e-14)
|
||||
np.testing.assert_allclose(g.weights.mean(axis=0), np.eye(len(samples))[1 if g.kind=='face' else 0],rtol=0,atol=2e-14)
|
||||
if g.kind=='face':
|
||||
beta=np.array([1.,.03,.02,0.])
|
||||
else:
|
||||
cx,cy=st.cpu.CX[g.q],st.cpu.CY[g.q]
|
||||
beta=np.array([1.,.03,.02,-cx*.03-cy*.02])
|
||||
steady=a@np.array([1.,.03,.02,0.])
|
||||
self.assertLess(abs((coeff@steady)[3]),2e-14)
|
||||
np.testing.assert_allclose(g.weights@(a@beta),e@beta,rtol=0,atol=2e-14)
|
||||
|
||||
def test_rest(self):
|
||||
initial=self.state()
|
||||
final,d=st.advance(self.spec,self.plan,initial)
|
||||
for a,b in zip((initial.coarse_f,initial.fine_f),(final.coarse_f,final.fine_f)):
|
||||
np.testing.assert_allclose(a,b,rtol=0,atol=2e-14,equal_nan=True)
|
||||
|
||||
def test_frozen_recovers_ivp(self):
|
||||
for collision in ('identity','srt'):
|
||||
spec=replace(self.spec,collision=collision)
|
||||
a=b=self.state(True)
|
||||
for _ in range(2):
|
||||
a,_=st.advance(spec,self.plan,a,'frozen')
|
||||
b,_=st.cpu.advance_coarse_once(spec,self.plan.maps,b)
|
||||
for aa,bb in zip((a.coarse_f,a.fine_f),(b.coarse_f,b.fine_f)):
|
||||
np.testing.assert_allclose(aa,bb,rtol=0,atol=2e-14,equal_nan=True)
|
||||
|
||||
def test_second_substep_uses_characteristic_origin(self):
|
||||
# Review counterexample in (x,y): ghost (7,10), m=1 traces back
|
||||
# to (6,9), parent (3,4), not its current parent (3,5).
|
||||
plan=st.Plan(16,(4,4,12,12))
|
||||
owners=[g for g in plan.groups if g.q==5 and (1,10*32+7) in g.events]
|
||||
self.assertEqual(len(owners),1)
|
||||
self.assertEqual(owners[0].source,4*16+3)
|
||||
spec=st.Spec(n_coarse=16,patch_lo=2,halo=4,collision='identity')
|
||||
arrays=[]
|
||||
for n,mask in ((16,plan.maps.coarse_active),(32,plan.maps.fine_active)):
|
||||
a=np.broadcast_to(st.cpu.W[:,None,None],(9,n,n)).copy()
|
||||
a[:,~mask]=np.nan
|
||||
arrays.append(a)
|
||||
arrays[0][5,4,3]=1.; arrays[0][5,5,3]=2.
|
||||
final,_=st.advance(spec,plan,st.cpu.State(*arrays),'frozen')
|
||||
self.assertEqual(final.fine_f[5,11,8],1.)
|
||||
self.assertNotEqual(final.fine_f[5,11,8],arrays[0][5,5,3])
|
||||
|
||||
def test_corner_affine_tradeoff_is_explicit(self):
|
||||
group=next(g for g in self.plan.groups if g.kind=='corner')
|
||||
_,a,e,coeff=st.interpolation(group.kind,group.points)
|
||||
linear_x=np.array([0.,1.,0.,0.])
|
||||
# The two paper constraints are consistent. Arbitrary affine exactness
|
||||
# is the incompatible additional demand.
|
||||
self.assertEqual(abs(float((e@linear_x).mean())),.125)
|
||||
fitted=coeff@(a@linear_x)
|
||||
self.assertLess(abs(fitted[3]),2e-14)
|
||||
self.assertLess(abs(float((e@fitted).mean())),2e-14)
|
||||
self.assertGreater(np.max(np.abs(e@fitted-e@linear_x)),.01)
|
||||
|
||||
def test_directional_budgets(self):
|
||||
for collision in ('identity','srt'):
|
||||
spec=replace(self.spec,collision=collision)
|
||||
a=self.state(True)
|
||||
for _ in range(2):
|
||||
a,d=st.advance(spec,self.plan,a)
|
||||
np.testing.assert_allclose(d['directional_residual'],0,rtol=0,atol=2e-12)
|
||||
self.assertLess(d['group_error'],2e-14)
|
||||
|
||||
def test_missing_packets_fail(self):
|
||||
for mutation in ('omit_leftover','omit_outgoing_0','omit_outgoing_1'):
|
||||
_,d=st.advance(self.spec,self.plan,self.state(True),mutation=mutation)
|
||||
self.assertGreater(np.max(np.abs(d['directional_residual'])),1e-5)
|
||||
|
||||
def test_negative_interpolation_is_reported_not_clipped(self):
|
||||
initial=self.state()
|
||||
group=self.plan.groups[0]
|
||||
initial.coarse_f.reshape(9,-1)[group.q,group.source]=-.01
|
||||
spec=replace(self.spec,collision='identity')
|
||||
_,d=st.advance(spec,self.plan,initial,'frozen')
|
||||
self.assertGreaterEqual(d['negative_boundary_count'],4)
|
||||
self.assertEqual(d['min_boundary'],-.01)
|
||||
self.assertEqual(d['nonfinite_boundary_count'],0)
|
||||
|
||||
|
||||
class Receipts(unittest.TestCase):
|
||||
def test_completed_and_failed_screen_receipts(self):
|
||||
for passed in (True, False):
|
||||
with self.subTest(passed=passed), tempfile.TemporaryDirectory() as directory:
|
||||
# Same NumPy comparison type produced by the final metric screen.
|
||||
checks = {'velocity_l2': np.float64(.5 if passed else 1.) <= .8,
|
||||
'mass_momentum': True}
|
||||
self.assertIsInstance(checks['velocity_l2'], np.bool_)
|
||||
result = dict(completed=True, checks=checks,
|
||||
experimental_pass=all(checks.values()),
|
||||
runs=[dict(rows=[dict(step=np.int64(1024),
|
||||
mass_relative=np.float32(0.))])])
|
||||
path = Path(directory)/'receipt.json'
|
||||
st.write_receipt(path, result)
|
||||
saved = json.loads(path.read_text())
|
||||
self.assertIs(saved['completed'], True)
|
||||
self.assertIs(saved['experimental_pass'], passed)
|
||||
self.assertIs(saved['checks']['velocity_l2'], passed)
|
||||
self.assertEqual(saved['runs'][0]['rows'][0],
|
||||
dict(step=1024, mass_relative=0.))
|
||||
|
||||
def test_receipts_still_reject_nonfinite_and_unknown_values(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory)/'receipt.json'
|
||||
with self.assertRaises(ValueError):
|
||||
st.write_receipt(path, {'invalid': np.float32('nan')})
|
||||
with self.assertRaises(TypeError):
|
||||
st.write_receipt(path, {'invalid': object()})
|
||||
self.assertFalse(path.exists())
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
unittest.main(verbosity=2)
|
||||
@@ -1,287 +0,0 @@
|
||||
"""Host-only ownership/geometry tests, never a GPU or accuracy admission."""
|
||||
import copy
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
from CelerisLab.config import LBMConfig
|
||||
from CelerisLab.lbm.blocks import BlockLayout2D
|
||||
from CelerisLab.lbm.refinement import FixedPatch2D, FixedPatchSet2D
|
||||
from spacetime_union_plan import UnionPlan, UnresolvedTopology, owned_slots, validate_ownership
|
||||
import spacetime_reference as ref
|
||||
import spacetime_gpu as frozen # pack_plan only: no CUDA import/owner construction
|
||||
from spacetime_table_guard import exact_weights
|
||||
|
||||
MATRIX=[]
|
||||
ENUMERATION={}
|
||||
CASES={
|
||||
'rectangle': ([(4,4,12,12)],True),
|
||||
'two_separated': ([(3,3,6,6),(9,9,12,12)],True),
|
||||
'one_cell_face_gap': ([(3,3,6,10),(7,3,10,10)],True),
|
||||
'one_cell_diagonal_gap': ([(3,3,6,6),(7,7,10,10)],True),
|
||||
'staggered_gap': ([(3,3,6,8),(7,5,11,10)],True),
|
||||
'thin_rectangle': ([(4,4,5,11)],True),
|
||||
'concave_L': ([(4,4,5,7),(4,5,7,7)],True),
|
||||
'corner_contact': ([(3,3,6,6),(6,6,9,9)],True),
|
||||
'cross': ([(3,6,13,9),(6,3,9,13)],True),
|
||||
'narrow_notch': ([(3,3,6,11),(7,3,10,11),(3,9,10,11)],True),
|
||||
'hole': ([(3,3,13,5),(3,11,13,13),(3,5,5,11),(11,5,13,11)],True),
|
||||
}
|
||||
|
||||
|
||||
def pair(rects,n=16,ny=None):
|
||||
cfg=LBMConfig(nx=n,ny=n if ny is None else ny,viscosity=.0192,velocity=0.)
|
||||
return FixedPatchSet2D.from_config(cfg,rects)
|
||||
|
||||
|
||||
def assert_same(test,a,b):
|
||||
# Includes event-child order, zero padding, integer dtype/shape and FP64 bits.
|
||||
aa,bb=frozen.pack_plan(a),frozen.pack_plan(b)
|
||||
test.assertEqual(aa.keys(),bb.keys())
|
||||
for key in aa:
|
||||
test.assertEqual((aa[key].dtype,aa[key].shape,aa[key].tobytes()),
|
||||
(bb[key].dtype,bb[key].shape,bb[key].tobytes()),key)
|
||||
for x,y in zip(a.groups,b.groups):
|
||||
test.assertEqual((x.q,x.source,x.receiver,x.kind,x.points,x.events),
|
||||
(y.q,y.source,y.receiver,y.kind,y.points,y.events))
|
||||
# Frozen pack_plan has no transit column and is NOT a union runtime format.
|
||||
np.testing.assert_array_equal(getattr(a,'transit',np.empty((0,3),int)),
|
||||
getattr(b,'transit',np.empty((0,3),int)))
|
||||
|
||||
|
||||
class UnionContract(unittest.TestCase):
|
||||
def test_single_rectangle_exact_707afe6(self):
|
||||
frozen.check_sources()
|
||||
for n,b in ((12,(3,3,9,8)),(16,(4,5,11,12)),(16,(4,4,5,7)),(64,(8,8,56,48))):
|
||||
with self.subTest(n=n,bounds=b):
|
||||
assert_same(self,UnionPlan(pair([b],n)),ref.Plan(n,b))
|
||||
self.assertNotIn('pycuda.driver',sys.modules)
|
||||
|
||||
def test_union_decomposition_and_permutation_invariance(self):
|
||||
baseline=UnionPlan(pair([(4,4,12,12)]))
|
||||
decompositions=[[(4,4,8,12),(8,4,12,12)],[(4,4,10,12),(6,4,12,12)],
|
||||
[(4,4,12,8),(4,8,12,12)],[(4,4,12,12),(4,4,12,12)]]
|
||||
for rectangles in decompositions:
|
||||
for order in (rectangles,list(reversed(rectangles))):
|
||||
assert_same(self,baseline,UnionPlan(pair(order)))
|
||||
two=CASES['two_separated'][0]
|
||||
assert_same(self,UnionPlan(pair(two)),UnionPlan(pair(list(reversed(two)))))
|
||||
lshape=CASES['concave_L'][0]
|
||||
for decomposition in (list(reversed(lshape)),[(4,4,5,5),(4,5,7,7)],lshape+lshape):
|
||||
assert_same(self,UnionPlan(pair(lshape)),UnionPlan(pair(decomposition)))
|
||||
|
||||
def test_supported_unresolved_matrix(self):
|
||||
for name,(rectangles,supported) in CASES.items():
|
||||
geometry=pair(rectangles) # Existing production accepts EVERY fixture.
|
||||
row=dict(case=name,rectangles=rectangles,production_geometry_accepted=True,
|
||||
runtime_supported=False,interpolation_accuracy='unqualified_for_new_unions')
|
||||
if supported:
|
||||
plan=UnionPlan(geometry); row.update(plan.proof)
|
||||
self.assertEqual(plan.proof['receiver_quarter_packets'],4*len(plan.receivers))
|
||||
else:
|
||||
with self.assertRaises(UnresolvedTopology) as caught: UnionPlan(geometry)
|
||||
row.update(topology='unresolved',code=caught.exception.code,witness=caught.exception.detail)
|
||||
MATRIX.append(row)
|
||||
|
||||
def test_L_witness_and_reflections(self):
|
||||
rectangles=CASES['concave_L'][0]
|
||||
plan=UnionPlan(pair(rectangles))
|
||||
self.assertIn((5,8*32+9,9*32+10),map(tuple,plan.transit))
|
||||
directions=set()
|
||||
for reflect in (False,True):
|
||||
transformed=[(16-x1,y0,16-x0,y1) if reflect else (x0,y0,x1,y1) for x0,y0,x1,y1 in rectangles]
|
||||
for _ in range(4):
|
||||
plan=UnionPlan(pair(transformed)); self.assertEqual(len(plan.transit),2)
|
||||
for q,source,ghost in plan.transit:
|
||||
directions.add(q); p=np.array(divmod(source,32)); c=np.array((ref.cpu.CY[q],ref.cpu.CX[q]))
|
||||
self.assertEqual(ghost,(p+c)[0]*32+(p+c)[1])
|
||||
self.assertEqual([bool(plan.maps.fine_active[tuple(p+j*c)]) for j in range(3)],[True,False,True])
|
||||
transformed=[(16-y1,x0,16-y0,x1) for x0,y0,x1,y1 in transformed]
|
||||
self.assertEqual(directions,{5,6,7,8})
|
||||
|
||||
def test_Fig19_two_hop_transport_and_quarter_budget(self):
|
||||
# Guzik §3.5.4 Fig.19: https://www.osti.gov/servlets/purl/1226215 .
|
||||
# Isolated tagged transport only: no collision law or union evolution.
|
||||
for name in ('concave_L','corner_contact','hole'):
|
||||
p=UnionPlan(pair(CASES[name][0])); q,source,ghost=p.transit.T
|
||||
stride=ref.cpu.CY[q]*p.nf+ref.cpu.CX[q]; target=ghost+stride
|
||||
tags=(np.arange(len(q),dtype=np.float32)+1)/256
|
||||
active=p.maps.fine_active
|
||||
def run(shifted,mutation=None):
|
||||
scratch=np.full((9,p.nf,p.nf),np.nan,np.float32)
|
||||
scratch.reshape(9,-1)[q,source]=tags
|
||||
def moment(field):
|
||||
value=field.astype(float)+(ref.cpu.W[:,None,None] if shifted else 0)
|
||||
return .25*np.where(np.isfinite(field)&active,value,0).sum(axis=(1,2))
|
||||
def pull(field):
|
||||
moved=np.stack([np.roll(field[j],(ref.cpu.CY[j],ref.cpu.CX[j]),axis=(0,1)) for j in range(9)])
|
||||
moved[:,~active]=np.nan
|
||||
return moved
|
||||
initial=moment(scratch)
|
||||
carry=[(int(j),int(h),v.copy()) for j,h,v in zip(q,ghost,scratch.reshape(9,-1)[q,source])]
|
||||
if mutation=='drop': carry.pop()
|
||||
if mutation=='duplicate': carry.append(carry[0])
|
||||
first=pull(scratch) # m0 ghost arrivals are outside the ACTIVE mask.
|
||||
inventory=np.zeros(9)
|
||||
for j,h,value in carry: inventory[j]+=.25*(float(value)+(ref.cpu.W[j] if shifted else 0))
|
||||
np.testing.assert_allclose(moment(first)+inventory,initial,rtol=0,atol=2e-14)
|
||||
if mutation=='early':
|
||||
for j,h,value in carry: scratch.reshape(9,-1)[j,h]=value
|
||||
scratch[:]=np.nan # m1 collision scratch reuse destroys an early injection.
|
||||
while carry:
|
||||
j,h,value=carry.pop()
|
||||
if mutation!='early': scratch.reshape(9,-1)[j,h]=value
|
||||
final=pull(scratch)
|
||||
self.assertEqual(carry,[]) # Consumed route inventory, not subtraction of identical sums.
|
||||
np.testing.assert_allclose(moment(final),initial,rtol=0,atol=2e-14)
|
||||
arrivals=final.reshape(9,-1)[q,target]
|
||||
np.testing.assert_array_equal(arrivals.view(np.uint32),tags.view(np.uint32))
|
||||
for shifted in (False,True):
|
||||
run(shifted)
|
||||
for mutation in ('drop','duplicate','early'):
|
||||
with self.assertRaises(AssertionError): run(shifted,mutation)
|
||||
for direction,slot,_ in p.transit:
|
||||
self.assertNotIn((direction,slot),{tuple(row[:2]) for row in p.outgoing[0]})
|
||||
for change in (lambda b:setattr(b,'transit',b.transit[1:]),
|
||||
lambda b:setattr(b,'transit',np.vstack((b.transit,b.transit[0]))),
|
||||
lambda b:b.transit.__setitem__((0,2),b.transit[0,2]+1),
|
||||
lambda b:b.transit.__setitem__((0,0),1)):
|
||||
bad=copy.deepcopy(p); change(bad)
|
||||
with self.assertRaises(ValueError): validate_ownership(bad)
|
||||
bad=copy.deepcopy(p); direction,slot,_=p.transit[0]
|
||||
y,x=divmod(slot,p.nf); cy,cx=ref.cpu.CY[direction],ref.cpu.CX[direction]
|
||||
covered_target=((y+2*cy)//2)*p.n+(x+2*cx)//2
|
||||
bad.outgoing[0]=np.vstack((bad.outgoing[0],[direction,slot,covered_target]))
|
||||
with self.assertRaises(ValueError): validate_ownership(bad)
|
||||
|
||||
def test_Fig13_first_entry_and_wrong_crossing(self):
|
||||
p=UnionPlan(pair(CASES['concave_L'][0]))
|
||||
g=next(g for g in p.groups if g.source==4*16+5 and g.q==8)
|
||||
self.assertEqual(g.kind,'corner')
|
||||
self.assertEqual(sorted(point[-1] for point in g.points),[-.25,-.25,-.25,.25])
|
||||
# For a concave T first entry is min, not the frozen convex max.
|
||||
damaged=copy.deepcopy(p); wrong=next(x for x in damaged.groups if x.source==g.source and x.q==g.q)
|
||||
origin=np.array((4,5)); c=np.array((ref.cpu.CY[g.q],ref.cpu.CX[g.q])); points=[]
|
||||
for child in ((0,0),(0,1),(1,0),(1,1)):
|
||||
position=origin+(np.array(child)+.5)/2
|
||||
time=max((origin+ (c>0)-position)/c); offset=position+c*time-(origin+.5+c*.5)
|
||||
points.append((float(offset[1]),float(offset[0]),float(time-.5)))
|
||||
wrong.points=tuple(points)
|
||||
with self.assertRaises(ValueError): validate_ownership(damaged)
|
||||
|
||||
def test_all_511_small_masks(self):
|
||||
counts=dict(masks=0,with_transit=0,transit_paths=0)
|
||||
for bits in range(1,512):
|
||||
rectangles=[(4+x,4+y,5+x,5+y) for y in range(3) for x in range(3) if bits & (1<<(3*y+x))]
|
||||
p=UnionPlan(pair(rectangles)); counts['masks']+=1
|
||||
counts['with_transit']+=bool(len(p.transit)); counts['transit_paths']+=len(p.transit)
|
||||
# Independent first-entry check: fine-cell membership before/after
|
||||
# each quarter crossing, without the plan's plane min/max helper.
|
||||
for g in p.groups:
|
||||
c=np.array((ref.cpu.CY[g.q],ref.cpu.CX[g.q])); origin=np.array(divmod(g.source,p.n))
|
||||
for k,child in enumerate(((0,0),(0,1),(1,0),(1,1))):
|
||||
start=2*origin+child; hits=[]
|
||||
for t4 in (1,3):
|
||||
before=(4*start+2+c*(2*t4-1))//4
|
||||
after=(4*start+2+c*(2*t4+1))//4
|
||||
if not p.maps.fine_active[tuple(before)] and p.maps.fine_active[tuple(after)]: hits.append(t4/4)
|
||||
if hits: self.assertEqual(g.points[k][-1]+.5,hits[0])
|
||||
self.assertEqual(counts,dict(masks=511,with_transit=384,transit_paths=2048)); ENUMERATION.update(counts)
|
||||
|
||||
def test_ordered_provenance_and_packet_mutations_fail(self):
|
||||
baseline=UnionPlan(pair(CASES['two_separated'][0]))
|
||||
def reject(change):
|
||||
bad=copy.deepcopy(baseline); change(bad)
|
||||
with self.assertRaises((ValueError,KeyError)): validate_ownership(bad)
|
||||
reject(lambda p:setattr(p.groups[0],'source',p.groups[0].source+1))
|
||||
reject(lambda p:setattr(p.groups[0],'samples',p.groups[0].samples+1))
|
||||
reject(lambda p:setattr(p.groups[0],'events',tuple(reversed(p.groups[0].events))))
|
||||
reject(lambda p:setattr(p.groups[0],'receiver',p.groups[0].receiver+1))
|
||||
reject(lambda p:p.outgoing.__setitem__(0,p.outgoing[0][1:]))
|
||||
reject(lambda p:p.outgoing.__setitem__(1,np.vstack((p.outgoing[1],p.outgoing[1][0]))))
|
||||
reject(lambda p:setattr(p,'receivers',p.receivers[1:]))
|
||||
def balanced(p):
|
||||
p.groups[0].weights[:2,:2]+=1e-5*np.array([[1,-1],[-1,1]])
|
||||
reject(balanced) # Preserves row/column sums, corrupts the actual operator.
|
||||
|
||||
def test_coefficients_and_directional_transfer_budget(self):
|
||||
rng=np.random.default_rng(31)
|
||||
for name in CASES:
|
||||
p=UnionPlan(pair(CASES[name][0])); total=np.zeros(9)
|
||||
receiver={tuple(row):[] for row in p.receivers}; first_leftover=None
|
||||
for g in p.groups:
|
||||
samples,a,e,coeff=ref.interpolation(g.kind,g.points)
|
||||
np.testing.assert_allclose(g.weights,exact_weights(g.kind,g.points),rtol=0,atol=2e-14)
|
||||
np.testing.assert_allclose(g.weights@np.ones(len(samples)),1,rtol=0,atol=2e-14)
|
||||
if g.kind=='face': beta=np.array([1.,.03,.02,0.])
|
||||
else:
|
||||
beta=np.array([1.,.03,.02,-ref.cpu.CX[g.q]*.03-ref.cpu.CY[g.q]*.02])
|
||||
self.assertLess(abs((coeff@(a@np.array([1.,.03,.02,0.])))[3]),2e-14)
|
||||
np.testing.assert_allclose(g.weights@(a@beta),e@beta,rtol=0,atol=2e-14)
|
||||
values=rng.normal(size=len(samples)); h=g.weights@values
|
||||
origin=values[0 if g.kind=='corner' else 1]
|
||||
# Fine incoming plus coarse leftovers must exactly split one
|
||||
# coarse directional source budget, regardless of sample values.
|
||||
incoming=sum(h[k] for k,event in enumerate(g.events) if event is not None)
|
||||
leftover=sum(h[k] for k,event in enumerate(g.events) if event is None)
|
||||
self.assertLess(abs(.25*(incoming+leftover)-origin),2e-14)
|
||||
total[g.q]+=.25*incoming-origin
|
||||
for k,event in enumerate(g.events):
|
||||
if event is None:
|
||||
receiver[(g.q,g.receiver)].append(h[k])
|
||||
if first_leftover is None: first_leftover=(g.q,h[k])
|
||||
# Tagged collision-free boundary-flux ledger, not a solver trajectory:
|
||||
# every outgoing fine debit is credited to its actual receiver.
|
||||
for rows in p.outgoing:
|
||||
for q,slot,target in rows:
|
||||
value=float(rng.normal()); total[q]-=.25*value
|
||||
receiver[(q,target)].append(value)
|
||||
for (q,target),values in receiver.items():
|
||||
self.assertEqual(len(values),4); total[q]+=.25*sum(values)
|
||||
np.testing.assert_allclose(total,0,rtol=0,atol=2e-14)
|
||||
damaged=total.copy(); q,value=first_leftover; damaged[q]-=.25*value
|
||||
self.assertGreater(float(abs(damaged).max()),1e-5)
|
||||
self.assertEqual(validate_ownership(p)['topology'],'supported')
|
||||
|
||||
def test_existing_block_addressing_and_sparse_holes(self):
|
||||
geometry=pair(CASES['two_separated'][0]); p=UnionPlan(geometry)
|
||||
layouts=(BlockLayout2D.build(16,16,4,4),geometry.fine_layout((4,4)))
|
||||
for fine,layout in enumerate(layouts):
|
||||
cells=layout.shape[0]*layout.shape[1]
|
||||
dense=np.arange(9*cells,dtype=np.float64).reshape(9,*layout.shape)
|
||||
blocked=layout.to_blocked(dense).ravel()
|
||||
refs=[]
|
||||
if not fine:
|
||||
refs=[(g.q,int(k)) for g in p.groups for k in [g.source,g.receiver,*g.samples]]
|
||||
else:
|
||||
refs=[(g.q,slot) for g in p.groups for event in g.events if event is not None for _,slot in [event]]
|
||||
refs += [(int(q),int(slot)) for rows in p.outgoing for q,slot,_ in rows]
|
||||
q,k=np.array(refs).T
|
||||
np.testing.assert_array_equal(blocked[owned_slots(layout,q,k)],dense.reshape(9,-1)[q,k])
|
||||
holes=np.flatnonzero(~layouts[1].owned)
|
||||
self.assertGreater(len(holes),0)
|
||||
with self.assertRaises(UnresolvedTopology): owned_slots(layouts[1],np.array([1]),holes[:1])
|
||||
|
||||
def test_deferred_layouts_and_phase_are_explicit(self):
|
||||
with self.assertRaises(UnresolvedTopology) as caught: UnionPlan(pair([(3,3,8,8)],16,12))
|
||||
self.assertEqual(caught.exception.code,'requires_level_wide_square_pair')
|
||||
cfg=LBMConfig(nx=16,ny=16,viscosity=.0192,velocity=0.)
|
||||
scalar=FixedPatch2D.from_config(cfg,(4,4,8,8))
|
||||
with self.assertRaises(UnresolvedTopology): UnionPlan(scalar)
|
||||
self.assertEqual(UnionPlan.phase,'arrival_f'); self.assertFalse(UnionPlan.runtime_supported)
|
||||
# Restriction and collision do not commute, even at omega=1. This is
|
||||
# why postcollision G storage cannot be relabeled/inverted into this F contract.
|
||||
w=ref.cpu.W; cx,cy=ref.cpu.CX,ref.cpu.CY
|
||||
def equilibrium(u):
|
||||
cu=cx*u;return w*(1+3*cu+4.5*cu*cu-1.5*u*u)
|
||||
children=np.array([equilibrium(u) for u in (.03,-.03,.03,-.03)])
|
||||
self.assertAlmostEqual(float(abs(w-children.mean(axis=0)).max()),.0006,places=14)
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
result=unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromTestCase(UnionContract))
|
||||
print(json.dumps(dict(passed=result.wasSuccessful(),tests=result.testsRun,
|
||||
scope='host-only topology/provenance/coefficient checks; no runtime or accuracy admission',matrix=MATRIX,
|
||||
enumeration=ENUMERATION),indent=2))
|
||||
sys.exit(not result.wasSuccessful())
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Steady DFG 2D-1 (Re 20): refined and uniform runs against the official reference intervals.
|
||||
|
||||
Not a full benchmark certification. Declared deviations from the official setup:
|
||||
pressure-NEQ outlet (``neq_extrap``) instead of do-nothing, local Zou-He inlet.
|
||||
Geometry, inflow profile, initial state, refined layout and observation reuse the
|
||||
pinned Re100 runner (``run_refined_dfg``) and ``re100_helpers`` unchanged; only the
|
||||
lattice viscosity is set for Re 20 (nu = U_lattice * D / 20 at the root level).
|
||||
|
||||
Units. The helpers use scaled DFG units with mean inflow speed 1, so
|
||||
Cd = 2 Fx / (rho U^2 D) and Cl = 2 Fy / (rho U^2 D) are scale-free, and
|
||||
``pressure_probes`` returns dp / (rho U^2). The official p_diff = p(0.15, 0.2) -
|
||||
p(0.25, 0.2) uses U = 0.2 m/s, rho = 1, so p_diff = dp * 0.04. Both are reported;
|
||||
only p_diff is compared with the official interval. Forces use the existing sign
|
||||
convention: F is the force of the fluid on the cylinder, i.e. the refined raw
|
||||
exchange impulse (window ledger, ``total.impulse``) or the uniform ``read_force``,
|
||||
with x along the inflow and y up from the bottom wall. Cd must be positive, and the
|
||||
official Cl is positive (centre 0.20 below the centreline 0.205), so a flipped sign
|
||||
shows up as Cl outside its interval.
|
||||
|
||||
Sampling. One sample every SAMPLE_TSTAR (t* = U t / D; 0.25 s), taken at the end of
|
||||
a completed root step with all levels synchronized; the step index is recorded. Cd
|
||||
and Cl are means over the sample interval (window exchange ledger refined, per-batch
|
||||
forces uniform), which filters acoustic and step-parity oscillation; p_diff is the
|
||||
end-of-interval value at the exact surface points.
|
||||
|
||||
Steady rule (our engineering choice, frozen before any run; not official). Over the
|
||||
trailing WINDOW_TSTAR (t* 50..60 inclusive, 21 samples), for each of Cd, Cl and
|
||||
p_diff: range (max - min) <= 10 % and |least-squares slope * window| <= 2 % of the
|
||||
width of its official interval. Absolute limits, so the small Cl is never a divisor;
|
||||
the slope term bounds slow monotone drift that the range alone would pass. If the
|
||||
rule fails at TSTAR_MAX the run reports "not steady" and gives no interval verdict;
|
||||
limits are never relaxed after a run. The verdict uses the window mean, and the
|
||||
distance of each mean to its nearest interval edge is reported (negative outside).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
import numpy as np
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
import run_refined_dfg as rr # noqa: E402
|
||||
|
||||
RE = 20.0
|
||||
U_PHYS = 0.2 # m/s mean inflow; rho = 1
|
||||
TSTAR_MAX = 60.0 # 30 s physical
|
||||
SAMPLE_TSTAR = 0.5 # 0.25 s
|
||||
WINDOW_TSTAR = 10.0 # trailing 5 s, 21 samples
|
||||
# Schaefer & Turek (1996), 2D-1 lower/upper bounds.
|
||||
BANDS = dict(cd=(5.57, 5.59), cl=(0.0104, 0.0110), p_diff=(0.1172, 0.1176))
|
||||
STEADY_RANGE = {k: (hi - lo) / 10 for k, (lo, hi) in BANDS.items()}
|
||||
STEADY_SLOPE = {k: (hi - lo) / 50 for k, (lo, hi) in BANDS.items()}
|
||||
DEVIATIONS = ("pressure-NEQ outlet (neq_extrap), not do-nothing", "local Zou-He inlet")
|
||||
|
||||
_re100_config = rr.config
|
||||
|
||||
|
||||
def config(h, diameter, collision="SRT", jet_scenario="none"):
|
||||
"""The pinned Re100 configuration with the lattice viscosity set for Re 20."""
|
||||
if jet_scenario != "none":
|
||||
raise ValueError("DFG 2D-1 runs have no Jets")
|
||||
cfg, body, p = _re100_config(h, diameter, collision, jet_scenario)
|
||||
nu = p["mean_speed"] * p["diameter"] / RE
|
||||
cfg["physics"]["viscosity"] = nu
|
||||
return cfg, body, dict(p, viscosity=nu, re=RE)
|
||||
|
||||
|
||||
def lattice_parameters(diameter, speed=rr.SPEED):
|
||||
nu = speed * diameter / RE
|
||||
return dict(viscosity=nu, tau=0.5 + 3 * nu, steps=math.ceil(TSTAR_MAX * diameter / speed),
|
||||
interval=round(SAMPLE_TSTAR * diameter / speed))
|
||||
|
||||
|
||||
def sample(impulse, n, diameter, dp):
|
||||
den = rr.SPEED**2 * diameter / 2
|
||||
return dict(cd=float(impulse[0] / n / den), cl=float(impulse[1] / n / den),
|
||||
dp_scaled=float(dp), p_diff=float(dp) * U_PHYS**2)
|
||||
|
||||
|
||||
def summarize(rows):
|
||||
"""Steady rule over the trailing window, then the interval verdict (or none)."""
|
||||
if not rows or rows[-1]["tstar"] < TSTAR_MAX - 1e-9:
|
||||
return dict(status="INCOMPLETE", steady=False, verdict=None)
|
||||
window = [r for r in rows if r["tstar"] >= TSTAR_MAX - WINDOW_TSTAR - 1e-9]
|
||||
t = np.array([r["tstar"] for r in window])
|
||||
ranges = {k: max(r[k] for r in window) - min(r[k] for r in window) for k in BANDS}
|
||||
drifts = {k: abs(np.polyfit(t, [r[k] for r in window], 1)[0]) * WINDOW_TSTAR for k in BANDS}
|
||||
steady = all(ranges[k] <= STEADY_RANGE[k] and drifts[k] <= STEADY_SLOPE[k] for k in BANDS)
|
||||
final = {k: float(np.mean([r[k] for r in window])) for k in BANDS}
|
||||
edge = {k: min(final[k] - BANDS[k][0], BANDS[k][1] - final[k]) for k in BANDS}
|
||||
result = dict(window_samples=len(window), ranges=ranges, drifts=drifts,
|
||||
range_limits=STEADY_RANGE, slope_limits=STEADY_SLOPE, steady=steady,
|
||||
window_mean=final, edge_distance=edge, bands=BANDS,
|
||||
deviations=DEVIATIONS, cd_positive=final["cd"] > 0)
|
||||
if not steady:
|
||||
return dict(result, status="NOT_STEADY", verdict=None)
|
||||
inside = {k: BANDS[k][0] <= final[k] <= BANDS[k][1] for k in BANDS}
|
||||
return dict(result, status="WITHIN_INTERVALS" if all(inside.values()) else "OUTSIDE_INTERVALS",
|
||||
verdict=inside)
|
||||
|
||||
|
||||
def healthy(row):
|
||||
return row["max_mach"] <= 0.2 and row["rho_min"] >= 0.5 and row["rho_max"] <= 1.5
|
||||
|
||||
|
||||
def run_case(a, h, case):
|
||||
from contextlib import ExitStack
|
||||
from CelerisLab.cuda.context import CudaContext
|
||||
from CelerisLab.lbm.descriptors import FLUID
|
||||
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
|
||||
from CelerisLab.simulation import Simulation
|
||||
|
||||
refined = case == "refined"
|
||||
cfgdict, bodydict, p = config(h, 40 if refined else 80, a.collision)
|
||||
lp = lattice_parameters(p["diameter"])
|
||||
rows = []
|
||||
with ExitStack() as stack:
|
||||
if refined:
|
||||
cfg, pair, body, host = rr.host_geometry(a.source, h, a.collision)
|
||||
context = stack.enter_context(CudaContext(0))
|
||||
backend = stack.enter_context(FixedPatchStepper(
|
||||
cfg, context=context, coarse_boundary="channel", refinements=[rr.PLACEMENTS["wide"]],
|
||||
level_blocks=[rr.BASE_BLOCK, rr.FINE_BLOCK], level_objects=[(), (body,)],
|
||||
observe_raw_exchange=True, observe_window_exchange=True))
|
||||
from CelerisLab.lbm.channel import channel_masks
|
||||
from CelerisLab.lbm.refinement import ACTIVE
|
||||
from CelerisLab.lbm.descriptors import SOLID
|
||||
masks = [channel_masks(pair.coarse_shape)[0] & ~pair.covered,
|
||||
(pair.fine_kind == ACTIVE)
|
||||
& ((host.flags.reshape(pair.fine_shape) & SOLID) == 0)]
|
||||
backend.initialize(rr.initial_state(pair.coarse_shape, (0.0, 0.0), 1.0, root=True),
|
||||
rr.initial_state(pair.fine_shape, pair.fine_origin, 0.5))
|
||||
probe_p = dict(p, center=[(80.5 - pair.fine_origin[0]) / 0.5 - 0.5,
|
||||
(81.0 - pair.fine_origin[1]) / 0.5 - 0.5], radius=40.0)
|
||||
else:
|
||||
tmp = Path(stack.enter_context(TemporaryDirectory()))
|
||||
(tmp / "lbm.json").write_text(json.dumps(cfgdict))
|
||||
(tmp / "body.json").write_text(json.dumps(bodydict))
|
||||
sim = stack.enter_context(Simulation(str(tmp / "lbm.json"), str(tmp / "body.json"),
|
||||
device_id=0))
|
||||
sim.initialize()
|
||||
probe_p = p
|
||||
fluid = (sim.field.flag.reshape(p["ny"], p["nx"]) & FLUID) != 0
|
||||
begin = 0
|
||||
while begin < lp["steps"]:
|
||||
rr.check_deadline(a.deadline)
|
||||
n = min(lp["interval"], lp["steps"] - begin)
|
||||
if refined:
|
||||
impulse = rr.advance_refined_interval(backend, n, a.deadline, "window")
|
||||
fields = [rr.macros(g) for g in backend.download()]
|
||||
rho, ux, uy = fields[1]
|
||||
fluid = masks[1]
|
||||
vals = [tuple(v[m] for v in f) for m, f in zip(masks, fields)]
|
||||
else:
|
||||
impulse, done = np.zeros(2), 0
|
||||
while done < n:
|
||||
batch = min(100, n - done)
|
||||
sim.run(batch)
|
||||
impulse += sim.read_force(0).astype(np.float64) * batch
|
||||
done += batch
|
||||
m = sim.get_macroscopic()
|
||||
rho, ux, uy = [np.asarray(m[k], np.float64).reshape(p["ny"], p["nx"])
|
||||
for k in ("rho", "ux", "uy")]
|
||||
vals = [(rho[fluid], ux[fluid], uy[fluid])]
|
||||
begin += n
|
||||
row = dict(step=begin, tstar=begin * rr.SPEED / p["diameter"],
|
||||
**sample(impulse, n, p["diameter"],
|
||||
h.pressure_probes(rho, fluid, probe_p)["dp"]),
|
||||
rho_min=float(min(v[0].min() for v in vals)),
|
||||
rho_max=float(max(v[0].max() for v in vals)),
|
||||
max_mach=float(max(np.sqrt(3 * (v[1]**2 + v[2]**2)).max() for v in vals)))
|
||||
rr.ensure_finite(row)
|
||||
rows.append(row)
|
||||
if not healthy(row):
|
||||
break
|
||||
summary = summarize(rows) if healthy(rows[-1]) else dict(status="UNHEALTHY", steady=False,
|
||||
verdict=None)
|
||||
return dict(case=case, collision=a.collision, lattice=lp, rows=rows, **summary)
|
||||
|
||||
|
||||
def plan(collision="SRT"):
|
||||
return dict(re=RE, collision=collision, tstar_max=TSTAR_MAX, sample_tstar=SAMPLE_TSTAR,
|
||||
window_tstar=WINDOW_TSTAR, bands=BANDS, range_limits=STEADY_RANGE, slope_limits=STEADY_SLOPE,
|
||||
deviations=DEVIATIONS, pressure_scale=U_PHYS**2,
|
||||
refined_root=lattice_parameters(40), refined_fine_tau=0.5 + 3 * 2 * rr.SPEED * 40 / RE,
|
||||
uniform=lattice_parameters(80),
|
||||
label="compared against the official reference bands (non-canonical neq_extrap outlet, zou_he_local inlet); not a full benchmark certification")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--case", choices=["refined", "uniform", "both"], default="refined")
|
||||
parser.add_argument("--collision", choices=["SRT", "MRT"], default="SRT")
|
||||
parser.add_argument("--source", type=Path, default=rr.DEFAULT_SOURCE)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--plan", action="store_true", help="Write the frozen plan; no CUDA")
|
||||
parser.add_argument("--max-wall-seconds", type=float)
|
||||
a = parser.parse_args()
|
||||
a.source = a.source.resolve()
|
||||
a.output.mkdir(parents=True, exist_ok=False)
|
||||
(a.output / "plan.json").write_text(json.dumps(plan(a.collision), indent=2, allow_nan=False))
|
||||
if a.plan:
|
||||
return
|
||||
if not a.max_wall_seconds or a.max_wall_seconds <= 0 or not os.environ.get("CUDA_VISIBLE_DEVICES"):
|
||||
raise SystemExit("GPU run needs --max-wall-seconds > 0 and explicit CUDA_VISIBLE_DEVICES")
|
||||
h, _ = rr.helpers(a.source)
|
||||
rr.config = config # host_geometry builds its root configuration through this name
|
||||
a.deadline = time.monotonic() + a.max_wall_seconds
|
||||
cases = ["refined", "uniform"] if a.case == "both" else [a.case]
|
||||
results = [run_case(a, h, case) for case in cases]
|
||||
(a.output / "result.json").write_text(json.dumps(results, indent=2, allow_nan=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -74,7 +74,25 @@ def cv(g, fluid, origin, rectangle, nu, h=0.5):
|
||||
Cauchy stress from centred velocity gradients, NOT postcollision non-equilibrium.
|
||||
Rectangle faces lie on cell edges; fluid mask excludes the staircase cylinder.
|
||||
"""
|
||||
rho, mom, vel = moments(g)
|
||||
return _cv_quantities(*moments(g), fluid, origin, rectangle, nu, h)
|
||||
|
||||
|
||||
def cv_from_macros(rho, ux, uy, fluid, origin, rectangle, nu, h=0.5):
|
||||
"""Same CV calculation from public macros, in one common length/time frame.
|
||||
|
||||
``origin`` is the lower cell edge, ``h`` the cell width, and ``nu`` uses
|
||||
that frame. Returns [mass, Px, Py] inventory and outward total flux.
|
||||
Crop fields/mask together and translate origin before calling if desired.
|
||||
No boundary-exchange observation enters this independent estimator.
|
||||
"""
|
||||
rho = np.asarray(rho, np.float64)
|
||||
if rho.ndim != 2 or any(np.shape(v) != rho.shape for v in (ux, uy, fluid)):
|
||||
raise ValueError("CV macros and fluid mask must have matching 2D shapes")
|
||||
vel = np.asarray([ux, uy], np.float64)
|
||||
return _cv_quantities(rho, rho[None] * vel, vel, fluid, origin, rectangle, nu, h)
|
||||
|
||||
|
||||
def _cv_quantities(rho, mom, vel, fluid, origin, rectangle, nu, h):
|
||||
x0, y0, x1, y1 = np.rint((np.array(rectangle) - np.tile(origin, 2)) / h).astype(int)
|
||||
np.testing.assert_allclose(
|
||||
np.array([x0, y0, x1, y1]) * h + np.tile(origin, 2), rectangle, atol=1e-12
|
||||
@@ -261,12 +279,11 @@ def run_case(c, ctx, output, provenance, continuation=False):
|
||||
PeriodicBlockStepper(fine_cfg, layout, context=ctx, objects=[fobj]) as uniform,
|
||||
FixedPatchStepper(
|
||||
cfg,
|
||||
c["bounds"],
|
||||
context=ctx,
|
||||
base_block=(8, 8),
|
||||
fine_block=(8, 8),
|
||||
fine_objects=[obj],
|
||||
jets=jets,
|
||||
refinements=[c["bounds"]],
|
||||
level_blocks=[(8, 8), (8, 8)],
|
||||
level_objects=[(), (obj,)],
|
||||
level_jets=[(), tuple(jets)],
|
||||
observe_raw_exchange=True,
|
||||
) as amr,
|
||||
):
|
||||
|
||||
@@ -17,8 +17,109 @@ HERE = Path(__file__).resolve().parent
|
||||
DEFAULT_SOURCE = HERE.parents[1]
|
||||
BOUNDS = (40, 24, 320, 140)
|
||||
BASE_BLOCK = (2, 881) # Exact 166 x 881 domain; no geometric padding.
|
||||
FINE_BLOCK = (8, 8) # 240 x 568 including four fine halo cells.
|
||||
FINE_BLOCK = (8, 8) # Wide 240 x 568 or compact 168 x 168, including fine halos.
|
||||
SPEED = 0.01
|
||||
JET_PEAK = 0.001
|
||||
JET_WINDOWS = [(t, t + 10) for t in range(250, 300, 10)]
|
||||
CV_CONTOURS = np.array([(48., 44., 160., 120.), (44., 36., 180., 128.)])
|
||||
PLACEMENTS = {"wide": BOUNDS, "compact": (48, 48, 128, 128)}
|
||||
COMPACT_CV_CONTOURS = np.array([(52., 52., 112., 112.), (50., 50., 120., 120.)])
|
||||
|
||||
|
||||
def selected_cases(selection="both", uniform_diameter=80):
|
||||
return [(case, diameter) for case, diameter in (("uniform", uniform_diameter), ("refined", 40))
|
||||
if selection in ("both", case)]
|
||||
|
||||
|
||||
def uniform_origin(diameter):
|
||||
h = 40 / diameter
|
||||
return (.5 - .5*h, 1 - h)
|
||||
|
||||
|
||||
def cv_contours(refined, diameter, placement="wide"):
|
||||
contours = CV_CONTOURS if placement == "wide" else COMPACT_CV_CONTOURS
|
||||
return contours if refined else contours + [20/diameter, 0., 20/diameter, 0.]
|
||||
|
||||
|
||||
def cv_selected(step, diameter, mode):
|
||||
return mode == "pilot" or round(290 * diameter / SPEED) <= step <= round(300 * diameter / SPEED)
|
||||
|
||||
|
||||
def cv_snapshot(rho, ux, uy, fluid, origin, refined, begin, n, diameter, impulse, placement="wide"):
|
||||
"""Cropped public macros only; all inventories, fluxes and impulses use root units."""
|
||||
from run_jet_circle_cv import cv_from_macros
|
||||
|
||||
rectangles = cv_contours(refined, diameter, placement)
|
||||
h_cell = .5 if refined else 40 / diameter
|
||||
origin = np.asarray(origin, dtype=float)
|
||||
x0, y0, x1, y1 = np.rint((rectangles[-1] - np.tile(origin, 2)) / h_cell).astype(int)
|
||||
selection = np.s_[y0-2:y1+2, x0-2:x1+2]
|
||||
cropped_origin = origin + h_cell * np.array([x0-2, y0-2])
|
||||
fields = [v[selection] for v in (rho, ux, uy, fluid)]
|
||||
values = [cv_from_macros(*fields, cropped_origin, rect, .004, h=h_cell)
|
||||
for rect in rectangles]
|
||||
h_native = 1.0 if refined else 40 / diameter
|
||||
return dict(step=begin+n, tstar=(begin+n)*SPEED/diameter,
|
||||
time_root=(begin+n)*h_native, interval_start_root=begin*h_native,
|
||||
interval_dt_root=n*h_native,
|
||||
interval_impulse_root=(np.asarray(impulse)*h_native**2).tolist(),
|
||||
inventory=[inventory.tolist() for inventory, _ in values],
|
||||
outward_flux=[flux.tolist() for _, flux in values])
|
||||
|
||||
|
||||
def public_jets(center, radius, scenario):
|
||||
"""Same physical apertures; caller supplies its public geometry frame."""
|
||||
if scenario == "none":
|
||||
return ()
|
||||
from CelerisLab.body.jets import Jet, Sector, Radial
|
||||
|
||||
peak = 0.0 if scenario == "zero" else JET_PEAK
|
||||
return tuple(
|
||||
Jet(Sector(center, angle, math.pi / 12, radius * 1.005),
|
||||
Radial(center, inward=False), "parabolic", amplitude)
|
||||
for angle, amplitude in ((math.pi / 2, peak), (3 * math.pi / 2, -peak))
|
||||
)
|
||||
|
||||
|
||||
def jet_interval(handle, scenario, begin, n, diameter):
|
||||
"""Apply a reversal only at a completed interval boundary, before advancing."""
|
||||
if scenario != "none":
|
||||
boundaries = [round(t * diameter / SPEED) for t in (250, 260, 270, 280, 290, 300, 305)]
|
||||
n = min([n] + [step - begin for step in boundaries if step > begin])
|
||||
if scenario != "reverse":
|
||||
return n, None
|
||||
switch = round(300 * diameter / SPEED)
|
||||
if begin < switch:
|
||||
return min(n, switch - begin), None
|
||||
if begin == switch:
|
||||
handle.set_jet_amplitude(0, -JET_PEAK)
|
||||
handle.set_jet_amplitude(1, JET_PEAK)
|
||||
return n, dict(step=begin, tstar=300.0, amplitudes=[-JET_PEAK, JET_PEAK])
|
||||
return n, None
|
||||
|
||||
|
||||
def jet_diagnostics(rows, scenario):
|
||||
"""Raw interval-weighted diagnostics only; no driven-flow qualification gate."""
|
||||
windows = JET_WINDOWS + ([(300, 305)] if scenario == "reverse" else [])
|
||||
result = []
|
||||
for start, end in windows:
|
||||
selected = [r for r in rows if start <= r["tstar_force"] < end]
|
||||
if not selected:
|
||||
continue
|
||||
if any(2 * r["tstar_force"] - r["tstar"] < start - 1e-9
|
||||
or r["tstar"] > end + 1e-9 for r in selected):
|
||||
raise ValueError("Jet diagnostic interval crosses a window boundary")
|
||||
weights = np.array([2 * (r["tstar"] - r["tstar_force"]) for r in selected])
|
||||
record = dict(start_tstar=start, end_tstar=end, samples=len(selected),
|
||||
covered_tstar=float(weights.sum()),
|
||||
complete=bool(np.isclose(weights.sum(), end - start, rtol=0, atol=1e-9)))
|
||||
for key in ("cd", "cl"):
|
||||
values = np.array([r[key] for r in selected])
|
||||
record[key] = dict(mean=float(np.average(values, weights=weights)),
|
||||
min=float(values.min()), max=float(values.max()),
|
||||
rms=float(np.sqrt(np.average(values**2, weights=weights))))
|
||||
result.append(record)
|
||||
return result
|
||||
|
||||
|
||||
def helpers(source):
|
||||
@@ -39,26 +140,34 @@ def helpers(source):
|
||||
return h, periodic
|
||||
|
||||
|
||||
def config(h, diameter, collision="SRT"):
|
||||
def config(h, diameter, collision="SRT", jet_scenario="none"):
|
||||
cfg, body, p = h.make_config(diameter, SPEED, collision, "neq_extrap", "zou_he_local")
|
||||
cfg["method"]["outlet"]["srt_neq_damp"] = 0.5
|
||||
if jet_scenario != "none":
|
||||
if collision != "MRT":
|
||||
raise ValueError("Public Jet scenarios require MRT")
|
||||
cfg["method"]["ddf_shifting"] = False
|
||||
return cfg, body, p
|
||||
|
||||
|
||||
def plan(h, collision="SRT", observation_mode="last_base_step"):
|
||||
_, _, c = config(h, 40, collision)
|
||||
_, _, f = config(h, 80, collision)
|
||||
x0, y0, x1, y1 = BOUNDS
|
||||
def plan(h, collision="SRT", observation_mode="last_base_step", jet_scenario="none", uniform_diameter=80, placement="wide"):
|
||||
_, _, c = config(h, 40, collision, jet_scenario)
|
||||
_, _, f = config(h, uniform_diameter, collision, jet_scenario)
|
||||
bounds = PLACEMENTS[placement]
|
||||
x0, y0, x1, y1 = bounds
|
||||
fine_active = 4 * (x1 - x0) * (y1 - y0)
|
||||
coarse_uncovered = c["nx"] * c["ny"] - fine_active // 4
|
||||
return dict(
|
||||
status="prepared_not_run",
|
||||
collision=collision,
|
||||
jet_scenario=jet_scenario,
|
||||
ddf_shifting=jet_scenario == "none",
|
||||
observation_mode=observation_mode,
|
||||
root_gpu_gates_required=True,
|
||||
root=c,
|
||||
uniform=f,
|
||||
bounds_root_edge=list(BOUNDS),
|
||||
bounds_root_edge=list(bounds),
|
||||
fine_origin_root_edge=[x0 - 2, y0 - 2],
|
||||
physical_patch_m=[
|
||||
(x0 - 0.5) * c["dx"],
|
||||
(y0 - 1) * c["dx"],
|
||||
@@ -78,19 +187,31 @@ def plan(h, collision="SRT", observation_mode="last_base_step"):
|
||||
physical_viscosity_m2_s=0.001,
|
||||
finest_dx_m=f["dx"],
|
||||
finest_dt_s=f["dt"],
|
||||
refined_finest_dx_m=c["dx"]/2,
|
||||
refined_finest_dt_s=c["dt"]/2,
|
||||
approximate_leaf_cell_ratio=(coarse_uncovered + fine_active)
|
||||
/ (f["nx"] * f["ny"]),
|
||||
approximate_collision_work_ratio=(coarse_uncovered + 2 * fine_active)
|
||||
/ (2 * f["nx"] * f["ny"]),
|
||||
/ ((uniform_diameter/40) * f["nx"] * f["ny"]),
|
||||
cost_caveat="Geometry-only estimates include ghosts/solids; exclude block halos, transfers, observation and launch overhead. Not measured speedup.",
|
||||
sampling_offset="For origin (38,22), fine physical x=(j+75.5)*dx80 versus uniform i*dx80: half-fine-cell x offset. Fine physical y=(k+42.5)*dx80 equals uniform (l-.5)*dx80 at l=k+43: y samples align. Body, wall and source planes coincide.",
|
||||
sampling_offset="Uniform nodes map to root edges X=.5+i*(40/D), Y=1+(j-.5)*(40/D). Refined centers are fine_origin_root_edge+(index+.5)*.5. Body, wall and source planes coincide; compare observables, not pointwise arrays.",
|
||||
outlet=(
|
||||
"SRT pressure NEQ, all nine fneq damped by .5, no backflow clamp; noncanonical DFG outlet"
|
||||
if collision == "SRT" else
|
||||
"MRT pressure NEQ, unknowns q2/6/8 extrapolated; no backflow clamp; noncanonical DFG outlet"
|
||||
),
|
||||
modes={"pilot_tstar": 5.0, "full_tstar": 300.0},
|
||||
modes={"pilot_tstar": 5.0, "full_tstar": 305.0 if jet_scenario == "reverse" else 300.0},
|
||||
observation_dense_tstar=0.02,
|
||||
**(dict(jets=dict(
|
||||
uniform_origin_node=f["center"], refined_origin_root_edge=[80.5, 81.0],
|
||||
angles_rad=[math.pi / 2, 3 * math.pi / 2], half_width_rad=math.pi / 12,
|
||||
radial_extent_over_radius=[0.0, 1.005],
|
||||
field="Radial(outward)", profile="parabolic", condition="velocity",
|
||||
initial_amplitudes=[0.0, 0.0] if jet_scenario == "zero" else [JET_PEAK, -JET_PEAK],
|
||||
reversal_tstar=300.0 if jet_scenario == "reverse" else None,
|
||||
diagnostic_windows_tstar=JET_WINDOWS + ([(300, 305)] if jet_scenario == "reverse" else []),
|
||||
transfer="constant", driven_qualification="not assessed",
|
||||
)) if jet_scenario != "none" else {}),
|
||||
)
|
||||
|
||||
|
||||
@@ -126,7 +247,7 @@ def initial_state(shape, origin, spacing, height=164.0, speed=SPEED, root=False)
|
||||
return w * (1 + 3 * cu + 4.5 * cu * cu - 1.5 * velocity * velocity)
|
||||
|
||||
|
||||
def host_geometry(source, h, collision="SRT"):
|
||||
def host_geometry(source, h, collision="SRT", jet_scenario="none", placement="wide"):
|
||||
"""Production donor/transfer and complete cut-link checks, without CUDA."""
|
||||
from CelerisLab.config import load_lbm_config
|
||||
from CelerisLab.lbm.refinement import FixedPatch2D, prepare_fine_boundary
|
||||
@@ -139,12 +260,12 @@ def host_geometry(source, h, collision="SRT"):
|
||||
from CelerisLab.body.geometry.circle import CircleGeometry
|
||||
from CelerisLab.body.objects import SimObject
|
||||
|
||||
cfgdict, _, _ = config(h, 40, collision)
|
||||
cfgdict, _, _ = config(h, 40, collision, jet_scenario)
|
||||
with TemporaryDirectory() as tmp:
|
||||
p = Path(tmp) / "cfg.json"
|
||||
p.write_text(json.dumps(cfgdict))
|
||||
cfg = load_lbm_config(str(p))
|
||||
pair = FixedPatch2D.from_config(cfg, BOUNDS)
|
||||
pair = FixedPatch2D.from_config(cfg, PLACEMENTS[placement])
|
||||
layout = BlockLayout2D.build(cfg.ny, cfg.nx, *BASE_BLOCK)
|
||||
validate_channel_config(cfg, [BASE_BLOCK, FINE_BLOCK])
|
||||
targets = blocked_base_tables(pair, layout, np.argsort(pair.coalesce_coarse))[1]
|
||||
@@ -153,18 +274,41 @@ def host_geometry(source, h, collision="SRT"):
|
||||
body = SimObject(
|
||||
0, geometry=CircleGeometry(80.5, 81.0, 20.0), center=(80.5, 81.0), radius=20.0
|
||||
)
|
||||
host = prepare_fine_boundary(cfg, pair, (body,), _shifted_blocked=True)
|
||||
host = prepare_fine_boundary(
|
||||
cfg, pair, (body,), jets=public_jets((80.5, 81.0), 20.0, jet_scenario),
|
||||
_shifted_blocked=jet_scenario == "none",
|
||||
)
|
||||
return cfg, pair, body, host
|
||||
|
||||
|
||||
def verify_source(source, collision="SRT", observation_mode="last_base_step"):
|
||||
def verify_source(source, collision="SRT", observation_mode="last_base_step", jet_scenario="none", observe_cv=False):
|
||||
if collision not in ("SRT", "MRT"):
|
||||
raise ValueError("Matched collision must be SRT or MRT")
|
||||
if observation_mode not in ("last_base_step", "window"):
|
||||
raise ValueError("Unknown refined observation mode")
|
||||
if observe_cv and jet_scenario == "none":
|
||||
raise ValueError("--observe-cv requires an explicit public Jet scenario")
|
||||
name = ("source_manifest_window.json" if observation_mode == "window" else
|
||||
"source_manifest.json" if collision == "SRT" else "source_manifest_mrt.json")
|
||||
expected = json.loads((HERE / "data/refined_dfg" / name).read_text())
|
||||
if jet_scenario != "none":
|
||||
if collision != "MRT" or observation_mode != "window":
|
||||
raise ValueError("Public Jet scenarios require MRT and --exchange-window")
|
||||
import subprocess
|
||||
sys.path.insert(0, str(HERE))
|
||||
from source_provenance import checkout_provenance
|
||||
|
||||
paths = set(expected["sha256"])
|
||||
if observe_cv:
|
||||
paths.add("tests/validation/run_jet_circle_cv.py")
|
||||
paths.update(subprocess.check_output(
|
||||
["git", "-C", str(source), "ls-files", "src"], text=True).splitlines())
|
||||
return dict(
|
||||
source_capture="current checkout, not historical DFG qualification",
|
||||
**checkout_provenance(source), observation_mode=observation_mode,
|
||||
sha256={name: hashlib.sha256((source / name).read_bytes()).hexdigest()
|
||||
for name in sorted(paths)},
|
||||
)
|
||||
for name, digest in expected["sha256"].items():
|
||||
if hashlib.sha256((source / name).read_bytes()).hexdigest() != digest:
|
||||
raise RuntimeError("Source mismatch: " + name)
|
||||
@@ -219,29 +363,32 @@ def run_case(a, h, periodic, case):
|
||||
from contextlib import ExitStack
|
||||
|
||||
refined = case == "refined"
|
||||
cfgdict, bodydict, p = config(h, 40 if refined else 80, a.collision)
|
||||
cap = 5.0 if a.mode == "pilot" else 300.0
|
||||
cfgdict, bodydict, p = config(h, 40 if refined else a.uniform_diameter, a.collision, a.jet_scenario)
|
||||
cap = 5.0 if a.mode == "pilot" else (305.0 if a.jet_scenario == "reverse" else 300.0)
|
||||
max_steps = math.ceil(cap * p["diameter"] / SPEED)
|
||||
rows = []
|
||||
healthy = True
|
||||
step_seconds = 0.0
|
||||
cv_seconds, cv_samples = 0.0, 0
|
||||
amplitude_changes = []
|
||||
started = time.monotonic()
|
||||
check_deadline(a.deadline)
|
||||
dest = a.output / case
|
||||
dest.mkdir()
|
||||
with ExitStack() as stack:
|
||||
cv_out = stack.enter_context((dest / "cv_history.jsonl").open("w")) if a.observe_cv else None
|
||||
if refined:
|
||||
cfg, pair, body, host = host_geometry(a.source, h, a.collision)
|
||||
cfg, pair, body, host = host_geometry(a.source, h, a.collision, a.jet_scenario, a.placement)
|
||||
context = stack.enter_context(CudaContext(0))
|
||||
backend = stack.enter_context(
|
||||
FixedPatchStepper(
|
||||
cfg,
|
||||
BOUNDS,
|
||||
context=context,
|
||||
coarse_boundary="channel",
|
||||
base_block=BASE_BLOCK,
|
||||
fine_block=FINE_BLOCK,
|
||||
fine_objects=(body,),
|
||||
refinements=[PLACEMENTS[a.placement]],
|
||||
level_blocks=[BASE_BLOCK, FINE_BLOCK],
|
||||
level_objects=[(), (body,)],
|
||||
level_jets=[(), public_jets((80.5, 81.0), 20.0, a.jet_scenario)],
|
||||
observe_raw_exchange=True,
|
||||
**({"observe_window_exchange": True} if a.observation_mode == "window" else {}),
|
||||
)
|
||||
@@ -274,6 +421,9 @@ def run_case(a, h, periodic, case):
|
||||
sim = stack.enter_context(
|
||||
Simulation(str(tmp / "lbm.json"), str(tmp / "body.json"), device_id=0)
|
||||
)
|
||||
for index, jet in enumerate(public_jets(p["center"], p["radius"], a.jet_scenario)):
|
||||
if sim.add_jet(jet) != index:
|
||||
raise RuntimeError("Unexpected public Jet registration index")
|
||||
sim.initialize()
|
||||
probe_p = p
|
||||
fluid = (sim.field.flag.reshape(p["ny"], p["nx"]) & FLUID) != 0
|
||||
@@ -290,6 +440,10 @@ def run_case(a, h, periodic, case):
|
||||
else 0.5
|
||||
)
|
||||
n = min(max(1, round(width * p["diameter"] / SPEED)), max_steps - begin)
|
||||
n, change = jet_interval(backend if refined else sim, a.jet_scenario,
|
||||
begin, n, p["diameter"])
|
||||
if change is not None:
|
||||
amplitude_changes.append(change)
|
||||
impulse = np.zeros(2)
|
||||
tick = time.monotonic()
|
||||
if refined:
|
||||
@@ -359,6 +513,19 @@ def run_case(a, h, periodic, case):
|
||||
/ SPEED,
|
||||
**h.pressure_probes(rho, fluid, probe_p),
|
||||
)
|
||||
if a.jet_scenario != "none":
|
||||
row.update(step_start=begin, tstar_start=begin * SPEED / p["diameter"],
|
||||
interval_steps=n, impulse_x=float(impulse[0]),
|
||||
impulse_y=float(impulse[1]))
|
||||
if a.observe_cv and cv_selected(begin+n, p["diameter"], a.mode):
|
||||
tick = time.monotonic()
|
||||
record = cv_snapshot(rho, ux, uy, fluid,
|
||||
pair.fine_origin if refined else uniform_origin(p["diameter"]),
|
||||
refined, begin, n, p["diameter"], impulse, a.placement)
|
||||
cv_out.write(json.dumps(record, allow_nan=False) + "\n")
|
||||
cv_out.flush()
|
||||
cv_samples += 1
|
||||
cv_seconds += time.monotonic() - tick
|
||||
check_deadline(a.deadline)
|
||||
ensure_finite(row)
|
||||
rows.append(row)
|
||||
@@ -402,14 +569,24 @@ def run_case(a, h, periodic, case):
|
||||
steps=begin,
|
||||
startup_seconds=startup,
|
||||
step_seconds=step_seconds,
|
||||
observation_and_overhead_seconds=time.monotonic() - started - startup - step_seconds,
|
||||
wall_seconds=time.monotonic() - started,
|
||||
force="raw stationary-body link momentum reaction, per-unit-depth; average over entire observation interval; normalized in each base lattice",
|
||||
mass_units="rho_reference * m^2 per unit depth",
|
||||
flux_units="rho_reference * m^2/s per unit depth",
|
||||
pressure="postcollision end-sample; physical pressure difference / (rho_reference * 1 m/s squared)",
|
||||
suite_validated=False,
|
||||
**periodic.assess(rows),
|
||||
**(periodic.assess(rows) if a.jet_scenario in ("none", "zero") else
|
||||
dict(periodic=False, checks={}, cycles=[])),
|
||||
)
|
||||
if a.jet_scenario != "none":
|
||||
result.update(jet_diagnostic_windows=jet_diagnostics(rows, a.jet_scenario),
|
||||
amplitude_changes=amplitude_changes,
|
||||
pressure_classification=("DFG reference verdict retained, including pressure"
|
||||
if a.jet_scenario == "zero" else "diagnostic only; no official DFG reference gate"))
|
||||
if a.observe_cv:
|
||||
result.update(cv_observation_seconds=cv_seconds, cv_samples=cv_samples,
|
||||
cv_qualification="not assessed; scalar observations only")
|
||||
if not healthy:
|
||||
result["periodic"] = False
|
||||
reference = json.loads(
|
||||
@@ -425,7 +602,8 @@ def run_case(a, h, periodic, case):
|
||||
else (
|
||||
"pilot_complete"
|
||||
if a.mode == "pilot"
|
||||
else ("periodic" if result["periodic"] else "incomplete_periodicity")
|
||||
else ("driven_diagnostics_complete" if a.jet_scenario in ("fixed", "reverse") else
|
||||
"periodic" if result["periodic"] else "incomplete_periodicity")
|
||||
)
|
||||
)
|
||||
(dest / "summary.json").write_text(json.dumps(result, indent=2, allow_nan=False))
|
||||
@@ -464,27 +642,39 @@ def check_deadline(deadline):
|
||||
)
|
||||
|
||||
|
||||
def verify_pilot(path, manifest, collision="SRT", observation_mode="last_base_step"):
|
||||
def verify_pilot(path, manifest, collision="SRT", observation_mode="last_base_step", jet_scenario="none", observe_cv=False,
|
||||
case_selection="both", uniform_diameter=80, placement="wide"):
|
||||
if path is None:
|
||||
raise ValueError(
|
||||
"Full comparison requires --pilot-results from a completed matched pilot"
|
||||
)
|
||||
previous = read_finite_json(path / "plan.json")
|
||||
options = dict(case_selection=(case_selection, "both"), uniform_diameter=(uniform_diameter, 80), placement=(placement, "wide"))
|
||||
if any(previous.get(k, default) != expected for k, (expected, default) in options.items()):
|
||||
raise ValueError("Pilot case, resolution or placement differs")
|
||||
if previous.get("observe_cv", False) != observe_cv:
|
||||
raise ValueError("Pilot CV observation mode differs")
|
||||
if previous.get("jet_scenario", "none") != jet_scenario:
|
||||
raise ValueError("Pilot Jet scenario differs")
|
||||
if previous.get("observation_mode") != observation_mode:
|
||||
raise ValueError("Pilot observation mode differs")
|
||||
if previous.get("collision") != collision:
|
||||
raise ValueError("Pilot collision differs")
|
||||
if previous["source_manifest"] != manifest or previous["bounds_root_edge"] != list(
|
||||
BOUNDS
|
||||
PLACEMENTS[placement]
|
||||
):
|
||||
raise ValueError("Pilot source or frozen geometry differs")
|
||||
if previous.get("runner_sha256") != runner_hash():
|
||||
raise ValueError("Pilot runner differs")
|
||||
calibrated_seconds = 0.0
|
||||
for case, diameter in (("uniform", 80), ("refined", 40)):
|
||||
for case, diameter in selected_cases(case_selection, uniform_diameter):
|
||||
summary = read_finite_json(path / case / "summary.json")
|
||||
if (
|
||||
summary.get("observation_mode") != observation_mode
|
||||
any(summary.get(k, default) != expected for k, (expected, default) in options.items())
|
||||
or summary.get("observe_cv", False) != observe_cv
|
||||
or (observe_cv and summary.get("cv_samples") != 250)
|
||||
or summary.get("jet_scenario", "none") != jet_scenario
|
||||
or summary.get("observation_mode") != observation_mode
|
||||
or summary.get("collision") != collision
|
||||
or summary.get("case") != case
|
||||
or summary.get("mode") != "pilot"
|
||||
@@ -537,7 +727,10 @@ def verify_pilot(path, manifest, collision="SRT", observation_mode="last_base_st
|
||||
raise ValueError("Unhealthy pilot observations")
|
||||
ready = read_finite_json(path / case / "ready.json")
|
||||
if (
|
||||
ready.get("observation_mode") != observation_mode
|
||||
any(ready.get(k, default) != expected for k, (expected, default) in options.items())
|
||||
or ready.get("observe_cv", False) != observe_cv
|
||||
or ready.get("jet_scenario", "none") != jet_scenario
|
||||
or ready.get("observation_mode") != observation_mode
|
||||
or ready.get("collision") != collision
|
||||
or ready.get("case") != case
|
||||
or ready.get("mode") != "pilot"
|
||||
@@ -555,9 +748,9 @@ def verify_pilot(path, manifest, collision="SRT", observation_mode="last_base_st
|
||||
or not 0 < stepping <= wall
|
||||
):
|
||||
raise ValueError("Invalid pilot calibration timings")
|
||||
# Full physical duration is 60 times pilot; dense pilot sampling makes
|
||||
# Full duration is 60 (or 61 with reversal) times pilot; dense sampling makes
|
||||
# this conservative for the sparser early full observations. Not a guarantee.
|
||||
calibrated_seconds += startup + 60 * (wall - startup)
|
||||
calibrated_seconds += startup + (61 if jet_scenario == "reverse" else 60) * (wall - startup)
|
||||
return 3 * calibrated_seconds
|
||||
|
||||
|
||||
@@ -566,6 +759,13 @@ def main():
|
||||
parser.add_argument("--exchange-window", action="store_true",
|
||||
help="Opt in to refined window-ledger observations on the pinned candidate")
|
||||
parser.add_argument("--collision", choices=["SRT", "MRT"], default="SRT")
|
||||
parser.add_argument("--case", choices=["both", "uniform", "refined"], default="both")
|
||||
parser.add_argument("--uniform-diameter", type=int, choices=[80, 160], default=80)
|
||||
parser.add_argument("--placement", choices=list(PLACEMENTS), default="wide")
|
||||
parser.add_argument("--jet-scenario", choices=["none", "zero", "fixed", "reverse"],
|
||||
default="none", help="Opt in to public unshifted MRT Jets; reverse switches at t*=300 and ends at 305")
|
||||
parser.add_argument("--observe-cv", action="store_true",
|
||||
help="Record independent cropped-macro CV samples during pilot or full t*=290..300")
|
||||
parser.add_argument("--source", type=Path, default=DEFAULT_SOURCE)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--mode", choices=["plan", "pilot", "full"], default="plan")
|
||||
@@ -577,15 +777,26 @@ def main():
|
||||
help="Required explicit campaign wall-time ceiling for GPU execution",
|
||||
)
|
||||
a = parser.parse_args()
|
||||
if a.uniform_diameter == 160 and a.case != "uniform":
|
||||
raise ValueError("D160 requires --case uniform; no matched-resolution claim")
|
||||
if a.placement == "compact" and a.case != "refined":
|
||||
raise ValueError("Compact placement requires --case refined")
|
||||
if (a.uniform_diameter != 80 or a.placement != "wide") and a.jet_scenario == "none":
|
||||
raise ValueError("Resolution/placement variants require an explicit public Jet scenario")
|
||||
a.source = a.source.resolve()
|
||||
a.observation_mode = "window" if a.exchange_window else "last_base_step"
|
||||
manifest = verify_source(a.source, a.collision, a.observation_mode)
|
||||
manifest = verify_source(a.source, a.collision, a.observation_mode, a.jet_scenario, a.observe_cv)
|
||||
h, periodic = helpers(a.source)
|
||||
from source_provenance import checkout_provenance
|
||||
|
||||
a.provenance = checkout_provenance(a.source)
|
||||
a.provenance.update(jet_scenario=a.jet_scenario, ddf_shifting=a.jet_scenario == "none")
|
||||
a.provenance.update(case_selection=a.case, uniform_diameter=a.uniform_diameter, placement=a.placement)
|
||||
if a.observe_cv:
|
||||
a.provenance["observe_cv"] = True
|
||||
calibration = (
|
||||
verify_pilot(a.pilot_results, manifest, a.collision, a.observation_mode)
|
||||
verify_pilot(a.pilot_results, manifest, a.collision, a.observation_mode, a.jet_scenario, a.observe_cv,
|
||||
a.case, a.uniform_diameter, a.placement)
|
||||
if a.mode == "full" else None
|
||||
)
|
||||
if a.mode != "plan" and (
|
||||
@@ -602,17 +813,31 @@ def main():
|
||||
if a.output.exists() and any(a.output.iterdir()):
|
||||
raise ValueError("Output must be empty/new")
|
||||
a.output.mkdir(parents=True, exist_ok=True)
|
||||
specification = plan(h, a.collision, a.observation_mode)
|
||||
specification = plan(h, a.collision, a.observation_mode, a.jet_scenario, a.uniform_diameter, a.placement)
|
||||
specification.update(
|
||||
source_manifest=manifest,
|
||||
**a.provenance,
|
||||
gates=periodic.GATES,
|
||||
reference_gates=periodic.REFERENCE_GATES,
|
||||
gates=periodic.GATES if a.jet_scenario in ("none", "zero") else None,
|
||||
reference_gates=periodic.REFERENCE_GATES if a.jet_scenario in ("none", "zero") else None,
|
||||
runner_sha256=runner_hash(),
|
||||
wall_time_budget_seconds=budget,
|
||||
full_pilot_calibration_seconds=calibration,
|
||||
)
|
||||
host_geometry(a.source, h, a.collision)
|
||||
if a.observe_cv:
|
||||
specification["cv_observation"] = dict(
|
||||
full_tstar=[290, 300], pilot_tstar=[.02, 5], spacing_tstar=.02,
|
||||
refined_contours_root=cv_contours(True, 40, a.placement).tolist(),
|
||||
uniform_contours_root=cv_contours(False, a.uniform_diameter, a.placement).tolist(),
|
||||
uniform_grid_origin_root=uniform_origin(a.uniform_diameter),
|
||||
refined_grid_origin_root=specification["fine_origin_root_edge"],
|
||||
cell_width_root={"uniform": 40/a.uniform_diameter, "refined": .5},
|
||||
viscosity_root=.004, components=["mass", "Px", "Py"],
|
||||
units="root lattice per unit depth; outward flux per root time",
|
||||
interval="Impulse belongs to the interval ending at each snapshot; first retained endpoint (pilot .02, full 290) supplies initial inventory only: exclude its preceding impulse",
|
||||
qualification="not assessed; trapezoidal integration and time-coarsening are offline",
|
||||
)
|
||||
if a.case != "uniform":
|
||||
host_geometry(a.source, h, a.collision, a.jet_scenario, a.placement)
|
||||
(a.output / "plan.json").write_text(json.dumps(specification, indent=2))
|
||||
if a.mode == "plan":
|
||||
return 0
|
||||
@@ -621,8 +846,9 @@ def main():
|
||||
"GPU execution blocked: root GPU gates approval and explicit device selection required"
|
||||
)
|
||||
results = []
|
||||
cases = selected_cases(a.case, a.uniform_diameter)
|
||||
a.deadline = time.monotonic() + budget
|
||||
for case in ("uniform", "refined"):
|
||||
for case, _ in cases:
|
||||
try:
|
||||
result = run_case(a, h, periodic, case)
|
||||
except TimeoutError as exc:
|
||||
@@ -650,7 +876,12 @@ def main():
|
||||
status="pilot_only" if a.mode == "pilot" else "incomplete",
|
||||
matched_case_pass=False,
|
||||
)
|
||||
if len(results) == 2 and all(r["healthy"] and r["periodic"] for r in results):
|
||||
if a.jet_scenario in ("fixed", "reverse"):
|
||||
comparison.update(status=("pilot_only" if a.mode == "pilot" else "driven_diagnostics_complete")
|
||||
if len(results) == len(cases) and all(r["healthy"] for r in results) else "invalid_state",
|
||||
qualification="not assessed; execution completion is not physical acceptance",
|
||||
jet_diagnostic_windows={r["case"]: r["jet_diagnostic_windows"] for r in results})
|
||||
if a.case == "both" and len(results) == 2 and all(r["healthy"] and r["periodic"] for r in results):
|
||||
comparison.update(
|
||||
status="periodic_comparison",
|
||||
refined_vs_uniform=periodic.compare(
|
||||
@@ -660,14 +891,20 @@ def main():
|
||||
comparison["matched_case_pass"] = comparison["refined_vs_uniform"][
|
||||
"reference_agreement"
|
||||
]
|
||||
if a.case != "both":
|
||||
comparison.update(status="single_case_" + results[0]["status"],
|
||||
own_reference_comparison=results[0].get("reference_comparison"))
|
||||
(a.output / "comparison.json").write_text(
|
||||
json.dumps(comparison, indent=2, allow_nan=False)
|
||||
)
|
||||
return (
|
||||
0
|
||||
if len(results) == 2
|
||||
if len(results) == len(cases)
|
||||
and all(r["healthy"] for r in results)
|
||||
and (a.mode == "pilot" or comparison["matched_case_pass"])
|
||||
and (a.mode == "pilot" or comparison["matched_case_pass"]
|
||||
or a.jet_scenario in ("fixed", "reverse")
|
||||
or (a.case != "both" and results[0]["periodic"]
|
||||
and results[0].get("reference_comparison", {}).get("reference_agreement", False)))
|
||||
else 2
|
||||
)
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ def decode_macros(data, w, cx, cy):
|
||||
EXPERIMENTS = {
|
||||
"historical": "source_pins.json",
|
||||
"dev8403956": "source_pins_dev8403956.json",
|
||||
"a135252": "source_pins_a135252.json",
|
||||
}
|
||||
|
||||
|
||||
@@ -69,11 +70,10 @@ def source_guard(root, variant, experiment="historical"):
|
||||
manifest_path = HERE / "data/les_flow" / EXPERIMENTS[experiment]
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
for name, sha in manifest["sources"].items():
|
||||
expected = (
|
||||
manifest["candidate_header_sha256"]
|
||||
if name == HEADER and variant == "corrected"
|
||||
else sha
|
||||
)
|
||||
expected = sha
|
||||
if name == HEADER:
|
||||
expected = (manifest["candidate_header_sha256"] if variant == "corrected"
|
||||
else manifest.get("baseline_header_sha256", sha))
|
||||
if hashlib.sha256((root / name).read_bytes()).hexdigest() != expected:
|
||||
raise RuntimeError("Source pin mismatch: " + name)
|
||||
identity = dict(
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""New-source four-case repeat of 11901a7; frozen oracle and gates are unchanged."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from assess import BASELINE_FACTOR, FLOORS
|
||||
from oracle import LIMITS, TIMES, U, rectangles
|
||||
from run_case import (INITIALIZATION, sample_steps, NUMERICAL_REVISION, SOURCE_SHA256,
|
||||
BUILD_CAPTURE_SHA256, REFERENCE_HARNESS)
|
||||
|
||||
# Host-generated snapshots of a135252 at the frozen two local configurations.
|
||||
# These describe expected inputs; actual sidecars are captured before cleanup.
|
||||
BUILD_SOURCES = {
|
||||
128: "803177416b7599ab89cc5afd35cf4a28a673b2f4bb8e8a6a1abc0b6d77610873",
|
||||
256: "17ec5e60f87b840d3d13b8778bc8a3aecf485469c5a061af46c5cc19b73396cf",
|
||||
}
|
||||
CASES = ((100, 128, 1, "constant"), (100, 256, 1, "constant"),
|
||||
(100, 128, 2, "constant"), (100, 128, 2, "projected_linear"))
|
||||
NUMERICS = dict(lattice_model="D2Q9", collision="SRT", store_precision="FP32",
|
||||
ddf_shifting=True, streaming="double_buffer", les_enabled=False,
|
||||
boundary="periodic", block_shape=[8, 8],
|
||||
sample_phase="postcollision G at root synchronization")
|
||||
|
||||
|
||||
def finite(value):
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
|
||||
|
||||
|
||||
def finite_tree(value):
|
||||
if isinstance(value, dict):
|
||||
return all(finite_tree(v) for v in value.values())
|
||||
if isinstance(value, list):
|
||||
return all(finite_tree(v) for v in value)
|
||||
return not isinstance(value, float) or math.isfinite(value)
|
||||
|
||||
|
||||
def at_steps(rows, steps, n):
|
||||
return len(rows) == len(steps) and all(
|
||||
type(r.get("step")) is int and r["step"] == step and finite(r.get("tstar"))
|
||||
and abs(r["tstar"] - step * U / n) <= 3e-17
|
||||
for r, step in zip(rows, steps)
|
||||
)
|
||||
|
||||
|
||||
def maximum(rows, metric):
|
||||
values = [r.get(metric) for r in rows]
|
||||
return max(values) if values and all(finite(v) and v >= 0 for v in values) else None
|
||||
|
||||
|
||||
def valid_builds(record, n, depth):
|
||||
builds = record.get("actual_build_sidecars", [])
|
||||
if not isinstance(builds, list) or len(builds) != depth:
|
||||
return False
|
||||
for level, build in enumerate(builds):
|
||||
if not isinstance(build, dict):
|
||||
return False
|
||||
size = n * 2**level
|
||||
cfg = build.get("config", {})
|
||||
expected = dict(nx=10, ny=10, nz=1, lattice_model="D2Q9", collision="SRT",
|
||||
store_precision="FP32", streaming="double_buffer", ddf_shifting=True,
|
||||
les_enabled=False, velocity=0.0, viscosity=U*size/100, rho=1.0,
|
||||
omega=1/(.5+3*U*size/100), threads_per_block=256)
|
||||
ptx = build.get("ptx_sha256", "")
|
||||
if (build.get("source_sha256") != BUILD_SOURCES[size]
|
||||
or build.get("arch") != "sm_70" or build.get("flags") != ["--use_fast_math"]
|
||||
or build.get("n_objects") != 0 or not isinstance(cfg, dict)
|
||||
or any(cfg.get(k) != value for k, value in expected.items())
|
||||
or not isinstance(ptx, str) or len(ptx) != 64
|
||||
or any(c not in "0123456789abcdef" for c in ptx)):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def assess(records, expected_revision=None):
|
||||
indexed, duplicates = {}, []
|
||||
for record in records:
|
||||
key = tuple(record.get(k) for k in ("re_L", "n", "depth", "explosion"))
|
||||
if key in indexed:
|
||||
duplicates.append(str(key))
|
||||
else:
|
||||
indexed[key] = record
|
||||
missing = [str(k) for k in CASES if k not in indexed]
|
||||
checks = dict(complete_matrix=not missing, no_extra_cases=set(indexed) <= set(CASES),
|
||||
no_duplicate_cases=not duplicates, finite_receipts=finite_tree(records))
|
||||
sources = [r.get("source", {}) for r in records]
|
||||
checks["same_source_and_harness"] = bool(sources) and len({
|
||||
json.dumps(s, sort_keys=True) for s in sources}) == 1
|
||||
checks["clean_a135252_numerical_source"] = bool(sources) and all(
|
||||
s.get("dirty") is False and s.get("source_tree_sha256") == SOURCE_SHA256
|
||||
and bool(s.get("revision")) and bool(s.get("harness_sha256"))
|
||||
and s.get("build_capture_sha256") == BUILD_CAPTURE_SHA256
|
||||
and (expected_revision is None or s.get("revision") == expected_revision) for s in sources)
|
||||
runtimes = [r.get("runtime_identity") for r in records]
|
||||
checks["same_recorded_runtime"] = bool(runtimes) and all(
|
||||
isinstance(v, dict) and all(v.get(k) for k in
|
||||
("device", "nvcc_path", "nvcc_version", "python", "numpy", "pycuda"))
|
||||
and isinstance(v["device"], dict) and v["device"].get("capability") == [7, 0]
|
||||
and all(v["device"].get(k) for k in ("name", "pci_bus_id", "driver", "runtime"))
|
||||
for v in runtimes
|
||||
) and len({json.dumps(v, sort_keys=True) for v in runtimes}) == 1
|
||||
devices = [r.get("cuda_visible_devices") for r in records]
|
||||
checks["same_explicit_device"] = bool(devices) and all(
|
||||
isinstance(d, str) and d and "," not in d and d != "-1" for d in devices
|
||||
) and len(set(devices)) == 1
|
||||
enabled = [r.get("early_pressure", {}).get("enabled") for r in records]
|
||||
checks["same_early_sampling"] = bool(enabled) and all(type(v) is bool for v in enabled) and len(set(enabled)) == 1
|
||||
shared_valid = all(checks.values())
|
||||
complete, case_valid, early = {}, {}, []
|
||||
for key in CASES:
|
||||
if key not in indexed:
|
||||
continue
|
||||
r = indexed[key]
|
||||
_, n, depth, scheme = key
|
||||
label = f"{n}/{depth}/{scheme}"
|
||||
rows = r.get("rows", [])
|
||||
main_steps, early_steps = sample_steps(n, True)
|
||||
complete[key] = r.get("completed") is True and at_steps(rows, main_steps, n)
|
||||
checks[label + " complete_times"] = complete[key]
|
||||
checks[label + " scales"] = (
|
||||
r.get("U") == U and finite(r.get("viscosity"))
|
||||
and abs(r["viscosity"] - U * n / 100) < 1e-15
|
||||
and r.get("tau_by_level") == [0.5 + 3 * U * n / 100 * 2**l for l in range(depth)]
|
||||
and r.get("mach") == U * math.sqrt(3)
|
||||
)
|
||||
checks[label + " actual_builds"] = valid_builds(r, n, depth)
|
||||
checks[label + " reference"] = (r.get("numerical_revision") == NUMERICAL_REVISION
|
||||
and r.get("reference_harness") == REFERENCE_HARNESS)
|
||||
checks[label + " numerics"] = r.get("numerics") == NUMERICS and r.get("initialization") == INITIALIZATION
|
||||
runtime = r.get("runtime_source", {})
|
||||
package = Path(runtime.get("checkout", "")) / "src/CelerisLab"
|
||||
checks[label + " runtime_source"] = bool(runtime.get("checkout")) and all(
|
||||
runtime.get(k) == str(package / tail) for k, tail in
|
||||
(("module", "__init__.py"), ("compiler", "cuda/compiler_v2.py"), ("kernel_root", "lbm/kernels")))
|
||||
expected_patch = [] if depth == 1 else rectangles(n, depth)
|
||||
checks[label + " patch"] = json.dumps(r.get("refinements")) == json.dumps(expected_patch)
|
||||
cells = [n*n] if depth == 1 else [8704, 30720]
|
||||
hops = [16*n * 2**l for l in range(depth)]
|
||||
work = r.get("workload", {})
|
||||
checks[label + " workload"] = (
|
||||
work.get("active_cells_by_level") == cells and work.get("level_hops") == hops
|
||||
and work.get("root_intervals") == 16*n
|
||||
and work.get("active_collision_updates_by_level") == [c*h for c, h in zip(cells, hops)]
|
||||
and work.get("active_collision_updates") == sum(c*h for c, h in zip(cells, hops))
|
||||
)
|
||||
checks[label + " absolute"] = bool(rows) and all(
|
||||
finite(row.get(metric)) and 0 <= row[metric] <= limit
|
||||
for row in rows for metric, limit in LIMITS.items())
|
||||
trace = r.get("early_pressure", {})
|
||||
trace_rows = trace.get("rows", [])
|
||||
peak = maximum(trace_rows, "pressure_l2")
|
||||
trace_complete = at_steps(trace_rows, early_steps, n) and peak is not None
|
||||
checks[label + " early_trace"] = (
|
||||
trace_complete and trace.get("sampled_max_pressure_l2") == peak
|
||||
and len(rows) >= 2 and all(trace_rows[i]["pressure_l2"] == rows[j].get("pressure_l2")
|
||||
for i, j in ((0, 0), (-1, 1)))
|
||||
if trace.get("enabled") is True else
|
||||
trace.get("enabled") is False and trace_rows == [] and trace.get("sampled_max_pressure_l2") is None
|
||||
)
|
||||
early.append(dict(n=n, depth=depth, explosion=scheme, enabled=trace.get("enabled"),
|
||||
samples=len(trace_rows), complete=trace_complete,
|
||||
sampled_max_pressure_l2=peak))
|
||||
case_valid[key] = all(value for name, value in checks.items() if name.startswith(label + " "))
|
||||
|
||||
def worst(key, metric):
|
||||
return maximum(indexed[key]["rows"][1:], metric) if complete.get(key) else None
|
||||
|
||||
comparisons, scheme_results = [], {}
|
||||
for scheme in ("constant", "projected_linear"):
|
||||
key = (100, 128, 2, scheme)
|
||||
for metric, floor in FLOORS.items():
|
||||
amr, coarse, fine = (worst(k, metric) for k in (key, CASES[0], CASES[1]))
|
||||
budget = None if coarse is None else max(BASELINE_FACTOR * coarse, floor)
|
||||
budget = budget if finite(budget) else None
|
||||
ratio = None if amr is None or fine is None else amr / max(fine, 1e-30)
|
||||
passed = amr is not None and budget is not None and amr <= budget
|
||||
checks[f"baseline {scheme}/{metric}"] = passed
|
||||
comparisons.append(dict(explosion=scheme, metric=metric, amr=amr,
|
||||
uniform128=coarse, baseline_budget=budget, passed=passed,
|
||||
uniform256=fine, amr_to_uniform256=ratio if finite(ratio) else None))
|
||||
baseline_passed = all(checks[f"baseline {scheme}/{metric}"] for metric in FLOORS)
|
||||
valid = shared_valid and all(case_valid.get(k, False) for k in (CASES[0], CASES[1], key))
|
||||
scheme_results[scheme] = dict(passed=valid and baseline_passed,
|
||||
valid_case_and_controls=valid, baseline_passed=baseline_passed)
|
||||
return dict(passed=all(checks.values()), missing=missing, duplicates=duplicates, checks=checks,
|
||||
comparisons=comparisons, scheme_results=scheme_results, early_pressure=early,
|
||||
numerical_revision=NUMERICAL_REVISION, source_tree_sha256=SOURCE_SHA256,
|
||||
reference_harness=REFERENCE_HARNESS, expected_revision=expected_revision,
|
||||
floors=FLOORS, baseline_factor=BASELINE_FACTOR,
|
||||
main_statistic="maximum of four post-initial samples at t*=.12,.24,.36,.48",
|
||||
early_statistic="sampled pressure maximum only, t*=k*.001875, k=0..64; not an all-step or continuous maximum",
|
||||
finest_control="uniform256 and AMR/ uniform256 ratios are descriptive, not acceptance gates",
|
||||
scope="2D periodic Re100 smooth-decay diagnostic; no new algorithm, convergence-order or general AMR qualification")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("files", type=Path, nargs="+")
|
||||
p.add_argument("--output", type=Path, required=True)
|
||||
p.add_argument("--expected-revision", required=True)
|
||||
args = p.parse_args()
|
||||
if args.output.exists():
|
||||
p.error("Output already exists; choose a new path to retain prior evidence")
|
||||
result = assess([json.loads(path.read_text()) for path in args.files], args.expected_revision)
|
||||
args.output.write_text(json.dumps(result, indent=2, allow_nan=False) + "\n")
|
||||
print(json.dumps(result, indent=2, allow_nan=False))
|
||||
raise SystemExit(0 if result["passed"] else 1)
|
||||
@@ -1,9 +1,10 @@
|
||||
"""One bounded GPU case. No production edits, plots, checkpoints or field dumps."""
|
||||
"""One bounded GPU case, with explicit opt-in experimental explosion."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -12,6 +13,35 @@ import time
|
||||
import numpy as np
|
||||
from oracle import U, TIMES, LIMITS, initial, metrics, rectangles
|
||||
|
||||
NUMERICAL_REVISION = "a135252ae78b47b57d7e77d19001700b3bedac8b"
|
||||
SOURCE_SHA256 = "a5dbfd804befce868067d79a23e5fd47dce7f505b3f9b1bb2145fb57cb5f2178"
|
||||
BUILD_CAPTURE_SHA256 = "3dbaa6547cb0a4cb1541054f3b64ad8b6e18cc70527ac6166e56b944e9bd464a"
|
||||
REFERENCE_HARNESS = "11901a7f77557a901e017a171eeae1758d584fa5"
|
||||
INITIALIZATION = "physical FP64 equilibrium G at actual cell centers; rho=1+3p"
|
||||
|
||||
|
||||
def sample_steps(n, early_pressure=False):
|
||||
"""Root synchronization steps; early ladder matches the 707afe6 diagnostic."""
|
||||
main = [round(tstar * n / U) for tstar in TIMES]
|
||||
early = list(range(0, 4 * n + 1, n // 16)) if early_pressure else []
|
||||
return main, early
|
||||
|
||||
|
||||
def runtime_source(repo):
|
||||
"""Fail before CUDA if Python or the compiler snapshot comes from another tree."""
|
||||
import CelerisLab
|
||||
from CelerisLab.cuda import compiler_v2
|
||||
|
||||
paths = dict(module=Path(CelerisLab.__file__).resolve(),
|
||||
compiler=Path(compiler_v2.__file__).resolve(),
|
||||
kernel_root=Path(compiler_v2.kernel_path("blocks.cu")).resolve().parent)
|
||||
package = repo / "src/CelerisLab"
|
||||
expected = dict(module=package / "__init__.py", compiler=package / "cuda/compiler_v2.py",
|
||||
kernel_root=package / "lbm/kernels")
|
||||
if paths != expected:
|
||||
raise RuntimeError("Wrong imported source or CUDA source/include root")
|
||||
return dict(checkout=str(repo), **{key: str(path) for key, path in paths.items()})
|
||||
|
||||
|
||||
def provenance(repo):
|
||||
git = lambda *a: subprocess.check_output(
|
||||
@@ -34,6 +64,46 @@ def provenance(repo):
|
||||
)
|
||||
|
||||
|
||||
def verify_source(repo, revision):
|
||||
"""Exact clean harness commit, fixed production tree, and reviewed capture helper."""
|
||||
import oracle
|
||||
from tests.validation.multigrid import check_projected_pairs as gate
|
||||
|
||||
harness = repo / "tests/validation/taylor_green"
|
||||
if (Path(__file__).resolve() != harness / "run_case.py"
|
||||
or Path(oracle.__file__).resolve() != harness / "oracle.py"):
|
||||
raise RuntimeError("Wrong runner or oracle checkout")
|
||||
helper = repo / "tests/validation/multigrid/check_projected_pairs.py"
|
||||
if (Path(gate.__file__).resolve() != helper
|
||||
or hashlib.sha256(helper.read_bytes()).hexdigest() != BUILD_CAPTURE_SHA256):
|
||||
raise RuntimeError("Wrong build-capture helper")
|
||||
if gate.preflight(repo, revision, "candidate") != NUMERICAL_REVISION:
|
||||
raise RuntimeError("Wrong production revision")
|
||||
result = provenance(repo)
|
||||
if result["source_tree_sha256"] != SOURCE_SHA256:
|
||||
raise RuntimeError("Wrong numerical source fingerprint")
|
||||
result["build_capture_sha256"] = BUILD_CAPTURE_SHA256
|
||||
return result
|
||||
|
||||
|
||||
def build_capture(report, save):
|
||||
from tests.validation.multigrid.check_projected_pairs import capture_builds
|
||||
return capture_builds(report, save)
|
||||
|
||||
|
||||
def runtime_identity(ctx):
|
||||
import pycuda
|
||||
import pycuda.driver as cuda
|
||||
|
||||
nvcc = shutil.which("nvcc") or "/usr/local/cuda/bin/nvcc"
|
||||
return dict(device=dict(name=ctx.device.name(), pci_bus_id=ctx.device.pci_bus_id(),
|
||||
capability=list(ctx.device.compute_capability()),
|
||||
driver=cuda.get_driver_version(), runtime=list(cuda.get_version())),
|
||||
nvcc_path=str(Path(nvcc).resolve()),
|
||||
nvcc_version=subprocess.check_output([nvcc, "--version"], text=True).strip(),
|
||||
python=sys.version, numpy=np.__version__, pycuda=pycuda.VERSION_TEXT)
|
||||
|
||||
|
||||
def config(n, re):
|
||||
from CelerisLab.config import LBMConfig
|
||||
|
||||
@@ -71,19 +141,31 @@ def workload(masks, base_steps):
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--repo", type=Path, required=True)
|
||||
p.add_argument("--expected-revision", required=True)
|
||||
p.add_argument("--run-gpu", action="store_true")
|
||||
p.add_argument("--dry-run", action="store_true")
|
||||
p.add_argument("--re", type=int, choices=[100, 1000], required=True)
|
||||
p.add_argument("--n", type=int, choices=[32, 64, 128, 256], required=True)
|
||||
p.add_argument("--depth", type=int, choices=[1, 2, 3], required=True)
|
||||
p.add_argument("--output", type=Path, required=True)
|
||||
p.add_argument("--explosion", choices=["constant", "projected_linear"], default="constant")
|
||||
p.add_argument("--early-pressure", action="store_true",
|
||||
help="also save 65 scalar pressure samples at t*=k*.001875, k=0..64")
|
||||
args = p.parse_args()
|
||||
if not os.environ.get("CUDA_VISIBLE_DEVICES"):
|
||||
p.error("Explicit physical CUDA_VISIBLE_DEVICES selection required")
|
||||
if args.output.exists():
|
||||
p.error("Output already exists; choose a new path to retain prior evidence")
|
||||
if args.explosion != "constant" and args.depth != 2:
|
||||
p.error("projected_linear requires depth 2")
|
||||
selected = os.environ.get("CUDA_VISIBLE_DEVICES", "")
|
||||
if not args.dry_run and (not args.run_gpu or not selected or "," in selected or selected == "-1"):
|
||||
p.error("--run-gpu and exactly one explicit CUDA_VISIBLE_DEVICES are required")
|
||||
repo = args.repo.resolve()
|
||||
sys.path.insert(0, str(repo / "src"))
|
||||
import CelerisLab
|
||||
|
||||
if Path(CelerisLab.__file__).resolve() != repo / "src/CelerisLab/__init__.py":
|
||||
raise RuntimeError("Wrong imported source checkout")
|
||||
sys.path[:0] = [str(repo / "src"), str(repo)]
|
||||
source = verify_source(repo, args.expected_revision)
|
||||
runtime = runtime_source(repo)
|
||||
if args.dry_run:
|
||||
print(json.dumps(dict(source=source, runtime_source=runtime, dry_run=True)))
|
||||
return 0
|
||||
from CelerisLab.cuda.context import CudaContext
|
||||
from CelerisLab.lbm.blocks import BlockLayout2D
|
||||
from CelerisLab.lbm.block_stepper import PeriodicBlockStepper
|
||||
@@ -96,9 +178,17 @@ def main():
|
||||
h = build_hierarchy(cfg, refinements)
|
||||
np.testing.assert_array_equal(leaf_partition(h), np.ones((args.n, args.n)))
|
||||
masks = [k == ACTIVE for k in h.kinds]
|
||||
main_steps, early_steps = sample_steps(args.n, args.early_pressure)
|
||||
report = dict(
|
||||
schema=1,
|
||||
source=provenance(repo),
|
||||
source=source,
|
||||
numerical_revision=NUMERICAL_REVISION,
|
||||
reference_harness=REFERENCE_HARNESS,
|
||||
actual_build_sidecars=[],
|
||||
runtime_identity=None,
|
||||
runtime_source=runtime,
|
||||
historical_baseline_revision="9805383f9c50d867e8555471baf5dbda8333a652",
|
||||
explosion=args.explosion,
|
||||
n=args.n,
|
||||
re_L=args.re,
|
||||
depth=args.depth,
|
||||
@@ -107,21 +197,35 @@ def main():
|
||||
mach=U * np.sqrt(3),
|
||||
viscosity=cfg.viscosity,
|
||||
tau_by_level=[0.5 + 3 * c.viscosity for c in h.configs],
|
||||
initialization="physical FP64 equilibrium G at actual cell centers; rho=1+3p",
|
||||
cuda_visible_devices=os.environ["CUDA_VISIBLE_DEVICES"],
|
||||
initialization=INITIALIZATION,
|
||||
numerics=dict(lattice_model=cfg.lattice_model, collision=cfg.collision,
|
||||
store_precision=cfg.store_precision, ddf_shifting=cfg.ddf_shifting,
|
||||
streaming=cfg.streaming, les_enabled=cfg.les_enabled,
|
||||
boundary="periodic", block_shape=[8, 8],
|
||||
sample_phase="postcollision G at root synchronization"),
|
||||
cuda_visible_devices=selected,
|
||||
device_id=0,
|
||||
limits=LIMITS,
|
||||
workload=workload(masks, round(TIMES[-1] * args.n / U)),
|
||||
rows=[],
|
||||
early_pressure=dict(enabled=args.early_pressure, rows=[],
|
||||
sampled_max_pressure_l2=None),
|
||||
completed=False,
|
||||
)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
save = lambda: args.output.write_text(
|
||||
json.dumps(report, indent=2, allow_nan=False) + "\n"
|
||||
)
|
||||
def save():
|
||||
temporary = args.output.with_suffix(args.output.suffix + ".tmp")
|
||||
text = json.dumps(report, indent=2, allow_nan=False) + "\n"
|
||||
if len(text.encode()) > 1024 * 1024:
|
||||
raise RuntimeError("Scalar receipt exceeds 1 MiB cap")
|
||||
temporary.write_text(text)
|
||||
temporary.replace(args.output)
|
||||
|
||||
save()
|
||||
started = time.monotonic()
|
||||
with CudaContext(0) as ctx:
|
||||
with CudaContext(0) as ctx, build_capture(report, save):
|
||||
report["runtime_identity"] = runtime_identity(ctx)
|
||||
save()
|
||||
if args.depth == 1:
|
||||
layout = BlockLayout2D.build(args.n, args.n, 8, 8, periodic=(True, True))
|
||||
run = PeriodicBlockStepper(cfg, layout, context=ctx)
|
||||
@@ -132,6 +236,7 @@ def main():
|
||||
coarse_boundary="periodic",
|
||||
refinements=refinements,
|
||||
level_blocks=[(8, 8)] * args.depth,
|
||||
explosion=args.explosion,
|
||||
)
|
||||
with run:
|
||||
run.initialize(
|
||||
@@ -141,24 +246,30 @@ def main():
|
||||
]
|
||||
)
|
||||
initial_mass = None
|
||||
for tstar in TIMES:
|
||||
step = round(tstar * args.n / U)
|
||||
for step in sorted(set(main_steps + early_steps)):
|
||||
run.step(step - run.step_count)
|
||||
arrays = (run.download(),) if args.depth == 1 else run.download()
|
||||
row = metrics(arrays, masks, step, args.n, cfg.viscosity, initial_mass)
|
||||
if not all(np.isfinite(value) for value in row.values()):
|
||||
raise FloatingPointError("nonfinite sample diagnostics")
|
||||
row["step"] = step
|
||||
if initial_mass is None:
|
||||
initial_mass = row["mass"]
|
||||
report["rows"].append(row)
|
||||
if step in main_steps:
|
||||
report["rows"].append(row)
|
||||
if step in early_steps:
|
||||
trace = report["early_pressure"]
|
||||
trace["rows"].append({key: row[key] for key in ("step", "tstar", "pressure_l2")})
|
||||
trace["sampled_max_pressure_l2"] = max(r["pressure_l2"] for r in trace["rows"])
|
||||
save()
|
||||
print(
|
||||
json.dumps(
|
||||
dict(
|
||||
event="PROGRESS",
|
||||
event="PROGRESS" if step in main_steps else "EARLY_PRESSURE",
|
||||
n=args.n,
|
||||
re_L=args.re,
|
||||
depth=args.depth,
|
||||
**row,
|
||||
**(row if step in main_steps else report["early_pressure"]["rows"][-1]),
|
||||
)
|
||||
),
|
||||
flush=True,
|
||||
@@ -168,7 +279,9 @@ def main():
|
||||
# This is the first measured trajectory step, not discarded warmup.
|
||||
run.step(1)
|
||||
probe = (run.download(),) if args.depth == 1 else run.download()
|
||||
metrics(probe, masks, 1, args.n, cfg.viscosity, initial_mass)
|
||||
probe_metrics = metrics(probe, masks, 1, args.n, cfg.viscosity, initial_mass)
|
||||
if not all(np.isfinite(value) for value in probe_metrics.values()):
|
||||
raise FloatingPointError("nonfinite READY diagnostics")
|
||||
report["ready_after_step"] = 1
|
||||
save()
|
||||
print(
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
"""Host geometry, receipt-corruption tests and mocked scheduling; no CUDA/flow."""
|
||||
|
||||
from contextlib import nullcontext, redirect_stdout
|
||||
from copy import deepcopy
|
||||
from dataclasses import asdict, replace
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
import run_case
|
||||
from assess_projected_pairs import (BUILD_SOURCES, CASES, NUMERICS, NUMERICAL_REVISION,
|
||||
SOURCE_SHA256, assess)
|
||||
from oracle import LIMITS, TIMES, U, rectangles
|
||||
|
||||
REPO = Path(__file__).resolve().parents[3]
|
||||
OLD_REVISION = "519473a61c7b319183aa54e55b9f097499dde0bf"
|
||||
MOCK_REVISION = "f" * 40
|
||||
RUNTIME = dict(device=dict(name="host mock", pci_bus_id="mock", capability=[7, 0],
|
||||
driver=12040, runtime=[12, 4, 0]), nvcc_path="/mock/nvcc",
|
||||
nvcc_version="mock nvcc", python="mock Python", numpy=np.__version__, pycuda="mock")
|
||||
|
||||
|
||||
def source():
|
||||
return dict(revision=MOCK_REVISION, dirty=False, source_tree_sha256=SOURCE_SHA256,
|
||||
harness_sha256={"run_case.py": "a" * 64},
|
||||
build_capture_sha256=run_case.BUILD_CAPTURE_SHA256)
|
||||
|
||||
|
||||
def build_metadata(size):
|
||||
cfg = replace(run_case.config(size, 100), nx=10, ny=10)
|
||||
cfg.validate()
|
||||
return dict(source_sha256=BUILD_SOURCES[size], config=asdict(cfg), n_objects=0,
|
||||
arch="sm_70", flags=["--use_fast_math"], ptx_sha256="b" * 64)
|
||||
|
||||
|
||||
def records(early=False):
|
||||
from CelerisLab.lbm.hierarchy import build_hierarchy
|
||||
from CelerisLab.lbm.refinement import ACTIVE
|
||||
|
||||
out = []
|
||||
for re, n, depth, scheme in CASES:
|
||||
cfg = run_case.config(n, re)
|
||||
refinements = [] if depth == 1 else rectangles(n, depth)
|
||||
h = build_hierarchy(cfg, refinements)
|
||||
main_steps, early_steps = run_case.sample_steps(n, early)
|
||||
out.append(dict(
|
||||
re_L=re, n=n, depth=depth, explosion=scheme, U=U, mach=float(U*np.sqrt(3)),
|
||||
viscosity=cfg.viscosity, tau_by_level=[0.5+3*c.viscosity for c in h.configs],
|
||||
refinements=refinements, numerics=NUMERICS.copy(), initialization=run_case.INITIALIZATION,
|
||||
source=source(), numerical_revision=NUMERICAL_REVISION,
|
||||
reference_harness=run_case.REFERENCE_HARNESS,
|
||||
actual_build_sidecars=[build_metadata(n * 2**level) for level in range(depth)],
|
||||
runtime_identity=deepcopy(RUNTIME), cuda_visible_devices="0",
|
||||
runtime_source=run_case.runtime_source(REPO), completed=True,
|
||||
workload=run_case.workload([k == ACTIVE for k in h.kinds], 16*n),
|
||||
rows=[dict(step=s, tstar=s*U/n, **{k: 0.0 for k in LIMITS}) for s in main_steps],
|
||||
early_pressure=dict(enabled=early, rows=[
|
||||
dict(step=s, tstar=s*U/n, pressure_l2=.01 if s not in main_steps else 0.0) for s in early_steps],
|
||||
sampled_max_pressure_l2=.01 if early else None)))
|
||||
return out
|
||||
|
||||
|
||||
class ProjectedPairsTests(unittest.TestCase):
|
||||
def test_exact_geometry_work_and_samples(self):
|
||||
from CelerisLab.lbm.hierarchy import build_hierarchy, leaf_partition
|
||||
|
||||
data = records(True)
|
||||
self.assertEqual(sum(r["workload"]["active_collision_updates"] for r in data), 589299712)
|
||||
for r in data:
|
||||
n, depth = r["n"], r["depth"]
|
||||
h = build_hierarchy(run_case.config(n, 100), r["refinements"])
|
||||
np.testing.assert_array_equal(leaf_partition(h), np.ones((n, n)))
|
||||
main, early = run_case.sample_steps(n, True)
|
||||
self.assertEqual(main, [0, 4*n, 8*n, 12*n, 16*n])
|
||||
self.assertEqual(early, [k*(n//16) for k in range(65)])
|
||||
np.testing.assert_allclose(np.array(early)*U/n, np.arange(65)*.001875, rtol=0, atol=3e-17)
|
||||
if depth == 2:
|
||||
self.assertEqual(r["refinements"], [[(16, 16, 112, 96)]])
|
||||
self.assertTrue(assess(data)["passed"])
|
||||
self.assertTrue(assess(records())["passed"])
|
||||
|
||||
def test_exact_old519_geometry_and_table_parity(self):
|
||||
from CelerisLab.lbm.blocks import BlockLayout2D
|
||||
from CelerisLab.lbm.hierarchy import build_hierarchy
|
||||
from CelerisLab.lbm.refinement_tables import projected_explosion_tables
|
||||
|
||||
# Frozen from executing exact 519473a61c7b319183aa54e55b9f097499dde0bf
|
||||
# geometry and table definitions, after checking unchanged config,
|
||||
# descriptors and blocks. No historical Git object is needed at runtime.
|
||||
# Historical source SHA256, refinement.py then refinement_tables.py:
|
||||
# 08c2a0557276a98cbcaa459d9bb7a9735d8bbbf64a6114c57af2fda61481a687
|
||||
# dabff948bb30496384d1a52c666b0edbc2085f9c61ed1bbc39f748502629113d
|
||||
frozen = {
|
||||
"covered": ("|b1", (128, 128), "14a1b5be54e3309bea825a00a653105951f0c805622b877a86dd453d65556df3"),
|
||||
"fine_kind": ("|u1", (256, 256), "891ff72c3d60686c62b1f35ffe59d054ac23b8f9d59aeb5782bc941202794cc0"),
|
||||
"ghost_fine": ("<u8", (2880,), "2d0311ac0feb1c0e1e2b6bcf806fe616b8217513de218407267dcd7652287579"),
|
||||
"ghost_coarse": ("<u4", (2880,), "5a3b305a0f841c8d05a13f105fda02b43ef875ab1321f9bca579f2b8ae313233"),
|
||||
"coalesce_coarse": ("<u8", (1060,), "a1d88c60f282153dcf2bb497c59d777968b6db773d061d8fea08163c98155967"),
|
||||
"coalesce_fine": ("<u8", (1060, 4), "08914c5d17c1ec8e2bfd234f581e4b3283de26296ae7d80403385feed7b89532"),
|
||||
"neighbors": ("<u8", (2880, 4), "cfef3a07103241e3dfff24fd65ab967afcf20520f699574b191044d47cf5d1f4"),
|
||||
"offsets": ("<f4", (2880, 2), "c98ba658d5f2cc18605e029fbe1da448ded3fd2436edcaf8ea097fa46aeb1f2d"),
|
||||
}
|
||||
hierarchy = build_hierarchy(run_case.config(128, 100), rectangles(128, 2))
|
||||
pair = hierarchy.pairs[0]
|
||||
self.assertEqual((pair.coarse_shape, pair.fine_shape, pair.fine_origin),
|
||||
((128, 128), (256, 256), (0.0, 0.0)))
|
||||
self.assertEqual(len(np.unique(pair.ghost_coarse)), 720)
|
||||
self.assertTrue((hierarchy.kinds[0].ravel()[pair.ghost_coarse] == 0).all())
|
||||
layout = BlockLayout2D.build(128, 128, 8, 8, periodic=(True, True))
|
||||
neighbors, offsets = projected_explosion_tables(pair, layout, hierarchy.kinds[0])
|
||||
arrays = dict(neighbors=neighbors, offsets=offsets)
|
||||
arrays.update({name: getattr(pair, name) for name in frozen if name not in arrays})
|
||||
for name, array in arrays.items():
|
||||
with self.subTest(array=name):
|
||||
self.assertEqual((array.dtype.str, array.shape, hashlib.sha256(array.tobytes()).hexdigest()),
|
||||
frozen[name])
|
||||
|
||||
def test_actual_build_capture_rejects_tampered_ptx_and_sidecar(self):
|
||||
from CelerisLab.cuda import compiler_v2
|
||||
from tests.validation.multigrid.check_projected_pairs import capture_builds
|
||||
|
||||
for tamper in (None, "ptx", "sidecar"):
|
||||
with self.subTest(tamper=tamper), tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "blocks.ptx"
|
||||
path.write_bytes(b"host-only mock PTX")
|
||||
metadata = dict(ptx_sha256=hashlib.sha256(path.read_bytes()).hexdigest())
|
||||
if tamper == "ptx":
|
||||
path.write_bytes(b"tampered")
|
||||
if tamper == "sidecar":
|
||||
metadata["ptx_sha256"] = "0" * 64
|
||||
Path(str(path) + ".json").write_text(json.dumps(metadata))
|
||||
report = {"actual_build_sidecars": []}
|
||||
with patch.object(compiler_v2, "load_module", return_value="mock-module") as load:
|
||||
with capture_builds(report, lambda: None):
|
||||
if tamper:
|
||||
with self.assertRaisesRegex(AssertionError, "PTX and build sidecar"):
|
||||
compiler_v2.load_module(str(path))
|
||||
load.assert_not_called()
|
||||
self.assertEqual(report["actual_build_sidecars"], [])
|
||||
else:
|
||||
self.assertEqual(compiler_v2.load_module(str(path)), "mock-module")
|
||||
self.assertEqual(report["actual_build_sidecars"], [metadata])
|
||||
self.assertIs(compiler_v2.load_module, load)
|
||||
|
||||
def test_frozen_formula_and_finest_control(self):
|
||||
data = records(True)
|
||||
data[0]["rows"][2]["velocity_l2"] = .002
|
||||
data[0]["rows"][0]["velocity_l2"] = .04 # retained absolute gate, excluded from worst
|
||||
for r in data[2:]:
|
||||
for row in r["rows"][1:]:
|
||||
row.update(velocity_l2=.0039, pressure_l2=.02, energy_relative=.004)
|
||||
r["early_pressure"]["rows"][-1]["pressure_l2"] = .02
|
||||
r["early_pressure"]["sampled_max_pressure_l2"] = .02
|
||||
data[1]["rows"][2]["velocity_l2"] = .00001
|
||||
data[3]["early_pressure"]["rows"][7]["pressure_l2"] = .9
|
||||
data[3]["early_pressure"]["sampled_max_pressure_l2"] = .9
|
||||
result = assess(data)
|
||||
self.assertTrue(result["passed"])
|
||||
velocity = result["comparisons"][0]
|
||||
self.assertEqual(velocity["baseline_budget"], .004)
|
||||
self.assertAlmostEqual(velocity["amr_to_uniform256"], 390)
|
||||
self.assertEqual(result["early_pressure"][-1]["sampled_max_pressure_l2"], .9)
|
||||
data[3]["rows"][-1]["pressure_l2"] = .02001
|
||||
result = assess(data)
|
||||
self.assertFalse(result["passed"])
|
||||
self.assertEqual(result["comparisons"][4]["amr"], .02001)
|
||||
|
||||
def test_scheme_verdicts_are_independent(self):
|
||||
data = records()
|
||||
data[2]["rows"][-1]["pressure_l2"] = .03
|
||||
result = assess(data)
|
||||
self.assertFalse(result["passed"])
|
||||
self.assertFalse(result["scheme_results"]["constant"]["passed"])
|
||||
self.assertTrue(result["scheme_results"]["projected_linear"]["passed"])
|
||||
|
||||
def test_corruptions_cannot_pass(self):
|
||||
good = records(True)
|
||||
corruptions = [
|
||||
lambda r: r.update(completed=False),
|
||||
lambda r: r["rows"].pop(),
|
||||
lambda r: r["rows"][-1].update(pressure_l2=float("nan")),
|
||||
lambda r: r["rows"][-1].update(energy_relative=float("inf")),
|
||||
lambda r: r["rows"][-1].update(pressure_l2=1e308),
|
||||
lambda r: r["rows"][-1].update(velocity_l2=-1),
|
||||
lambda r: r["rows"][-1].update(step=2047),
|
||||
lambda r: r["rows"][-1].update(tstar=.4801),
|
||||
lambda r: r["source"].update(dirty=True),
|
||||
lambda r: r["source"].update(source_tree_sha256="wrong"),
|
||||
lambda r: r["source"].update(revision=OLD_REVISION),
|
||||
lambda r: r["source"].pop("build_capture_sha256"),
|
||||
lambda r: r.update(numerical_revision=OLD_REVISION),
|
||||
lambda r: r.update(reference_harness="wrong"),
|
||||
lambda r: r.pop("runtime_identity"),
|
||||
lambda r: r["runtime_identity"].pop("nvcc_version"),
|
||||
lambda r: r["runtime_identity"].update(device="wrong"),
|
||||
lambda r: r["runtime_identity"]["device"].update(capability=[8, 0]),
|
||||
lambda r: r.update(cuda_visible_devices="0,1"),
|
||||
lambda r: r.pop("actual_build_sidecars"),
|
||||
lambda r: r["actual_build_sidecars"].pop(),
|
||||
lambda r: r["actual_build_sidecars"].reverse(),
|
||||
lambda r: r["actual_build_sidecars"][0].update(source_sha256="c" * 64),
|
||||
lambda r: r["actual_build_sidecars"][0].update(arch="sm_80"),
|
||||
lambda r: r["actual_build_sidecars"][0].update(flags=[]),
|
||||
lambda r: r["actual_build_sidecars"][0].update(ptx_sha256="not-a-hash"),
|
||||
lambda r: r["actual_build_sidecars"][0]["config"].update(ddf_shifting=False),
|
||||
lambda r: r["source"]["harness_sha256"].update(extra="mixed"),
|
||||
lambda r: r.update(viscosity=.01),
|
||||
lambda r: r["tau_by_level"].reverse(),
|
||||
lambda r: r["numerics"].update(ddf_shifting=False),
|
||||
lambda r: r.update(initialization="different equilibrium"),
|
||||
lambda r: r.update(refinements=[[(16, 16, 112, 104)]]),
|
||||
lambda r: r["workload"].update(active_collision_updates=0),
|
||||
lambda r: r["runtime_source"].update(kernel_root="/wrong/installed/tree"),
|
||||
lambda r: r["early_pressure"]["rows"].pop(),
|
||||
lambda r: r["early_pressure"]["rows"][8].update(step=65),
|
||||
lambda r: r["early_pressure"]["rows"][-1].update(pressure_l2=.005),
|
||||
lambda r: r["early_pressure"]["rows"][8].update(pressure_l2=float("nan")),
|
||||
lambda r: r["early_pressure"].update(sampled_max_pressure_l2=.5),
|
||||
lambda r: r["early_pressure"].update(enabled=False),
|
||||
]
|
||||
for mutation in corruptions:
|
||||
bad = deepcopy(good)
|
||||
mutation(bad[-1])
|
||||
with self.subTest(mutation=mutation):
|
||||
result = assess(bad)
|
||||
self.assertFalse(result["passed"])
|
||||
json.dumps(result, allow_nan=False) # failure summary itself remains serializable
|
||||
self.assertTrue(assess(good, expected_revision=MOCK_REVISION)["passed"])
|
||||
self.assertFalse(assess(good, expected_revision=OLD_REVISION)["passed"])
|
||||
old = deepcopy(good)
|
||||
for record in old:
|
||||
record["source"].update(revision=OLD_REVISION,
|
||||
source_tree_sha256="766e173df81d2881d3871007f15d5e321f5c7205ae578b0f80b43d1e7fe3e220")
|
||||
self.assertFalse(assess(old)["passed"])
|
||||
self.assertFalse(assess(good[:-1])["passed"])
|
||||
self.assertFalse(assess(good + [good[-1]])["passed"])
|
||||
self.assertFalse(assess([])["passed"])
|
||||
incomplete = deepcopy(good)
|
||||
incomplete[-1]["completed"] = False
|
||||
self.assertFalse(assess(incomplete)["comparisons"][3]["passed"])
|
||||
|
||||
def test_pinned_build_sources_match_host_generated_inputs(self):
|
||||
"""Check the pinned fingerprints using NUMERICAL_REVISION's renderer and kernels.
|
||||
The imported LBMConfig is unchanged from that revision; this is a host
|
||||
build-input check, not a reconstruction of the historical CUDA toolchain."""
|
||||
import importlib.util
|
||||
import tarfile
|
||||
|
||||
kernels = "src/CelerisLab/lbm/kernels"
|
||||
renderer = "src/CelerisLab/cuda/compiler_v2.py"
|
||||
if subprocess.run(["git", "-C", str(REPO), "cat-file", "-e",
|
||||
f"{NUMERICAL_REVISION}^{{commit}}"], capture_output=True).returncode:
|
||||
self.fail(f"{NUMERICAL_REVISION} is not in this clone; the pinned build "
|
||||
"sources cannot be checked")
|
||||
archive = subprocess.run(["git", "-C", str(REPO), "archive", "--format=tar",
|
||||
NUMERICAL_REVISION, kernels, renderer],
|
||||
capture_output=True, check=True).stdout
|
||||
with tempfile.TemporaryDirectory() as snapshot:
|
||||
with tarfile.open(fileobj=io.BytesIO(archive)) as tar:
|
||||
tar.extractall(snapshot)
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"CelerisLab.cuda._compiler_a135252", Path(snapshot) / renderer)
|
||||
compiler = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(compiler)
|
||||
with patch.object(compiler, "_KERNEL_DIR", str(Path(snapshot) / kernels)):
|
||||
for size in (128, 256):
|
||||
with self.subTest(size=size):
|
||||
cfg = replace(run_case.config(size, 100), nx=10, ny=10)
|
||||
cfg.validate()
|
||||
root = compiler.generate_config(cfg)
|
||||
try:
|
||||
actual = json.loads((Path(root) / ".celeris-build").read_text())
|
||||
expected = build_metadata(size)
|
||||
self.assertEqual(actual, {key: expected[key] for key in
|
||||
("source_sha256", "config", "n_objects")})
|
||||
finally:
|
||||
compiler.cleanup_build_dir(root)
|
||||
|
||||
def test_source_preflight_requires_clean_exact_revision_and_tree(self):
|
||||
import oracle
|
||||
from tests.validation.multigrid import check_projected_pairs as gate
|
||||
|
||||
for module in (run_case, oracle):
|
||||
with self.subTest(imported_module=module.__name__), \
|
||||
patch.object(module, "__file__", "/wrong/checkout/" + Path(module.__file__).name), \
|
||||
patch.object(gate, "preflight") as preflight:
|
||||
with self.assertRaisesRegex(RuntimeError, "Wrong runner or oracle checkout"):
|
||||
run_case.verify_source(REPO, MOCK_REVISION)
|
||||
preflight.assert_not_called()
|
||||
for expected, status, diff, fingerprint in (
|
||||
(MOCK_REVISION, "", 0, SOURCE_SHA256),
|
||||
(OLD_REVISION, "", 0, SOURCE_SHA256),
|
||||
(MOCK_REVISION, " M tracked.py", 0, SOURCE_SHA256),
|
||||
(MOCK_REVISION, "", 1, SOURCE_SHA256),
|
||||
(MOCK_REVISION, "", 0, "wrong")):
|
||||
provenance = dict(source(), source_tree_sha256=fingerprint)
|
||||
with self.subTest(expected=expected, status=status, diff=diff, fingerprint=fingerprint), \
|
||||
patch.object(gate.subprocess, "check_output", side_effect=[MOCK_REVISION, status]), \
|
||||
patch.object(gate.subprocess, "call", return_value=diff), \
|
||||
patch.object(run_case, "provenance", return_value=provenance):
|
||||
if expected != MOCK_REVISION or status or diff or fingerprint != SOURCE_SHA256:
|
||||
with self.assertRaises((ValueError, RuntimeError)):
|
||||
run_case.verify_source(REPO, expected)
|
||||
else:
|
||||
self.assertEqual(run_case.verify_source(REPO, expected), source())
|
||||
|
||||
def test_explicit_opt_in_selector_and_dry_run_without_cuda(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
output = Path(tmp) / "case.json"
|
||||
base = ["run_case.py", "--repo", str(REPO), "--re", "100", "--n", "128",
|
||||
"--depth", "1", "--output", str(output)]
|
||||
pinned = base + ["--expected-revision", MOCK_REVISION]
|
||||
for argv, device in ((base + ["--run-gpu"], "0"), (pinned, "0"),
|
||||
(pinned + ["--run-gpu"], ""), (pinned + ["--run-gpu"], "0,1"),
|
||||
(pinned + ["--run-gpu"], "-1"),
|
||||
(pinned + ["--run-gpu", "--explosion", "projected"], "0"),
|
||||
(pinned + ["--run-gpu", "--explosion", "projected_linear"], "0")):
|
||||
with self.subTest(argv=argv, device=device), patch.object(sys, "argv", argv), \
|
||||
patch.dict("os.environ", CUDA_VISIBLE_DEVICES=device), \
|
||||
patch.object(run_case, "verify_source") as verify, patch("sys.stderr", io.StringIO()):
|
||||
with self.assertRaises(SystemExit) as caught:
|
||||
run_case.main()
|
||||
self.assertEqual(caught.exception.code, 2)
|
||||
verify.assert_not_called()
|
||||
self.assertFalse(output.exists())
|
||||
stdout = io.StringIO()
|
||||
cuda_modules = {name: None for name in ("pycuda", "CelerisLab.cuda.context",
|
||||
"CelerisLab.lbm.block_stepper", "CelerisLab.lbm.refinement_stepper")}
|
||||
with patch.object(sys, "argv", pinned + ["--dry-run"]), \
|
||||
patch.dict(sys.modules, cuda_modules), patch.dict("os.environ", CUDA_VISIBLE_DEVICES=""), \
|
||||
patch.object(run_case, "verify_source", return_value=source()) as verify, \
|
||||
patch.object(run_case, "runtime_identity", side_effect=AssertionError("CUDA reached")), \
|
||||
redirect_stdout(stdout):
|
||||
self.assertEqual(run_case.main(), 0)
|
||||
verify.assert_called_once_with(REPO, MOCK_REVISION)
|
||||
self.assertTrue(json.loads(stdout.getvalue())["dry_run"])
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
def test_runtime_rejects_installed_kernel_tree(self):
|
||||
from CelerisLab.cuda import compiler_v2
|
||||
with patch.object(compiler_v2, "_KERNEL_DIR", "/other/CelerisLab/lbm/kernels"):
|
||||
with self.assertRaisesRegex(RuntimeError, "CUDA source/include root"):
|
||||
run_case.runtime_source(REPO)
|
||||
|
||||
def test_existing_output_is_never_overwritten(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
output = Path(tmp) / "failed.json"
|
||||
output.write_text('{"completed": false}\n')
|
||||
argv = ["run_case.py", "--repo", str(REPO), "--re", "100", "--n", "128",
|
||||
"--depth", "1", "--output", str(output),
|
||||
"--expected-revision", MOCK_REVISION, "--run-gpu"]
|
||||
with patch.object(sys, "argv", argv), patch("sys.stderr", io.StringIO()):
|
||||
with self.assertRaises(SystemExit) as caught:
|
||||
run_case.main()
|
||||
self.assertEqual(caught.exception.code, 2)
|
||||
self.assertEqual(output.read_text(), '{"completed": false}\n')
|
||||
result = subprocess.run([sys.executable, str(Path(__file__).with_name("assess_projected_pairs.py")),
|
||||
"unused.json", "--expected-revision", MOCK_REVISION,
|
||||
"--output", str(output)], capture_output=True)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertEqual(output.read_text(), '{"completed": false}\n')
|
||||
|
||||
def test_mocked_runner_default_early_and_failure(self):
|
||||
# Replace the GPU context and steppers before import. No CUDA module or flow is used.
|
||||
for early, depth, fail in ((False, 1, None), (False, 2, None), (True, 2, None),
|
||||
(True, 2, "resource"), (True, 2, "nonfinite"),
|
||||
(True, 2, "probe_nonfinite")):
|
||||
calls, selectors = [], []
|
||||
|
||||
class FakeRun:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.step_count = 0
|
||||
if "coarse_boundary" in kwargs:
|
||||
selectors.append(kwargs["explosion"])
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
def initialize(self, *arrays):
|
||||
pass
|
||||
|
||||
def step(self, count):
|
||||
self.assertion = count >= 0
|
||||
if not self.assertion:
|
||||
raise AssertionError("backward step")
|
||||
if fail == "resource" and self.step_count >= 16:
|
||||
raise MemoryError("mock resource failure")
|
||||
self.step_count += count
|
||||
calls.append(self.step_count)
|
||||
|
||||
def download(self):
|
||||
return None
|
||||
|
||||
modules = {
|
||||
"CelerisLab.cuda.context": SimpleNamespace(CudaContext=FakeRun),
|
||||
"CelerisLab.lbm.block_stepper": SimpleNamespace(PeriodicBlockStepper=FakeRun),
|
||||
"CelerisLab.lbm.refinement_stepper": SimpleNamespace(FixedPatchStepper=FakeRun),
|
||||
}
|
||||
|
||||
def metric(arrays, masks, step, n, nu, mass):
|
||||
return dict(step=step, tstar=step*U/n, mass=16384.,
|
||||
**{k: float("nan") if ((fail == "nonfinite" and step == 16)
|
||||
or (fail == "probe_nonfinite" and step == 1))
|
||||
and k == "energy_relative" else 0.0 for k in LIMITS})
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
output = Path(tmp) / "case.json"
|
||||
argv = ["run_case.py", "--repo", str(REPO), "--re", "100", "--n", "128",
|
||||
"--depth", str(depth), "--output", str(output),
|
||||
"--expected-revision", MOCK_REVISION, "--run-gpu"]
|
||||
if early:
|
||||
argv += ["--early-pressure", "--explosion", "projected_linear"]
|
||||
with patch.dict(sys.modules, modules), patch.object(sys, "argv", argv), \
|
||||
patch.dict("os.environ", CUDA_VISIBLE_DEVICES="mock"), \
|
||||
patch.object(run_case, "verify_source", return_value=source()) as verify, \
|
||||
patch.object(run_case, "runtime_identity", return_value=deepcopy(RUNTIME)), \
|
||||
patch.object(run_case, "build_capture", return_value=nullcontext()), \
|
||||
patch.object(run_case, "metrics", side_effect=metric), redirect_stdout(io.StringIO()):
|
||||
if fail:
|
||||
with self.assertRaises(MemoryError if fail == "resource" else FloatingPointError):
|
||||
run_case.main()
|
||||
else:
|
||||
self.assertEqual(run_case.main(), 0)
|
||||
receipt = json.loads(output.read_text())
|
||||
verify.assert_called_once_with(REPO, MOCK_REVISION)
|
||||
self.assertEqual(selectors, ["projected_linear" if early else "constant"] if depth == 2 else [])
|
||||
self.assertEqual(receipt["source"], source())
|
||||
self.assertEqual(receipt["runtime_identity"], RUNTIME)
|
||||
self.assertEqual(receipt["completed"], not fail)
|
||||
self.assertEqual(calls[:2], [0, 1]) # READY probe is part of this trajectory
|
||||
if not fail:
|
||||
self.assertEqual([r["tstar"] for r in receipt["rows"]], TIMES)
|
||||
self.assertEqual(len(receipt["early_pressure"]["rows"]), 65 if early else 0)
|
||||
self.assertEqual(calls[-1], 2048)
|
||||
else:
|
||||
self.assertEqual(len(receipt["rows"]), 1)
|
||||
self.assertEqual(len(receipt["early_pressure"]["rows"]),
|
||||
{"resource": 3, "nonfinite": 2, "probe_nonfinite": 1}[fail])
|
||||
if fail == "probe_nonfinite":
|
||||
self.assertNotIn("ready_after_step", receipt)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user