Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9660894a1b | ||
|
|
ba3772e3b8 | ||
|
|
b4692e6ec4 | ||
|
|
5731c5171b | ||
|
|
05de02ee60 | ||
|
|
87c3a3165e | ||
|
|
eec4677db1 | ||
|
|
2cf9853952 | ||
|
|
1aa8110ec8 | ||
|
|
a135252ae7 | ||
|
|
ed9d8c937d | ||
|
|
fc0e544c0a | ||
|
|
95cb6d2b9f | ||
|
|
53fad76759 | ||
|
|
8403956a9a | ||
|
|
185a919cc5 | ||
|
|
aceea293a3 | ||
|
|
a233c90324 | ||
|
|
f851aecf73 | ||
|
|
51202fc1c3 | ||
|
|
d930323479 | ||
|
|
6c53d2e74a | ||
|
|
724afb607c | ||
|
|
77bab1c4ea | ||
|
|
15fb58e36f | ||
|
|
4d4b9ba00a | ||
|
|
2abc48f0d8 | ||
|
|
3ea131e92c | ||
|
|
00d7a54829 |
@@ -123,7 +123,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 can use level lists of arbitrary depth; the
|
||||
legacy two/three-level forms are shown below.
|
||||
- `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)`),
|
||||
@@ -135,16 +137,20 @@ Scope and limits:
|
||||
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
|
||||
`bounds`, with both `fine_block` and `base_block`. 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. `fine_objects`/`jets` 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, use the level lists or legacy child forms below.
|
||||
|
||||
```python
|
||||
cfg = LBMConfig(nx=32, ny=16, viscosity=.1)
|
||||
@@ -158,7 +164,7 @@ Scope and limits:
|
||||
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
|
||||
- Legacy three-level scalar 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
|
||||
@@ -192,14 +198,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: `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.
|
||||
These lists are mutually exclusive with the legacy patch keywords. 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,62 @@
|
||||
# Selective LES normalization integration on dev8403956
|
||||
|
||||
This change reuses the reviewed correction from 5465832 without merging its
|
||||
branch history. The only production change is the two closed-form coefficients
|
||||
18 -> 18 sqrt(2) and their explanatory comments in turbulence_smag.cuh. Its SHA256
|
||||
is a3db46c2e7d27530b2d1436e65cdbed0b72cc7e047bfbeef2a640877271b51aa.
|
||||
Defaults, iterative/deviatoric/capped closure, collision dispatch, AMR guards,
|
||||
and all other production source files are unchanged.
|
||||
|
||||
## Separate source contract; unchanged experiment
|
||||
|
||||
The single existing flow runner now accepts `--experiment dev8403956`.
|
||||
Its separate source_pins_dev8403956.json pins all 101 production files from the
|
||||
committed 8403956 baseline; corrected mode permits only the exact reviewed
|
||||
header substitution. Results record the actual checkout commit, import path,
|
||||
experiment name and manifest SHA256. No arbitrary manifest-path override exists.
|
||||
|
||||
Omitting the option still selects the original historical source manifest.
|
||||
That manifest, the original paired receipts, scalar preparation manifests and
|
||||
independent continuum oracle are unchanged. Historical baseline and corrected
|
||||
receipts remain evidence for their own exact sources, not new dev receipts.
|
||||
Historical guards intentionally reject this different dev source tree. Eight
|
||||
production files already differed between the historical source snapshot and
|
||||
8403956; neither the old manifest nor its acceptance is silently regenerated.
|
||||
|
||||
The physical experiment and every numerical acceptance threshold are unchanged:
|
||||
8 x 34 nodes, halfway y walls, periodic x, unforced D2Q9/SRT, shifted FP32,
|
||||
double-buffer streaming, Cs=0/.16/1, checkpoints 519/1038/2075. Each case has one
|
||||
actual advancing warm-up followed by complete initial-state restoration before
|
||||
READY. Total launches are 6,225 measured plus three warm-ups. Cs=1 remains an
|
||||
amplified diagnostic, not a physical coefficient recommendation.
|
||||
|
||||
## Host checks
|
||||
|
||||
PYTHONDONTWRITEBYTECODE=1 python -m unittest discover -s tests/unit -p 'test_smagorinsky_normalization.py' -v
|
||||
PYTHONDONTWRITEBYTECODE=1 python -m unittest discover -s tests/unit -p 'test_les_flow_*.py' -v
|
||||
|
||||
The direct-current-header test is copied unchanged from reviewed 5465832:
|
||||
120 tensor parameter cases, Cs=0/equilibrium and omega guards, converged
|
||||
trace-free iterative comparison, and mutation detection in both lattice
|
||||
algebras. This is scalar D2/D3 algebra coverage, not 3D-flow qualification.
|
||||
Flow tests retain analytic/reference checks and add current named-source,
|
||||
reverse-patched baseline, variant rejection, unrelated-source tampering and
|
||||
immutable historical-default checks. The temporary baseline is constructed
|
||||
only inside a test directory; production is never reverse-patched.
|
||||
|
||||
## Pending final-commit GPU gate
|
||||
|
||||
After independent review and explicit executor authorization, run from the
|
||||
exact committed candidate checkout, selecting the authorized physical GPU:
|
||||
|
||||
CUDA_VISIBLE_DEVICES=<physical-GPU> PYTHONDONTWRITEBYTECODE=1 python tests/validation/run_uniform_les_flow.py --root "$PWD" --experiment dev8403956 --variant corrected --run-gpu --device 0 --output <new-evidence-directory>/corrected.json
|
||||
|
||||
Retain all source hashes, exact commit/import/device identity, manifest hash,
|
||||
three READY/reset records, complete result JSON, stdout/stderr and actual exit.
|
||||
Require all frozen gates, DONE passed=true and exit 0. Compare with historical
|
||||
corrected evidence without relabeling it as current evidence. The old-vs-new
|
||||
normalization discrimination already exists; no new baseline GPU run is needed
|
||||
unless review finds a changed exercised uniform solver path.
|
||||
|
||||
No GPU result is claimed by this integration preparation. This does not expand
|
||||
LES qualification to MRT, forcing, AMR LES or three-dimensional turbulent flows.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,34 @@
|
||||
# MRT reuse in blocked/refined stepping (candidate)
|
||||
|
||||
Base `9805383`. Opt-in by `LBMConfig(collision="MRT")`; SRT remains the default and its
|
||||
kernel statements, including the shifted retained-delta/direct-momentum path, are unchanged
|
||||
under the alternate compile branch.
|
||||
|
||||
## What changes
|
||||
- `blocks.cu` (`BlockStep`), `step/block_channel.cu` (`BlockChannelStep`) and `refinement.cu`
|
||||
(`patch_collide`) gain a compile-time `COLLISION_MODEL == 2` branch that repeats the uniform
|
||||
`collide_dispatch` sequence with zero forcing: `compute_rho_u`, omega clamp to
|
||||
`[OMEGA_COLLISION_MIN, OMEGA_COLLISION_MAX]`, `collide_mrt_no_force`.
|
||||
- Kernel and host guards accept SRT or MRT only. TRT, LES, non-FP32 storage, non-double-buffer
|
||||
streaming, and shifted dense/mixed refinement remain rejected.
|
||||
- Transfers, subcycling, BFL, raw exchange ledgers and channel closures are untouched. The
|
||||
existing pressure-NEQ outlet selects its MRT branch (unknowns-only NEQ extrapolation) from
|
||||
`COLLISION_MODEL`, so MRT channel parity must use uniform MRT as reference.
|
||||
|
||||
## Contracts (existing operator, not redefined here)
|
||||
- Shifted storage uses the existing MRT representation (equilibrium moments from `rho - 1`),
|
||||
not the SRT retained-delta/direct-momentum precision. Mass and momentum are conserved
|
||||
moments (relaxation rate 0).
|
||||
- Kinetic rates are the operator's fixed `s_e = s_eps = s_q = 1.2` on every level; the shear
|
||||
rate is the existing per-level omega. Under acoustic 2:1 scaling the physical bulk viscosity
|
||||
therefore halves on each finer level. No rate rescaling is applied.
|
||||
- No LES, no external forcing (`d_params` force fields stay zero).
|
||||
- Shifted Jets stay SRT-only: `body/jets.py` keeps its existing guard. This extension does not
|
||||
provide shifted MRT Jets.
|
||||
- `PeriodicBlockStepper` (uniform blocked stepper) remains SRT-only.
|
||||
|
||||
## Evidence status
|
||||
CPU: `tests/unit/test_refinement_mrt.py` (host guards, source contract). GPU:
|
||||
`tests/validation/multigrid/test_refinement_mrt_gpu.py` (uniform-MRT channel parity, AMR MRT
|
||||
smoke) and unchanged SRT GPU tests. Strict parity may be subject to cross-kernel FMA
|
||||
contraction differences already documented for SRT. Not a DFG/benchmark validation.
|
||||
@@ -1,8 +1,11 @@
|
||||
# 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 the legacy three-level entry point of `FixedPatchStepper`,
|
||||
with rectangle unions on either adjacent pair. The `refinements` level-list
|
||||
entry point also 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.
|
||||
@@ -30,12 +33,15 @@ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,53 @@
|
||||
# Bounded 2D implementation and validation scope
|
||||
|
||||
This inventory starts from dev `8403956a9afc0b0bb89265cec374392013d89016` and
|
||||
integrates only the reviewed LES normalization production correction from
|
||||
`95cb6d2b9f43c1bdd46cbf27f03f0dfc0877cfb0`. The merged production bytes match that
|
||||
tested candidate exactly; the scope-test changes are retained from `53fad767`.
|
||||
GPU receipts remain associated with their exact tested commits, not relabeled as
|
||||
a new merged-commit run. Historical source identities and gates remain unchanged.
|
||||
|
||||
## Implementation boundaries
|
||||
|
||||
- Uniform D2Q9 supports SRT, TRT and MRT. An available collision selector does not
|
||||
qualify every wall, inlet/outlet, forcing, storage or LES combination.
|
||||
- Fixed refinement supports SRT/MRT, FP32 and double-buffer streaming; TRT and LES
|
||||
are rejected. Shifted refinement requires all-blocked storage. Refined channel
|
||||
boundaries have narrower configuration guards than uniform boundaries.
|
||||
- Uniform checkpoints support compatible same-method continuation. Changing
|
||||
collision or LES parameters is a different evolution, not exact continuation.
|
||||
Jet checkpoints and refined checkpoints are unsupported; synchronized remapping
|
||||
is not persistent restart.
|
||||
- Keep these conclusions 2D. Local D3Q19 operator tests do not establish a supported
|
||||
3D Simulation runtime.
|
||||
|
||||
## Evidence and remaining gaps
|
||||
|
||||
| Area | Available evidence | Boundary of the conclusion |
|
||||
|---|---|---|
|
||||
| MRT cylinder flow and observation windows | Full matched uniform/refined MRT DFG and the window-ledger campaign passed their frozen periodicity, reference and matched-comparison gates. The window campaign used `185a919`; its MRT/no-LES numerical path remains unchanged. Current dev also contains independently scoped test/documentation changes and the LES correction, which do not extend MRT qualification. | This does not qualify other collision/boundary combinations or every refinement interface. Timing improvements are not additional accuracy evidence. |
|
||||
| Uniform forced channel | Six Poiseuille GPU cases on `8403956`: SRT/TRT/MRT at wall-normal resolutions H16/H32 passed. | This is the tested analytic channel configuration, not open-boundary or all-layout qualification. |
|
||||
| Uniform restart | Ten SRT/MRT checkpoint GPU tests passed on `8403956`; the separate representative TRT same-method double-buffer continuation GPU case also passed (one test). | This covers the tested same-method continuation configurations. No LES, Jet or refined checkpoint claim follows. |
|
||||
| Taylor–Green refinement | Independent topology checks are available. The actual-rate phase GPU regression passed one test across two layouts, with errors 1.584e-7 and 4.500e-9. | The physical interface-accuracy gap remains open. Passing topology and phase checks does not establish convergence or close that accuracy gate. |
|
||||
| Porous Couette and annulus | Prior campaign evidence exists and must remain associated with its original setup and source. | The reconstructed annulus is a validation helper, not qualification of a public boundary-condition API. Do not infer absence of evidence from a missing current rerun. |
|
||||
| LES normalization | The reviewed correction is integrated. Exact `95cb6d2` GPU validation passed all nine observations and frozen gates, with three warm-up/reset records, verified 101-source/import identity, DONE and exit 0. Last two SGS decrement relative errors were 6.276e-4 and 6.124e-5 against the unchanged 0.15 gate. | Scope is unforced uniform D2Q9/SRT, shifted FP32, double-buffer streaming and the tested wall-bounded x-periodic shear. No TRT/MRT LES, forcing, EsoPull, objects, refinement or 3D turbulence qualification follows. |
|
||||
|
||||
Historical uniform SRT cylinder-startup and inlet-rounding findings remain separate
|
||||
from the passed MRT DFG result. Raw exchange/window consistency is a measurement
|
||||
contract, not a general stress or force-accuracy certification. Existing source-pinned
|
||||
runners may intentionally reject a newer checkout; never silently repin old receipts.
|
||||
|
||||
## Remaining work
|
||||
|
||||
The related host checks passed (67 checks); they do not replace the GPU or
|
||||
physical-accuracy evidence above. Preserve the existing SRT checkpoint cases,
|
||||
historical LES manifests and all frozen numerical criteria.
|
||||
|
||||
Continue the TG accuracy investigation using its actual fine-level rates and
|
||||
the established analytic convention. Keep the physical accuracy gate open until
|
||||
the relevant comparison passes. Review this integration independently before
|
||||
publication; no new merged-commit GPU run is claimed here.
|
||||
|
||||
No repeat of the accepted full MRT DFG or Cartesian expansion across unsupported
|
||||
combinations is required by this document. Main integration remains a separate
|
||||
review of complete, bounded feature scope.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Matched MRT DFG pilot preparation
|
||||
|
||||
Preparation only: this test-only extension reuses the existing runner and existing MRT
|
||||
operator. GPU admission awaits reviewed MRT capability results; no pilot/full execution
|
||||
is implied by CPU preflight. No production, historical SRT manifest, reference data,
|
||||
periodic gate, geometry, initialization or observation algorithm is changed here.
|
||||
|
||||
## Selection and provenance
|
||||
|
||||
Use `--collision MRT` for **both** uniform D80 and refined root D40/fine D80. There are
|
||||
no independently selectable collision models per case. Default remains SRT.
|
||||
|
||||
MRT verifies `data/refined_dfg/source_manifest_mrt.json`, pinned to reviewed numerical
|
||||
source snapshot `3ea131e92c057843034f11a1c574301a2430dfce`, including the shared geometry,
|
||||
pressure/cycle helpers and official reference. Test-only commits may change HEAD without
|
||||
changing this numerical source pin. Plan, READY, case summaries and comparison separately
|
||||
record `checkout_revision` and `checkout_dirty` using existing source-provenance helper;
|
||||
the runner hash remains separate. Collision is recorded in every receipt, and full-mode
|
||||
validation rejects missing or different collision labels in plan, either summary or either
|
||||
READY receipt, in addition to existing source/runner/health/time checks.
|
||||
|
||||
The historical SRT manifest remains byte-for-byte unchanged. SRT on the MRT candidate
|
||||
checkout deliberately fails that source pin; use a matching historical checkout via
|
||||
`--source` for SRT. No fallback or automatic repinning is permitted. Historical SRT
|
||||
failure/diagnostic evidence remains attached to its original source and runner hashes;
|
||||
it cannot authorize an MRT full run. The existing failed-observation step-reporting
|
||||
undercount is not changed in this narrowly scoped selection patch.
|
||||
|
||||
## Frozen physical comparison
|
||||
|
||||
All settings remain those documented in `refined_dfg.md`: U_lattice=.01, physical mean
|
||||
U=1 m/s, D=.1 m, shear ν=.001 m²/s, fixed physical domain/body/wall planes, patch
|
||||
(40,24,320,140), shifted FP32 double buffers, no LES/forcing/Jets, finest dx=.00125 m,
|
||||
finest dt=.0000125 s, unchanged half-cell frame conversion and axis-specific phase.
|
||||
Force interval accumulation, physical leaf mass, pressure interpolation, flux sampling,
|
||||
safety thresholds, t*=5 pilot and t*=300 cap are unchanged. Existing accuracy/periodicity
|
||||
criteria are imported unchanged. Both MRT cases are fresh runs; old MRT or SRT results
|
||||
are not substituted as controls.
|
||||
|
||||
Both cases use the **existing MRT pressure-NEQ branch**: only unknown directions q2/6/8
|
||||
are extrapolated. This differs from SRT's all-nine .5-damped branch; the retained
|
||||
`srt_neq_damp=.5` configuration value is inactive under MRT. Backflow clamp remains off.
|
||||
The outlet is still noncanonical relative to exact DFG do-nothing conditions.
|
||||
|
||||
The reused MRT operator keeps fixed kinetic rates `s_e=s_eps=s_q=1.2` on all levels;
|
||||
only the shear rate follows existing per-level viscosity. Physical bulk relaxation
|
||||
therefore differs between coarse and fine levels. Transfer operators and subcycling
|
||||
are unchanged. This is the candidate's declared behavior, not a new fully mode-scaled
|
||||
MRT transfer scheme. Do not tune rates, the patch or gates after seeing outcomes.
|
||||
Capability smoke/parity gates do not establish DFG accuracy or speedup.
|
||||
|
||||
## CPU and admission commands
|
||||
|
||||
From this checkout:
|
||||
|
||||
```sh
|
||||
python tests/unit/test_refined_dfg.py
|
||||
python tests/validation/run_refined_dfg.py --collision MRT --mode plan --output /tmp/new-mrt-dfg-plan
|
||||
```
|
||||
|
||||
After root verifies the candidate MRT root/parity/refinement capability results and admits
|
||||
an idle device, the paired pilot may use the existing bounds and external watchdog:
|
||||
|
||||
```sh
|
||||
CUDA_VISIBLE_DEVICES=<admitted-device> timeout --signal=TERM --kill-after=30s <hard-budget>s python tests/validation/run_refined_dfg.py --collision MRT --mode pilot --gpu-gates-approved --max-wall-seconds <soft-budget> --output <new-mrt-pilot-directory>
|
||||
```
|
||||
|
||||
The flag is caller attestation, not evidence. An unhealthy first case prevents the second.
|
||||
Existing strict pilot receipt validation and calibrated wall-time controls remain; full
|
||||
mode is not being admitted as part of this preparation. Root must review both healthy
|
||||
pilot outcomes and separately decide any full-run admission.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Refined DFG MRT timing harness (measurement only)
|
||||
|
||||
`tests/validation/time_refined_dfg.py` times the matched MRT refined/uniform DFG Re100 setup of
|
||||
`run_refined_dfg.py` (same configuration, geometry and initial state functions). No numerical,
|
||||
physics-gate or production change; no force or accuracy result is produced.
|
||||
|
||||
| Variant | Loop | Force reads |
|
||||
|---|---|---|
|
||||
| A | refined `step(1)` + `read_raw_exchange` per base step | runner-like force-read workload (values discarded) |
|
||||
| B | refined `step(1)` only, no host reads (`observe_raw_exchange` stays on) | timing-only |
|
||||
| C | refined single `step(N)` | timing-only; ledger keeps the final base interval only |
|
||||
| E | uniform `sim.run(<=100)` + `read_force` | runner-like force-read workload (values discarded) |
|
||||
| E' | uniform single `sim.run(2N)` | timing-only; at most a whole-window average, different summation order |
|
||||
|
||||
- No variant retains a valid force history; A/E omit the runner's interval aggregation and
|
||||
validation, and no variant includes macro/probe/CSV costs. Results are not full benchmark
|
||||
throughput.
|
||||
- Status lines: `CONSTRUCTED` (construction/JIT complete) and `WARM_READY` (first warm-up and
|
||||
health check passed) per case.
|
||||
- Equal simulated duration: N refined base steps vs 2N uniform D80 steps.
|
||||
- Every repeat starts from the same fresh state (refined `initialize`, uniform `snapshot/restore`)
|
||||
and the same warm-up; construction/JIT, warm-up, downloads and parity checks are untimed.
|
||||
- The actual stepping stream is synchronized explicitly before and after each timed loop; the
|
||||
wall timer (`time.perf_counter`) is the only source. No CUDA-event span is called kernel time.
|
||||
- `--max-wall-seconds` is required for GPU runs (outer timeout, checked between variants and
|
||||
every 100 steps in per-step loops).
|
||||
- Final populations and NaN masks must be bitwise identical to the first A repeat (A/B/C) or the
|
||||
first E repeat (E/E'); refined and uniform grids are never compared. Leaf/fluid populations
|
||||
must be finite. Physical health (finite leaf/fluid macros, rho in [0.5, 1.5], max Mach <= 0.2,
|
||||
the existing runner limits) is checked after warm-up and after each timed window. Matching NaN
|
||||
is accepted only as a storage mask; any infinity or level-count mismatch fails parity. All
|
||||
parity/health checks are untimed.
|
||||
- Cases run sequentially (uniform, then refined) in one process with startup serialized;
|
||||
variants are interleaved within each repeat. A-B and B-C are measured comparative
|
||||
differences under GPU variability, not exact causal decompositions.
|
||||
|
||||
Usage (root selects one verified idle GPU):
|
||||
|
||||
PYTHONPATH=src python -m unittest tests/unit/test_time_refined_dfg.py
|
||||
python tests/validation/time_refined_dfg.py --output <new> --plan
|
||||
CUDA_VISIBLE_DEVICES=<gpu> python tests/validation/time_refined_dfg.py --output <new> --max-wall-seconds <s>
|
||||
|
||||
Defaults: 2000 base steps, 200 warm-up base steps, 3 repeats. Profiler capture and the
|
||||
`observe_raw_exchange=False` variant are deferred until these measurements exist.
|
||||
|
||||
## A versus observation-window ledger (`tests/validation/time_window_dfg.py`)
|
||||
|
||||
Measurement-only comparison on the same shifted-MRT refined setup: A (per-base `step(1)` +
|
||||
`read_raw_exchange`, summed per 80-base-step interval), W (`start_exchange_window`, `step(80)`,
|
||||
one window read) and W1 (window with 80 x `step(1)`, isolating the read pattern from call
|
||||
batching). Same fresh state, warm-up and simulated duration; populations must be bitwise equal
|
||||
to the first A repeat and per-interval total impulses within the documented engineering
|
||||
tolerance, all outside timing. The candidate source is recorded (checkout revision, dirty flag,
|
||||
file hashes); frozen refined-DFG manifests/results are not used or changed.
|
||||
|
||||
PYTHONPATH=src python -m unittest tests/unit/test_time_window_dfg.py
|
||||
python tests/validation/time_window_dfg.py --output <new> --plan
|
||||
CUDA_VISIBLE_DEVICES=<gpu> python tests/validation/time_window_dfg.py --output <new> --max-wall-seconds <s>
|
||||
@@ -0,0 +1,117 @@
|
||||
# Opt-in observation-window ledger in the DFG runner
|
||||
|
||||
This test-only integration adds `--exchange-window` to `run_refined_dfg.py`. It uses the
|
||||
existing production ledger; no collision, boundary, transfer, physical setup, reference,
|
||||
periodicity gate or acceptance threshold changes. Default remains the historical
|
||||
per-base-step observation path. Uniform stepping/force accumulation is unchanged.
|
||||
|
||||
For the refined case, each existing observation interval opens a new ledger at completed
|
||||
base step `begin`, advances exactly the same `n` base intervals, and reads the integrated
|
||||
root-lattice impulse for `[begin, begin+n]`. The contract requires root units, `window`
|
||||
kind, `2*n` fine samples, matching endpoints, and fine dt=.5. The existing coefficient
|
||||
calculation still divides this impulse by `n` and `.5*U_lattice**2*D_root`; force timestamps
|
||||
remain `(begin+n/2)*U_lattice/D_root`. It does not divide an already averaged force again.
|
||||
Pressure/macro/physical-leaf mass measurements and safety checks remain at the original
|
||||
interval end. Pilot interval is 80 root steps; early full-run intervals remain 2000, and
|
||||
any final shortened interval uses its actual `n`.
|
||||
|
||||
The opt-in uses one `step(n)` call and one window read per observation, rather than `n`
|
||||
Python calls/readbacks. Internal subcycling and per-base synchronization remain owned by
|
||||
the same backend. Cooperative deadline checks surround that complete interval; unlike
|
||||
the default path they cannot interrupt between base steps inside `step(n)`. The existing
|
||||
external hard watchdog is still required. The full-run cadence/cap is documented here,
|
||||
but this preparation does not admit a full run or a GPU operation.
|
||||
|
||||
## Provenance and receipt separation
|
||||
|
||||
`source_manifest_window.json` pins the reviewed a233c9032487a6006e950d1e327079b00466c410
|
||||
snapshot (production ledger origin d930) plus existing helper/reference files. Both SRT
|
||||
and MRT are supported by this explicit opt-in source pin; collision selection remains
|
||||
independent and shared by the two cases. Existing SRT and MRT manifests are untouched.
|
||||
Default mode continues to reject the changed candidate under those historical pins;
|
||||
use a matching historical `--source` checkout to run its default path. There is no
|
||||
silent repinning, source fallback, or reuse of historical outputs as candidate evidence.
|
||||
|
||||
Plan, READY, summaries, timeout/comparison receipts now record `observation_mode` as
|
||||
`last_base_step` or `window`, alongside collision and actual checkout provenance. On a
|
||||
uniform receipt this labels its **paired campaign's refined observation mode**, not a
|
||||
change to uniform observation. Full-mode pilot validation rejects missing or mismatched
|
||||
mode labels in the plan, either case summary, and either READY receipt. Source/runner
|
||||
hash, collision, completeness and health checks remain. Cross-mode pilot reuse is denied.
|
||||
|
||||
The earlier short A/W/W1 timing/capability evidence is not a full-run speedup or accuracy
|
||||
claim. Mathematical impulse integration/units are unchanged; floating-point reduction
|
||||
order can differ, so bitwise equality of force sums is not promised. Physics acceptance
|
||||
continues to use the existing frozen gates. Historical failures/results are untouched.
|
||||
|
||||
CPU preparation commands, from the repository root:
|
||||
|
||||
```sh
|
||||
python tests/unit/test_refined_dfg.py
|
||||
PYTHONPATH=src python -m unittest tests.unit.test_raw_exchange_window tests.unit.test_time_window_dfg -v
|
||||
python tests/validation/run_refined_dfg.py --collision MRT --exchange-window --mode plan --output /tmp/new-window-dfg-plan
|
||||
```
|
||||
|
||||
Root must independently review and admit any execution. An admitted paired pilot uses
|
||||
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.
|
||||
@@ -1,72 +0,0 @@
|
||||
# One bounded bodyless SRT inlet control
|
||||
|
||||
Preparation only; review precedes any GPU admission. This test-only opt-in is based on
|
||||
8e378aa047bb35ae4ec46037eff7256bd2f074e7. Historical first-health evidence and its source
|
||||
manifest are untouched. The control calls the same pinned D80 config factory and uses
|
||||
unchanged production `Simulation.initialize()` / `run(1)`; `--without-cylinder` changes
|
||||
only the single cylinder object list to empty. No profile, wall/source plane, domain,
|
||||
collision, relaxation, speed, representation, step cap, health limit or field observation
|
||||
is adjusted. SRT tau=.524 and the existing inlet/outlet remain as in the failed run.
|
||||
|
||||
Production already supports zero objects through `ensure_packed_buffers`. The absent
|
||||
body has no force observable: the control never calls `read_force(0)`, records `force:null`
|
||||
and `force_observation:not_applicable_no_body`, and does not invent a zero force. Physical
|
||||
population/macro/Mach/density checks remain intact. Existing per-step diagnostics, first
|
||||
health crossing, immediately preceding/current raw+decoded state capture, 128 MiB budget,
|
||||
external watchdog and maximum 320 steps remain unchanged. No extra baseline run is needed.
|
||||
|
||||
Removing the cylinder necessarily turns its former solid cells into fluid. Those cells
|
||||
receive the same production parabolic fluid initializer; shared fluid and boundary-ghost
|
||||
cells retain the original initializer and configuration. This is not a transplant of the
|
||||
baseline's solid-cell state into new fluid. Removing the body also removes its cut links
|
||||
and specializes the existing kernel for zero objects, as ordinary Simulation already does.
|
||||
The spatial `body_near` category is retained only as the **former-cylinder neighborhood**
|
||||
for comparing locations. It does not claim an actual body is present; body-solid flag
|
||||
counts should be zero in the control.
|
||||
|
||||
## Explicit identity and source checks
|
||||
|
||||
The unchanged manifest still pins numerical source 9805383, as used by the baseline8e
|
||||
harness. The new test-only HEAD does not pretend to be that historical run. Preflight,
|
||||
READY and diagnostic/result records include the control label, original baseline harness
|
||||
commit, new diagnostic checkout revision/dirty status, selected numerical checkout
|
||||
revision/dirty status, and new diagnostic file hash. Production/helper bytes are verified
|
||||
against the original manifest before either case. A later development checkout with
|
||||
changed numerical files is rejected; the old pin is not silently accepted or regenerated.
|
||||
The default cylinder case remains available for compatibility, but this requested control
|
||||
uses only `--without-cylinder` and a distinct new output directory.
|
||||
|
||||
## Interpretation before seeing outcomes
|
||||
|
||||
- Similar early inlet-region failure without a cylinder: cylinder presence is not
|
||||
necessary for this observed short-time instability under these settings. This supports
|
||||
investigating inlet/collision/kinetic-state dynamics; it does not prove a specific
|
||||
boundary defect or exclude other shared boundaries/initialization effects.
|
||||
- No health violation through 320: removing the body changes the observed onset, consistent
|
||||
with a body-generated transient or altered amplification interacting with the inlet.
|
||||
This does not prove the cylinder is erroneous, establish inlet stability beyond320,
|
||||
or certify SRT generally.
|
||||
- Different failure location/time: geometry affects the transient; classify the preserved
|
||||
incoming distributions and moments before assigning any mechanism.
|
||||
|
||||
The baseline failed at196 on finite Mach>.2, not nonfinite state. A control can likewise
|
||||
stop on a finite threshold crossing. Do not describe either as a NaN blow-up unless the
|
||||
separate diagnostic flags establish that.
|
||||
|
||||
CPU-only checks:
|
||||
|
||||
```sh
|
||||
python tests/validation/uniform_srt_diagnostic/test_diagnostic.py
|
||||
python tests/validation/uniform_srt_diagnostic/diagnose_uniform_srt.py --without-cylinder --output /tmp/new-bodyless-preflight
|
||||
```
|
||||
|
||||
After review and root admission, from the explicitly recorded test checkout:
|
||||
|
||||
```sh
|
||||
CUDA_VISIBLE_DEVICES=<admitted-device> timeout --signal=TERM --kill-after=30s 240s python tests/validation/uniform_srt_diagnostic/diagnose_uniform_srt.py --without-cylinder --execute --gpu-admitted --wall-seconds 180 --output output/new-srt-bodyless-control
|
||||
```
|
||||
|
||||
This patch performs no GPU run or remote push. Original runner gates/results and
|
||||
production files are unchanged. The extra tests verify removal-only selection without
|
||||
mutating inputs, reject unexpected object sets, verify absence of force readback, and
|
||||
retain physical health failures when force is unavailable.
|
||||
@@ -1,31 +0,0 @@
|
||||
# Fixed-window inlet history (test-only)
|
||||
|
||||
Opt in with `--record-inlet-history`, together with `--without-cylinder` for the requested control. Default execution still retains only the original first-health pair. This followup is based on bodyless diagnostic `66a27410504fef477808d6079ce79ca1ae6b903c`; the numerical source manifest remains unchanged at `9805383f9c50d867e8555471baf5dbda8333a652`. Actual diagnostic/numerical checkout revisions and script SHA256 are recorded separately.
|
||||
|
||||
The requested observation window is **completed steps 0 through 196 inclusive**. Recording stops after196, but evolution continues under the original first-health/320-step/wall-time stop behavior. An earlier failure preserves the recorded prefix, including its failing observation. `complete_for_requested_window` means all197 requested frames exist; it never means complete history past196. `diagnostic_terminal_step` records the terminal observed step separately.
|
||||
|
||||
`inlet_history.npz` contains:
|
||||
- `raw_shifted[time,q,y,x]`: unchanged FP32 bits, all9q, all330 rows, x0..3, copied from already-existing active-buffer raw snapshots.
|
||||
- Static uint16 `flags[y,x]`, global x/y indices, contiguous int64 `steps`.
|
||||
- `time_s=steps*dt` and `tstar=steps*mean_speed/diameter`, plus requested range and diagnostic terminal step.
|
||||
- Step zero is the initialized state; subsequent states are completed postcollision active-buffer observations. Ghost/solid flags remain distinct from physical-fluid health checks.
|
||||
|
||||
Full configuration, physical parameters and source identity remain in `preflight.json`; `diagnostics.json` adds the history SHA256, size, window and completeness receipt. No additional full-field copies or solver calls are introduced. The new history copies only x0..3 from the baseline's existing raw snapshot. Baseline two-state evidence and health gates remain intact.
|
||||
|
||||
The full requested raw payload is9,361,440 bytes (8.928MiB). Flags, time arrays, coordinates and NPZ overhead keep it below12MiB. Initialization checks the fixed-window allocation against that bound with64KiB overhead reserve, and saving checks actual artifact size. This fits inside the existing128MiB total evidence cap with the original pair. The12MiB limit is an artifact-size bound, not peak RAM: the retained strip list and stacked output briefly coexist. There is no variable compression/overflow policy.
|
||||
|
||||
CPU checks:
|
||||
|
||||
```sh
|
||||
python tests/validation/uniform_srt_diagnostic/test_diagnostic.py
|
||||
python tests/validation/uniform_srt_diagnostic/test_history.py
|
||||
python tests/validation/uniform_srt_diagnostic/diagnose_uniform_srt.py --without-cylinder --record-inlet-history --output /tmp/new-inlet-history-preflight
|
||||
```
|
||||
|
||||
After independent review and root GPU admission, from this exact test checkout, with a new output directory:
|
||||
|
||||
```sh
|
||||
CUDA_VISIBLE_DEVICES=<admitted-device> timeout --signal=TERM --kill-after=30s 240s python tests/validation/uniform_srt_diagnostic/diagnose_uniform_srt.py --without-cylinder --record-inlet-history --execute --gpu-admitted --wall-seconds 180 --output output/new-srt-inlet-history
|
||||
```
|
||||
|
||||
The homogeneous linear surrogate is a separate analysis artifact. This history will allow comparison with actual startup, including initialization mismatch and wall-junction propagation; neither the recorder nor the surrogate changes a boundary or establishes a corrective scheme. No GPU run is included in this commit.
|
||||
@@ -1,52 +0,0 @@
|
||||
# First-failure uniform SRT diagnostic (prepared, not executed)
|
||||
|
||||
Purpose: locate the first physical failure in the unchanged uniform D80 SRT control that was healthy at executed step 160 and nonfinite at the next observation, executed step 320. The original failure's summary `steps=160` is the last accepted observation, not the last executed step. This diagnostic does not select different numerical parameters, run an AMR case or perform a new benchmark campaign.
|
||||
|
||||
## Exact source and configuration
|
||||
|
||||
The default source is the containing repository (override with `--source`); the exact pinned production/runtime source is commit `9805383f9c50d867e8555471baf5dbda8333a652`. The manifest pins production files and the helper/runner dependencies directly against the original failure's `source_before.json`. All pinned contents match. Import guards require CelerisLab and the runner from the selected pinned checkout.
|
||||
|
||||
The script calls the original `run_refined_dfg.config(helper,80)` factory and unchanged `Simulation(...).initialize()`. Thus it retains D80, U=.01, ν=.008, τ=.524, ω≈1.9084, shifted FP32, SRT, double buffer, parabolic local Zou–He inlet, all-nine pressure-NEQ damping .5, backflow clamp disabled, identical cylinder geometry and initialization. No clipping, equilibrium rewrite or solver patch is added.
|
||||
|
||||
The inlet overload in this source may differ by FP32 rounding from older d79 code even where the algebra matches; that is not changed here and is not established as the cause of failure. MRT's outlet branch also differs. Neither prior MRT success nor this diagnostic establishes an SRT/outlet/body attribution.
|
||||
|
||||
## Observation and stop contract
|
||||
|
||||
- At most 320 actual solver steps, starting from the original fresh initialization. Stop at the first physical health failure (or wall deadline).
|
||||
- Read the production macroscopic path, its decoded populations, and force after every completed step. Preserve the actual executed count separately from the last observed count.
|
||||
- Independently report nonfinite physical populations, nonfinite physical macros and nonfinite reaction. Apply the original density [.5,1.5] and Mach≤.2 checks to physical FLUID nodes. A new population-only check may stop earlier than the original macro-only health check; the report explicitly distinguishes it.
|
||||
- Per-step `Simulation.run(1)` resets/reads the observation buffer for a one-step force window. This changes observation cadence and force accumulation windows, not configuration or fluid updates. A cumulative-force-only overflow from the original 100/60-step batching would not be reproduced identically; that limitation is explicit. Original macro warnings already show nonfinite populations/macros, so this instrument prioritizes locating the physical failure.
|
||||
- Ghost/inactive/body-solid populations are reported in separate categories; their NaNs do not independently trigger physical failure. Category masks are disjoint and complete. Boundary subtypes are decoded as the nibble, not mistaken for independent bit flags.
|
||||
- Physical region labels are body-near (within radius+3 cells), outlet/inlet-near (three fluid columns), wall-near (three fluid rows), and remaining bulk. These locate symptoms, not causal mechanisms.
|
||||
- Initial, preceding and firstbad/final compact reports retain category counts, finite global extrema and positions, the first 16 offending nodes in row-major order, their bad q indices/all populations, and up to four 5×5 population/flag neighborhoods. “First” identifies earliest sampled step; within that step the list is row-major, not an asserted temporal order among GPU threads.
|
||||
|
||||
## Bounded evidence
|
||||
|
||||
Only the immediately preceding and firstbad/final active-buffer states are retained. `states.npz` contains their exact raw shifted FP32 values, production-decoded FP32 populations and step numbers, plus flags. This is ~81 MiB at the exact D80 shape, with a 128 MiB artifact limit enforced. There are no saved field trajectories. Initial conditions are reproducible from the source/config; only their diagnostic summary is retained.
|
||||
|
||||
These are diagnostic snapshots, not full restart checkpoints (spare buffers and observation/control state are not a restart bundle). `diagnostics.json` stores nonfinite numbers as explicit `NaN`, `+Inf`, `-Inf` strings and is strict finite JSON; NPZ preserves actual IEEE values. A force-only failure can have no offending fluid node. A clean cap means no sampled failure through 320, not benchmark acceptance.
|
||||
|
||||
## CPU validation
|
||||
|
||||
```sh
|
||||
python tests/validation/uniform_srt_diagnostic/test_diagnostic.py
|
||||
python tests/validation/uniform_srt_diagnostic/diagnose_uniform_srt.py --output /tmp/new-srt-preflight
|
||||
```
|
||||
|
||||
Seven CPU-only tests pass: source/config, complete category partition, inactive nonfinite exclusion, independent population/macro/force reasons, health thresholds, bounded two-state artifact writing, and deadline/source guards. `py_compile` and the real-source default preflight pass. No CUDA import is required by preflight/tests and no GPU was executed here.
|
||||
|
||||
## Root-only admitted execution
|
||||
|
||||
After independent review and root GPU admission:
|
||||
|
||||
```sh
|
||||
CUDA_VISIBLE_DEVICES=<admitted-device> timeout --signal=TERM --kill-after=30s <hard-budget>s python tests/validation/uniform_srt_diagnostic/diagnose_uniform_srt.py --source <exact-9805383-checkout> --execute --gpu-admitted --wall-seconds <soft-budget> --output <new-output-directory>
|
||||
```
|
||||
|
||||
The external watchdog is required for a blocked CUDA call; cooperative wall checks cannot interrupt one. Budget should allow per-step downloads and ~81 MiB evidence writing; this diagnostic is not a performance measurement. The step ceiling cannot be increased by a CLI flag. `READY` is emitted after the first completed step and all observation paths. First physical failure is expected diagnostic evidence and returns exit 2; step cap returns 0. `preflight.json` records the pre-execution configuration, not a completion receipt.
|
||||
|
||||
Original failure files, production source, original pilot runner and gates are untouched. This test-only handoff is based on exact commit 9805383; added diagnostic files are not part of the historical failure source manifest.
|
||||
|
||||
## Historical reporting undercount (not changed here)
|
||||
|
||||
The failed pilot executed to step 320 before its second observation failed. Its summary `steps=160` reflects `begin`, which advances only after an accepted observation. This is a reporting undercount, not evidence that the second batch did not execute. The original failure package and runner/gates are preserved unchanged. This diagnostic records both `executed_steps` and `observation_step`, and labels previous-state steps explicitly. Fixing the historical runner's reporting is a separate change.
|
||||
@@ -216,11 +216,8 @@ def surface_link_velocity(objects, cfg, flags, fluid_idx, dir, body_id, rx, ry,
|
||||
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")
|
||||
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)
|
||||
for obj in enabled:
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -32,7 +32,6 @@ INLET_SCHEME_MAP = {
|
||||
"channel_stabilized": 1,
|
||||
"equilibrium": 2,
|
||||
"regularized": 3,
|
||||
"experimental_regularized_full9": 4,
|
||||
}
|
||||
OUTLET_MAP = {"neq_extrap": 0, "zero_gradient": 1, "blended": 2}
|
||||
Y_WALL_BC_MAP = {"bounce_back": 0, "free_slip": 1, "on_site_velocity": 2}
|
||||
@@ -163,11 +162,6 @@ class LBMConfig:
|
||||
raise ValueError("wall_slot_width requires on_site_velocity and velocity=0")
|
||||
if self.top_wall_bc == "reservoir" and (self.y_wall_bc != "on_site_velocity" or self.velocity != 0):
|
||||
raise ValueError("reservoir requires on_site_velocity and velocity=0")
|
||||
if self.inlet_scheme == "experimental_regularized_full9":
|
||||
if (not self.is_d2q9 or self.collision != "SRT"
|
||||
or self.streaming != "double_buffer" or self.store_precision != "FP32"
|
||||
or self.les_enabled or self.y_wall_bc != "bounce_back"):
|
||||
raise ValueError("experimental_regularized_full9 requires D2Q9/SRT/FP32/double_buffer, bounce_back walls, no LES")
|
||||
if self.y_wall_bc == "on_site_velocity":
|
||||
if (not self.is_d2q9 or self.collision != "SRT"
|
||||
or self.streaming != "double_buffer" or self.store_precision != "FP32"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Host contract for the blocked root's uniform-SRT channel boundary.
|
||||
"""Host contract for the blocked root's uniform SRT/MRT channel boundary.
|
||||
|
||||
Boundary ghosts are evolved state, but are not physical fluid leaves. Halo
|
||||
ownership remains periodic to reproduce the uniform kernel's wrapped pulls.
|
||||
@@ -29,7 +29,7 @@ def validate_channel_config(cfg, blocks):
|
||||
def validate_channel_transfers(pair, layout, targets):
|
||||
"""Reject actual exchange/closure overlap, not a padded geometric envelope.
|
||||
|
||||
Explosion donors and restriction receivers must use ordinary bulk SRT
|
||||
Explosion donors and restriction receivers must use ordinary bulk collision
|
||||
semantics. Restriction writes receiver-local source slots; these may not
|
||||
overwrite the channel's extra immutable-input reads (pressure donor and
|
||||
same-node halfway-wall corrections). The slot check deliberately respects
|
||||
|
||||
@@ -14,12 +14,15 @@
|
||||
#include "core/obs.cuh"
|
||||
#include "operators/macro.cuh"
|
||||
#include "operators/collision_srt.cuh"
|
||||
#if COLLISION_MODEL == 2
|
||||
#include "operators/collision_mrt.cuh"
|
||||
#endif
|
||||
#include "streaming/pull_double_buffer.cuh"
|
||||
#include "boundary/curved_boundary.cuh"
|
||||
#include "boundary/planar_velocity.cuh"
|
||||
|
||||
#if DIM != 2 || NQ != 9 || COLLISION_MODEL != 0 || STORE_PRECISION != 0 || USE_LES || STREAMING_MODEL != 0
|
||||
#error "Blocks require FP32 D2Q9 SRT double buffers without LES"
|
||||
#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.
|
||||
@@ -60,6 +63,39 @@ extern "C" __global__ void BlockExplode(
|
||||
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(
|
||||
@@ -141,6 +177,13 @@ extern "C" __global__ void BlockStep(const fpxx* in, fpxx* out, unsigned int hal
|
||||
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
|
||||
@@ -153,6 +196,7 @@ extern "C" __global__ void BlockStep(const fpxx* in, fpxx* out, unsigned int hal
|
||||
compute_feq(rho, ux, uy, feq);
|
||||
#endif
|
||||
collide_srt_no_force(f, feq, d_params.omega);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
stream_pull_store(k, f, out + base);
|
||||
|
||||
@@ -74,7 +74,7 @@ __device__ __forceinline__ float west_velocity_rho_closure_d3q19(
|
||||
|
||||
__device__ __forceinline__ bool inlet_scheme_uses_post_collision_ghost()
|
||||
{
|
||||
#if INLET_SCHEME == 0 || INLET_SCHEME == 4
|
||||
#if INLET_SCHEME == 0
|
||||
return true;
|
||||
#else
|
||||
return false;
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
// Experimental D2Q9 west velocity boundary: full-state stress regularization.
|
||||
// Palabos boundaryTemplates::compute_PiNeq / regularized velocity completion.
|
||||
// This returns PRE-collision populations. The existing step kernel collides once.
|
||||
#ifndef CELERIS_EXPERIMENTAL_FULL9_INLET_CUH
|
||||
#define CELERIS_EXPERIMENTAL_FULL9_INLET_CUH
|
||||
|
||||
#if LATTICE_MODEL == LATTICE_D2Q9
|
||||
__device__ inline void apply_experimental_full9_inlet_d2q9(
|
||||
float* __restrict__ f, float ux_target, float uy_target)
|
||||
{
|
||||
const float rho = west_velocity_rho_closure_d2q9(f, ux_target);
|
||||
float eq[9];
|
||||
compute_feq(rho, ux_target, uy_target, eq);
|
||||
// Only known pulled directions are read; unknown wrapped q1/5/7 are ignored.
|
||||
// f and eq use the same shifting convention, so weights cancel in K.
|
||||
const float k2 = f[2] - eq[2];
|
||||
const float k3 = f[3] - eq[3];
|
||||
const float k4 = f[4] - eq[4];
|
||||
const float k6 = f[6] - eq[6];
|
||||
const float k8 = f[8] - eq[8];
|
||||
const float pxx = 2.0f * (k2 + k6 + k8);
|
||||
const float pyy = k3 + k4 + 2.0f * (k6 + k8);
|
||||
const float pxy = 2.0f * (k6 - k8);
|
||||
const int cx[9] = {0, 1,-1, 0, 0, 1,-1, 1,-1};
|
||||
const int cy[9] = {0, 0, 0, 1,-1, 1,-1,-1, 1};
|
||||
for (int q = 0; q < 9; ++q) {
|
||||
const float weight = q == 0 ? W0_VAL : (q < 5 ? WS_VAL : WE_VAL);
|
||||
const float stress = weight * (
|
||||
4.5f * (((float)(cx[q]*cx[q]) - 1.0f/3.0f) * pxx
|
||||
+ ((float)(cy[q]*cy[q]) - 1.0f/3.0f) * pyy)
|
||||
+ 9.0f * (float)(cx[q]*cy[q]) * pxy);
|
||||
f[q] = eq[q] + stress;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -24,7 +24,6 @@
|
||||
#include "inlet/channel_stabilized.cuh"
|
||||
#include "inlet/equilibrium.cuh"
|
||||
#include "inlet/regularized.cuh"
|
||||
#include "inlet/experimental_full9.cuh"
|
||||
#include "outlet/pressure_neq.cuh"
|
||||
|
||||
#if DIM == 2
|
||||
@@ -46,8 +45,6 @@ __device__ __forceinline__ void apply_inlet_pull_d2q9(
|
||||
|
||||
#if INLET_SCHEME == 0
|
||||
apply_zou_he_left_velocity_inlet_d2q9(f, u_target, v_target);
|
||||
#elif INLET_SCHEME == 4
|
||||
apply_experimental_full9_inlet_d2q9(f, u_target, v_target);
|
||||
#elif INLET_SCHEME == 1 || INLET_SCHEME == 3
|
||||
float f_neb[NQ];
|
||||
const unsigned long k_neb = linear_index(x + 1u, y);
|
||||
@@ -146,8 +143,6 @@ __device__ __forceinline__ void apply_inlet_esopull_d2q9(
|
||||
|
||||
#if INLET_SCHEME == 0
|
||||
apply_zou_he_left_velocity_inlet_d2q9(f, u_target, v_target);
|
||||
#elif INLET_SCHEME == 4
|
||||
apply_experimental_full9_inlet_d2q9(f, u_target, v_target);
|
||||
#elif INLET_SCHEME == 1 || INLET_SCHEME == 3
|
||||
#if INLET_SCHEME == 1
|
||||
apply_channel_stabilized_inlet_d2q9(f, f_neb, (float)y);
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
// nu_t = (C_s * Delta)^2 * |S|, Delta = 1 (lattice unit)
|
||||
// omega_eff = 1 / (3*(nu + nu_t) + 0.5)
|
||||
//
|
||||
// |S| is estimated from non-equilibrium second-order moments using tau0.
|
||||
// |S| = sqrt(2 S_ij S_ij), with Delta = 1 in lattice units.
|
||||
// The stress estimate uses self-consistent tau_eff; tau0 is molecular.
|
||||
// Closed form uses full stress. Iteration uses deviatoric stress and a cap.
|
||||
// ============================================================================
|
||||
|
||||
#ifndef CELERIS_OPERATORS_TURBULENCE_SMAG_CUH
|
||||
@@ -65,12 +67,12 @@ __device__ __forceinline__ float compute_omega_smag(const float* __restrict__ f,
|
||||
|
||||
#if LES_CLOSED_FORM
|
||||
// Closed-form Smagorinsky (no iteration):
|
||||
// ω = 2 / (τ₀ + √(τ₀² + 18·Cs²·√Q / ρ))
|
||||
// ω = 2 / (τ₀ + √(τ₀² + 18·√2·Cs²·√Q / ρ))
|
||||
// where Q = Πneq_ij · Πneq_ij (Frobenius norm squared)
|
||||
const float Q = pixx*pixx + piyy*piyy + 2.0f*pixy*pixy;
|
||||
const float sqrtQ = sqrtf(fmaxf(Q, 0.0f));
|
||||
const float smag_const_sq = LES_CS * LES_CS;
|
||||
const float discriminant = tau0*tau0 + 18.0f * smag_const_sq * sqrtQ / rho_safe;
|
||||
const float discriminant = tau0*tau0 + 18.0f * sqrtf(2.0f) * smag_const_sq * sqrtQ / rho_safe;
|
||||
const float tau_eff = 0.5f * (tau0 + sqrtf(fmaxf(discriminant, tau0*tau0)));
|
||||
return clamp_omega(1.0f / fmaxf(tau_eff, 0.500001f));
|
||||
#else
|
||||
@@ -140,7 +142,7 @@ __device__ __forceinline__ float compute_omega_smag(const float* __restrict__ f,
|
||||
+ 2.0f*(pixy*pixy + pixz*pixz + piyz*piyz);
|
||||
const float sqrtQ = sqrtf(fmaxf(Q, 0.0f));
|
||||
const float smag_const_sq = LES_CS * LES_CS;
|
||||
const float discriminant = tau0*tau0 + 18.0f * smag_const_sq * sqrtQ / rho_safe;
|
||||
const float discriminant = tau0*tau0 + 18.0f * sqrtf(2.0f) * smag_const_sq * sqrtQ / rho_safe;
|
||||
const float tau_eff = 0.5f * (tau0 + sqrtf(fmaxf(discriminant, tau0*tau0)));
|
||||
return clamp_omega(1.0f / fmaxf(tau_eff, 0.500001f));
|
||||
#else
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
#include "core/obs.cuh"
|
||||
#include "operators/macro.cuh"
|
||||
#include "operators/collision_srt.cuh"
|
||||
#if COLLISION_MODEL == 2
|
||||
#include "operators/collision_mrt.cuh"
|
||||
#endif
|
||||
#include "streaming/pull_double_buffer.cuh"
|
||||
#include "boundary/planar_velocity.cuh"
|
||||
#include "boundary/curved_boundary.cuh"
|
||||
@@ -19,8 +22,8 @@ extern "C" {
|
||||
#include "step/curved_double.cu"
|
||||
}
|
||||
|
||||
#if DIM != 2 || NQ != 9 || COLLISION_MODEL != 0 || STORE_PRECISION != 0 || USE_DDF_SHIFTING || USE_LES || STREAMING_MODEL != 0
|
||||
#error "Refinement requires unshifted FP32 D2Q9 SRT double buffers without LES"
|
||||
#if DIM != 2 || NQ != 9 || (COLLISION_MODEL != 0 && COLLISION_MODEL != 2) || STORE_PRECISION != 0 || USE_DDF_SHIFTING || USE_LES || STREAMING_MODEL != 0
|
||||
#error "Refinement requires unshifted FP32 D2Q9 SRT or MRT double buffers without LES"
|
||||
#endif
|
||||
|
||||
// Enabled only in the validation build; no diagnostic arrays in normal builds.
|
||||
@@ -33,10 +36,17 @@ __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
|
||||
|
||||
@@ -54,12 +54,20 @@ extern "C" __global__ void BlockChannelStep(
|
||||
f[7] = load_ddf(in + base, index_f(k, 8));
|
||||
}
|
||||
if (!wall && !outlet) {
|
||||
#if COLLISION_MODEL == 2
|
||||
// Match uniform MRT arithmetic (collide_dispatch, zero forcing).
|
||||
float rho, ux, uy;
|
||||
compute_rho_u(f, rho, ux, uy);
|
||||
collide_mrt_no_force(f, rho, ux, uy,
|
||||
fminf(OMEGA_COLLISION_MAX, fmaxf(OMEGA_COLLISION_MIN, d_params.omega)));
|
||||
#else
|
||||
// Match uniform SRT arithmetic, including its shifted equilibrium path.
|
||||
float rho, ux, uy, feq[NQ], forcing[NQ] = {};
|
||||
compute_rho_u(f, rho, ux, uy);
|
||||
compute_feq(rho, ux, uy, feq);
|
||||
const float omega = fminf(OMEGA_COLLISION_MAX, fmaxf(OMEGA_COLLISION_MIN, d_params.omega));
|
||||
collide_srt(f, feq, forcing, omega);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
stream_pull_store(k, f, out + base);
|
||||
|
||||
@@ -7,16 +7,26 @@ import numpy as np
|
||||
|
||||
|
||||
def summarize_exchange(rows, body_ids, aperture, *, body_id, level, h,
|
||||
time_end, window, reference_center):
|
||||
time_end, window, reference_center, duration=None):
|
||||
"""Reduce FP64 interval integrals or convert FP32 last-substep raw rows.
|
||||
|
||||
dt_l=h_l under acoustic 2:1 refinement. Local momentum j -> base force
|
||||
h*j -> impulse h*dt*j; torque adds one h; mass is h**2*dm.
|
||||
window="window" reduces an FP64 observation-window ledger spanning
|
||||
``duration`` (integer >= 1) base intervals; rates divide by that duration.
|
||||
"""
|
||||
if window not in ("last_base_step", "last_substep"):
|
||||
if window not in ("last_base_step", "last_substep", "window"):
|
||||
raise ValueError("unknown exchange window")
|
||||
if window == "window":
|
||||
if isinstance(duration, bool) or not isinstance(duration, (int, np.integer)) or duration < 1:
|
||||
raise ValueError("window duration must be an integer number of base intervals >= 1")
|
||||
elif duration is not None:
|
||||
raise ValueError("duration applies only to window='window'")
|
||||
values = np.array(rows, dtype=np.float64, copy=True)
|
||||
duration = 1.0 if window == "last_base_step" else h
|
||||
if window == "window":
|
||||
duration = float(duration)
|
||||
else:
|
||||
duration = 1.0 if window == "last_base_step" else h
|
||||
if window == "last_substep":
|
||||
values *= np.array([h*h, h*h, h*h*h, h*h])
|
||||
selected = np.asarray(body_ids) == body_id
|
||||
@@ -31,6 +41,7 @@ def summarize_exchange(rows, body_ids, aperture, *, body_id, level, h,
|
||||
return dict(wall=reduce(selected & ~aperture), aperture=reduce(selected & aperture),
|
||||
total=reduce(selected), body_id=int(body_id), level=int(level),
|
||||
time_start=time_end-duration, time_end=time_end, dt=h,
|
||||
sample_count=round(1/h) if window == "last_base_step" else 1,
|
||||
sample_count=(round(duration/h) if window == "window"
|
||||
else round(1/h) if window == "last_base_step" else 1),
|
||||
window=window, units="base_lattice_per_unit_depth",
|
||||
definition="raw_boundary_exchange", reference_center=tuple(reference_center), valid=True)
|
||||
|
||||
@@ -52,17 +52,17 @@ class FixedPatch2D:
|
||||
def from_config(cls, cfg: LBMConfig, bounds) -> "FixedPatch2D":
|
||||
"""Prepare maps without changing cfg or allocating coarse-sized tables.
|
||||
|
||||
The initial backend contract is D2Q9/SRT/FP32/double_buffer,
|
||||
The backend contract is D2Q9/SRT or MRT/FP32/double_buffer,
|
||||
without LES. Two coarse cells of margin keep all halo donors in bounds;
|
||||
this does NOT certify that those donors are fluid or periodic.
|
||||
"""
|
||||
checked = replace(cfg)
|
||||
checked.validate()
|
||||
if (not checked.is_d2q9 or checked.collision != "SRT"
|
||||
if (not checked.is_d2q9 or checked.collision not in ("SRT", "MRT")
|
||||
or checked.streaming != "double_buffer"
|
||||
or checked.store_precision != "FP32"
|
||||
or checked.les_enabled):
|
||||
raise ValueError("FixedPatch2D requires D2Q9/SRT/FP32/double_buffer without LES")
|
||||
raise ValueError("FixedPatch2D requires D2Q9/SRT or MRT/FP32/double_buffer without LES")
|
||||
bounds = tuple(bounds)
|
||||
if len(bounds) != 4 or any(isinstance(v, (bool, np.bool_))
|
||||
or not isinstance(v, Integral) for v in bounds):
|
||||
@@ -451,7 +451,7 @@ def prepare_fine_boundary(cfg, patch, objects, *, wall_aware=False, jets=(),
|
||||
checked = replace(cfg)
|
||||
checked.validate()
|
||||
if (patch.coarse_shape != (cfg.ny, cfg.nx) or not checked.is_d2q9
|
||||
or checked.collision != "SRT" or checked.streaming != "double_buffer"
|
||||
or checked.collision not in ("SRT", "MRT") or checked.streaming != "double_buffer"
|
||||
or checked.store_precision != "FP32" or checked.les_enabled
|
||||
or patch.tau_coarse != 3*checked.viscosity+.5):
|
||||
raise ValueError("Fine boundary requires the matching fixed-patch configuration")
|
||||
|
||||
@@ -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
|
||||
@@ -172,6 +174,9 @@ class FixedPatchStepper:
|
||||
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.
|
||||
``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.
|
||||
@@ -203,6 +208,12 @@ class FixedPatchStepper:
|
||||
halo -> BFL -> step four times per base step. Runtime child Jet changes
|
||||
require ``set_jet_amplitude(index, amplitude, level=2)``.
|
||||
|
||||
``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.
|
||||
|
||||
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,
|
||||
@@ -216,7 +227,7 @@ class FixedPatchStepper:
|
||||
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_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"):
|
||||
@@ -245,6 +256,15 @@ class FixedPatchStepper:
|
||||
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")
|
||||
@@ -315,6 +335,13 @@ class FixedPatchStepper:
|
||||
for b, layout in zip(self._boundary_hosts, self._layouts)):
|
||||
raise ValueError("raw exchange currently requires blocked body levels")
|
||||
self._observe_raw_exchange = observe_raw_exchange
|
||||
if not isinstance(observe_window_exchange, bool):
|
||||
raise ValueError("observe_window_exchange must be bool")
|
||||
if observe_window_exchange and not observe_raw_exchange:
|
||||
raise ValueError("observe_window_exchange requires observe_raw_exchange")
|
||||
self._observe_window = observe_window_exchange
|
||||
self._window_valid = False
|
||||
self._window_start = None
|
||||
self._exchange_valid = False
|
||||
self._exchange_spacing = tuple(spacings)
|
||||
self._exchange_centers = tuple(tuple(tuple(o.geometry.center) for o in objects)
|
||||
@@ -365,6 +392,8 @@ class FixedPatchStepper:
|
||||
count = len(b.tables["cell"])
|
||||
b.last_raw = self._allocate(count * 4 * 4)
|
||||
b.interval_integral = self._allocate(count * 4 * 8)
|
||||
if self._observe_window:
|
||||
b.window_integral = self._allocate(count * 4 * 8)
|
||||
b.accumulate = level.module.get_function("BlockAccumulateExchange")
|
||||
b.kernel = level.module.get_function("BlockCurvedBoundary")
|
||||
if self._level_boundaries[1] is not None:
|
||||
@@ -467,6 +496,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:
|
||||
@@ -559,6 +593,7 @@ class FixedPatchStepper:
|
||||
cuda.memset_d8(self._fine_curved.link_exchange_gpu, 0, self._fine_curved.count * 12)
|
||||
self._step_count = 0
|
||||
self._exchange_valid = False
|
||||
self._window_valid = False
|
||||
self._status = "ready"
|
||||
except BaseException:
|
||||
self._status = "failed"
|
||||
@@ -639,7 +674,8 @@ class FixedPatchStepper:
|
||||
fine_block=fine_block, child_block=child_block,
|
||||
child_rectangles_in_parent=new.child.bounds,
|
||||
child_objects=child_objects, child_jets=jets,
|
||||
observe_raw_exchange=self._observe_raw_exchange)
|
||||
observe_raw_exchange=self._observe_raw_exchange,
|
||||
observe_window_exchange=self._observe_window, explosion=self._explosion)
|
||||
try:
|
||||
if self._ddf_shifting:
|
||||
candidate._initialize_raw(_StoredPopulations(transferred.g, transferred.shifted))
|
||||
@@ -732,6 +768,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)
|
||||
@@ -771,6 +808,10 @@ class FixedPatchStepper:
|
||||
b.accumulate(b.last_raw, b.interval_integral, np.uint32(count),
|
||||
np.float64(h), np.float64(h),
|
||||
grid=((count + self._block[0] - 1) // self._block[0], 1), **kw)
|
||||
if self._window_valid and b.window_integral is not None:
|
||||
b.accumulate(b.last_raw, b.window_integral, np.uint32(count),
|
||||
np.float64(h), np.float64(h),
|
||||
grid=((count + self._block[0] - 1) // self._block[0], 1), **kw)
|
||||
flags = np.uintp(0) if level.boundary is None else level.boundary.flags
|
||||
level.kernel(src, dst, np.uint32(level.layout.halo), flags, level.kind, grid=level.grid, **kw)
|
||||
|
||||
@@ -804,9 +845,57 @@ class FixedPatchStepper:
|
||||
self._exchange_valid = self._observe_raw_exchange
|
||||
self._status = "ready"
|
||||
except BaseException:
|
||||
self._window_valid = False
|
||||
self._status = "failed"
|
||||
raise
|
||||
|
||||
def start_exchange_window(self):
|
||||
"""Zero the window ledgers and open a window at the current completed step count."""
|
||||
with self._lock, self.ctx.activate():
|
||||
self._require("ready")
|
||||
if not self._observe_window:
|
||||
raise RuntimeError("window exchange observation is disabled")
|
||||
self._window_valid = False
|
||||
try:
|
||||
for b in self._level_boundaries:
|
||||
if b is not None and b.window_integral is not None:
|
||||
cuda.memset_d8(b.window_integral, 0, len(b.tables["cell"]) * 32)
|
||||
except BaseException:
|
||||
self._status = "failed"
|
||||
raise
|
||||
self._window_start = self._step_count
|
||||
self._window_valid = True
|
||||
|
||||
def read_exchange_window(self, body_id, *, level=1):
|
||||
"""Non-destructive read of [window start, current step count] in base-lattice units."""
|
||||
with self._lock, self.ctx.activate():
|
||||
self._require("ready")
|
||||
if not self._observe_window:
|
||||
raise RuntimeError("window exchange observation is disabled")
|
||||
if not self._window_valid:
|
||||
raise RuntimeError("no valid exchange window; call start_exchange_window()")
|
||||
duration = self._step_count - self._window_start
|
||||
if duration < 1:
|
||||
raise RuntimeError("exchange window requires at least one completed base interval")
|
||||
if (isinstance(level, bool) or not isinstance(level, Integral)
|
||||
or not 0 <= level < len(self._levels)):
|
||||
raise ValueError("level must name an existing level")
|
||||
b = self._level_boundaries[level]
|
||||
if (b is None or isinstance(body_id, bool) or not isinstance(body_id, Integral)
|
||||
or not 0 <= body_id < len(b.host.objects)):
|
||||
raise ValueError("body_id must name a body on this level")
|
||||
rows = np.empty((len(b.tables["cell"]), 4), np.float64)
|
||||
try:
|
||||
cuda.memcpy_dtoh(rows, b.window_integral)
|
||||
except BaseException:
|
||||
self._status = "failed"
|
||||
raise
|
||||
return summarize_exchange(rows, b.tables["body_id"], b.tables["aperture"],
|
||||
body_id=body_id, level=level, h=self._exchange_spacing[level],
|
||||
time_end=self._step_count, window="window",
|
||||
reference_center=self._exchange_centers[level][body_id],
|
||||
duration=duration)
|
||||
|
||||
def read_raw_exchange(self, body_id, *, level=1, window="last_base_step"):
|
||||
"""Read raw boundary reaction about its geometric center in the root edge frame.
|
||||
|
||||
@@ -885,6 +974,7 @@ class FixedPatchStepper:
|
||||
if b is not None:
|
||||
b.flags = b.device = b.kernel = None
|
||||
b.last_raw = b.interval_integral = b.accumulate = None
|
||||
b.window_integral = None
|
||||
self._levels.clear()
|
||||
self._pairs = []
|
||||
if getattr(self, "_fine_curved", None) is not None:
|
||||
|
||||
@@ -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")
|
||||
@@ -85,6 +129,7 @@ class _BlockedBoundary:
|
||||
last_raw: object = None
|
||||
interval_integral: object = None
|
||||
accumulate: object = None
|
||||
window_integral: object = None # optional observation-window FP64 ledger
|
||||
|
||||
|
||||
def _prepare_blocked_boundary(boundary, layout, cfg, jets, *, _shifted_blocked=False):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -65,9 +65,14 @@ def test_shifted_topology_add_remove_preserves_cache_and_finite_flow(streaming,p
|
||||
assert np.max(macro['rho'][fluid]) < 1.5
|
||||
|
||||
|
||||
@pytest.mark.parametrize('streaming',['double_buffer','esopull'])
|
||||
def test_checkpoint_exact_continuation_and_controls(streaming,tmp_path):
|
||||
with make_sim(nx=48,ny=24,streaming=streaming,shifted=True) as sim:
|
||||
@pytest.mark.parametrize('collision,streaming', [
|
||||
pytest.param('SRT', 'double_buffer', id='double_buffer'),
|
||||
pytest.param('SRT', 'esopull', id='esopull'),
|
||||
pytest.param('TRT', 'double_buffer', id='trt-double_buffer'),
|
||||
])
|
||||
def test_checkpoint_exact_continuation_and_controls(collision,streaming,tmp_path):
|
||||
# Same collision throughout save/load/continue; this is not a method-switch restart.
|
||||
with make_sim(nx=48,ny=24,streaming=streaming,shifted=True,collision=collision) as sim:
|
||||
sim.add_body('circle',center=(14,12),radius=3)
|
||||
sim.add_body('force_region',center=(30,12),radius=2)
|
||||
sim.initialize()
|
||||
|
||||
@@ -16,6 +16,7 @@ from run_jet_circle_cv import (
|
||||
case,
|
||||
state,
|
||||
cv,
|
||||
cv_from_macros,
|
||||
aperture_probe,
|
||||
measured_aperture_flux,
|
||||
verify,
|
||||
@@ -28,6 +29,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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -41,45 +41,74 @@ class GuardTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(RuntimeError, "velocity/profile"):
|
||||
decode_macros(invalid, w, cx, cy)
|
||||
|
||||
def test_baseline_pins(self):
|
||||
def test_current_corrected_pins(self):
|
||||
self.assertEqual(
|
||||
source_guard(ROOT, "baseline")["base_commit"],
|
||||
"cd3a657f9d3833e812c3cca173525f165b48710a",
|
||||
source_guard(ROOT, "corrected", "a135252")["base_commit"],
|
||||
"a135252ae78b47b57d7e77d19001700b3bedac8b",
|
||||
)
|
||||
|
||||
def test_corrected_and_tamper_guard(self):
|
||||
pins = json.loads((HERE / "data/les_flow/source_pins.json").read_text())
|
||||
pins = json.loads((HERE / "data/les_flow/source_pins_a135252.json").read_text())
|
||||
with TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
for name in pins["sources"]:
|
||||
dest = root / name
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(ROOT / name, dest)
|
||||
# Rebuild the reviewed candidate from actual pinned headers, not fixtures.
|
||||
candidate = root / "header_candidate"
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(HERE / "les/prepare.py"),
|
||||
"--repo",
|
||||
str(ROOT),
|
||||
"--output-dir",
|
||||
str(candidate),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
shutil.copyfile(candidate / "patched" / HEADER, root / HEADER)
|
||||
with patch(
|
||||
"run_uniform_les_flow.subprocess.check_output",
|
||||
return_value="isolated-fixture\n",
|
||||
):
|
||||
source_guard(root, "corrected")
|
||||
source_guard(root, "corrected", "a135252")
|
||||
with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"):
|
||||
source_guard(root, "baseline")
|
||||
target = root / "src/CelerisLab/lbm/kernels/step/one_step_double.cu"
|
||||
target.write_bytes(target.read_bytes() + b"\n// tamper\n")
|
||||
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):
|
||||
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, "corrected")
|
||||
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())
|
||||
tracked = subprocess.check_output(["git", "-C", str(ROOT), "ls-files", "src"], text=True)
|
||||
self.assertEqual(set(pins["sources"]), set(tracked.splitlines()))
|
||||
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, "baseline", "a135252")
|
||||
|
||||
def test_named_baseline_accepts_only_reversed_header(self):
|
||||
pins = json.loads((HERE / "data/les_flow/source_pins_a135252.json").read_text())
|
||||
with TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
for name in pins["sources"]:
|
||||
dest = root / name
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(ROOT / name, dest)
|
||||
subprocess.run(["git", "apply", "--reverse", str(HERE / "les/data/smagorinsky_normalization.patch")], cwd=root, check=True)
|
||||
with patch("run_uniform_les_flow.subprocess.check_output", return_value="baseline-fixture\n"):
|
||||
source_guard(root, "baseline", "a135252")
|
||||
with self.assertRaisesRegex(RuntimeError, "Source pin mismatch"):
|
||||
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/blocks.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()
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Host reduction contract for the optional observation-window exchange ledger."""
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
from CelerisLab.lbm.raw_exchange import summarize_exchange
|
||||
|
||||
|
||||
def reduce(rows, window, duration=None, h=.5, time_end=10):
|
||||
return summarize_exchange(rows, np.array([0, 0, 1]), np.array([False, True, False]),
|
||||
body_id=0, level=1, h=h, time_end=time_end, window=window,
|
||||
reference_center=(0., 0.), duration=duration)
|
||||
|
||||
|
||||
class ExchangeWindowReduction(unittest.TestCase):
|
||||
def test_window_duration_scaling(self):
|
||||
rows = np.array([[1., 2., 3., 4.], [.5, -1., 0., 1.], [9., 9., 9., 9.]])
|
||||
out = reduce(rows, "window", duration=4)
|
||||
np.testing.assert_array_equal(out["total"]["impulse"], [1.5, 1.])
|
||||
np.testing.assert_array_equal(out["total"]["force"], [1.5 / 4, 1. / 4])
|
||||
self.assertEqual(out["total"]["torque"], 3. / 4)
|
||||
self.assertEqual(out["total"]["mass_rate"], 5. / 4)
|
||||
np.testing.assert_array_equal(out["aperture"]["impulse"], [.5, -1.])
|
||||
self.assertEqual((out["time_start"], out["time_end"]), (6., 10))
|
||||
self.assertEqual(out["sample_count"], 8) # duration / h fine substeps
|
||||
self.assertEqual(out["window"], "window")
|
||||
|
||||
def test_existing_windows_unchanged(self):
|
||||
rows = np.array([[1., 2., 3., 4.], [0., 0., 0., 0.], [0., 0., 0., 0.]])
|
||||
base = reduce(rows, "last_base_step")
|
||||
self.assertEqual((base["time_start"], base["sample_count"]), (9., 2))
|
||||
np.testing.assert_array_equal(base["total"]["force"], [1., 2.])
|
||||
sub = reduce(rows, "last_substep")
|
||||
self.assertEqual(sub["sample_count"], 1)
|
||||
|
||||
def test_duration_validation(self):
|
||||
rows = np.zeros((3, 4))
|
||||
for bad in (None, 0, -1, 1.5, True):
|
||||
with self.subTest(duration=bad), self.assertRaises(ValueError):
|
||||
reduce(rows, "window", duration=bad)
|
||||
with self.assertRaises(ValueError):
|
||||
reduce(rows, "last_base_step", duration=2)
|
||||
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
+348
-10
@@ -13,7 +13,15 @@ h, periodic = r.helpers(r.DEFAULT_SOURCE)
|
||||
|
||||
|
||||
def test_source_pin():
|
||||
assert r.verify_source(r.DEFAULT_SOURCE)["commit"].startswith("cd3a657")
|
||||
historical = json.loads((r.HERE / "data/refined_dfg/source_manifest.json").read_text())
|
||||
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")
|
||||
with unittest.TestCase().assertRaisesRegex(RuntimeError, "Source mismatch"):
|
||||
r.verify_source(r.DEFAULT_SOURCE, "SRT")
|
||||
|
||||
|
||||
def test_matched_units_and_planes():
|
||||
@@ -135,22 +143,29 @@ def test_axis_specific_sampling_phase():
|
||||
|
||||
def test_full_requires_completed_pilot():
|
||||
with unittest.TestCase().assertRaisesRegex(ValueError, "pilot-results"):
|
||||
r.verify_pilot(None, r.verify_source(r.DEFAULT_SOURCE))
|
||||
r.verify_pilot(None, r.verify_source(r.DEFAULT_SOURCE, "MRT", "window"))
|
||||
|
||||
|
||||
def pilot_fixture(path):
|
||||
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)
|
||||
manifest = (r.verify_source(r.DEFAULT_SOURCE, "MRT", "window", jet_scenario)
|
||||
if observation_mode == "window" else
|
||||
json.loads((r.HERE / "data/refined_dfg/source_manifest_mrt.json").read_text()))
|
||||
selection = dict(case_selection=case_selection, uniform_diameter=uniform_diameter, placement=placement)
|
||||
(path / "plan.json").write_text(
|
||||
json.dumps(
|
||||
dict(
|
||||
source_manifest=manifest,
|
||||
bounds_root_edge=list(r.BOUNDS),
|
||||
**selection,
|
||||
collision="MRT",
|
||||
jet_scenario=jet_scenario,
|
||||
observation_mode=observation_mode,
|
||||
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)
|
||||
@@ -177,6 +192,10 @@ def pilot_fixture(path):
|
||||
json.dumps(
|
||||
dict(
|
||||
case=case,
|
||||
**selection,
|
||||
collision="MRT",
|
||||
jet_scenario=jet_scenario,
|
||||
observation_mode=observation_mode,
|
||||
mode="pilot",
|
||||
status="pilot_complete",
|
||||
healthy=True,
|
||||
@@ -192,6 +211,10 @@ def pilot_fixture(path):
|
||||
json.dumps(
|
||||
dict(
|
||||
case=case,
|
||||
**selection,
|
||||
collision="MRT",
|
||||
jet_scenario=jet_scenario,
|
||||
observation_mode=observation_mode,
|
||||
mode="pilot",
|
||||
step=interval,
|
||||
runner_sha256=r.runner_hash(),
|
||||
@@ -208,7 +231,7 @@ def test_pilot_validation_and_calibration():
|
||||
with TemporaryDirectory() as tmp:
|
||||
path = Path(tmp)
|
||||
manifest = pilot_fixture(path)
|
||||
assert r.verify_pilot(path, manifest) == 3606.0
|
||||
assert r.verify_pilot(path, manifest, "MRT", "window") == 3606.0
|
||||
|
||||
|
||||
def test_reject_malformed_pilot_receipts():
|
||||
@@ -238,7 +261,7 @@ def test_reject_malformed_pilot_receipts():
|
||||
mutate(bad)
|
||||
receipt.write_text(json.dumps(bad))
|
||||
with unittest.TestCase().assertRaises(ValueError):
|
||||
r.verify_pilot(path, manifest)
|
||||
r.verify_pilot(path, manifest, "MRT", "window")
|
||||
# Exact reviewer regression: previously accepted NaN endpoint.
|
||||
receipt.write_text(
|
||||
json.dumps(
|
||||
@@ -250,11 +273,11 @@ def test_reject_malformed_pilot_receipts():
|
||||
)
|
||||
)
|
||||
with unittest.TestCase().assertRaises(ValueError):
|
||||
r.verify_pilot(path, manifest)
|
||||
r.verify_pilot(path, manifest, "MRT", "window")
|
||||
receipt.write_text(json.dumps(valid))
|
||||
(path / "uniform/ready.json").write_text("{}")
|
||||
with unittest.TestCase().assertRaises(ValueError):
|
||||
r.verify_pilot(path, manifest)
|
||||
r.verify_pilot(path, manifest, "MRT", "window")
|
||||
|
||||
|
||||
def test_strict_finite_json_and_deadline():
|
||||
@@ -283,6 +306,321 @@ def test_pressure_probes_actual_body_mask():
|
||||
assert h.pressure_probes(np.ones(pair.fine_shape), fluid, p)["dp"] == 0.0
|
||||
|
||||
|
||||
def test_matched_mrt_selection():
|
||||
for diameter in (40, 80):
|
||||
srt, sbody, sp = r.config(h, diameter)
|
||||
mrt, mbody, mp = r.config(h, diameter, "MRT")
|
||||
assert mrt["method"]["collision"] == "MRT"
|
||||
mrt["method"]["collision"] = "SRT"
|
||||
assert mrt == srt and mbody == sbody and mp == sp
|
||||
srt_plan, mrt_plan = r.plan(h), r.plan(h, "MRT")
|
||||
for key in ("root", "uniform", "bounds_root_edge", "finest_dx_m", "finest_dt_s"):
|
||||
assert srt_plan[key] == mrt_plan[key]
|
||||
assert "q2/6/8" in mrt_plan["outlet"] and "all nine" in srt_plan["outlet"]
|
||||
cfg, pair, _, host = r.host_geometry(r.DEFAULT_SOURCE, h, "MRT")
|
||||
assert cfg.collision == "MRT" and pair.fine_shape == (240, 568)
|
||||
assert len(host.boundary_cells) > 0
|
||||
|
||||
|
||||
def test_reject_collision_receipts():
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
for rel in ("plan.json", "uniform/summary.json", "refined/summary.json",
|
||||
"uniform/ready.json", "refined/ready.json"):
|
||||
for invalid in ("SRT", None):
|
||||
with TemporaryDirectory() as tmp:
|
||||
path = Path(tmp)
|
||||
manifest = pilot_fixture(path)
|
||||
receipt = path / rel
|
||||
record = json.loads(receipt.read_text())
|
||||
if invalid is None:
|
||||
record.pop("collision")
|
||||
else:
|
||||
record["collision"] = invalid
|
||||
receipt.write_text(json.dumps(record))
|
||||
with unittest.TestCase().assertRaises(ValueError):
|
||||
r.verify_pilot(path, manifest, "MRT", "window")
|
||||
|
||||
|
||||
def test_record_actual_checkout_separately():
|
||||
from source_provenance import checkout_provenance
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def test_window_plan_preserves_physics():
|
||||
for collision in ("SRT", "MRT"):
|
||||
baseline = r.plan(h, collision)
|
||||
window = r.plan(h, collision, "window")
|
||||
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"
|
||||
with unittest.TestCase().assertRaises(ValueError):
|
||||
r.verify_source(r.DEFAULT_SOURCE, "MRT", "unknown")
|
||||
|
||||
|
||||
def test_reject_observation_mode_receipts():
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
for mode, other in (("window", "last_base_step"), ("last_base_step", "window")):
|
||||
with TemporaryDirectory() as tmp:
|
||||
path = Path(tmp)
|
||||
manifest = pilot_fixture(path, mode)
|
||||
assert r.verify_pilot(path, manifest, "MRT", mode) == 3606.0
|
||||
with unittest.TestCase().assertRaises(ValueError):
|
||||
r.verify_pilot(path, manifest, "MRT", other)
|
||||
for rel in ("plan.json", "uniform/summary.json", "refined/summary.json",
|
||||
"uniform/ready.json", "refined/ready.json"):
|
||||
for invalid in (other, None):
|
||||
with TemporaryDirectory() as tmp:
|
||||
path = Path(tmp)
|
||||
manifest = pilot_fixture(path, mode)
|
||||
receipt = path / rel
|
||||
record = json.loads(receipt.read_text())
|
||||
if invalid is None:
|
||||
record.pop("observation_mode")
|
||||
else:
|
||||
record["observation_mode"] = invalid
|
||||
receipt.write_text(json.dumps(record))
|
||||
with unittest.TestCase().assertRaises(ValueError):
|
||||
r.verify_pilot(path, manifest, "MRT", mode)
|
||||
|
||||
|
||||
class ExchangeFixture:
|
||||
"""Synthetic interval ledger for orchestration tests, not a solver or GPU result."""
|
||||
def __init__(self):
|
||||
self.step_count = 11
|
||||
self.events = []
|
||||
self.begin = None
|
||||
self.override = {}
|
||||
|
||||
def start_exchange_window(self):
|
||||
self.begin = self.step_count
|
||||
self.events.append(("start", self.step_count))
|
||||
|
||||
def step(self, n):
|
||||
self.events.append(("step", n))
|
||||
self.step_count += n
|
||||
|
||||
def result(self, start, window):
|
||||
from CelerisLab.lbm.raw_exchange import summarize_exchange
|
||||
|
||||
total = sum(range(start + 1, self.step_count + 1))
|
||||
result = summarize_exchange([[total, -2*total, 0., 0.]], [0], [False],
|
||||
body_id=0, level=1, h=.5, time_end=self.step_count, window=window,
|
||||
reference_center=(80.5, 81.),
|
||||
**({"duration": self.step_count-start} if window == "window" else {}))
|
||||
result.update(self.override)
|
||||
return result
|
||||
|
||||
def read_exchange_window(self, body_id, *, level):
|
||||
assert body_id == 0 and level == 1
|
||||
self.events.append(("read_window", self.step_count))
|
||||
return self.result(self.begin, "window")
|
||||
|
||||
def read_raw_exchange(self, body_id, *, level, window):
|
||||
assert body_id == 0 and level == 1 and window == "last_base_step"
|
||||
self.events.append(("read_raw", self.step_count))
|
||||
return self.result(self.step_count-1, window)
|
||||
|
||||
|
||||
def test_window_interval_impulse_sampling_and_units():
|
||||
base, window = ExchangeFixture(), ExchangeFixture()
|
||||
for n in (80, 2000, 3): # Pilot/dense, early full, and final partial interval.
|
||||
begin = base.step_count
|
||||
a = r.advance_refined_interval(base, n, float("inf"), "last_base_step")
|
||||
w = r.advance_refined_interval(window, n, float("inf"), "window")
|
||||
np.testing.assert_array_equal(w, a)
|
||||
expected = sum(range(begin+1, begin+n+1))
|
||||
np.testing.assert_array_equal(w, [expected, -2*expected])
|
||||
den = .5 * r.SPEED**2 * 40
|
||||
np.testing.assert_array_equal(w/n/den, a/n/den)
|
||||
assert window.step_count == base.step_count == begin+n
|
||||
assert window.events[-3:] == [("start", begin), ("step", n), ("read_window", begin+n)]
|
||||
assert (begin+n/2)*r.SPEED/40 == (window.step_count-n/2)*r.SPEED/40
|
||||
assert not any(event[0] == "read_raw" for event in window.events)
|
||||
|
||||
|
||||
def test_window_contract_rejection():
|
||||
for key, value in (("units", "local"), ("window", "last_base_step"),
|
||||
("sample_count", 2), ("time_start", 0), ("time_end", 0), ("dt", 1.)):
|
||||
backend = ExchangeFixture()
|
||||
backend.override[key] = value
|
||||
with unittest.TestCase().assertRaisesRegex(RuntimeError, "window force"):
|
||||
r.advance_refined_interval(backend, 80, float("inf"), "window")
|
||||
backend = ExchangeFixture()
|
||||
with unittest.TestCase().assertRaises(TimeoutError):
|
||||
r.advance_refined_interval(backend, 80, 0, "window")
|
||||
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)
|
||||
|
||||
@@ -142,7 +142,7 @@ class FixedPatchTests(unittest.TestCase):
|
||||
FixedPatch2D.from_config(LBMConfig(nx=nx, ny=ny, viscosity=.1), bounds)
|
||||
|
||||
def test_unsupported_backend_and_fine_relaxation_rejected(self):
|
||||
for kwargs in ({"collision": "TRT"}, {"collision": "MRT"}, {"streaming": "esopull"},
|
||||
for kwargs in ({"collision": "TRT"}, {"streaming": "esopull"},
|
||||
{"les_enabled": True},
|
||||
{"store_precision": "FP16S"}, {"lattice_model": "D3Q19"},
|
||||
{"data_type": "FP64"}, {"omega_min": 1.0}):
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Host guards and source contract for MRT reuse in refined/blocked stepping (no CUDA)."""
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from CelerisLab.config import LBMConfig
|
||||
from CelerisLab.lbm.refinement import FixedPatch2D
|
||||
|
||||
KERNELS = Path(__file__).resolve().parents[2] / "src/CelerisLab/lbm/kernels"
|
||||
BOUNDS = (8, 6, 16, 14)
|
||||
|
||||
|
||||
class RefinementMRTHost(unittest.TestCase):
|
||||
def test_patch_accepts_implemented_models(self):
|
||||
for collision in ("SRT", "MRT"):
|
||||
with self.subTest(collision=collision):
|
||||
FixedPatch2D.from_config(LBMConfig(nx=24, ny=20, viscosity=.1, collision=collision), BOUNDS)
|
||||
|
||||
def test_patch_rejects_unimplemented(self):
|
||||
for options in (dict(collision="TRT"), dict(collision="MRT", les_enabled=True)):
|
||||
with self.subTest(options=options), self.assertRaises(ValueError):
|
||||
FixedPatch2D.from_config(LBMConfig(nx=24, ny=20, viscosity=.1, **options), BOUNDS)
|
||||
|
||||
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()
|
||||
channel = (KERNELS / "step/block_channel.cu").read_text()
|
||||
refinement = (KERNELS / "refinement.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("collide_srt(f, feq, forcing, omega);", channel)
|
||||
self.assertIn("collide_srt_no_force(f, feq, d_params.omega);", refinement)
|
||||
for source in (blocks, refinement):
|
||||
self.assertIn("(COLLISION_MODEL != 0 && COLLISION_MODEL != 2)", source)
|
||||
self.assertIn("USE_LES", source)
|
||||
self.assertIn("STORE_PRECISION != 0", source)
|
||||
self.assertIn('#if COLLISION_MODEL == 2\n#include "operators/collision_mrt.cuh"\n#endif', source)
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Direct-current-header regression; historical candidate manifests stay frozen."""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HELPERS = ROOT / "tests/validation/les"
|
||||
sys.path.insert(0, str(HELPERS))
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"smagorinsky_oracle", HELPERS / "test_candidate.py"
|
||||
)
|
||||
oracle = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(oracle)
|
||||
HEADER = Path("src/CelerisLab/lbm/kernels/operators/turbulence_smag.cuh")
|
||||
DESCRIPTORS = Path("src/CelerisLab/lbm/kernels/core/descriptors.cuh")
|
||||
|
||||
|
||||
class SmagorinskyNormalization(unittest.TestCase):
|
||||
def test_current_header_matches_independent_tensor_oracle(self):
|
||||
with TemporaryDirectory() as directory:
|
||||
build_dir = Path(directory)
|
||||
for dim in (2, 3):
|
||||
for cs in (0.0, 0.16):
|
||||
# Compile directly against this checkout, not saved candidate headers.
|
||||
closed = oracle.build(
|
||||
ROOT.parent, ROOT.name, dim, cs, 1, 3, build_dir
|
||||
)
|
||||
converged = oracle.build(
|
||||
ROOT.parent, ROOT.name, dim, cs, 0, 200, build_dir
|
||||
)
|
||||
for rho in (0.7, 1.0, 1.3):
|
||||
for tau0 in (0.56, 0.8):
|
||||
for kind, magnitude in (
|
||||
("equilibrium", 0.0),
|
||||
("shear", -0.02),
|
||||
("shear", 0.02),
|
||||
("extension", -0.02),
|
||||
("extension", 0.02),
|
||||
):
|
||||
stress = oracle.tensor(
|
||||
dim, kind, magnitude * rho, 0, dim - 1
|
||||
)
|
||||
f, eq = oracle.state(dim, rho, stress)
|
||||
expected = 1 / oracle.standard_tau(
|
||||
tau0, cs, oracle.norm(stress), rho
|
||||
)
|
||||
with self.subTest(
|
||||
dim=dim,
|
||||
cs=cs,
|
||||
rho=rho,
|
||||
tau=tau0,
|
||||
kind=kind,
|
||||
sign=magnitude,
|
||||
):
|
||||
self.assertAlmostEqual(
|
||||
closed(f, eq, rho, tau0), expected, delta=3e-6
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
converged(f, eq, rho, tau0),
|
||||
expected,
|
||||
delta=3e-6,
|
||||
)
|
||||
if cs == 0:
|
||||
f, eq = oracle.state(
|
||||
dim, 1.0, oracle.tensor(dim, "equilibrium", 0.0)
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
closed(f, eq, 1.0, 0.50001), 1.99, delta=3e-6
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
closed(f, eq, 1.0, 200.0), 0.01, delta=3e-6
|
||||
)
|
||||
|
||||
def test_removing_sqrt_two_is_detected(self):
|
||||
with TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
for relative in (HEADER, DESCRIPTORS):
|
||||
target = root / "old_convention" / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(ROOT / relative, target)
|
||||
target = root / "old_convention" / HEADER
|
||||
text = target.read_text()
|
||||
factor = "18.0f * sqrtf(2.0f) * smag_const_sq"
|
||||
self.assertEqual(text.count(factor), 2)
|
||||
target.write_text(text.replace(factor, "18.0f * smag_const_sq"))
|
||||
for dim in (2, 3):
|
||||
old = oracle.build(root, "old_convention", dim, 0.16, 1, 3, root)
|
||||
stress = oracle.tensor(dim, "shear", 0.02)
|
||||
f, eq = oracle.state(dim, 1.0, stress)
|
||||
expected = 1 / oracle.standard_tau(0.8, 0.16, oracle.norm(stress), 1.0)
|
||||
self.assertGreater(abs(old(f, eq, 1.0, 0.8) - expected), 1e-3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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,86 @@
|
||||
"""CPU contract of the measurement-only refined DFG timing harness (no CUDA)."""
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "validation"))
|
||||
import time_refined_dfg as t # noqa: E402
|
||||
|
||||
|
||||
class TimingHarnessContract(unittest.TestCase):
|
||||
def test_equal_simulated_duration(self):
|
||||
s = t.schedule(2000, 200)
|
||||
self.assertEqual(s["refined"], (200, 2000))
|
||||
self.assertEqual(s["uniform"], (400, 4000)) # one refined base step = two D80 steps
|
||||
self.assertAlmostEqual(s["tstar_window"], 2000 * .01 / 40)
|
||||
for bad in ((0, 1), (1, -1)):
|
||||
with self.assertRaises(ValueError):
|
||||
t.schedule(*bad)
|
||||
|
||||
def test_timing_only_labels(self):
|
||||
for name in ("B", "C", "E'"):
|
||||
self.assertFalse(t.VARIANTS[name]["force_history_valid"])
|
||||
self.assertTrue(t.VARIANTS[name]["timing_only"])
|
||||
for name in ("A", "E"):
|
||||
self.assertFalse(t.VARIANTS[name]["force_history_valid"]) # reads discarded
|
||||
self.assertTrue(t.VARIANTS[name]["force_reads"])
|
||||
self.assertIn("runner-like", t.VARIANTS[name]["force_note"])
|
||||
self.assertEqual({v["case"] for v in t.VARIANTS.values()}, {"refined", "uniform"})
|
||||
self.assertIn("final base interval", t.VARIANTS["C"]["force_note"])
|
||||
self.assertIn("whole-window", t.VARIANTS["E'"]["force_note"])
|
||||
|
||||
def test_deadline(self):
|
||||
import time
|
||||
with self.assertRaises(TimeoutError):
|
||||
t.check_deadline(time.monotonic() - 1)
|
||||
t.check_deadline(time.monotonic() + 60)
|
||||
|
||||
def test_parity_detects_bits_and_nan_masks(self):
|
||||
a = np.array([[1., np.nan], [2., 3.]], np.float32)
|
||||
self.assertIsNone(t.same_state([a], [a.copy()]))
|
||||
b = a.copy(); b[1, 1] = np.nextafter(np.float32(3), np.float32(4))
|
||||
self.assertIn("bits", t.same_state([a], [b]))
|
||||
c = a.copy(); c[0, 0] = np.nan
|
||||
self.assertIn("NaN", t.same_state([a], [c]))
|
||||
self.assertIn("level count", t.same_state([a], [a, a]))
|
||||
inf = a.copy(); inf[1, 0] = np.inf
|
||||
self.assertIn("infinite", t.same_state([inf], [inf.copy()])) # matching inf still fails
|
||||
self.assertIn("infinite", t.same_state([a], [inf]))
|
||||
|
||||
def test_flat_uniform_ddf(self):
|
||||
fluid = np.array([[True, False, True], [True, True, False]])
|
||||
flat = np.ones(9 * fluid.size, np.float32) # Simulation.get_ddf() layout
|
||||
self.assertTrue(t.uniform_populations_finite(flat, fluid))
|
||||
flat[1 * fluid.size + 1] = np.nan # q=1, solid cell 1: ignored
|
||||
self.assertTrue(t.uniform_populations_finite(flat, fluid))
|
||||
flat[4 * fluid.size + 0] = np.nan # q=4, fluid cell 0: detected
|
||||
self.assertFalse(t.uniform_populations_finite(flat, fluid))
|
||||
self.assertTrue(t.uniform_populations_finite(np.ones((9,) + fluid.shape), fluid))
|
||||
with self.assertRaises(RuntimeError):
|
||||
t.uniform_populations_finite(np.ones(9 * fluid.size + 1), fluid)
|
||||
|
||||
def test_physical_health_limits(self):
|
||||
ok = (np.array([1., 1.01]), np.array([.01, .02]), np.array([0., .01]))
|
||||
health = t.physical_health([ok])
|
||||
self.assertAlmostEqual(health["rho_max"], 1.01)
|
||||
for bad in ((np.array([1., np.nan]),) + ok[1:],
|
||||
(np.array([.4, 1.]),) + ok[1:],
|
||||
(ok[0], np.array([.2, 0.]), ok[2]),
|
||||
(np.array([]), np.array([]), np.array([]))):
|
||||
with self.assertRaises(RuntimeError):
|
||||
t.physical_health([bad])
|
||||
|
||||
def test_summary_reports_differences_without_claims(self):
|
||||
rows = [dict(variant=v, seconds=s) for v, s in
|
||||
(("A", 3.), ("A", 5.), ("B", 2.), ("C", 1.), ("E", 2.), ("E'", 1.5))]
|
||||
s = t.summarize(rows, .5)
|
||||
self.assertEqual(s["A"]["median_seconds"], 4.)
|
||||
self.assertEqual(s["A_minus_B_seconds"], 2.)
|
||||
self.assertEqual(s["B_minus_C_seconds"], 1.)
|
||||
self.assertEqual(s["A_over_E"], 2.)
|
||||
self.assertIn("not exact causal", s["interpretation"])
|
||||
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
@@ -0,0 +1,38 @@
|
||||
"""CPU contract of the A-versus-window timing harness (no CUDA)."""
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "validation"))
|
||||
import time_window_dfg as w # noqa: E402
|
||||
|
||||
|
||||
class WindowTimingContract(unittest.TestCase):
|
||||
def test_interval_and_variants(self):
|
||||
self.assertEqual(w.INTERVAL, 80)
|
||||
self.assertEqual(w.VARIANTS, ("A", "W", "W1"))
|
||||
|
||||
def test_components(self):
|
||||
obs = dict(total=dict(impulse=np.array([1., 2.]), angular_impulse=3., mass_into_fluid=4.))
|
||||
np.testing.assert_array_equal(w.components(obs), [1., 2., 3., 4.])
|
||||
|
||||
def test_interval_comparison(self):
|
||||
ref = np.array([[1., -1., 0., 0.]])
|
||||
scale = np.array([[10., 10., 1., 1.]])
|
||||
self.assertEqual(w.compare_intervals(ref, scale, ref.copy()), 0.)
|
||||
near = ref + np.array([[5e-9, 0., 0., 0.]])
|
||||
self.assertLessEqual(w.compare_intervals(ref, scale, near), 1.)
|
||||
far = ref + np.array([[1e-7, 0., 0., 0.]])
|
||||
self.assertGreater(w.compare_intervals(ref, scale, far), 1.)
|
||||
zero = np.zeros((1, 4))
|
||||
self.assertGreater(w.compare_intervals(zero, zero, zero + 1e-12), 1.) # floor, no div-by-0
|
||||
|
||||
def test_candidate_identity_records_files(self):
|
||||
identity = w.candidate_identity(Path(__file__).resolve().parents[2])
|
||||
self.assertIn("checkout_revision", identity)
|
||||
self.assertEqual(set(identity["sha256"]), set(w.CANDIDATE_FILES))
|
||||
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
@@ -1,17 +0,0 @@
|
||||
# Frozen CPU-only cycle-assessor v2 plan
|
||||
|
||||
Predeclared before evaluating v2 acceptance on archived flows, 2026-10-18.
|
||||
|
||||
Cycle boundaries are every ascending lift Cl=0 crossing, found by linear interpolation between consecutive samples with Cl<0 then Cl>=0. Direction and zero level are fixed. No smoothing, optimized window, fitted phase, or alternative-method selection. Exact sampled zeros count once. Existing period2..5, lift amplitude>=.2, five recent consecutive cycles, period variation<=.005, extrema variation<=.005, mass/flux gates and official-reference tolerances remain unchanged.
|
||||
|
||||
Complete cycles are resampled on257 evenly spaced phase points with the existing linear interpolation, extrema and trapezoidal integration. Force timing remains interval midpoint; pressure/mass/flux use their end-sample times. Partial cycles are excluded; implausible periods are rejected, and missing cycles must still fail consecutiveness. No density, collision, geometry, initialization, outlet or flow-data change.
|
||||
|
||||
Official reference is rebuilt from the same pinned archive, lv6/dt4 line forces and pressure values, selecting its last complete ascending-crossing cycle. It uses the same v2 phase convention for scalar and waveform comparison. Original trough-phase reference and frozen422e549 assessment remain unchanged.
|
||||
|
||||
Qualification order:
|
||||
1. Independent review of this definition and plan.
|
||||
2. Synthetic constant-period sinusoidal/harmonic signals sampled at dt*=.02, with fixed deterministic small perturbations confined to extrema; false local extrema must not create cycles. Test exact-zero ties, zero offset phase, genuine period drift above.005, low amplitude, insufficient cycles, invalid/nonfinite/nonmonotone input, and missing/extra crossings. No fitted noise parameters.
|
||||
3. Reanalyse official source waveform, archived full9 uniform data, and archived MRT uniform/refined data. Report every case and every gate, old and v2 side by side, including failures. No favorable-window selection.
|
||||
4. Independent code/result review. Write versioned reanalysis receipts with input hashes, assessor hash/version, reference construction and gates. Do not overwrite original results or alter the original failed exit2.
|
||||
|
||||
Scope: DFG shedding signals with recurrent sign-changing lift. This does not define a universal cycle detector for signals without zero crossings. New acceptance is a separately identified measurement, never a claim that the historical frozen assessor passed.
|
||||
@@ -1,27 +0,0 @@
|
||||
# DFG ascending-zero assessor v2
|
||||
|
||||
This CPU-only measurement correction is separate from the frozen assessor. It changes cycle boundaries, not numerical flow data or any gate value. PLAN.md was committed at1ca3bb8 before archived v2 outcomes; the crossing algorithm was committed at2369949 before those evaluations. No flow run is included.
|
||||
|
||||
Every consecutive ascending Cl<0 to Cl>=0 crossing defines a candidate cycle. Times are linearly interpolated, with no suppression, smoothing or prominence choices. Fixed period/amplitude plausibility and all existing five-cycle, consecutive, recent, extrema, mass, flux and reference gates are reused. Invalid intervals are not reconnected across gaps. This definition is specific to sign-changing DFG shedding lift; no-zero or poorly sampled signals need another explicitly reviewed measurement, not silent fallback.
|
||||
|
||||
Cycle extrema and trapezoidal integrals retain the existing257-point phase grid. Force uses midpoint time, pressure/mass/flux endpoint time. The official raw lv6/dt4 reference is rebuilt in the same ascending phase, using its last complete cycle over the entire supplied shared time coverage. Original trough-phase reference files are untouched. Raw pressure columns7 minus12 are interpolated at force sample times; physical time is multiplied by10. Reference mass/flux placeholders are not measured conservation evidence.
|
||||
|
||||
`reanalyse.py` verifies the official archive hash, exact CSV equality to original summaries, and identical frozen gate outcomes. It reports any floating-point difference when replaying old cycle statistics instead of claiming bitwise identity across numerical library environments. All five supplied histories are reported, with their input/summary/code hashes, original identity, old and v2 results. Its new receipt is called reanalysis.json and cannot overwrite an existing output directory. It does not change the old run's exit code or accuracy_qualified field.
|
||||
|
||||
Tests include constant and noncommensurate/asymmetric signals, fixed small peak noise, explicit false local extrema, genuine period drift above the unchanged gate, low amplitude, insufficient cycles, zero plateaus, extra/missing crossings, missing sample points, invalid data and coverage. The algorithm is still vulnerable to sign noise and aliasing near a crossing; these tests are bounded validation, not universal robustness proof. Synthetic source conditions and perturbations are specified in the test file, not fitted to flow results.
|
||||
|
||||
Run tests:
|
||||
|
||||
```
|
||||
python tests/validation/cycle_v2/test_periodic_v2.py
|
||||
```
|
||||
|
||||
Reanalyse with every archived case named explicitly:
|
||||
|
||||
```
|
||||
python tests/validation/cycle_v2/reanalyse.py --official-archive <pinned-official.zip> --history full9=<full9-history.csv> --history mrt_uniform=<mrt-uniform-history.csv> --history mrt_refined=<mrt-refined-history.csv> --output <new-directory>
|
||||
```
|
||||
|
||||
Each history needs its original summary.json alongside it. Multiple observer variants may be listed separately; duplicate refined histories are not independent physical replications. Historical data and thresholds remain untouched. Any future production-run use requires explicit integration/review; this module does not alter default assessors.
|
||||
|
||||
Frozen replay integrity permits at most1e-10 absolute floating difference per stored cycle number, with identical schema, counts and gate decisions. This is a CPU replay check only, not a change to physical accuracy or convergence thresholds. Actual archived uniform MRT differences were at most1.081e-13; other replayed cycle statistics matched exactly.
|
||||
@@ -1,102 +0,0 @@
|
||||
"""Versioned ascending-zero DFG measurement; original data and gates unchanged."""
|
||||
|
||||
import numpy as np
|
||||
|
||||
from periodic import GATES, REFERENCE_GATES, compare
|
||||
|
||||
VERSION = "dfg-ascending-zero-v2"
|
||||
|
||||
|
||||
def ascending_crossings(t, y):
|
||||
t = np.asarray(t, dtype=float); y = np.asarray(y, dtype=float)
|
||||
if t.ndim != 1 or y.shape != t.shape or not np.isfinite(t).all() or not np.isfinite(y).all():
|
||||
raise ValueError("Finite one-dimensional time/signal arrays required")
|
||||
if np.any(np.diff(t) <= 0):
|
||||
raise ValueError("Strictly increasing times required")
|
||||
i = np.flatnonzero((y[:-1] < 0) & (y[1:] >= 0))
|
||||
return (t[i] - y[i] * (t[i+1] - t[i]) / (y[i+1] - y[i])).tolist()
|
||||
|
||||
|
||||
def validate_rows(rows):
|
||||
keys = ("tstar", "tstar_force", "cd", "cl", "dp", "mass", "flux_in", "flux_out")
|
||||
values = np.asarray([[row[k] for k in keys] for row in rows], dtype=float)
|
||||
if not np.isfinite(values).all():
|
||||
raise ValueError("Nonfinite observation")
|
||||
if len(rows) and (np.any(np.diff(values[:, 0]) <= 0) or np.any(np.diff(values[:, 1]) <= 0)):
|
||||
raise ValueError("Strictly increasing observation times required")
|
||||
|
||||
|
||||
def extract(rows, min_time=250.0):
|
||||
validate_rows(rows)
|
||||
if len(rows) < 3:
|
||||
return []
|
||||
t = np.array([r["tstar_force"] for r in rows])
|
||||
cl = np.array([r["cl"] for r in rows])
|
||||
ends = ascending_crossings(t, cl)
|
||||
cycles = []
|
||||
for a, b in zip(ends, ends[1:]):
|
||||
if a < min_time or not GATES["min_period"] < b - a < GATES["max_period"]:
|
||||
continue
|
||||
grid = np.linspace(a, b, 257)
|
||||
c = dict(start=a, end=b, period=b - a, st=1 / (b - a))
|
||||
for key in ("cd", "cl", "dp", "mass", "flux_in", "flux_out"):
|
||||
times = np.array(
|
||||
[r["tstar" if key not in ("cd", "cl") else "tstar_force"] for r in rows]
|
||||
)
|
||||
# No endpoint extrapolation: pressure/mass must cover the whole cycle.
|
||||
if a < times[0] or b > times[-1]:
|
||||
break
|
||||
v = np.interp(grid, times, [r[key] for r in rows])
|
||||
c[key + "_min"] = float(v.min())
|
||||
c[key + "_max"] = float(v.max())
|
||||
c[key + "_midrange"] = float((v.min() + v.max()) / 2)
|
||||
c[key + "_amplitude"] = float(v.max() - v.min())
|
||||
c[key + "_time_mean"] = float(
|
||||
np.sum((v[1:] + v[:-1]) * np.diff(grid) / 2) / (b - a)
|
||||
)
|
||||
if key in ("cd", "cl", "dp"):
|
||||
c[key + "_waveform"] = v.tolist()
|
||||
else:
|
||||
c["flux_relative"] = abs(
|
||||
c["flux_in_time_mean"] - c["flux_out_time_mean"]
|
||||
) / max(abs(c["flux_in_time_mean"]), 1e-30)
|
||||
cycles.append(c)
|
||||
return cycles
|
||||
|
||||
|
||||
def assess(rows):
|
||||
cycles = extract(rows, min_time=GATES["min_tstar"])
|
||||
tail = cycles[-GATES["cycles"] :]
|
||||
checks = {}
|
||||
checks["enough_cycles"] = len(tail) == GATES["cycles"]
|
||||
if checks["enough_cycles"]:
|
||||
checks["consecutive"] = all(
|
||||
abs(a["end"] - b["start"]) < 1e-8 for a, b in zip(tail, tail[1:])
|
||||
)
|
||||
checks["recent"] = (
|
||||
0 <= rows[-1]["tstar_force"] - tail[-1]["end"] <= GATES["max_period"]
|
||||
)
|
||||
checks["shedding"] = (
|
||||
min(c["cl_amplitude"] for c in tail) >= GATES["min_lift_amplitude"]
|
||||
)
|
||||
periods = [c["period"] for c in tail]
|
||||
checks["period"] = (
|
||||
np.ptp(periods) / np.mean(periods) <= GATES["period_relative_range"]
|
||||
)
|
||||
checks["extrema"] = all(
|
||||
np.ptp([c[k + "_" + e] for c in tail]) <= GATES["extrema_absolute_range"]
|
||||
for k in ("cd", "cl", "dp")
|
||||
for e in ("min", "max")
|
||||
)
|
||||
mass = [c["mass_time_mean"] for c in tail]
|
||||
checks["mass"] = (
|
||||
np.ptp(mass) / abs(np.mean(mass)) <= GATES["mass_relative_range"]
|
||||
)
|
||||
checks["flux"] = (
|
||||
max(c["flux_relative"] for c in tail) <= GATES["cycle_mean_flux_relative"]
|
||||
)
|
||||
return dict(
|
||||
periodic=bool(checks["enough_cycles"] and all(checks.values())),
|
||||
checks={k: bool(v) for k, v in checks.items()},
|
||||
cycles=cycles,
|
||||
)
|
||||
@@ -1,112 +0,0 @@
|
||||
"""Versioned read-only DFG reanalysis; never overwrites a flow receipt."""
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import zipfile
|
||||
import numpy as np
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
import periodic as frozen
|
||||
import periodic_v2 as v2
|
||||
|
||||
OFFICIAL_SHA256 = '08db59605b2f60efc512d2bbc79628ea5d1cc720c0986938a16920884a0215be'
|
||||
PREFIX = 'logs/results_cc2d_bench2_referencenew/'
|
||||
|
||||
|
||||
def sha(path): return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def official_reference(path):
|
||||
if sha(path) != OFFICIAL_SHA256: raise ValueError('Wrong official archive')
|
||||
with zipfile.ZipFile(path) as z:
|
||||
force = np.loadtxt(io.BytesIO(z.read(PREFIX+'bdforces_q2_lv6_dt4')))
|
||||
pressure = np.loadtxt(io.BytesIO(z.read(PREFIX+'pointvalues_q2_lv6_dt4')))
|
||||
tf=force[:,1]*10; tp=pressure[:,1]*10
|
||||
if not np.isfinite(force).all() or not np.isfinite(pressure).all():
|
||||
raise ValueError('Nonfinite official input')
|
||||
if np.any(np.diff(tf)<=0) or np.any(np.diff(tp)<=0): raise ValueError('Official time order')
|
||||
# Entire supplied interval, restricted only to shared force/pressure coverage.
|
||||
mask=(tf>=tp[0])&(tf<=tp[-1]); force=force[mask]; tf=tf[mask]
|
||||
dp=np.interp(tf,tp,pressure[:,6]-pressure[:,11])
|
||||
rows=[dict(tstar=float(t),tstar_force=float(t),cd=float(f[3]),cl=float(f[4]),dp=float(p),
|
||||
mass=1.,flux_in=1.,flux_out=1.) for t,f,p in zip(tf,force,dp)]
|
||||
cycles=v2.extract(rows,min_time=0.)
|
||||
if not cycles: raise ValueError('No complete official cycle')
|
||||
ref=cycles[-1]
|
||||
return ref,dict(archive_sha256=OFFICIAL_SHA256,selection='lv6/dt4 last complete ascending Cl=0 cycle in entire shared input coverage',
|
||||
eligible_time=[float(tf[0]),float(tf[-1])],complete_cycles=len(cycles),
|
||||
pressure='columns7 minus12, interpolated at original force times; physical time multiplied by10',
|
||||
artificial_fields='mass/flux placeholders only; official archive is not used to certify conservation')
|
||||
|
||||
|
||||
def frozen_replay_difference(current_cycles, archived_cycles):
|
||||
"""Integrity-only FP replay allowance, not a physical acceptance threshold."""
|
||||
if len(current_cycles) != len(archived_cycles):
|
||||
raise ValueError('Frozen cycle count differs from original')
|
||||
deltas=[]
|
||||
for current, archived in zip(current_cycles, archived_cycles):
|
||||
if current.keys()!=archived.keys(): raise ValueError('Frozen cycle schema differs')
|
||||
for key in current:
|
||||
x=np.asarray(current[key]); y=np.asarray(archived[key])
|
||||
if x.shape!=y.shape or not np.isfinite(x).all() or not np.isfinite(y).all():
|
||||
raise ValueError('Frozen cycle shape or finite-state mismatch')
|
||||
deltas.append(float(np.max(np.abs(x-y))))
|
||||
difference=max(deltas,default=0.)
|
||||
if difference>1e-10: raise ValueError('Frozen replay exceeds FP integrity allowance')
|
||||
return difference
|
||||
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--official-archive',type=Path,required=True)
|
||||
parser.add_argument('--history',action='append',required=True,help='Unique label=CSV path; all cases reported')
|
||||
parser.add_argument('--output',type=Path,required=True)
|
||||
a=parser.parse_args()
|
||||
if a.output.exists(): raise ValueError('Use a new output directory')
|
||||
cases=[]; labels=set()
|
||||
reference,reference_info=official_reference(a.official_archive)
|
||||
old_ref_path=Path(frozen.__file__).parent/'data/dfg_re100/reference_cycle.json'
|
||||
old_ref=json.loads(old_ref_path.read_text())
|
||||
for item in a.history:
|
||||
label,path=item.split('=',1); path=Path(path)
|
||||
if label in labels: raise ValueError('Duplicate case label')
|
||||
labels.add(label)
|
||||
rows=[{k:float(v) for k,v in r.items()} for r in csv.DictReader(path.open())]
|
||||
source_receipt_path=path.with_name('summary.json')
|
||||
source_receipt=json.loads(source_receipt_path.read_text())
|
||||
if rows != source_receipt.get('history'):
|
||||
raise ValueError('CSV differs from original summary history')
|
||||
v2.validate_rows(rows)
|
||||
old=frozen.assess(rows); new=v2.assess(rows)
|
||||
for key in ('periodic', 'checks'):
|
||||
if old[key] != source_receipt.get(key):
|
||||
raise ValueError('Frozen reassessment differs from original summary')
|
||||
replay_difference=frozen_replay_difference(old['cycles'],source_receipt['cycles'])
|
||||
old_comparison=frozen.compare(old['cycles'][-1],old_ref) if old['cycles'] else None
|
||||
new_comparison=v2.compare(new['cycles'][-1],reference) if new['cycles'] else None
|
||||
# This is assessment of recorded observables, not proof of unrecorded population health.
|
||||
healthy=bool(rows and all(.5<=r['rho_min']<=r['rho_max']<=1.5 and 0<=r['max_mach']<=.2 for r in rows))
|
||||
cases.append(dict(label=label,input_csv_sha256=sha(path),
|
||||
original_summary_sha256=sha(source_receipt_path),
|
||||
frozen_cycle_max_absolute_replay_difference=replay_difference,
|
||||
original_identity={k:source_receipt[k] for k in ('case','collision','observation_mode','numerical_source_commit','checkout','checkout_revision','mode','status') if k in source_receipt},
|
||||
observations=len(rows),
|
||||
recorded_end_tstar=rows[-1]['tstar'] if rows else None,recorded_observations_healthy=healthy,
|
||||
frozen=old,frozen_reference=old_comparison,v2=new,v2_reference=new_comparison,
|
||||
v2_recorded_observable_agreement=bool(healthy and new['periodic'] and new_comparison and new_comparison['reference_agreement'])))
|
||||
a.output.mkdir(parents=True)
|
||||
result=dict(assessment_version=v2.VERSION,original_receipts_unchanged=True,
|
||||
phase='Every ascending Cl<0 to Cl>=0 crossing; linear interpolation, no suppression',
|
||||
gates=v2.GATES,reference_gates=v2.REFERENCE_GATES,reference=reference,reference_provenance=reference_info,
|
||||
code_sha256={Path(p).name:sha(p) for p in [__file__,v2.__file__,frozen.__file__]},
|
||||
frozen_reference_sha256=sha(old_ref_path),cases=cases)
|
||||
(a.output/'reanalysis.json').write_text(json.dumps(result,indent=2,allow_nan=False)+'\n')
|
||||
print(json.dumps([dict(case=c['label'],old_periodic=c['frozen']['periodic'],
|
||||
v2_checks=c['v2']['checks'],v2_reference=c['v2_reference'],
|
||||
recorded_observable_agreement=c['v2_recorded_observable_agreement']) for c in cases],indent=2))
|
||||
|
||||
|
||||
if __name__=='__main__': main()
|
||||
@@ -1,108 +0,0 @@
|
||||
"""Predeclared synthetic measurement tests; no flow outcome is used for tuning."""
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
import numpy as np
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
import periodic
|
||||
import periodic_v2 as v2
|
||||
|
||||
|
||||
def signal(periods=None, noise=0., offset=0., amplitude=1., irregular=False, harmonic=0.):
|
||||
periods = np.full(20, 3.3) if periods is None else np.asarray(periods)
|
||||
boundaries = 250.37 + np.r_[0., np.cumsum(periods)]
|
||||
t = np.arange(boundaries[0]-.1, boundaries[-1]+.1, .02)
|
||||
if irregular: t = t[np.arange(len(t)) % 7 != 0]
|
||||
phase = np.interp(t, boundaries, np.arange(len(boundaries))) * 2*np.pi
|
||||
lift = amplitude*(np.sin(phase) + harmonic*np.sin(2*phase)) + offset
|
||||
# Fixed seed and fixed peak-local mask; not fitted to any flow dataset.
|
||||
lift += noise*np.random.default_rng(1729).uniform(-1,1,len(t))*(abs(np.sin(phase))>.9)
|
||||
return [dict(tstar=x+.01, tstar_force=x, cl=l,
|
||||
cd=3.2+.03*np.cos(2*p), dp=2.45+.05*np.cos(p),
|
||||
mass=1., flux_in=1., flux_out=1.) for x,l,p in zip(t,lift,phase)]
|
||||
|
||||
|
||||
def test_constant_period_peak_noise_and_mean_offset():
|
||||
for noise, offset in [(0.,0.),(.0001,0.),(.001,0.),(.001,.08)]:
|
||||
result=v2.assess(signal(noise=noise,offset=offset))
|
||||
assert result['periodic'], result['checks']
|
||||
periods=np.array([c['period'] for c in result['cycles'][-5:]])
|
||||
assert max(abs(periods-3.3))<1e-5
|
||||
|
||||
|
||||
def test_noncommensurate_asymmetric_waveform():
|
||||
result=v2.assess(signal(periods=[3.313]*20,harmonic=.1,offset=.08,noise=.001))
|
||||
assert result['periodic'], result['checks']
|
||||
periods=np.array([c['period'] for c in result['cycles'][-5:]])
|
||||
assert max(abs(periods-3.313))<5e-5
|
||||
|
||||
|
||||
def test_false_local_extrema_do_not_split_cycles():
|
||||
rows=signal()
|
||||
y=np.array([r['cl'] for r in rows])
|
||||
# A fixed .002 dip at every positive peak creates a local minimum while
|
||||
# preserving all zero crossings. This is the predeclared false-extremum case.
|
||||
peaks=np.flatnonzero((y[1:-1]>y[:-2]) & (y[1:-1]>=y[2:]) & (y[1:-1]>.9))+1
|
||||
for i in peaks: rows[i]['cl']-=.002
|
||||
assert v2.assess(rows)['periodic']
|
||||
assert not periodic.assess(rows)['periodic']
|
||||
|
||||
|
||||
def test_true_drift_is_rejected():
|
||||
result=v2.assess(signal(periods=np.arange(20)*.01+3.2))
|
||||
assert not result['periodic'] and not result['checks']['period']
|
||||
measured=np.array([c['period'] for c in result['cycles'][-5:]])
|
||||
assert np.ptp(measured)/np.mean(measured)>.005
|
||||
|
||||
|
||||
def test_insufficient_cycles_and_amplitude():
|
||||
assert not v2.assess(signal(periods=[3.3]*4))['periodic']
|
||||
result=v2.assess(signal(amplitude=.05))
|
||||
assert not result['periodic'] and not result['checks']['shedding']
|
||||
|
||||
|
||||
def test_exact_zero_plateau_and_crossing_noise():
|
||||
assert v2.ascending_crossings([0,1,2,3,4,5],[-1,0,0,1,-1,0])==[1.,5.]
|
||||
# All crossings retained, even a short sign reversal: no hidden debounce.
|
||||
assert len(v2.ascending_crossings([0,1,2,3,4,5],[-1,.001,-.001,.001,1,2]))==2
|
||||
assert v2.ascending_crossings([0,1,2],[1,.1,2])==[]
|
||||
|
||||
|
||||
def test_extra_and_missing_cycles_not_reconnected():
|
||||
for last in [[3.3,.2,3.1,3.3],[3.3,6.6,3.3,3.3]]:
|
||||
result=v2.assess(signal(periods=[3.3]*12+last))
|
||||
assert not result['periodic'] and not result['checks']['consecutive']
|
||||
|
||||
|
||||
def test_invalid_inputs_and_irregular_sampling():
|
||||
for t,y in [([0,0],[-1,1]),([1,0],[-1,1]),([0,1],[-1,float('nan')])]:
|
||||
with unittest.TestCase().assertRaises(ValueError):v2.ascending_crossings(t,y)
|
||||
rows=signal();rows[1]['dp']=float('inf')
|
||||
with unittest.TestCase().assertRaises(ValueError):v2.assess(rows)
|
||||
assert v2.assess(signal(irregular=True))['periodic']
|
||||
|
||||
|
||||
def test_coverage_and_unchanged_gates():
|
||||
rows=signal()
|
||||
cycles=v2.extract(rows,min_time=250.)
|
||||
# Move end-sample coverage late; force remains unchanged. Partial cycles excluded.
|
||||
delayed=[dict(r,tstar=r['tstar']+10.) for r in rows]
|
||||
other=v2.extract(delayed,min_time=250.)
|
||||
assert len(other)<len(cycles) and all(c['start']>=delayed[0]['tstar'] for c in other)
|
||||
assert v2.GATES is periodic.GATES and v2.REFERENCE_GATES is periodic.REFERENCE_GATES
|
||||
assert v2.compare is periodic.compare
|
||||
|
||||
|
||||
def test_frozen_replay_integrity_allowance():
|
||||
from reanalyse import frozen_replay_difference
|
||||
baseline=[dict(period=3.3,wave=[0.,1.,0.])]
|
||||
assert frozen_replay_difference(baseline,[dict(period=3.3+1e-13,wave=[0.,1.,0.])])<1e-10
|
||||
for bad in [[dict(period=3.3+1e-6,wave=[0.,1.,0.])],
|
||||
[dict(period=float('nan'),wave=[0.,1.,0.])],[]]:
|
||||
with unittest.TestCase().assertRaises(ValueError): frozen_replay_difference(baseline,bad)
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
result=unittest.TextTestRunner(verbosity=2).run(unittest.TestSuite(
|
||||
unittest.FunctionTestCase(fn) for name,fn in sorted(globals().items()) if name.startswith('test_')))
|
||||
raise SystemExit(not result.wasSuccessful())
|
||||
+19
-32
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"commit": "926ca27407c26ca11283282a6e53249925725376",
|
||||
"historical_numerical_source_commit": "9805383f9c50d867e8555471baf5dbda8333a652",
|
||||
"variant": "experimental_regularized_full9",
|
||||
"sha256": {
|
||||
"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",
|
||||
@@ -30,7 +30,7 @@
|
||||
"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": "215b09829e10453b333aae69811ffc638c7aa26ae418f25f8654a1a20a74f212",
|
||||
"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",
|
||||
@@ -44,25 +44,24 @@
|
||||
"src/CelerisLab/lbm/__init__.py": "7f60fb3aaae81501b510d08c2fedff44fcb05657260eb10b6f5cc91e7204306b",
|
||||
"src/CelerisLab/lbm/block_stepper.py": "00b313daddc6e63d8ab8423c9190ca7e92ad291de20a2f6b80eb53e3f2d85ec7",
|
||||
"src/CelerisLab/lbm/blocks.py": "1e644c5719993f418185ad4a10b6950a30636fc912befd9eab41ff48b4cf85cd",
|
||||
"src/CelerisLab/lbm/channel.py": "2d5b5ff43414cb35633ac4ce90ce2c3a3353950b25c168e1c2ab58d498dd8b0f",
|
||||
"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": "e3b9606f861bfea8c4b0f34e8356bb230081f899c584f67be89178d889099b17",
|
||||
"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": "1203739c346cb4986e4ecb3090a70114eecc1688ae886d5abfcdb643be256acb",
|
||||
"src/CelerisLab/lbm/kernels/boundary/inlet/common.cuh": "59b38a89439878e00de7de16b9c7b03feff443fcc5a421593f7d2536dcbb7ab8",
|
||||
"src/CelerisLab/lbm/kernels/boundary/inlet/equilibrium.cuh": "f0964c811b2c41a5aaa1c6d99c490b06e850c634eb8749f1f05ef61f93c2de1d",
|
||||
"src/CelerisLab/lbm/kernels/boundary/inlet/experimental_full9.cuh": "f2475d6322f060b256f37d81e43d6aa3a1918e2eac83c076c32c2599b674f596",
|
||||
"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": "4955786adc6d0853f2a9703611ea76e58ef045d6d6f4295bd795b05dc2f00dcd",
|
||||
"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",
|
||||
@@ -84,10 +83,10 @@
|
||||
"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": "ff7f000297ee0817e255ad9fcb6a13b647e3ddd5b206518d35f38d2895a9f501",
|
||||
"src/CelerisLab/lbm/kernels/refinement.cu": "129f3d91da93b4324f88a0da73a4da1e5390f72c14cce574ee63306712da9895",
|
||||
"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": "befbf6d757e532a59c166d41f920d16d71557cd3a574478e7aa83da737c48cbd",
|
||||
"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",
|
||||
@@ -98,26 +97,14 @@
|
||||
"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": "8694202466eba848563e298656353b0f5db94e21a01fa05f51aad6dc2b9fd311",
|
||||
"src/CelerisLab/lbm/refinement.py": "08c2a0557276a98cbcaa459d9bb7a9735d8bbbf64a6114c57af2fda61481a687",
|
||||
"src/CelerisLab/lbm/refinement_stepper.py": "f2bb34fa90805ad21c3e7cf9f9a67d88d3ae3e249b4517d3898567b4bf9e364d",
|
||||
"src/CelerisLab/lbm/refinement_tables.py": "fdd344024b89df3a83174dc810ca5e32db5e19eb8b1609f8f73b0821f56546b6",
|
||||
"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",
|
||||
"tests/validation/data/refined_dfg/source_manifest.json": "e495948b10f8112c77ca67255122b5e8bc9e32570e8c899802afcb85e608fef4",
|
||||
"tests/validation/periodic.py": "a2212c0e2705126327a5019e9cafe64eeca398da1b75061ff23630c9843697a6",
|
||||
"tests/validation/re100_helpers.py": "289c6e01075ff361573f862b90cfd61e5980c9a8efcbc78aef69979b4004fd60",
|
||||
"tests/validation/run_refined_dfg.py": "992169a38ab16ab20a9b9bb4a29853cafe1d6bc07adba0df435a104be1de7b68",
|
||||
"tests/validation/source_provenance.py": "6dfe8f2d819dba0e240ba761d31162a5033685d492d09db75eb2b8aaab3a5a4c"
|
||||
"src/CelerisLab/simulation.py": "0a03904dc6de88667d7af699005a289adf8b09f0976cb0d099b041f4830a8f4c"
|
||||
},
|
||||
"runtime_sha256": {
|
||||
"tests/validation/experimental_full9/run_dfg.py": "400f6ff373623e5312c8dfae3cb8dc0172360e40feb2a9780443ec7f7ded6eea",
|
||||
"tests/validation/uniform_srt_diagnostic/diagnose_uniform_srt.py": "56b9ef786001c7760abab8d870f6437d719b427c21964f00067188f5fb27caad",
|
||||
"tests/validation/source_provenance.py": "6dfe8f2d819dba0e240ba761d31162a5033685d492d09db75eb2b8aaab3a5a4c",
|
||||
"tests/validation/data/dfg_re100/reference_cycle.json": "073d1bda1629697efebad95667bfe80598e217d708dbffd7d5f6c6999379c55f",
|
||||
"tests/validation/experimental_full9/run_dfg_full.py": "b82f3fcab73f20ce86394a4de80022022434215b3e07fa9aaf5cbd8118413ef1",
|
||||
"tests/validation/experimental_full9/dfg_source_manifest.json": "f5b7ebfc136555f5cdfebd95b886bd8263491f27b8abd723bbe76c9b1aeba476"
|
||||
},
|
||||
"experiment": "uniform_full9_dfg_full_qualification"
|
||||
"candidate_header_sha256": "a3db46c2e7d27530b2d1436e65cdbed0b72cc7e047bfbeef2a640877271b51aa",
|
||||
"baseline_header_sha256": "ff7f000297ee0817e255ad9fcb6a13b647e3ddd5b206518d35f38d2895a9f501"
|
||||
}
|
||||
+17
-29
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"commit": "926ca27407c26ca11283282a6e53249925725376",
|
||||
"historical_numerical_source_commit": "9805383f9c50d867e8555471baf5dbda8333a652",
|
||||
"variant": "experimental_regularized_full9",
|
||||
"sha256": {
|
||||
"base_commit": "8403956a9afc0b0bb89265cec374392013d89016",
|
||||
"production_origin_commit": "8403956a9afc0b0bb89265cec374392013d89016",
|
||||
"source_experiment": "Explicit dev8403956 uniform SRT LES experiment; historical 9805383/5465832 receipts remain historical. Corrected permits only the reviewed normalization header.",
|
||||
"sources": {
|
||||
"src/CelerisLab/__init__.py": "21cc469bd6e970a1f9ff5970c8eea4602065e41fc881854759ee565ee55c10c6",
|
||||
"src/CelerisLab/body/__init__.py": "e4e0e8f0769a94428d5b02fac790443997556cfc5515a5f1ed1595d2c694d69d",
|
||||
"src/CelerisLab/body/action_smoother.py": "b1a23ee6bbd4850b172cb8738c5c3546ee465a54189e6af9d8044f023d4687dc",
|
||||
@@ -30,7 +30,7 @@
|
||||
"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": "215b09829e10453b333aae69811ffc638c7aa26ae418f25f8654a1a20a74f212",
|
||||
"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",
|
||||
@@ -44,25 +44,24 @@
|
||||
"src/CelerisLab/lbm/__init__.py": "7f60fb3aaae81501b510d08c2fedff44fcb05657260eb10b6f5cc91e7204306b",
|
||||
"src/CelerisLab/lbm/block_stepper.py": "00b313daddc6e63d8ab8423c9190ca7e92ad291de20a2f6b80eb53e3f2d85ec7",
|
||||
"src/CelerisLab/lbm/blocks.py": "1e644c5719993f418185ad4a10b6950a30636fc912befd9eab41ff48b4cf85cd",
|
||||
"src/CelerisLab/lbm/channel.py": "2d5b5ff43414cb35633ac4ce90ce2c3a3353950b25c168e1c2ab58d498dd8b0f",
|
||||
"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": "e3b9606f861bfea8c4b0f34e8356bb230081f899c584f67be89178d889099b17",
|
||||
"src/CelerisLab/lbm/kernels/blocks.cu": "2fa894b20a29f006911ac3e3aad0793cc4aaf9ccd7a67d01c6320a0836b1d8ef",
|
||||
"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": "1203739c346cb4986e4ecb3090a70114eecc1688ae886d5abfcdb643be256acb",
|
||||
"src/CelerisLab/lbm/kernels/boundary/inlet/common.cuh": "59b38a89439878e00de7de16b9c7b03feff443fcc5a421593f7d2536dcbb7ab8",
|
||||
"src/CelerisLab/lbm/kernels/boundary/inlet/equilibrium.cuh": "f0964c811b2c41a5aaa1c6d99c490b06e850c634eb8749f1f05ef61f93c2de1d",
|
||||
"src/CelerisLab/lbm/kernels/boundary/inlet/experimental_full9.cuh": "f2475d6322f060b256f37d81e43d6aa3a1918e2eac83c076c32c2599b674f596",
|
||||
"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": "4955786adc6d0853f2a9703611ea76e58ef045d6d6f4295bd795b05dc2f00dcd",
|
||||
"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",
|
||||
@@ -85,9 +84,9 @@
|
||||
"src/CelerisLab/lbm/kernels/operators/helpers.cuh": "0b3a4d95d7d19f5fec09e5423b8f4cc99f8a226b541967425967330da5a051ec",
|
||||
"src/CelerisLab/lbm/kernels/operators/macro.cuh": "2be20eb2b7d7f5522daaad7f4b57b1ff8cdf883efe4a67846a96783b2fe846a3",
|
||||
"src/CelerisLab/lbm/kernels/operators/turbulence_smag.cuh": "ff7f000297ee0817e255ad9fcb6a13b647e3ddd5b206518d35f38d2895a9f501",
|
||||
"src/CelerisLab/lbm/kernels/refinement.cu": "129f3d91da93b4324f88a0da73a4da1e5390f72c14cce574ee63306712da9895",
|
||||
"src/CelerisLab/lbm/kernels/refinement.cu": "b45aad9c022cecb64f7e968ec3cae2d8385a3cc430ee409700c404a3f4dd8cf8",
|
||||
"src/CelerisLab/lbm/kernels/step/aux_kernels.cu": "578a5b20a157241ee678547059abb1372dd5a589ad06bb2c15b987eb01ca1013",
|
||||
"src/CelerisLab/lbm/kernels/step/block_channel.cu": "befbf6d757e532a59c166d41f920d16d71557cd3a574478e7aa83da737c48cbd",
|
||||
"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",
|
||||
@@ -98,24 +97,13 @@
|
||||
"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": "8694202466eba848563e298656353b0f5db94e21a01fa05f51aad6dc2b9fd311",
|
||||
"src/CelerisLab/lbm/refinement.py": "08c2a0557276a98cbcaa459d9bb7a9735d8bbbf64a6114c57af2fda61481a687",
|
||||
"src/CelerisLab/lbm/refinement_stepper.py": "f2bb34fa90805ad21c3e7cf9f9a67d88d3ae3e249b4517d3898567b4bf9e364d",
|
||||
"src/CelerisLab/lbm/refinement_tables.py": "fdd344024b89df3a83174dc810ca5e32db5e19eb8b1609f8f73b0821f56546b6",
|
||||
"src/CelerisLab/lbm/raw_exchange.py": "9031a196006a0f74dee4028e6f3bb673bc19ef7959f9f04dde9e01f93293aeb3",
|
||||
"src/CelerisLab/lbm/refinement.py": "100d5e46ac894f6008b61364cdad5d37c2ca7ed949d8d71405572d6366526417",
|
||||
"src/CelerisLab/lbm/refinement_stepper.py": "b6b835cf97f5aa045045bb4a47cfde34b89929d3872be410cb155727dfca2d33",
|
||||
"src/CelerisLab/lbm/refinement_tables.py": "6eea593aaf791a2c644f1c74df0a41f6f5e62a2fce9feae76e19cf84b615607a",
|
||||
"src/CelerisLab/lbm/stepper.py": "0dcb7cd7ae87e33e3dc67a38b6aa43042fbe673e37d9ad165342ee1d695933ce",
|
||||
"src/CelerisLab/lbm/synchronized_remap.py": "7400fc4beaffd4649d6a9c524d38ce5fd6fa7eeb32eb5332e4030a4b6648f197",
|
||||
"src/CelerisLab/simulation.py": "0a03904dc6de88667d7af699005a289adf8b09f0976cb0d099b041f4830a8f4c",
|
||||
"tests/validation/data/refined_dfg/source_manifest.json": "e495948b10f8112c77ca67255122b5e8bc9e32570e8c899802afcb85e608fef4",
|
||||
"tests/validation/periodic.py": "a2212c0e2705126327a5019e9cafe64eeca398da1b75061ff23630c9843697a6",
|
||||
"tests/validation/re100_helpers.py": "289c6e01075ff361573f862b90cfd61e5980c9a8efcbc78aef69979b4004fd60",
|
||||
"tests/validation/run_refined_dfg.py": "992169a38ab16ab20a9b9bb4a29853cafe1d6bc07adba0df435a104be1de7b68",
|
||||
"tests/validation/source_provenance.py": "6dfe8f2d819dba0e240ba761d31162a5033685d492d09db75eb2b8aaab3a5a4c"
|
||||
"src/CelerisLab/simulation.py": "0a03904dc6de88667d7af699005a289adf8b09f0976cb0d099b041f4830a8f4c"
|
||||
},
|
||||
"runtime_sha256": {
|
||||
"tests/validation/experimental_full9/run_dfg.py": "400f6ff373623e5312c8dfae3cb8dc0172360e40feb2a9780443ec7f7ded6eea",
|
||||
"tests/validation/uniform_srt_diagnostic/diagnose_uniform_srt.py": "56b9ef786001c7760abab8d870f6437d719b427c21964f00067188f5fb27caad",
|
||||
"tests/validation/source_provenance.py": "6dfe8f2d819dba0e240ba761d31162a5033685d492d09db75eb2b8aaab3a5a4c",
|
||||
"tests/validation/data/dfg_re100/reference_cycle.json": "073d1bda1629697efebad95667bfe80598e217d708dbffd7d5f6c6999379c55f"
|
||||
},
|
||||
"experiment": "uniform_full9_dfg_sparse_pilot"
|
||||
"candidate_header_sha256": "a3db46c2e7d27530b2d1436e65cdbed0b72cc7e047bfbeef2a640877271b51aa"
|
||||
}
|
||||
+12
-12
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"commit": "9805383f9c50d867e8555471baf5dbda8333a652",
|
||||
"failure_source_receipt_sha256": "a77f71550c52d4b8b6e946a3cb98fc5de5db3da1bcbbb3457c6d60167677ce4e",
|
||||
"commit": "3ea131e92c057843034f11a1c574301a2430dfce",
|
||||
"production_origin_commit": "3ea131e92c057843034f11a1c574301a2430dfce",
|
||||
"collision": "MRT",
|
||||
"sha256": {
|
||||
"src/CelerisLab/__init__.py": "21cc469bd6e970a1f9ff5970c8eea4602065e41fc881854759ee565ee55c10c6",
|
||||
"src/CelerisLab/body/__init__.py": "e4e0e8f0769a94428d5b02fac790443997556cfc5515a5f1ed1595d2c694d69d",
|
||||
@@ -43,14 +44,14 @@
|
||||
"src/CelerisLab/lbm/__init__.py": "7f60fb3aaae81501b510d08c2fedff44fcb05657260eb10b6f5cc91e7204306b",
|
||||
"src/CelerisLab/lbm/block_stepper.py": "00b313daddc6e63d8ab8423c9190ca7e92ad291de20a2f6b80eb53e3f2d85ec7",
|
||||
"src/CelerisLab/lbm/blocks.py": "1e644c5719993f418185ad4a10b6950a30636fc912befd9eab41ff48b4cf85cd",
|
||||
"src/CelerisLab/lbm/channel.py": "2d5b5ff43414cb35633ac4ce90ce2c3a3353950b25c168e1c2ab58d498dd8b0f",
|
||||
"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": "e3b9606f861bfea8c4b0f34e8356bb230081f899c584f67be89178d889099b17",
|
||||
"src/CelerisLab/lbm/kernels/blocks.cu": "2fa894b20a29f006911ac3e3aad0793cc4aaf9ccd7a67d01c6320a0836b1d8ef",
|
||||
"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",
|
||||
@@ -63,12 +64,12 @@
|
||||
"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/config.h": "7d1124125e0b354558297934460e64c760fb68cdd7bf6d01a1238bb2eaab8c37",
|
||||
"src/CelerisLab/lbm/kernels/core/descriptors.cuh": "8440bc14857da9007982650f828a23608eec9f7cc4bf772e8c8dc1239cb45356",
|
||||
"src/CelerisLab/lbm/kernels/core/flags.cuh": "0025705f24710330d9d9195f713879bfb650a85a3449baef0e196b831af104ab",
|
||||
"src/CelerisLab/lbm/kernels/core/layout.cuh": "1fbd820021c89ac3efcde24145996cb0147769e607e498e61f308ed3e0b13706",
|
||||
@@ -83,9 +84,9 @@
|
||||
"src/CelerisLab/lbm/kernels/operators/helpers.cuh": "0b3a4d95d7d19f5fec09e5423b8f4cc99f8a226b541967425967330da5a051ec",
|
||||
"src/CelerisLab/lbm/kernels/operators/macro.cuh": "2be20eb2b7d7f5522daaad7f4b57b1ff8cdf883efe4a67846a96783b2fe846a3",
|
||||
"src/CelerisLab/lbm/kernels/operators/turbulence_smag.cuh": "ff7f000297ee0817e255ad9fcb6a13b647e3ddd5b206518d35f38d2895a9f501",
|
||||
"src/CelerisLab/lbm/kernels/refinement.cu": "129f3d91da93b4324f88a0da73a4da1e5390f72c14cce574ee63306712da9895",
|
||||
"src/CelerisLab/lbm/kernels/refinement.cu": "b45aad9c022cecb64f7e968ec3cae2d8385a3cc430ee409700c404a3f4dd8cf8",
|
||||
"src/CelerisLab/lbm/kernels/step/aux_kernels.cu": "578a5b20a157241ee678547059abb1372dd5a589ad06bb2c15b987eb01ca1013",
|
||||
"src/CelerisLab/lbm/kernels/step/block_channel.cu": "befbf6d757e532a59c166d41f920d16d71557cd3a574478e7aa83da737c48cbd",
|
||||
"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",
|
||||
@@ -97,16 +98,15 @@
|
||||
"src/CelerisLab/lbm/kernels/streaming/pull_double_buffer.cuh": "b65d741ed78b7b62f99531ba0c0ce1936663d8dc9640e7d7d16c707f1baf6549",
|
||||
"src/CelerisLab/lbm/population_storage.py": "a6a86c29c52c892edfe4802fb773c387212e3f4cc86708d80e154986ddf88044",
|
||||
"src/CelerisLab/lbm/raw_exchange.py": "8694202466eba848563e298656353b0f5db94e21a01fa05f51aad6dc2b9fd311",
|
||||
"src/CelerisLab/lbm/refinement.py": "08c2a0557276a98cbcaa459d9bb7a9735d8bbbf64a6114c57af2fda61481a687",
|
||||
"src/CelerisLab/lbm/refinement.py": "100d5e46ac894f6008b61364cdad5d37c2ca7ed949d8d71405572d6366526417",
|
||||
"src/CelerisLab/lbm/refinement_stepper.py": "f2bb34fa90805ad21c3e7cf9f9a67d88d3ae3e249b4517d3898567b4bf9e364d",
|
||||
"src/CelerisLab/lbm/refinement_tables.py": "fdd344024b89df3a83174dc810ca5e32db5e19eb8b1609f8f73b0821f56546b6",
|
||||
"src/CelerisLab/lbm/stepper.py": "0dcb7cd7ae87e33e3dc67a38b6aa43042fbe673e37d9ad165342ee1d695933ce",
|
||||
"src/CelerisLab/lbm/synchronized_remap.py": "7400fc4beaffd4649d6a9c524d38ce5fd6fa7eeb32eb5332e4030a4b6648f197",
|
||||
"src/CelerisLab/simulation.py": "0a03904dc6de88667d7af699005a289adf8b09f0976cb0d099b041f4830a8f4c",
|
||||
"tests/validation/data/refined_dfg/source_manifest.json": "e495948b10f8112c77ca67255122b5e8bc9e32570e8c899802afcb85e608fef4",
|
||||
"tests/validation/periodic.py": "a2212c0e2705126327a5019e9cafe64eeca398da1b75061ff23630c9843697a6",
|
||||
"tests/validation/re100_helpers.py": "289c6e01075ff361573f862b90cfd61e5980c9a8efcbc78aef69979b4004fd60",
|
||||
"tests/validation/run_refined_dfg.py": "256aacd84b3f8412a31ffaba9c4b5635db895c3177686dd34ef8e31f08119414",
|
||||
"tests/validation/periodic.py": "a2212c0e2705126327a5019e9cafe64eeca398da1b75061ff23630c9843697a6",
|
||||
"tests/validation/data/dfg_re100/reference_cycle.json": "073d1bda1629697efebad95667bfe80598e217d708dbffd7d5f6c6999379c55f",
|
||||
"tests/validation/source_provenance.py": "6dfe8f2d819dba0e240ba761d31162a5033685d492d09db75eb2b8aaab3a5a4c"
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-21
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"commit": "926ca27407c26ca11283282a6e53249925725376",
|
||||
"historical_numerical_source_commit": "9805383f9c50d867e8555471baf5dbda8333a652",
|
||||
"variant": "experimental_regularized_full9",
|
||||
"commit": "a233c9032487a6006e950d1e327079b00466c410",
|
||||
"production_origin_commit": "d9303234795fccf8591ba6274dfc544a84d956ff",
|
||||
"observation_mode": "window",
|
||||
"supported_collisions": [
|
||||
"SRT",
|
||||
"MRT"
|
||||
],
|
||||
"sha256": {
|
||||
"src/CelerisLab/__init__.py": "21cc469bd6e970a1f9ff5970c8eea4602065e41fc881854759ee565ee55c10c6",
|
||||
"src/CelerisLab/body/__init__.py": "e4e0e8f0769a94428d5b02fac790443997556cfc5515a5f1ed1595d2c694d69d",
|
||||
@@ -30,7 +34,7 @@
|
||||
"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": "215b09829e10453b333aae69811ffc638c7aa26ae418f25f8654a1a20a74f212",
|
||||
"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",
|
||||
@@ -44,25 +48,24 @@
|
||||
"src/CelerisLab/lbm/__init__.py": "7f60fb3aaae81501b510d08c2fedff44fcb05657260eb10b6f5cc91e7204306b",
|
||||
"src/CelerisLab/lbm/block_stepper.py": "00b313daddc6e63d8ab8423c9190ca7e92ad291de20a2f6b80eb53e3f2d85ec7",
|
||||
"src/CelerisLab/lbm/blocks.py": "1e644c5719993f418185ad4a10b6950a30636fc912befd9eab41ff48b4cf85cd",
|
||||
"src/CelerisLab/lbm/channel.py": "2d5b5ff43414cb35633ac4ce90ce2c3a3353950b25c168e1c2ab58d498dd8b0f",
|
||||
"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": "e3b9606f861bfea8c4b0f34e8356bb230081f899c584f67be89178d889099b17",
|
||||
"src/CelerisLab/lbm/kernels/blocks.cu": "2fa894b20a29f006911ac3e3aad0793cc4aaf9ccd7a67d01c6320a0836b1d8ef",
|
||||
"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": "1203739c346cb4986e4ecb3090a70114eecc1688ae886d5abfcdb643be256acb",
|
||||
"src/CelerisLab/lbm/kernels/boundary/inlet/common.cuh": "59b38a89439878e00de7de16b9c7b03feff443fcc5a421593f7d2536dcbb7ab8",
|
||||
"src/CelerisLab/lbm/kernels/boundary/inlet/equilibrium.cuh": "f0964c811b2c41a5aaa1c6d99c490b06e850c634eb8749f1f05ef61f93c2de1d",
|
||||
"src/CelerisLab/lbm/kernels/boundary/inlet/experimental_full9.cuh": "f2475d6322f060b256f37d81e43d6aa3a1918e2eac83c076c32c2599b674f596",
|
||||
"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": "4955786adc6d0853f2a9703611ea76e58ef045d6d6f4295bd795b05dc2f00dcd",
|
||||
"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",
|
||||
@@ -85,9 +88,9 @@
|
||||
"src/CelerisLab/lbm/kernels/operators/helpers.cuh": "0b3a4d95d7d19f5fec09e5423b8f4cc99f8a226b541967425967330da5a051ec",
|
||||
"src/CelerisLab/lbm/kernels/operators/macro.cuh": "2be20eb2b7d7f5522daaad7f4b57b1ff8cdf883efe4a67846a96783b2fe846a3",
|
||||
"src/CelerisLab/lbm/kernels/operators/turbulence_smag.cuh": "ff7f000297ee0817e255ad9fcb6a13b647e3ddd5b206518d35f38d2895a9f501",
|
||||
"src/CelerisLab/lbm/kernels/refinement.cu": "129f3d91da93b4324f88a0da73a4da1e5390f72c14cce574ee63306712da9895",
|
||||
"src/CelerisLab/lbm/kernels/refinement.cu": "b45aad9c022cecb64f7e968ec3cae2d8385a3cc430ee409700c404a3f4dd8cf8",
|
||||
"src/CelerisLab/lbm/kernels/step/aux_kernels.cu": "578a5b20a157241ee678547059abb1372dd5a589ad06bb2c15b987eb01ca1013",
|
||||
"src/CelerisLab/lbm/kernels/step/block_channel.cu": "befbf6d757e532a59c166d41f920d16d71557cd3a574478e7aa83da737c48cbd",
|
||||
"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",
|
||||
@@ -98,21 +101,16 @@
|
||||
"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": "8694202466eba848563e298656353b0f5db94e21a01fa05f51aad6dc2b9fd311",
|
||||
"src/CelerisLab/lbm/refinement.py": "08c2a0557276a98cbcaa459d9bb7a9735d8bbbf64a6114c57af2fda61481a687",
|
||||
"src/CelerisLab/lbm/refinement_stepper.py": "f2bb34fa90805ad21c3e7cf9f9a67d88d3ae3e249b4517d3898567b4bf9e364d",
|
||||
"src/CelerisLab/lbm/refinement_tables.py": "fdd344024b89df3a83174dc810ca5e32db5e19eb8b1609f8f73b0821f56546b6",
|
||||
"src/CelerisLab/lbm/raw_exchange.py": "9031a196006a0f74dee4028e6f3bb673bc19ef7959f9f04dde9e01f93293aeb3",
|
||||
"src/CelerisLab/lbm/refinement.py": "100d5e46ac894f6008b61364cdad5d37c2ca7ed949d8d71405572d6366526417",
|
||||
"src/CelerisLab/lbm/refinement_stepper.py": "b6b835cf97f5aa045045bb4a47cfde34b89929d3872be410cb155727dfca2d33",
|
||||
"src/CelerisLab/lbm/refinement_tables.py": "6eea593aaf791a2c644f1c74df0a41f6f5e62a2fce9feae76e19cf84b615607a",
|
||||
"src/CelerisLab/lbm/stepper.py": "0dcb7cd7ae87e33e3dc67a38b6aa43042fbe673e37d9ad165342ee1d695933ce",
|
||||
"src/CelerisLab/lbm/synchronized_remap.py": "7400fc4beaffd4649d6a9c524d38ce5fd6fa7eeb32eb5332e4030a4b6648f197",
|
||||
"src/CelerisLab/simulation.py": "0a03904dc6de88667d7af699005a289adf8b09f0976cb0d099b041f4830a8f4c",
|
||||
"tests/validation/data/refined_dfg/source_manifest.json": "e495948b10f8112c77ca67255122b5e8bc9e32570e8c899802afcb85e608fef4",
|
||||
"tests/validation/data/dfg_re100/reference_cycle.json": "073d1bda1629697efebad95667bfe80598e217d708dbffd7d5f6c6999379c55f",
|
||||
"tests/validation/periodic.py": "a2212c0e2705126327a5019e9cafe64eeca398da1b75061ff23630c9843697a6",
|
||||
"tests/validation/re100_helpers.py": "289c6e01075ff361573f862b90cfd61e5980c9a8efcbc78aef69979b4004fd60",
|
||||
"tests/validation/run_refined_dfg.py": "256aacd84b3f8412a31ffaba9c4b5635db895c3177686dd34ef8e31f08119414",
|
||||
"tests/validation/source_provenance.py": "6dfe8f2d819dba0e240ba761d31162a5033685d492d09db75eb2b8aaab3a5a4c"
|
||||
},
|
||||
"runtime_sha256": {
|
||||
"tests/validation/uniform_srt_diagnostic/diagnose_uniform_srt.py": "56b9ef786001c7760abab8d870f6437d719b427c21964f00067188f5fb27caad",
|
||||
"tests/validation/experimental_full9/run_bodyless.py": "840edd524edb66ec06a3b8784c9e561f139a77cf49550125817193f25363c694"
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
# Uniform full-nine DFG qualification
|
||||
|
||||
This is a separate full-run entrypoint. The pilot wrapper, pilot manifest, shared observer, reference data, gates and all production files remain byte-identical to c0be841. Numerical source remains 926ca27407c26ca11283282a6e53249925725376; actual execution HEAD is recorded separately. A new full manifest pins the added wrapper and the original pilot manifest.
|
||||
|
||||
## Admission and case
|
||||
|
||||
Require the completed exact c0be841 uniform SRT/full9 cylinder pilot directory, including result.json, preflight.json and uniform/ready.json. Admission checks finite complete pilot history, collision/inlet/case/observation/source identity, physical configuration, body, parameters, frozen gates, READY, clean checkout and positive timings. Bodyless, MRT, refined, stale-source or malformed receipts cannot admit this run.
|
||||
|
||||
Restart from the unchanged original production initialization, not a pilot continuation. Keep D80, lattice mean U=.01, viscosity=.008, tau=.524, shifted FP32 double buffer, cylinder/walls/domain, no LES/force, and pressure-NEQ outlet all-nine damping .5. Only the already tested experimental inlet differs from the historical baseline.
|
||||
|
||||
Maximum 2,400,000 steps to t*=300. Reuse the existing observer: t*=.5 intervals before240, then .02 through300. All force batches are included; force time is the interval midpoint, pressure time its endpoint. Early calibration remains accounted in the physical trajectory. Sparse health checks identify first observed failure, not exact onset. Timeout retains progress.
|
||||
|
||||
Success requires the full physical cap, healthy completion, frozen periodic convergence AND official-reference agreement. Healthy completion alone or periodicity alone returns failure. The project tolerances remain unchanged; the reference data are official DFG data. This does not qualify a canonical do-nothing outlet, grid convergence, AMR, MRT, LES or other precision paths.
|
||||
|
||||
## Cost and bounds
|
||||
|
||||
Measured pilot startup: 3.3353 s. Step plus force: 3.6794 s for 40,000 steps. Linear step-work extrapolation is 220.764 s, before additional late-time dense observations and altered batch subdivision. The full observer has 3,480 intervals (480 sparse and 3,000 dense), compared with 10 pilot intervals. Prior same-size MRT full runs took 354–373 s total. Expect several minutes, with timing reported rather than promised.
|
||||
|
||||
Internal cap: 720 s maximum. Outer watchdog: 900 s, then 30 s termination grace. The wrapper rejects a larger internal cap. Expiry preserves an incomplete result and does not qualify accuracy. No GPU execution is performed during preparation.
|
||||
|
||||
## Commands
|
||||
|
||||
CPU tests:
|
||||
|
||||
```
|
||||
python tests/validation/experimental_full9/test_dfg_full.py
|
||||
python tests/validation/experimental_full9/test_dfg.py
|
||||
python tests/validation/experimental_full9/test_full9.py
|
||||
```
|
||||
|
||||
Preflight against the actual retained pilot directory:
|
||||
|
||||
```
|
||||
python tests/validation/experimental_full9/run_dfg_full.py --pilot-results <actual-c0be841-pilot-directory> --output <new-preflight-directory>
|
||||
```
|
||||
|
||||
After independent review and explicit full-run admission:
|
||||
|
||||
```
|
||||
CUDA_VISIBLE_DEVICES=<admitted-device> timeout --signal=TERM --kill-after=30s 900s python tests/validation/experimental_full9/run_dfg_full.py --pilot-results <actual-c0be841-pilot-directory> --execute --gpu-admitted --wall-seconds 720 --output output/new-full9-dfg-full
|
||||
```
|
||||
|
||||
The four admission tests use clearly labeled synthetic receipts. They are host regression fixtures, not measured pilot evidence. Runtime admission uses the actual retained pilot receipts.
|
||||
@@ -1,26 +0,0 @@
|
||||
# Experimental uniform DFG pilot
|
||||
|
||||
Numerical source remains926ca27407c26ca11283282a6e53249925725376. This test-only followup uses a separate dfg_source_manifest.json, pins the official reference-cycle file, and records actual execution HEAD independently. The historical bodyless manifest is unchanged and correctly rejects this checkout because the shared runner gained optional hooks. Use historical939c10c for that historical entrypoint; do not repin old evidence.
|
||||
|
||||
The new run_dfg.py is uniform-only, with the original D80 cylinder, SRT tau.524, shiftedFP32 double buffer, original initialization, noLES/force, pressure-NEQ outlet all-nine damping.5. Only inlet scheme is experimental_regularized_full9. It reuses run_refined_dfg.run_case and the existing pressure, force, physical mass/flux and periodic/reference helpers. Legacy run_case callers retain the original inlet and dense pilot cadence.
|
||||
|
||||
This pilot advances40,000steps to t*=5 with sparse observations every4000steps (t*=.5), matching the standard pre240 cadence. Every100-step batch contributes its full momentum impulse; force time is the interval midpoint, pressure time its endpoint. First100steps provide warmup; the next four100-step batches measure warmed step+force cost. All500steps are included in the physical trajectory and observation impulse; no reset or discarded evolution. Calibration durations and total step/force/startup/wall timings are recorded. Batch force download synchronizes GPU work through the existing API.
|
||||
|
||||
A first unhealthy sample identifies only the first observed failure, not its onset. Receipts separate executed_steps, last_observed_step and the existing steps field; timeout retains available progress, history and calibration. No per-step field download or new health threshold is introduced. Runtime admission uses180seconds internally, with an external240second watchdog. Later full-run admission must use measured batched timings, not the earlier per-step snapshot diagnostic rate. Same-size historical MRT full campaigns took roughly354–373seconds, but this pilot measures the present SRT/full9 case.
|
||||
|
||||
CPU checks from this checkout:
|
||||
|
||||
```
|
||||
python tests/validation/experimental_full9/test_dfg.py
|
||||
python tests/validation/experimental_full9/test_full9.py
|
||||
python -m py_compile tests/validation/experimental_full9/run_dfg.py tests/validation/run_refined_dfg.py
|
||||
python tests/validation/experimental_full9/run_dfg.py --output /tmp/new-full9-dfg-preflight
|
||||
```
|
||||
|
||||
After independent review and explicit GPU admission only:
|
||||
|
||||
```
|
||||
CUDA_VISIBLE_DEVICES=<admitted-device> timeout --signal=TERM --kill-after=30s 240s python tests/validation/experimental_full9/run_dfg.py --execute --gpu-admitted --wall-seconds 180 --output output/new-full9-dfg-pilot
|
||||
```
|
||||
|
||||
This entrypoint intentionally has no full-run execution option. Existing official-reference assessment is reused and accuracy_pass requires health, periodicity and reference agreement for future qualification; pilot accuracy_qualified is alwaysfalse. A healthy pilot qualifies bounded admission/timing only. No grid convergence, canonical outlet, refined/MRT/other precision, long-time accuracy or general stability claim follows.
|
||||
@@ -1,38 +0,0 @@
|
||||
# Isolated experimental full-nine inlet comparison
|
||||
|
||||
This is preparation for one separately admitted, at-most320-step bodyless robustness comparison. It is not public support, a default change, proof of root cause, or boundary-accuracy acceptance.
|
||||
|
||||
## Source and isolation
|
||||
Numerical commit: `926ca27407c26ca11283282a6e53249925725376`, based on history diagnostic `e92de3742476ea5afd6b84ff9538992df48548f7`. The new manifest pins numerical files and separately hashes the two runtime harness files. Actual checkout HEAD/dirty state is recorded on execution; --source must resolve to the executing harness checkout. The historical manifest is unchanged and intentionally rejects the new numerical tree. The original Zou–He default and IDs0..3 remain unchanged.
|
||||
|
||||
Explicit scheme `experimental_regularized_full9` is ID4. Configuration guards restrict it to D2Q9, SRT, double-buffer, FP32 storage, bounce-back y walls, and no LES; shifted and unshifted storage are tested. The bodyless runner uses exactly the old config factory, changes only inlet scheme, and removes only the original cylinder. Original initialization, profile, boundary planes, outlet/all-nine damping, viscosity, timestep and health gates remain unchanged.
|
||||
|
||||
The new helper computes rho from the existing west known-population closure. At the prescribed velocity, known Kq=Fq−Feq gives Pi_xx=2(K2+K6+K8), Pi_yy=K3+K4+2(K6+K8), Pi_xy=2(K6−K8). It reconstructs all nine pre-collision populations from equilibrium plus the second-order stress projection. The existing step kernel then applies SRT exactly once. Old-buffer timing is unchanged. This preserves prescribed physical velocity moments while intentionally changing the boundary's kinetic reconstruction; it is not numerically identical to Zou–He.
|
||||
|
||||
Primary mapping: [Palabos stress construction](https://unigespc.gitlab.io/palabos/docs/html/d0/dfa/boundary_templates_8h_source.html), [velocity completion before base collision](https://unigespc.gitlab.io/palabos/docs/html/d2/d68/regularized_boundary_dynamics_8hh_source.html), [Latt–Chopard equations10–12](https://arxiv.org/pdf/physics/0506157). This independently implemented mapping does not use Celeris's differently defined existing incoming-only donor-damped `regularized` option.
|
||||
|
||||
Near-wall inlet known inputs are unchanged. This comparison includes their existing interaction; it does not silently move walls or repair initialization. An EsoPull dispatch arm is present because that function is preprocessed even in double-buffer builds; the configuration guard rejects running that streaming variant.
|
||||
|
||||
## Tests and bounded analytical check
|
||||
|
||||
```sh
|
||||
python tests/validation/experimental_full9/test_full9.py
|
||||
python tests/validation/experimental_full9/test_runner.py
|
||||
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 python tests/validation/experimental_full9/check_linear_strip.py
|
||||
python tests/validation/experimental_full9/run_bodyless.py --record-inlet-history --output /tmp/new-full9-preflight
|
||||
```
|
||||
|
||||
The host tests compile the actual helper and canonical macro/SRT headers with g++, in shifted/unshifted modes. They check equilibrium, mass/momentum, prescribed stress, ignored wrapped q1/5/7, exactly-one collision, source ordering, legacy IDs/defaults and rejected configurations. These tests do not certify CUDA compilation.
|
||||
|
||||
The fixed homogeneous linear comparison uses the original five k samples, width32, rho1, U=.0028664576821029186, tau=.524 and zero far incoming perturbation. The prior3π/4 Zou–He radius1.0186489 becomes .9802484 with full9; all five sampled full9 radii are below1. This is a finite sampled surrogate result, not a continuous stability proof or a match to the actual dominant≈2π/3 history spectrum. No parameter tuning or new flow is part of this check.
|
||||
|
||||
## Exact admitted command
|
||||
From the final clean reviewed checkout, after explicit GPU admission:
|
||||
|
||||
```sh
|
||||
CUDA_VISIBLE_DEVICES=<admitted-device> timeout --signal=TERM --kill-after=30s 240s python tests/validation/experimental_full9/run_bodyless.py --record-inlet-history --execute --gpu-admitted --wall-seconds 180 --output output/new-experimental-full9-bodyless
|
||||
```
|
||||
|
||||
The shared diagnostic still stops on its unchanged first-health/320-step/wall ceiling. Optional raw history records the fixed0..196 window only, with the existing12MiB bound and completeness receipt; evolution can continue beyond196. Use a new output directory and preserve old evidence.
|
||||
|
||||
A healthy320-step result would support only bounded robustness to this exact startup. Failure remains evidence, not a reason to tune the scheme after seeing results. Compare step-count/health, finite fields, density/Mach, inlet target moments and retained modal history; conservation and long-time/cylinder accuracy require later separate tests. No default/dev integration is authorized by this experiment.
|
||||
@@ -1,31 +0,0 @@
|
||||
"""Bounded fixed-parameter linear comparison; no nonlinear flow or GPU run."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from scipy.linalg import eigvals
|
||||
cx=np.array([0,1,-1,0,0,1,-1,1,-1]);cy=np.array([0,0,0,1,-1,1,-1,-1,1]);w=np.array([4/9]+[1/9]*4+[1/36]*4)
|
||||
U=.0028664576821029186;omega=1/.524;cu=cx*U
|
||||
base=w*(1+3*cu+4.5*cu**2-1.5*U**2)
|
||||
E=w[:,None]*((1+3*cu+4.5*cu**2-1.5*U*U)[:,None]+(3*cx+9*cu*cx-3*U)[:,None]*(cx-U)[None,:]+(3*cy+9*cu*cy)[:,None]*cy[None,:])
|
||||
C=(1-omega)*np.eye(9)+omega*E
|
||||
r=np.array([1,0,2,1,1,0,2,0,2])/(1-U);I=np.eye(9)
|
||||
B=I.copy();B[1]=I[2]+2/3*U*r;B[5]=I[6]+.5*(I[4]-I[3])+U/6*r;B[7]=I[8]+.5*(I[3]-I[4])+U/6*r
|
||||
T=np.array([[0,0,2,0,0,0,2,0,2],[0,0,0,1,1,0,2,0,2],[0,0,0,0,0,0,2,0,-2]])
|
||||
R=w[:,None]*np.array([4.5*(cx*cx-1/3),4.5*(cy*cy-1/3),9*cx*cy]).T
|
||||
Bfull=base[:,None]*r+R@T@(I-base[:,None]*r)
|
||||
assert np.max(abs(Bfull[:,[1,5,7]]))<1e-15
|
||||
assert np.max(abs(np.array([np.ones(9),cx,cy])@Bfull-np.array([r,U*r,np.zeros(9)])))<1e-14
|
||||
|
||||
def radius(n,k,b):
|
||||
A=np.zeros((9*n,9*n),complex)
|
||||
for x in range(n):
|
||||
local=C@b if x==0 else C
|
||||
for q in range(9):
|
||||
donor=x-int(cx[q])
|
||||
if 0<=donor<n:A[9*x:9*x+9,9*donor+q]+=local[:,q]*np.exp(-1j*k*cy[q])
|
||||
return float(np.max(abs(eigvals(A,check_finite=False))))
|
||||
|
||||
def main():
|
||||
rows=[dict(k_over_pi=k/np.pi,zou_he=radius(32,k,B),experimental_full9=radius(32,k,Bfull)) for k in [0,np.pi/4,np.pi/2,3*np.pi/4,np.pi]]
|
||||
print(json.dumps(dict(scope='Width32, zero incoming far perturbation, five original fixed k samples; negative samples do not prove stability',U=U,tau=.524,rows=rows),indent=2,allow_nan=False))
|
||||
if __name__=='__main__':main()
|
||||
@@ -1,58 +0,0 @@
|
||||
"""Explicit experimental full9 inlet, exact original bodyless SRT control otherwise.
|
||||
|
||||
CPU preflight by default. No acceptance claim; requires separate GPU admission.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
HERE=Path(__file__).resolve().parent
|
||||
ROOT=HERE.parents[2]
|
||||
sys.path.insert(0,str(HERE.parent/'uniform_srt_diagnostic'))
|
||||
import diagnose_uniform_srt as diagnostic
|
||||
|
||||
VARIANT='experimental_regularized_full9'
|
||||
|
||||
def configuration(source):
|
||||
if source.resolve()!=ROOT.resolve():
|
||||
raise ValueError('Experimental source must equal the executing harness checkout')
|
||||
cfg,body,p,manifest=diagnostic.source_config(source,HERE/'source_manifest.json')
|
||||
if cfg['method']['collision']!='SRT' or cfg['method']['inlet']['scheme']!='zou_he_local':
|
||||
raise ValueError('Unexpected baseline factory')
|
||||
cfg['method']['inlet']['scheme']=VARIANT
|
||||
return cfg,diagnostic.select_body(body,True),p,manifest
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--source',type=Path,default=ROOT)
|
||||
parser.add_argument('--output',type=Path,required=True)
|
||||
parser.add_argument('--record-inlet-history',action='store_true')
|
||||
parser.add_argument('--execute',action='store_true')
|
||||
parser.add_argument('--gpu-admitted',action='store_true')
|
||||
parser.add_argument('--wall-seconds',type=float)
|
||||
a=parser.parse_args();a.source=a.source.resolve()
|
||||
cfg,body,p,manifest=configuration(a.source)
|
||||
from source_provenance import checkout_provenance
|
||||
a.identity=dict(experimental_variant=VARIANT,
|
||||
observation_mode='inlet_raw_history' if a.record_inlet_history else 'first_health_pair',
|
||||
history_limit_bytes=diagnostic.HISTORY_LIMIT_BYTES if a.record_inlet_history else None,
|
||||
history_requested_steps=[0,196] if a.record_inlet_history else None,
|
||||
control='without_cylinder',force_observation='not_applicable_no_body',
|
||||
baseline_diagnostic_commit='66a27410504fef477808d6079ce79ca1ae6b903c',
|
||||
numerical_source_commit=manifest['commit'],
|
||||
diagnostic_checkout=checkout_provenance(ROOT),numerical_checkout=checkout_provenance(a.source),
|
||||
diagnostic_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
shared_diagnostic_sha256=hashlib.sha256(Path(diagnostic.__file__).read_bytes()).hexdigest())
|
||||
if a.output.exists() and any(a.output.iterdir()):raise ValueError('Use a new/empty output directory')
|
||||
if a.execute and (not a.gpu_admitted or not os.environ.get('CUDA_VISIBLE_DEVICES') or
|
||||
a.wall_seconds is None or not math.isfinite(a.wall_seconds) or a.wall_seconds<=0):
|
||||
raise ValueError('Explicit root admission, device selection and finite wall ceiling required')
|
||||
a.output.mkdir(parents=True,exist_ok=True)
|
||||
diagnostic.dump(a.output/'preflight.json',dict(**a.identity,source=manifest,config=cfg,body=body,parameters=p,
|
||||
max_steps=320,max_evidence_bytes=diagnostic.LIMIT_BYTES,executed=False))
|
||||
return diagnostic.execute(a,cfg,body,p) if a.execute else 0
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
@@ -1,113 +0,0 @@
|
||||
"""Uniform experimental full9 DFG pilot; CPU preflight unless explicitly admitted."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[2]
|
||||
sys.path.insert(0, str(HERE.parent / 'uniform_srt_diagnostic'))
|
||||
import diagnose_uniform_srt as diagnostic
|
||||
|
||||
VARIANT = 'experimental_regularized_full9'
|
||||
OBSERVATION = 'uniform_sparse_pilot_v1'
|
||||
|
||||
|
||||
def configuration(source):
|
||||
if source.resolve() != ROOT.resolve():
|
||||
raise ValueError('Source must equal executing harness checkout')
|
||||
cfg, body, p, manifest = diagnostic.source_config(source, HERE / 'dfg_source_manifest.json')
|
||||
cfg['method']['inlet']['scheme'] = VARIANT
|
||||
return cfg, body, p, manifest
|
||||
|
||||
|
||||
def accuracy_pass(result):
|
||||
"""Standard later qualification predicate; pilot never qualifies accuracy."""
|
||||
return (result.get('healthy') is True and result.get('periodic') is True
|
||||
and result.get('reference_comparison', {}).get('reference_agreement') is True)
|
||||
|
||||
|
||||
def identity(manifest):
|
||||
return dict(experimental_variant=VARIANT, collision='SRT', control='with_cylinder',
|
||||
observation_mode=OBSERVATION, numerical_source_commit=manifest['commit'],
|
||||
manifest_sha256=hashlib.sha256((HERE / 'dfg_source_manifest.json').read_bytes()).hexdigest())
|
||||
|
||||
|
||||
def verify_pilot(result, expected):
|
||||
import run_refined_dfg as runner
|
||||
runner.ensure_finite(result)
|
||||
if any(result.get(k) != v for k, v in expected.items()):
|
||||
raise ValueError('Pilot identity mismatch')
|
||||
if (result.get('case') != 'uniform' or result.get('mode') != 'pilot'
|
||||
or result.get('status') != 'pilot_complete' or result.get('healthy') is not True
|
||||
or result.get('steps') != 40000 or result.get('executed_steps') != 40000):
|
||||
raise ValueError('Incomplete or mislabeled pilot')
|
||||
timings = result.get('calibration_batches_seconds', [])
|
||||
if len(timings) != 5 or any(isinstance(v, bool) or not isinstance(v, (int, float)) or v <= 0 for v in timings):
|
||||
raise ValueError('Missing positive calibration timings')
|
||||
rows = result.get('history', [])
|
||||
if len(rows) != 10:
|
||||
raise ValueError('Incomplete sparse pilot history')
|
||||
for i, row in enumerate(rows, 1):
|
||||
for key in ('cd', 'cl', 'mass', 'flux_in', 'flux_out', 'dp'):
|
||||
if isinstance(row.get(key), bool) or not isinstance(row.get(key), (int, float)):
|
||||
raise ValueError('Missing numeric pilot observation')
|
||||
if (row.get('step') != i * 4000 or row.get('tstar') != i * .5
|
||||
or row.get('tstar_force') != i * .5 - .25
|
||||
or not .5 <= row.get('rho_min', 0) <= row.get('rho_max', 2) <= 1.5
|
||||
or not 0 <= row.get('max_mach', 1) <= .2):
|
||||
raise ValueError('Invalid sparse pilot observation')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--source', type=Path, default=ROOT)
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
parser.add_argument('--execute', action='store_true')
|
||||
parser.add_argument('--gpu-admitted', action='store_true')
|
||||
parser.add_argument('--wall-seconds', type=float, default=180.)
|
||||
a = parser.parse_args()
|
||||
a.source = a.source.resolve()
|
||||
cfg, body, p, manifest = configuration(a.source)
|
||||
import run_refined_dfg as runner
|
||||
from source_provenance import checkout_provenance
|
||||
h, periodic = runner.helpers(a.source)
|
||||
if not math.isfinite(a.wall_seconds) or a.wall_seconds <= 0:
|
||||
raise ValueError('Finite positive wall ceiling required')
|
||||
if a.execute and (not a.gpu_admitted or not os.environ.get('CUDA_VISIBLE_DEVICES')):
|
||||
raise ValueError('Explicit GPU admission and device selection required')
|
||||
if a.output.exists() and any(a.output.iterdir()):
|
||||
raise ValueError('Use new/empty output directory')
|
||||
a.output.mkdir(parents=True, exist_ok=True)
|
||||
receipt = identity(manifest)
|
||||
receipt['checkout'] = checkout_provenance(ROOT)
|
||||
diagnostic.dump(a.output / 'preflight.json', dict(**receipt, config=cfg, body=body,
|
||||
parameters=p, source=manifest, max_steps=40000, cap_tstar=5.,
|
||||
observation_width_tstar=.5, force_batch_steps=100,
|
||||
calibration='First 100 steps warmup; next 400 timed, counted in physical trajectory',
|
||||
gates=periodic.GATES, reference_gates=periodic.REFERENCE_GATES,
|
||||
wall_seconds=a.wall_seconds, executed=False, accuracy_qualified=False))
|
||||
if not a.execute:
|
||||
return 0
|
||||
a.mode = 'pilot'
|
||||
a.deadline = time.monotonic() + a.wall_seconds
|
||||
progress = {}
|
||||
try:
|
||||
result = runner.run_case(a, h, periodic, 'uniform', inlet_scheme=VARIANT,
|
||||
sparse_pilot=True, receipt_identity=receipt, progress=progress)
|
||||
except TimeoutError:
|
||||
diagnostic.dump(a.output / 'result.json', dict(**receipt, status='wall_time_limit',
|
||||
healthy=False, accuracy_qualified=False, **progress))
|
||||
return 3
|
||||
if result['healthy']:
|
||||
verify_pilot(result, receipt)
|
||||
diagnostic.dump(a.output / 'result.json', dict(**result, accuracy_qualified=False))
|
||||
return 0 if result['healthy'] else 2
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -1,115 +0,0 @@
|
||||
"""Separately admitted full uniform DFG qualification after the pinned sparse pilot."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
import run_dfg as pilot
|
||||
|
||||
OBSERVATION = 'uniform_standard_full_v1'
|
||||
|
||||
|
||||
def configuration(source):
|
||||
cfg, body, parameters, pilot_manifest = pilot.configuration(source)
|
||||
_, _, _, full_manifest = pilot.diagnostic.source_config(source, HERE / 'dfg_full_source_manifest.json')
|
||||
return cfg, body, parameters, pilot_manifest, full_manifest
|
||||
|
||||
|
||||
def admit_pilot(path, cfg, body, parameters, manifest):
|
||||
import run_refined_dfg as runner
|
||||
expected = pilot.identity(manifest)
|
||||
result = runner.read_finite_json(path / 'result.json')
|
||||
preflight = runner.read_finite_json(path / 'preflight.json')
|
||||
ready = runner.read_finite_json(path / 'uniform/ready.json')
|
||||
pilot.verify_pilot(result, expected)
|
||||
for name, document in [('preflight', preflight), ('ready', ready)]:
|
||||
if any(document.get(k) != v for k, v in expected.items()):
|
||||
raise ValueError('Pilot ' + name + ' identity mismatch')
|
||||
if (preflight.get('config') != cfg or preflight.get('body') != body
|
||||
or preflight.get('parameters') != parameters or preflight.get('source') != manifest
|
||||
or preflight.get('max_steps') != 40000 or preflight.get('cap_tstar') != 5.
|
||||
or preflight.get('observation_width_tstar') != .5
|
||||
or preflight.get('gates') != runner.helpers(pilot.ROOT)[1].GATES
|
||||
or preflight.get('reference_gates') != runner.helpers(pilot.ROOT)[1].REFERENCE_GATES):
|
||||
raise ValueError('Pilot physical plan or gates mismatch')
|
||||
if (ready.get('case') != 'uniform' or ready.get('mode') != 'pilot'
|
||||
or ready.get('step') != 4000 or ready.get('tstar') != .5
|
||||
or ready.get('runner_sha256') != runner.runner_hash()
|
||||
or ready.get('all_runtime_and_observation_paths_exercised') is not True
|
||||
or ready.get('calibration_batches_seconds') != result.get('calibration_batches_seconds')):
|
||||
raise ValueError('Pilot READY mismatch')
|
||||
checkout = result.get('checkout')
|
||||
if (checkout != preflight.get('checkout') or checkout != ready.get('checkout')
|
||||
or not isinstance(checkout, dict) or checkout.get('checkout_dirty') is not False
|
||||
or checkout.get('checkout_revision') != 'c0be841b2e6e1ccba3b8343cecd3cee2fe0ed14f'):
|
||||
raise ValueError('Pilot checkout provenance mismatch')
|
||||
for key in ['startup_seconds', 'step_seconds', 'wall_seconds']:
|
||||
value = result.get(key)
|
||||
if isinstance(value, bool) or not isinstance(value, (float, int)) or value <= 0:
|
||||
raise ValueError('Invalid pilot timing')
|
||||
return dict(result_sha256=hashlib.sha256((path/'result.json').read_bytes()).hexdigest(),
|
||||
checkout=checkout, startup_seconds=result['startup_seconds'],
|
||||
step_seconds=result['step_seconds'], wall_seconds=result['wall_seconds'],
|
||||
step_force_scaled_seconds=60 * result['step_seconds'])
|
||||
|
||||
|
||||
def qualified(result):
|
||||
return (result.get('mode') == 'full' and result.get('case') == 'uniform'
|
||||
and result.get('steps') == 2400000 and result.get('executed_steps') == 2400000
|
||||
and pilot.accuracy_pass(result))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--source', type=Path, default=pilot.ROOT)
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
parser.add_argument('--pilot-results', type=Path, required=True)
|
||||
parser.add_argument('--execute', action='store_true')
|
||||
parser.add_argument('--gpu-admitted', action='store_true')
|
||||
parser.add_argument('--wall-seconds', type=float, default=720.)
|
||||
a = parser.parse_args(); a.source = a.source.resolve()
|
||||
cfg, body, parameters, pilot_manifest, full_manifest = configuration(a.source)
|
||||
import run_refined_dfg as runner
|
||||
from source_provenance import checkout_provenance
|
||||
h, periodic = runner.helpers(a.source)
|
||||
admission = admit_pilot(a.pilot_results, cfg, body, parameters, pilot_manifest)
|
||||
if not math.isfinite(a.wall_seconds) or not 0 < a.wall_seconds <= 720:
|
||||
raise ValueError('Full-run internal ceiling must be positive and at most720 seconds')
|
||||
if a.execute and (not a.gpu_admitted or not os.environ.get('CUDA_VISIBLE_DEVICES')):
|
||||
raise ValueError('Explicit GPU admission and device selection required')
|
||||
if a.output.exists() and any(a.output.iterdir()):
|
||||
raise ValueError('Use new/empty output directory')
|
||||
a.output.mkdir(parents=True, exist_ok=True)
|
||||
receipt = dict(experimental_variant=pilot.VARIANT, collision='SRT', control='with_cylinder',
|
||||
observation_mode=OBSERVATION, numerical_source_commit=full_manifest['commit'],
|
||||
manifest_sha256=hashlib.sha256((HERE/'dfg_full_source_manifest.json').read_bytes()).hexdigest(),
|
||||
checkout=checkout_provenance(pilot.ROOT), pilot_admission=admission)
|
||||
pilot.diagnostic.dump(a.output/'preflight.json', dict(**receipt, config=cfg, body=body,
|
||||
parameters=parameters, source=full_manifest, max_steps=2400000, cap_tstar=300.,
|
||||
observations='t*=.5 before240; .02 thereafter; forces at interval midpoint, pressure at endpoint',
|
||||
initialization='Original production initialization; restart, no pilot continuation',
|
||||
gates=periodic.GATES, reference_gates=periodic.REFERENCE_GATES,
|
||||
wall_seconds=a.wall_seconds, executed=False, accuracy_qualified=False))
|
||||
if not a.execute: return 0
|
||||
a.mode = 'full'; a.deadline = time.monotonic() + a.wall_seconds
|
||||
progress = {}
|
||||
try:
|
||||
result = runner.run_case(a, h, periodic, 'uniform', inlet_scheme=pilot.VARIANT,
|
||||
sparse_pilot=True, receipt_identity=receipt, progress=progress)
|
||||
except TimeoutError:
|
||||
pilot.diagnostic.dump(a.output/'result.json', dict(**receipt, status='wall_time_limit',
|
||||
healthy=False, accuracy_qualified=False, **progress))
|
||||
return 3
|
||||
passed = qualified(result)
|
||||
pilot.diagnostic.dump(a.output/'result.json', dict(**result, accuracy_qualified=passed))
|
||||
return 0 if passed else 2
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -1,153 +0,0 @@
|
||||
"""Focused host checks for the uniform experimental DFG pilot."""
|
||||
from copy import deepcopy
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import run_dfg as r
|
||||
|
||||
|
||||
def test_factory_and_default():
|
||||
cfg, body, p, manifest = r.configuration(r.ROOT)
|
||||
r.configuration(r.ROOT)
|
||||
import run_refined_dfg as runner
|
||||
h, _ = runner.helpers(r.ROOT)
|
||||
old, old_body, old_p = runner.config(h, 80)
|
||||
assert old['method']['inlet']['scheme'] == 'zou_he_local'
|
||||
old['method']['inlet']['scheme'] = r.VARIANT
|
||||
assert cfg == old and body == old_body and p == old_p
|
||||
assert len(body['objects']) == 1 and p['diameter'] == 80
|
||||
assert cfg == runner.config(h, 80, r.VARIANT)[0]
|
||||
assert manifest['commit'] == '926ca27407c26ca11283282a6e53249925725376'
|
||||
|
||||
|
||||
def receipt():
|
||||
_, _, _, m = r.configuration(r.ROOT)
|
||||
expected = r.identity(m)
|
||||
rows = [dict(step=i*4000, tstar=i*.5, tstar_force=i*.5-.25,
|
||||
rho_min=.99, rho_max=1.01, max_mach=.03,
|
||||
cd=3., cl=.1, dp=2., mass=.8, flux_in=.41, flux_out=.41)
|
||||
for i in range(1, 11)]
|
||||
return expected, dict(**expected, case='uniform', mode='pilot', status='pilot_complete',
|
||||
healthy=True, steps=40000, executed_steps=40000, history=rows,
|
||||
calibration_batches_seconds=[.001]*5)
|
||||
|
||||
|
||||
def test_receipts():
|
||||
expected, result = receipt()
|
||||
r.verify_pilot(result, expected)
|
||||
for key, value in [('collision', 'MRT'), ('experimental_variant', 'zou_he_local'),
|
||||
('observation_mode', 'first_health_pair'), ('control', 'without_cylinder'),
|
||||
('numerical_source_commit', 'other'), ('case', 'refined'),
|
||||
('steps', 39999), ('executed_steps', 39999), ('healthy', False)]:
|
||||
bad = deepcopy(result); bad[key] = value
|
||||
with unittest.TestCase().assertRaises(ValueError): r.verify_pilot(bad, expected)
|
||||
for change in ('nan', 'missing', 'time', 'count'):
|
||||
bad = deepcopy(result)
|
||||
if change == 'nan': bad['history'][-1]['tstar'] = float('nan')
|
||||
if change == 'missing': del bad['history'][0]['cd']
|
||||
if change == 'time': bad['history'][0]['tstar_force'] = .5
|
||||
if change == 'count': bad['history'].pop()
|
||||
with unittest.TestCase().assertRaises(ValueError): r.verify_pilot(bad, expected)
|
||||
|
||||
|
||||
def test_accuracy_requires_reference():
|
||||
assert not r.accuracy_pass(dict(healthy=True, periodic=True))
|
||||
assert r.accuracy_pass(dict(healthy=True, periodic=True,
|
||||
reference_comparison=dict(reference_agreement=True)))
|
||||
assert not r.accuracy_pass(dict(healthy=True, periodic=False,
|
||||
reference_comparison=dict(reference_agreement=True)))
|
||||
|
||||
|
||||
def test_preflight_and_source_guards():
|
||||
with TemporaryDirectory() as temp:
|
||||
subprocess.run([sys.executable, str(Path(r.__file__)), '--output', temp], check=True)
|
||||
out = json.loads((Path(temp)/'preflight.json').read_text())
|
||||
assert out['max_steps'] == 40000 and out['observation_width_tstar'] == .5
|
||||
assert not out['executed'] and not out['accuracy_qualified']
|
||||
assert out['wall_seconds'] == 180 and out['control'] == 'with_cylinder'
|
||||
with unittest.TestCase().assertRaises(ValueError): r.configuration(r.ROOT.parent)
|
||||
with unittest.TestCase().assertRaisesRegex(ValueError, 'Pinned source mismatch'):
|
||||
r.diagnostic.source_config(r.ROOT, Path(r.__file__).with_name('source_manifest.json'))
|
||||
|
||||
|
||||
def test_shared_source_semantics():
|
||||
r.configuration(r.ROOT)
|
||||
import run_refined_dfg as runner
|
||||
text = Path(runner.__file__).read_text()
|
||||
assert 'sparse_pilot=False' in text
|
||||
assert '(a.mode == "pilot" and not sparse_pilot)' in text
|
||||
assert 'impulse += sim.read_force(0).astype(np.float64) * batch' in text
|
||||
assert 'tstar_force=(begin + n / 2) * SPEED / p["diameter"]' in text
|
||||
assert 'cd=float(impulse[0] / n / den)' in text
|
||||
assert 'mass = float(rho[fluid].sum()) * p["dx"] ** 2' in text
|
||||
assert 'executed_steps += batch' in text
|
||||
assert 'First unhealthy observation; onset between observations is unknown' in text
|
||||
|
||||
|
||||
def test_accounted_calibration_and_failure_progress():
|
||||
import numpy as np
|
||||
import types
|
||||
from unittest.mock import patch
|
||||
r.configuration(r.ROOT)
|
||||
import run_refined_dfg as runner
|
||||
h, periodic = runner.helpers(r.ROOT)
|
||||
_, _, p = runner.config(h, 80)
|
||||
count = [0]
|
||||
fail = [False]
|
||||
class Simulation:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.field = types.SimpleNamespace(flag=np.ones(p['nx']*p['ny'], np.uint8))
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *args): pass
|
||||
def initialize(self): count[0] = 0
|
||||
def run(self, n): count[0] += n
|
||||
def read_force(self, body): return np.array([.004, .002])
|
||||
def get_macroscopic(self):
|
||||
shape = (p['ny'], p['nx'])
|
||||
return dict(rho=np.full(shape, float('nan') if fail[0] else 1.),
|
||||
ux=np.full(shape, .01), uy=np.zeros(shape))
|
||||
modules = {
|
||||
'CelerisLab.simulation': types.SimpleNamespace(Simulation=Simulation),
|
||||
'CelerisLab.cuda.context': types.SimpleNamespace(CudaContext=object),
|
||||
'CelerisLab.lbm.refinement_stepper': types.SimpleNamespace(FixedPatchStepper=object),
|
||||
}
|
||||
with patch.dict(sys.modules, modules), patch.object(h, 'pressure_probes', return_value=dict(dp=2.)):
|
||||
with TemporaryDirectory() as temp:
|
||||
args = types.SimpleNamespace(source=r.ROOT, output=Path(temp), mode='pilot', deadline=float('inf'))
|
||||
progress = {}
|
||||
result = runner.run_case(args, h, periodic, 'uniform', inlet_scheme=r.VARIANT,
|
||||
sparse_pilot=True, progress=progress)
|
||||
assert count[0] == 40000 and result['executed_steps'] == 40000
|
||||
assert len(result['history']) == 10 and len(result['calibration_batches_seconds']) == 5
|
||||
assert abs(result['history'][0]['cd'] - 1.) < 1e-14
|
||||
assert result['history'][0]['tstar_force'] == .25
|
||||
fail[0] = True
|
||||
with TemporaryDirectory() as temp:
|
||||
args.output = Path(temp)
|
||||
result = runner.run_case(args, h, periodic, 'uniform', inlet_scheme=r.VARIANT,
|
||||
sparse_pilot=True, progress=progress)
|
||||
assert result['executed_steps'] == 4000 and result['steps'] == 0
|
||||
assert result['first_observed_failure_step'] == 4000 and result['last_observed_step'] == 0
|
||||
fail[0] = False
|
||||
with TemporaryDirectory() as temp:
|
||||
args.output = Path(temp)
|
||||
def deadline(_):
|
||||
if count[0] >= 500: raise TimeoutError('test deadline')
|
||||
count[0] = 0
|
||||
with patch.object(runner, 'check_deadline', side_effect=deadline):
|
||||
with unittest.TestCase().assertRaises(TimeoutError):
|
||||
runner.run_case(args, h, periodic, 'uniform', inlet_scheme=r.VARIANT,
|
||||
sparse_pilot=True, progress=progress)
|
||||
assert progress['executed_steps'] == 500 and progress['last_observed_step'] == 0
|
||||
assert len(progress['calibration_batches_seconds']) == 5
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
result = unittest.TextTestRunner(verbosity=2).run(unittest.TestSuite(
|
||||
unittest.FunctionTestCase(fn) for name, fn in sorted(globals().items()) if name.startswith('test_')))
|
||||
raise SystemExit(not result.wasSuccessful())
|
||||
@@ -1,97 +0,0 @@
|
||||
"""CPU-only full admission and unchanged observer tests; fixture receipts are synthetic."""
|
||||
from copy import deepcopy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import run_dfg_full as f
|
||||
import test_dfg as pilot_tests
|
||||
|
||||
|
||||
def fixture(path):
|
||||
cfg, body, parameters, manifest, _ = f.configuration(f.pilot.ROOT)
|
||||
import run_refined_dfg as runner
|
||||
_, periodic = runner.helpers(f.pilot.ROOT)
|
||||
expected, result = pilot_tests.receipt()
|
||||
checkout = dict(checkout_revision='c0be841b2e6e1ccba3b8343cecd3cee2fe0ed14f', checkout_dirty=False)
|
||||
result.update(checkout=checkout, startup_seconds=3.3353, step_seconds=3.6794, wall_seconds=8.)
|
||||
preflight = dict(**expected, checkout=checkout, config=cfg, body=body, parameters=parameters,
|
||||
source=manifest, max_steps=40000, cap_tstar=5., observation_width_tstar=.5,
|
||||
gates=periodic.GATES, reference_gates=periodic.REFERENCE_GATES)
|
||||
ready = dict(**expected, checkout=checkout, case='uniform', mode='pilot', step=4000,
|
||||
tstar=.5, runner_sha256=runner.runner_hash(),
|
||||
all_runtime_and_observation_paths_exercised=True,
|
||||
calibration_batches_seconds=result['calibration_batches_seconds'])
|
||||
(path/'uniform').mkdir(exist_ok=True)
|
||||
for name, data in [('result.json', result), ('preflight.json', preflight), ('uniform/ready.json', ready)]:
|
||||
(path/name).write_text(json.dumps(data))
|
||||
return cfg, body, parameters, manifest
|
||||
|
||||
|
||||
def test_admission_rejects_mismatches():
|
||||
with TemporaryDirectory() as temp:
|
||||
path = Path(temp); args = fixture(path)
|
||||
receipt = f.admit_pilot(path, *args)
|
||||
assert abs(receipt['step_force_scaled_seconds'] - 220.764) < 1e-10
|
||||
cases = [('result.json', 'collision', 'MRT'),
|
||||
('result.json', 'step_seconds', float('nan')),
|
||||
('result.json', 'healthy', False),
|
||||
('uniform/ready.json', 'step', 160),
|
||||
('uniform/ready.json', 'runner_sha256', 'wrong'),
|
||||
('preflight.json', 'body', {'objects': []}),
|
||||
('preflight.json', 'gates', {}),
|
||||
('preflight.json', 'source', {})]
|
||||
for filename, key, value in cases:
|
||||
fixture(path)
|
||||
target = path/filename; data=json.loads(target.read_text()); data[key]=value
|
||||
target.write_text(json.dumps(data))
|
||||
with unittest.TestCase().assertRaises(ValueError): f.admit_pilot(path, *args)
|
||||
fixture(path); (path/'uniform/ready.json').unlink()
|
||||
with unittest.TestCase().assertRaises(FileNotFoundError): f.admit_pilot(path, *args)
|
||||
|
||||
|
||||
def test_full_requires_health_periodicity_and_reference():
|
||||
good = dict(case='uniform', mode='full', steps=2400000, executed_steps=2400000,
|
||||
healthy=True, periodic=True, reference_comparison=dict(reference_agreement=True))
|
||||
assert f.qualified(good)
|
||||
for key, value in [('healthy', False), ('periodic', False), ('steps', 40000),
|
||||
('executed_steps', 2399900), ('mode', 'pilot'), ('case', 'refined'),
|
||||
('reference_comparison', {}),
|
||||
('reference_comparison', dict(reference_agreement=False))]:
|
||||
bad=deepcopy(good); bad[key]=value; assert not f.qualified(bad)
|
||||
|
||||
|
||||
def test_preflight_preserves_physics_and_gates():
|
||||
with TemporaryDirectory() as temp:
|
||||
root=Path(temp); incoming=root/'pilot'; incoming.mkdir(); cfg, body, params, _=fixture(incoming)
|
||||
output=root/'full'
|
||||
subprocess.run([sys.executable, str(Path(f.__file__)), '--pilot-results', str(incoming),
|
||||
'--output', str(output)], check=True)
|
||||
data=json.loads((output/'preflight.json').read_text())
|
||||
assert data['config']==cfg and data['body']==body and data['parameters']==params
|
||||
assert data['max_steps']==2400000 and data['cap_tstar']==300 and data['wall_seconds']==720
|
||||
assert not data['accuracy_qualified'] and not data['executed']
|
||||
assert data['numerical_source_commit']=='926ca27407c26ca11283282a6e53249925725376'
|
||||
assert 'restart' in data['initialization']
|
||||
|
||||
|
||||
def test_old_files_and_late_observer_unchanged():
|
||||
root=f.pilot.ROOT
|
||||
for name in ['tests/validation/run_refined_dfg.py',
|
||||
'tests/validation/experimental_full9/run_dfg.py',
|
||||
'tests/validation/experimental_full9/dfg_source_manifest.json',
|
||||
'tests/validation/periodic.py']:
|
||||
old=subprocess.check_output(['git','-C',str(root),'show','c0be841:'+name])
|
||||
assert old==(root/name).read_bytes()
|
||||
source=(root/'tests/validation/run_refined_dfg.py').read_text()
|
||||
assert 'begin * SPEED / p["diameter"] >= 240' in source
|
||||
assert 'cap = 5.0 if a.mode == "pilot" else 300.0' in source
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
result=unittest.TextTestRunner(verbosity=2).run(unittest.TestSuite(
|
||||
unittest.FunctionTestCase(fn) for name,fn in sorted(globals().items()) if name.startswith('test_')))
|
||||
raise SystemExit(not result.wasSuccessful())
|
||||
@@ -1,105 +0,0 @@
|
||||
"""Host-compile the actual numerical helper; no CUDA or flow evolution."""
|
||||
import ctypes
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
import numpy as np
|
||||
ROOT=Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0,str(ROOT/'src'))
|
||||
from CelerisLab.config import LBMConfig, INLET_SCHEME_MAP
|
||||
cx=np.array([0,1,-1,0,0,1,-1,1,-1]);cy=np.array([0,0,0,1,-1,1,-1,-1,1]);w=np.array([4/9]+[1/9]*4+[1/36]*4)
|
||||
M=np.array([np.ones(9),cx,cy]);Q=np.array([cx*cx,cy*cy,cx*cy])
|
||||
TEMP=TemporaryDirectory();libs=[]
|
||||
for shifted in [0,1]:
|
||||
code=f'''
|
||||
#include <cmath>
|
||||
#define __device__
|
||||
#define __forceinline__ inline
|
||||
#define __fdiv_rn(a,b) ((a)/(b))
|
||||
#define LATTICE_D2Q9 0
|
||||
#define LATTICE_MODEL 0
|
||||
#define NQ 9
|
||||
#define CS2 (1.0f/3.0f)
|
||||
#define NY 330
|
||||
#define U0 .01f
|
||||
#define W0_VAL (4.0f/9.0f)
|
||||
#define WS_VAL (1.0f/9.0f)
|
||||
#define WE_VAL (1.0f/36.0f)
|
||||
#define USE_DDF_SHIFTING {shifted}
|
||||
#define INLET_SCHEME 4
|
||||
#include "operators/macro.cuh"
|
||||
#include "operators/collision_srt.cuh"
|
||||
#include "boundary/inlet/common.cuh"
|
||||
#include "boundary/inlet/experimental_full9.cuh"
|
||||
extern "C" int apply(float* f,float u,float v,int collision) {{
|
||||
apply_experimental_full9_inlet_d2q9(f,u,v);
|
||||
int count=0;
|
||||
if (collision && inlet_scheme_uses_post_collision_ghost()) {{
|
||||
float rho,ux,uy,eq[9]; compute_rho_u(f,rho,ux,uy); compute_feq(rho,ux,uy,eq);
|
||||
collide_srt_no_force(f,eq,1.0f/.524f); ++count;
|
||||
}}
|
||||
return count;
|
||||
}}
|
||||
'''
|
||||
src=Path(TEMP.name)/f'test{shifted}.cpp';src.write_text(code);lib=src.with_suffix('.so')
|
||||
subprocess.run(['g++','-std=c++11','-shared','-fPIC','-O2','-ffp-contract=off','-I',str(ROOT/'src/CelerisLab/lbm/kernels'),str(src),'-o',str(lib)],check=True)
|
||||
dll=ctypes.CDLL(str(lib));dll.apply.argtypes=[ctypes.POINTER(ctypes.c_float),ctypes.c_float,ctypes.c_float,ctypes.c_int];dll.apply.restype=ctypes.c_int;libs.append(dll)
|
||||
|
||||
def eq(rho,u,v):
|
||||
cu=cx*u+cy*v;return w*rho*(1+3*cu+4.5*cu*cu-1.5*(u*u+v*v))
|
||||
def reference(f,u,v):
|
||||
rho=(f[0]+f[3]+f[4]+2*(f[2]+f[6]+f[8]))/(1-u);e=eq(rho,u,v);k=f-e
|
||||
pi=np.array([2*(k[2]+k[6]+k[8]),k[3]+k[4]+2*(k[6]+k[8]),2*(k[6]-k[8])])
|
||||
stress=w*(4.5*((cx*cx-1/3)*pi[0]+(cy*cy-1/3)*pi[1])+9*cx*cy*pi[2])
|
||||
return e,stress,pi
|
||||
|
||||
def apply(f,u,v,shifted,collision=0):
|
||||
raw=np.asarray(f-shifted*w,dtype=np.float32);count=libs[shifted].apply(raw.ctypes.data_as(ctypes.POINTER(ctypes.c_float)),u,v,collision)
|
||||
return raw.astype(float)+shifted*w,count
|
||||
|
||||
def test_moments_stress_and_shift():
|
||||
rng=np.random.default_rng(92)
|
||||
for _ in range(20):
|
||||
f=eq(1.02,.003,0)+rng.normal(0,.001,9);e,s,pi=reference(f,.003,.001)
|
||||
outputs=[]
|
||||
for shifted in [0,1]:
|
||||
out,count=apply(f,.003,.001,shifted);outputs.append(out)
|
||||
assert count==0
|
||||
np.testing.assert_allclose(out,e+s,atol=2e-7,rtol=0)
|
||||
np.testing.assert_allclose(M@out,M@e,atol=3e-7,rtol=0)
|
||||
np.testing.assert_allclose(Q@(out-e),pi,atol=2e-7,rtol=0)
|
||||
np.testing.assert_allclose(*outputs,atol=2e-7,rtol=0)
|
||||
|
||||
def test_unknown_inputs_ignored_and_equilibrium():
|
||||
for shifted in [0,1]:
|
||||
f=eq(1.01,.004,0);out,_=apply(f,.004,0,shifted)
|
||||
np.testing.assert_allclose(out,f,atol=2e-7,rtol=0)
|
||||
f[[1,5,7]]=np.nan;ignored,_=apply(f,.004,0,shifted)
|
||||
assert np.isfinite(ignored).all()
|
||||
np.testing.assert_array_equal(out,ignored)
|
||||
|
||||
def test_exactly_once_srt():
|
||||
f=eq(1,.003,0)+np.array([.001,0,.002,.003,-.001,0,.002,0,-.002]);e,s,_=reference(f,.003,0)
|
||||
for shifted in [0,1]:
|
||||
out,count=apply(f,.003,0,shifted,1)
|
||||
assert count==1
|
||||
np.testing.assert_allclose(out,e+(1-1/.524)*s,atol=3e-7,rtol=0)
|
||||
assert np.max(abs(out-(e+(1-1/.524)**2*s)))>1e-4
|
||||
source=(ROOT/'src/CelerisLab/lbm/kernels/step/one_step_double.cu').read_text()
|
||||
part=source.split('#if DIM == 2',2)[-1].split('#elif DIM == 3')[0]
|
||||
assert part.index('apply_boundary_pull(f,')<part.index('collide_dispatch(f,')<part.index('stream_pull_store(k,')
|
||||
assert part.count('collide_dispatch(f,')==1
|
||||
|
||||
def test_config_guard_and_legacy_ids():
|
||||
assert {k:INLET_SCHEME_MAP[k] for k in ['zou_he_local','channel_stabilized','equilibrium','regularized']}==dict(zou_he_local=0,channel_stabilized=1,equilibrium=2,regularized=3)
|
||||
assert LBMConfig().inlet_scheme=='zou_he_local'
|
||||
for shifted in [False,True]:
|
||||
c=LBMConfig(inlet_scheme='experimental_regularized_full9',ddf_shifting=shifted);c.validate();assert c.to_macros()['INLET_SCHEME']==4
|
||||
for change in [dict(collision='MRT'),dict(collision='TRT'),dict(streaming='esopull'),dict(store_precision='FP16S'),dict(les_enabled=True),dict(y_wall_bc='free_slip'),dict(lattice_model='D3Q19')]:
|
||||
with unittest.TestCase().assertRaises(ValueError):LBMConfig(inlet_scheme='experimental_regularized_full9',**change).validate()
|
||||
|
||||
if __name__=='__main__':
|
||||
result=unittest.TextTestRunner(verbosity=2).run(unittest.TestSuite(unittest.FunctionTestCase(fn) for name,fn in sorted(globals().items()) if name.startswith('test_')))
|
||||
TEMP.cleanup();raise SystemExit(not result.wasSuccessful())
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Experimental provenance and removal-only config checks, CPU only."""
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parent))
|
||||
import run_bodyless as r
|
||||
|
||||
def test_config_only_boundary_changes():
|
||||
cfg,body,p,manifest=r.configuration(r.ROOT)
|
||||
import run_refined_dfg as runner
|
||||
h,_=runner.helpers(r.ROOT);baseline,baseline_body,baseline_p=runner.config(h,80)
|
||||
expected=deepcopy(baseline);expected['method']['inlet']['scheme']=r.VARIANT
|
||||
assert cfg==expected and p==baseline_p
|
||||
expected_body=deepcopy(baseline_body);expected_body['objects']=[]
|
||||
assert body==expected_body
|
||||
assert manifest['commit']=='926ca27407c26ca11283282a6e53249925725376'
|
||||
assert cfg['method']['collision']=='SRT' and cfg['method']['outlet']['srt_neq_damp']==.5
|
||||
|
||||
def test_historical_manifest_rejects_candidate():
|
||||
with unittest.TestCase().assertRaisesRegex(ValueError,'Pinned source mismatch'):
|
||||
r.diagnostic.source_config(r.ROOT)
|
||||
|
||||
def test_different_runtime_checkout_rejected():
|
||||
with unittest.TestCase().assertRaisesRegex(ValueError,'executing harness checkout'):
|
||||
r.configuration(r.ROOT.parent)
|
||||
|
||||
def test_experimental_preflight():
|
||||
import subprocess,json
|
||||
from tempfile import TemporaryDirectory
|
||||
with TemporaryDirectory() as temp:
|
||||
subprocess.run([sys.executable,str(Path(r.__file__)),'--output',temp,'--record-inlet-history'],check=True,capture_output=True)
|
||||
result=json.loads((Path(temp)/'preflight.json').read_text())
|
||||
assert result['experimental_variant']==r.VARIANT
|
||||
assert result['history_requested_steps']==[0,196] and result['max_steps']==320
|
||||
assert not result['executed']
|
||||
assert result['numerical_source_commit']=='926ca27407c26ca11283282a6e53249925725376'
|
||||
|
||||
if __name__=='__main__':
|
||||
result=unittest.TextTestRunner(verbosity=2).run(unittest.TestSuite(unittest.FunctionTestCase(fn) for name,fn in sorted(globals().items()) if name.startswith('test_')))
|
||||
raise SystemExit(not result.wasSuccessful())
|
||||
@@ -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()
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Optional observation-window exchange ledger: host contract (mocked driver) and GPU gate.
|
||||
|
||||
Run CPU and GPU classes in separate processes; GPU callers select the device.
|
||||
ExchangeWindowGPU uses the SRT/unshifted Jet fixture; ExchangeWindowShiftedMRTGPU covers a
|
||||
shifted MRT, all-blocked, stationary-body case without Jets (no shifted-MRT Jet claim).
|
||||
GPU tolerance (conservative engineering tolerance, not a universal proof): the window ledger
|
||||
sums h*dt*raw per link over fine substeps, while the reference sums per-base interval ledgers
|
||||
over links and then over base steps. Both are FP64 reorderings of the same FP32-exact substep
|
||||
contributions, so the scale is the sum of the ACTUAL absolute substep contributions (read from
|
||||
last_raw after every fine hop), not of per-base totals that could hide substep cancellation:
|
||||
|window - reference| <= 4 * m * eps * sum|substep terms| + 1e-300, m = links * substeps.
|
||||
"""
|
||||
from contextlib import ExitStack
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import pycuda.driver as cuda
|
||||
|
||||
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
|
||||
from test_refinement_blocked_base import mocked_backend
|
||||
from test_refinement_blocked_fine import BOUNDS, circle, config
|
||||
|
||||
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()],
|
||||
observe_raw_exchange=raw, observe_window_exchange=window)
|
||||
|
||||
|
||||
def same_bits(test, a, b):
|
||||
"""Identical NaN masks, no infinities, identical finite FP32 bits (incl. signed zero)."""
|
||||
a, b = np.asarray(a), np.asarray(b)
|
||||
test.assertEqual(a.dtype, b.dtype)
|
||||
test.assertFalse(np.isinf(a).any() or np.isinf(b).any())
|
||||
np.testing.assert_array_equal(np.isnan(a), np.isnan(b))
|
||||
finite = np.isfinite(a)
|
||||
np.testing.assert_array_equal(a[finite].view(np.uint32), b[finite].view(np.uint32))
|
||||
|
||||
|
||||
def accumulations(m):
|
||||
return [c for c in m["calls"] if c[0] == "BlockAccumulateExchange"]
|
||||
|
||||
|
||||
class ExchangeWindowCPU(unittest.TestCase):
|
||||
def test_constructor_validation(self):
|
||||
with mocked_backend() as m:
|
||||
with self.assertRaisesRegex(ValueError, "requires observe_raw_exchange"):
|
||||
build(m, window=True, raw=False)
|
||||
with self.assertRaisesRegex(ValueError, "must be bool"):
|
||||
build(m, window=1)
|
||||
|
||||
def test_state_machine_and_launches(self):
|
||||
with mocked_backend() as m, ExitStack() as stack:
|
||||
stack.enter_context(patch.object(cuda, "memset_d8"))
|
||||
stack.enter_context(patch.object(cuda, "memset_d8_async"))
|
||||
stack.enter_context(patch.object(cuda, "memcpy_dtoh",
|
||||
side_effect=lambda out, src: out.fill(0)))
|
||||
backend = build(m)
|
||||
b = backend._level_boundaries[1]
|
||||
self.assertIsNotNone(b.window_integral)
|
||||
with self.assertRaises(RuntimeError):
|
||||
backend.start_exchange_window() # not initialized
|
||||
arrays = [np.full(level.shape, 1 / 9, np.float32) for level in backend._levels]
|
||||
backend.initialize(*arrays)
|
||||
with self.assertRaisesRegex(RuntimeError, "no valid exchange window"):
|
||||
backend.read_exchange_window(0)
|
||||
backend.step(1)
|
||||
self.assertEqual(len(accumulations(m)), 2) # closed window: interval ledger only
|
||||
backend.start_exchange_window()
|
||||
with self.assertRaisesRegex(RuntimeError, "at least one completed"):
|
||||
backend.read_exchange_window(0)
|
||||
m["calls"].clear()
|
||||
backend.step(2)
|
||||
calls = accumulations(m)
|
||||
self.assertEqual(len(calls), 8) # two fine hops x two ledgers x two base steps
|
||||
self.assertEqual({id(c[1][1]) for c in calls},
|
||||
{id(b.interval_integral), id(b.window_integral)})
|
||||
out = backend.read_exchange_window(0)
|
||||
self.assertEqual((out["time_start"], out["time_end"], out["sample_count"]), (1., 3, 4))
|
||||
self.assertEqual(backend.read_exchange_window(0)["time_end"], 3) # non-destructive
|
||||
backend.initialize(*arrays) # invalidates
|
||||
with self.assertRaisesRegex(RuntimeError, "no valid exchange window"):
|
||||
backend.read_exchange_window(0)
|
||||
backend.start_exchange_window()
|
||||
with patch.object(backend, "_hop", side_effect=RuntimeError("injected")):
|
||||
with self.assertRaises(RuntimeError):
|
||||
backend.step(1)
|
||||
self.assertFalse(backend._window_valid)
|
||||
with self.assertRaises(RuntimeError):
|
||||
backend.read_exchange_window(0) # failed status
|
||||
|
||||
def test_disabled_default_has_no_window_cost(self):
|
||||
with mocked_backend() as m, ExitStack() as stack:
|
||||
stack.enter_context(patch.object(cuda, "memset_d8_async"))
|
||||
backend = build(m, window=False)
|
||||
self.assertIsNone(backend._level_boundaries[1].window_integral)
|
||||
backend.initialize(*[np.full(l.shape, 1 / 9, np.float32) for l in backend._levels])
|
||||
backend.step(1)
|
||||
self.assertEqual(len(accumulations(m)), 2)
|
||||
with self.assertRaisesRegex(RuntimeError, "disabled"):
|
||||
backend.start_exchange_window()
|
||||
|
||||
|
||||
class ExchangeWindowGPU(unittest.TestCase):
|
||||
def test_window_matches_summed_base_reads_and_populations_unchanged(self):
|
||||
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()],
|
||||
observe_raw_exchange=True)
|
||||
with FixedPatchStepper(CFG, RB, observe_window_exchange=True, **kw) as windowed, \
|
||||
FixedPatchStepper(CFG, RB, **kw) as plain:
|
||||
arrays = [state(level.shape) for level in windowed._levels]
|
||||
windowed.initialize(*arrays)
|
||||
plain.initialize(*arrays)
|
||||
windowed.step(2)
|
||||
plain.step(2)
|
||||
windowed.start_exchange_window()
|
||||
b = windowed._level_boundaries[1]
|
||||
count = len(b.tables["cell"])
|
||||
factors = np.array([.25, .25, .125, .25]) # h*dt, h*h*dt, h*h for level-1 h=.5
|
||||
scale = np.zeros(4)
|
||||
substeps = 0
|
||||
kernel = windowed._levels[1].kernel
|
||||
|
||||
def audited(src, dst, *args, **kw):
|
||||
nonlocal scale, substeps
|
||||
windowed._stream.synchronize() # BFL and both accumulations are complete
|
||||
raw = np.empty((count, 4), np.float32)
|
||||
cuda.memcpy_dtoh(raw, b.last_raw)
|
||||
scale = scale + (np.abs(raw.astype(np.float64)) * factors).sum(axis=0)
|
||||
substeps += 1
|
||||
kernel(src, dst, *args, **kw)
|
||||
windowed._levels[1].kernel = audited
|
||||
keys = ("impulse", "angular_impulse", "mass_into_fluid")
|
||||
summed = {part: [0., 0., 0., 0.] for part in ("total", "wall", "aperture")}
|
||||
steps = 0
|
||||
for speed in (0., .002, .002, -.001, 0., 0.): # amplitude updates stay in-window
|
||||
windowed.set_jet_amplitude(0, speed)
|
||||
plain.set_jet_amplitude(0, speed)
|
||||
windowed.step(1)
|
||||
plain.step(1)
|
||||
steps += 1
|
||||
obs = windowed.read_raw_exchange(0)
|
||||
for part in summed:
|
||||
o = obs[part]
|
||||
summed[part] = [summed[part][0] + o["impulse"][0], summed[part][1] + o["impulse"][1],
|
||||
summed[part][2] + o["angular_impulse"],
|
||||
summed[part][3] + o["mass_into_fluid"]]
|
||||
for a, e in zip(windowed._download_raw().values, plain._download_raw().values):
|
||||
same_bits(self, a, e)
|
||||
out = windowed.read_exchange_window(0)
|
||||
self.assertEqual((out["time_end"] - out["time_start"], out["sample_count"]),
|
||||
(steps, 2 * steps))
|
||||
self.assertEqual(substeps, 2 * steps)
|
||||
bound = 4 * (count * substeps) * EPS * scale + 1e-300
|
||||
for part in summed:
|
||||
o = out[part]
|
||||
got = np.array([o["impulse"][0], o["impulse"][1], o["angular_impulse"],
|
||||
o["mass_into_fluid"]])
|
||||
diff = np.abs(got - np.array(summed[part]))
|
||||
self.assertTrue((diff <= bound).all(), f"{part}: {diff} > {bound}")
|
||||
np.testing.assert_array_equal(o["force"], o["impulse"] / steps)
|
||||
again = windowed.read_exchange_window(0)
|
||||
np.testing.assert_array_equal(again["total"]["impulse"], out["total"]["impulse"])
|
||||
windowed.initialize(*arrays)
|
||||
with self.assertRaises(RuntimeError):
|
||||
windowed.read_exchange_window(0)
|
||||
|
||||
|
||||
def audit_substeps(stepper, b, count, record):
|
||||
"""Wrap the level-1 step kernel: after BFL and accumulation, add |substep terms| to record."""
|
||||
factors = np.array([.25, .25, .125, .25])
|
||||
kernel = stepper._levels[1].kernel
|
||||
|
||||
def audited(src, dst, *args, **kw):
|
||||
stepper._stream.synchronize()
|
||||
raw = np.empty((count, 4), np.float32)
|
||||
cuda.memcpy_dtoh(raw, b.last_raw)
|
||||
record["scale"] = record["scale"] + (np.abs(raw.astype(np.float64)) * factors).sum(axis=0)
|
||||
record["substeps"] += 1
|
||||
kernel(src, dst, *args, **kw)
|
||||
stepper._levels[1].kernel = audited
|
||||
|
||||
|
||||
def components(obs):
|
||||
o = obs["total"]
|
||||
return np.array([o["impulse"][0], o["impulse"][1], o["angular_impulse"], o["mass_into_fluid"]])
|
||||
|
||||
|
||||
class ExchangeWindowShiftedMRTGPU(unittest.TestCase):
|
||||
"""Shifted MRT, all-blocked, stationary body, NO Jet (scoped separately from SRT/Jet)."""
|
||||
|
||||
def test_window_step0_reset_and_split_step(self):
|
||||
from test_raw_exchange_gpu import BOUNDS as RB, circle as rc, state
|
||||
from CelerisLab.config import LBMConfig
|
||||
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:
|
||||
arrays = [state(level.shape) for level in windowed._levels]
|
||||
for run in (windowed, split, plain):
|
||||
run.initialize(*arrays)
|
||||
run.step(2)
|
||||
if run is not plain:
|
||||
run.start_exchange_window()
|
||||
b = windowed._level_boundaries[1]
|
||||
count = len(b.tables["cell"])
|
||||
record = dict(scale=np.zeros(4), substeps=0)
|
||||
audit_substeps(windowed, b, count, record)
|
||||
steps, summed = 5, np.zeros(4)
|
||||
for _ in range(steps):
|
||||
windowed.step(1)
|
||||
plain.step(1)
|
||||
summed += components(windowed.read_raw_exchange(0))
|
||||
for a, e in zip(windowed._download_raw().values, plain._download_raw().values):
|
||||
same_bits(self, a, e)
|
||||
split.step(steps) # one call: same ledger arithmetic as five step(1) calls
|
||||
for a, e in zip(windowed._download_raw().values, split._download_raw().values):
|
||||
same_bits(self, a, e)
|
||||
rows = [np.empty((count, 4), np.float64) for _ in range(2)]
|
||||
cuda.memcpy_dtoh(rows[0], b.window_integral)
|
||||
cuda.memcpy_dtoh(rows[1], split._level_boundaries[1].window_integral)
|
||||
np.testing.assert_array_equal(rows[0].view(np.uint64), rows[1].view(np.uint64))
|
||||
|
||||
out = windowed.read_exchange_window(0)
|
||||
self.assertEqual(record["substeps"], 2 * steps)
|
||||
self.assertEqual((out["time_end"] - out["time_start"], out["sample_count"]),
|
||||
(steps, 2 * steps))
|
||||
bound = 4 * (count * record["substeps"]) * EPS * record["scale"] + 1e-300
|
||||
diff = np.abs(components(out) - summed)
|
||||
self.assertTrue((diff <= bound).all(), f"{diff} > {bound}")
|
||||
np.testing.assert_array_equal(split.read_exchange_window(0)["total"]["impulse"],
|
||||
out["total"]["impulse"])
|
||||
|
||||
windowed.step(0) # no interval: window and its duration unchanged
|
||||
again = windowed.read_exchange_window(0)
|
||||
self.assertEqual(again["time_end"], out["time_end"])
|
||||
np.testing.assert_array_equal(again["total"]["impulse"], out["total"]["impulse"])
|
||||
|
||||
windowed.start_exchange_window() # reset after a window
|
||||
with self.assertRaisesRegex(RuntimeError, "at least one completed"):
|
||||
windowed.read_exchange_window(0)
|
||||
record.update(scale=np.zeros(4), substeps=0)
|
||||
windowed.step(1)
|
||||
single = components(windowed.read_raw_exchange(0))
|
||||
reset = windowed.read_exchange_window(0)
|
||||
self.assertEqual(reset["time_end"] - reset["time_start"], 1)
|
||||
bound = 4 * (count * record["substeps"]) * EPS * record["scale"] + 1e-300
|
||||
self.assertTrue((np.abs(components(reset) - single) <= bound).all())
|
||||
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
@@ -7,6 +7,7 @@ At most eight coarse steps per case. No files, plots or flow arrays are saved.
|
||||
import hashlib
|
||||
import importlib.util
|
||||
from contextlib import nullcontext
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
import resource
|
||||
import sys
|
||||
@@ -21,7 +22,7 @@ import CelerisLab
|
||||
from CelerisLab.config import LBMConfig
|
||||
from CelerisLab.cuda import compiler_v2 as compiler
|
||||
from CelerisLab.cuda.context import CudaContext
|
||||
from CelerisLab.lbm.refinement_stepper import PeriodicPatchStepper
|
||||
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper, PeriodicPatchStepper
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
POP_ATOL = 3e-6
|
||||
@@ -60,6 +61,69 @@ def global_array(module, name, shape):
|
||||
|
||||
|
||||
class CoupledGPU(unittest.TestCase):
|
||||
def test_actual_tg_rates_one_interval(self):
|
||||
"""Algebraic rate/phase check, not a new physical Taylor--Green benchmark.
|
||||
|
||||
The N64/Re100 TG rates are exercised on the existing small patch with
|
||||
a nonequilibrium state. One interval checks g=C(f), including the
|
||||
shifted blocked path; no accuracy gate or production source changes.
|
||||
"""
|
||||
self.assertEqual(Path(CelerisLab.__file__).resolve(), ROOT / "src/CelerisLab/__init__.py")
|
||||
self.assertEqual(Path(compiler._KERNEL_DIR).resolve(), ROOT / "src/CelerisLab/lbm/kernels")
|
||||
spec = replace(ref.make_spec(collision="srt"),
|
||||
tau_coarse=.5576, tau_fine=.6152)
|
||||
maps = ref.build_maps(spec)
|
||||
state = ref.make_state(spec, velocity=(.03, -.02))
|
||||
rng = np.random.default_rng(1)
|
||||
state = ref.State(*(a * (1 + 1e-3 * rng.standard_normal(a.shape))
|
||||
for a in (state.coarse_f, state.fine_f)))
|
||||
initial = postcollision(state, spec, maps)
|
||||
next_state, _ = ref.advance_coarse_once(spec, maps, state)
|
||||
expected = postcollision(next_state, spec, maps)
|
||||
arrivals = (next_state.coarse_f, next_state.fine_f)
|
||||
|
||||
cases = (("dense_unshifted", False, {}),
|
||||
("blocked_shifted", True,
|
||||
dict(base_block=(4, 4), fine_block=(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,
|
||||
**storage) as backend:
|
||||
self.assertAlmostEqual(backend.patch.tau_coarse, spec.tau_coarse)
|
||||
self.assertAlmostEqual(backend.patch.tau_fine, spec.tau_fine)
|
||||
self.assertEqual(tuple(level.shape for level in backend._levels),
|
||||
((9, 16, 16), (9, 24, 24)))
|
||||
# Physical FP64 g is encoded by initialize before FP32 rounding.
|
||||
backend.initialize(*initial)
|
||||
before = totals(backend.download(), maps)
|
||||
backend.step(1)
|
||||
actual = backend.download()
|
||||
self.assertEqual(backend.step_count, 1)
|
||||
backend.patch.check_synchronization(
|
||||
1, next_state.tick, phase="postcollision_g")
|
||||
for got, want, arrival in zip(actual, expected, arrivals):
|
||||
np.testing.assert_array_equal(np.isnan(got), np.isnan(want))
|
||||
np.testing.assert_allclose(got, want, rtol=0, atol=POP_ATOL,
|
||||
equal_nan=True)
|
||||
# A raw arrival-f comparison must fail on BOTH levels.
|
||||
# Equilibrium-only data would make this control vacuous.
|
||||
with self.assertRaises(AssertionError):
|
||||
np.testing.assert_allclose(got, arrival, rtol=0,
|
||||
atol=POP_ATOL, equal_nan=True)
|
||||
np.testing.assert_allclose(totals(actual, maps), before,
|
||||
rtol=0, atol=MOMENT_ATOL)
|
||||
np.testing.assert_allclose(totals(actual, maps),
|
||||
totals(expected, maps),
|
||||
rtol=0, atol=MOMENT_ATOL)
|
||||
error = max(float(np.nanmax(np.abs(a - e)))
|
||||
for a, e in zip(actual, expected))
|
||||
print(f"actual-TG-rate algebraic check: {label} steps=1 "
|
||||
f"tau=.5576/.6152 max_population={error:.9g}; "
|
||||
"wrong-phase controls PASS", flush=True)
|
||||
|
||||
def test_cleanup_attempts_every_allocation(self):
|
||||
backend = PeriodicPatchStepper.__new__(PeriodicPatchStepper)
|
||||
backend.ctx = Mock(activate=nullcontext)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""MRT reuse GPU gates. Caller owns GPU admission (CUDA_VISIBLE_DEVICES).
|
||||
|
||||
Run: python -m unittest discover -s tests/validation/multigrid -p test_refinement_mrt_gpu.py
|
||||
Parity compares the blocked MRT channel root with uniform MRT OneStep: the existing
|
||||
pressure-NEQ outlet branch differs by collision model, so SRT is never the reference.
|
||||
AMR checks are finite-evolution/split-invariance smoke tests, not benchmark validation.
|
||||
"""
|
||||
from dataclasses import replace
|
||||
import unittest
|
||||
import numpy as np
|
||||
|
||||
from CelerisLab.config import LBMConfig
|
||||
from CelerisLab.cuda.context import CudaContext
|
||||
from CelerisLab.lbm.population_storage import _encode_physical
|
||||
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
|
||||
from test_channel_root_gpu import run_root, state
|
||||
from test_refinement_blocked_base import raw, exact
|
||||
|
||||
|
||||
class RefinementMRTGPU(unittest.TestCase):
|
||||
def test_uniform_matched_mrt_root(self):
|
||||
with CudaContext(0) as context:
|
||||
for shifted in (False, True):
|
||||
for profile in ("uniform", "parabolic"):
|
||||
cfg = LBMConfig(nx=24, ny=20, viscosity=.1, velocity=.025, collision="MRT",
|
||||
ddf_shifting=shifted, inlet_profile=profile)
|
||||
initial = _encode_physical(state(cfg.ny, cfg.nx), shifted)
|
||||
expected = run_root(cfg, context, initial)
|
||||
self.assertTrue(np.isfinite(expected).all())
|
||||
for block in ((20, 24), (5, 6), (4, 1)):
|
||||
np.testing.assert_array_equal(run_root(cfg, context, initial, block), expected)
|
||||
|
||||
def test_amr_mrt_smoke(self):
|
||||
from CelerisLab.body.geometry.circle import CircleGeometry
|
||||
from CelerisLab.body.objects import SimObject
|
||||
center = (24., 16.)
|
||||
body = SimObject(0, geometry=CircleGeometry(*center, 2.15), center=center, radius=2.15)
|
||||
with CudaContext(0) as context:
|
||||
for boundary in ("periodic", "channel"):
|
||||
for shifted in (False, True):
|
||||
cfg = LBMConfig(nx=48, ny=32, viscosity=.1, velocity=.015 if boundary == "channel" else 0.,
|
||||
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:
|
||||
backend.initialize(*[state(*level.shape[1:]) for level in backend._levels])
|
||||
backend.step(8)
|
||||
raw = backend._download_raw()
|
||||
for a, mask in zip(raw.values, backend._state):
|
||||
self.assertTrue(np.isfinite(a[:, mask]).all())
|
||||
if reference is None: reference = raw.values
|
||||
else:
|
||||
for a, b in zip(reference, raw.values): np.testing.assert_array_equal(a, b)
|
||||
|
||||
def test_dense_versus_blocked_mrt_two_level(self):
|
||||
"""Unshifted MRT: all-dense PatchCoarseStep/PatchFineStep vs mixed/all-blocked storage."""
|
||||
from test_refinement_blocked_base import BASE_BLOCKS, FINE_BLOCK
|
||||
from test_refinement_blocked_fine import NX, NY, BOUNDS
|
||||
import test_refinement_blocked_fine as fixture
|
||||
cfg = LBMConfig(nx=NX, ny=NY, viscosity=.1, collision="MRT")
|
||||
with CudaContext(0) as context:
|
||||
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:
|
||||
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)
|
||||
for layout in layouts[1:]:
|
||||
for level, (a, b) in enumerate(zip(results[layout], results[None, None])):
|
||||
exact(self, a, b, f"layout={layout} level={level}")
|
||||
|
||||
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))
|
||||
with CudaContext(0) as context:
|
||||
with FixedPatchStepper(cfg, (4, 4, 12, 12), 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)
|
||||
checked.validate() # omega is derived only by validate()
|
||||
self.assertEqual(checked.collision, "MRT")
|
||||
self.assertTrue(checked.ddf_shifting)
|
||||
self.assertTrue(np.isclose(checked.omega, 1 / (3 * 2**level * .1 + .5), rtol=1e-14))
|
||||
backend.initialize(*[state(*level.shape[1:]) for level in backend._levels])
|
||||
backend.step(4)
|
||||
values = backend._download_raw().values
|
||||
for a, mask in zip(values, backend._state):
|
||||
self.assertTrue(np.isfinite(a[:, mask]).all())
|
||||
|
||||
|
||||
if __name__ == "__main__": unittest.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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Frozen matched SRT DFG comparison. GPU execution requires explicit root admission."""
|
||||
"""Frozen matched SRT/MRT DFG comparison. GPU execution requires explicit root admission."""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
@@ -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,24 +140,34 @@ def helpers(source):
|
||||
return h, periodic
|
||||
|
||||
|
||||
def config(h, diameter, inlet_scheme="zou_he_local"):
|
||||
cfg, body, p = h.make_config(diameter, SPEED, "SRT", "neq_extrap", inlet_scheme)
|
||||
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):
|
||||
_, _, c = config(h, 40)
|
||||
_, _, f = config(h, 80)
|
||||
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"],
|
||||
@@ -76,15 +187,31 @@ def plan(h):
|
||||
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.",
|
||||
outlet="SRT pressure NEQ, all nine fneq damped by .5, no backflow clamp; noncanonical DFG outlet",
|
||||
modes={"pilot_tstar": 5.0, "full_tstar": 300.0},
|
||||
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": 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 {}),
|
||||
)
|
||||
|
||||
|
||||
@@ -120,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):
|
||||
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
|
||||
@@ -133,12 +260,12 @@ def host_geometry(source, h):
|
||||
from CelerisLab.body.geometry.circle import CircleGeometry
|
||||
from CelerisLab.body.objects import SimObject
|
||||
|
||||
cfgdict, _, _ = config(h, 40)
|
||||
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]
|
||||
@@ -147,20 +274,88 @@ def host_geometry(source, h):
|
||||
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):
|
||||
expected = json.loads((HERE / "data/refined_dfg/source_manifest.json").read_text())
|
||||
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)
|
||||
return expected
|
||||
|
||||
|
||||
def run_case(a, h, periodic, case, *, inlet_scheme="zou_he_local",
|
||||
sparse_pilot=False, receipt_identity=None, progress=None):
|
||||
def advance_refined_interval(backend, n, deadline, observation_mode):
|
||||
"""Return the same root-unit interval impulse, never an already averaged force."""
|
||||
impulse = np.zeros(2)
|
||||
if observation_mode == "window":
|
||||
begin = backend.step_count
|
||||
check_deadline(deadline)
|
||||
backend.start_exchange_window()
|
||||
backend.step(n)
|
||||
exchange = backend.read_exchange_window(0, level=1)
|
||||
if (
|
||||
exchange["units"] != "base_lattice_per_unit_depth"
|
||||
or exchange["window"] != "window"
|
||||
or exchange["sample_count"] != 2 * n
|
||||
or exchange["time_start"] != begin
|
||||
or exchange["time_end"] != begin + n
|
||||
or exchange["dt"] != 0.5
|
||||
):
|
||||
raise RuntimeError("Unexpected window force observation contract")
|
||||
impulse += exchange["total"]["impulse"]
|
||||
check_deadline(deadline)
|
||||
return impulse
|
||||
if observation_mode != "last_base_step":
|
||||
raise ValueError("Unknown refined observation mode")
|
||||
for _ in range(n):
|
||||
check_deadline(deadline)
|
||||
backend.step(1)
|
||||
exchange = backend.read_raw_exchange(
|
||||
0, level=1, window="last_base_step"
|
||||
)
|
||||
if (
|
||||
exchange["units"] != "base_lattice_per_unit_depth"
|
||||
or exchange["sample_count"] != 2
|
||||
or exchange["time_end"] - exchange["time_start"] != 1
|
||||
):
|
||||
raise RuntimeError("Unexpected force observation contract")
|
||||
impulse += exchange["total"]["impulse"]
|
||||
check_deadline(deadline)
|
||||
return impulse
|
||||
|
||||
|
||||
def run_case(a, h, periodic, case):
|
||||
from CelerisLab.simulation import Simulation
|
||||
from CelerisLab.cuda.context import CudaContext
|
||||
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
|
||||
@@ -168,38 +363,35 @@ def run_case(a, h, periodic, case, *, inlet_scheme="zou_he_local",
|
||||
from contextlib import ExitStack
|
||||
|
||||
refined = case == "refined"
|
||||
if refined and inlet_scheme != "zou_he_local":
|
||||
raise ValueError("Experimental inlet is uniform-only")
|
||||
cfgdict, bodydict, p = config(h, 40 if refined else 80, inlet_scheme)
|
||||
identity = {} if receipt_identity is None else dict(receipt_identity)
|
||||
calibration_batches = []
|
||||
executed_steps = 0
|
||||
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 = []
|
||||
if progress is not None:
|
||||
progress.update(executed_steps=0, last_observed_step=0, history=rows,
|
||||
calibration_batches_seconds=calibration_batches)
|
||||
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)
|
||||
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,
|
||||
PLACEMENTS[a.placement],
|
||||
context=context,
|
||||
coarse_boundary="channel",
|
||||
base_block=BASE_BLOCK,
|
||||
fine_block=FINE_BLOCK,
|
||||
fine_objects=(body,),
|
||||
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 {}),
|
||||
)
|
||||
)
|
||||
from CelerisLab.lbm.channel import channel_masks
|
||||
@@ -230,6 +422,9 @@ def run_case(a, h, periodic, case, *, inlet_scheme="zou_he_local",
|
||||
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
|
||||
@@ -242,40 +437,27 @@ def run_case(a, h, periodic, case, *, inlet_scheme="zou_he_local",
|
||||
check_deadline(a.deadline)
|
||||
width = (
|
||||
0.02
|
||||
if (a.mode == "pilot" and not sparse_pilot) or begin * SPEED / p["diameter"] >= 240
|
||||
if a.mode == "pilot" or begin * SPEED / p["diameter"] >= 240
|
||||
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:
|
||||
for _ in range(n):
|
||||
check_deadline(a.deadline)
|
||||
backend.step(1)
|
||||
exchange = backend.read_raw_exchange(
|
||||
0, level=1, window="last_base_step"
|
||||
)
|
||||
if (
|
||||
exchange["units"] != "base_lattice_per_unit_depth"
|
||||
or exchange["sample_count"] != 2
|
||||
or exchange["time_end"] - exchange["time_start"] != 1
|
||||
):
|
||||
raise RuntimeError("Unexpected force observation contract")
|
||||
impulse += exchange["total"]["impulse"]
|
||||
check_deadline(a.deadline)
|
||||
impulse = advance_refined_interval(
|
||||
backend, n, a.deadline, a.observation_mode
|
||||
)
|
||||
else:
|
||||
done = 0
|
||||
while done < n:
|
||||
check_deadline(a.deadline)
|
||||
batch = min(100, n - done)
|
||||
batch_started = time.monotonic() if sparse_pilot else None
|
||||
sim.run(batch)
|
||||
executed_steps += batch
|
||||
if progress is not None:
|
||||
progress["executed_steps"] = executed_steps
|
||||
impulse += sim.read_force(0).astype(np.float64) * batch
|
||||
if sparse_pilot and len(calibration_batches) < 5:
|
||||
calibration_batches.append(time.monotonic() - batch_started)
|
||||
done += batch
|
||||
check_deadline(a.deadline)
|
||||
step_seconds += time.monotonic() - tick
|
||||
@@ -332,11 +514,22 @@ def run_case(a, h, periodic, case, *, inlet_scheme="zou_he_local",
|
||||
/ 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)
|
||||
if progress is not None:
|
||||
progress["last_observed_step"] = row["step"]
|
||||
if writer is None:
|
||||
writer = csv.DictWriter(out, fieldnames=row)
|
||||
writer.writeheader()
|
||||
@@ -352,10 +545,10 @@ def run_case(a, h, periodic, case, *, inlet_scheme="zou_he_local",
|
||||
break
|
||||
if len(rows) == 1:
|
||||
ready = dict(
|
||||
**identity,
|
||||
calibration_batches_seconds=calibration_batches,
|
||||
calibration_note="First 100 steps warmup, next 400 timed; all included in trajectory and force interval",
|
||||
case=case,
|
||||
collision=a.collision,
|
||||
observation_mode=a.observation_mode,
|
||||
**a.provenance,
|
||||
mode=a.mode,
|
||||
step=begin,
|
||||
tstar=row["tstar"],
|
||||
@@ -367,27 +560,34 @@ def run_case(a, h, periodic, case, *, inlet_scheme="zou_he_local",
|
||||
)
|
||||
print("READY " + json.dumps(ready, allow_nan=False), flush=True)
|
||||
result = dict(
|
||||
**identity,
|
||||
calibration_batches_seconds=calibration_batches,
|
||||
first_observed_failure_step=executed_steps if not healthy and not refined else None,
|
||||
last_observed_step=rows[-1]["step"] if rows else 0,
|
||||
failure_timing="First unhealthy observation; onset between observations is unknown" if not healthy else None,
|
||||
executed_steps=executed_steps if not refined else begin,
|
||||
case=case,
|
||||
collision=a.collision,
|
||||
observation_mode=a.observation_mode,
|
||||
**a.provenance,
|
||||
mode=a.mode,
|
||||
history=rows,
|
||||
healthy=bool(healthy),
|
||||
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(
|
||||
@@ -403,7 +603,8 @@ def run_case(a, h, periodic, case, *, inlet_scheme="zou_he_local",
|
||||
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))
|
||||
@@ -442,23 +643,41 @@ def check_deadline(deadline):
|
||||
)
|
||||
|
||||
|
||||
def verify_pilot(path, manifest):
|
||||
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("case") != case
|
||||
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"
|
||||
or summary.get("status") != "pilot_complete"
|
||||
or summary.get("healthy") is not True
|
||||
@@ -509,7 +728,12 @@ def verify_pilot(path, manifest):
|
||||
raise ValueError("Unhealthy pilot observations")
|
||||
ready = read_finite_json(path / case / "ready.json")
|
||||
if (
|
||||
ready.get("case") != case
|
||||
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"
|
||||
or ready.get("step") != interval
|
||||
or ready.get("runner_sha256") != runner_hash()
|
||||
@@ -525,14 +749,24 @@ def verify_pilot(path, manifest):
|
||||
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
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
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")
|
||||
@@ -544,10 +778,28 @@ 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()
|
||||
manifest = verify_source(a.source)
|
||||
a.observation_mode = "window" if a.exchange_window else "last_base_step"
|
||||
manifest = verify_source(a.source, a.collision, a.observation_mode, a.jet_scenario, a.observe_cv)
|
||||
h, periodic = helpers(a.source)
|
||||
calibration = verify_pilot(a.pilot_results, manifest) if a.mode == "full" else None
|
||||
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, a.jet_scenario, a.observe_cv,
|
||||
a.case, a.uniform_diameter, a.placement)
|
||||
if a.mode == "full" else None
|
||||
)
|
||||
if a.mode != "plan" and (
|
||||
a.max_wall_seconds is None
|
||||
or not math.isfinite(a.max_wall_seconds)
|
||||
@@ -562,16 +814,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)
|
||||
specification = plan(h, a.collision, a.observation_mode, a.jet_scenario, a.uniform_diameter, a.placement)
|
||||
specification.update(
|
||||
source_manifest=manifest,
|
||||
gates=periodic.GATES,
|
||||
reference_gates=periodic.REFERENCE_GATES,
|
||||
**a.provenance,
|
||||
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)
|
||||
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
|
||||
@@ -580,13 +847,17 @@ 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:
|
||||
failure = dict(
|
||||
status="wall_time_limit",
|
||||
collision=a.collision,
|
||||
observation_mode=a.observation_mode,
|
||||
**a.provenance,
|
||||
case=case,
|
||||
matched_case_pass=False,
|
||||
error=str(exc),
|
||||
@@ -600,10 +871,18 @@ def main():
|
||||
if not result["healthy"]:
|
||||
break
|
||||
comparison = dict(
|
||||
collision=a.collision,
|
||||
observation_mode=a.observation_mode,
|
||||
**a.provenance,
|
||||
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(
|
||||
@@ -613,14 +892,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
|
||||
)
|
||||
|
||||
|
||||
@@ -59,17 +59,24 @@ def decode_macros(data, w, cx, cy):
|
||||
return profile, *metrics
|
||||
|
||||
|
||||
def source_guard(root, variant):
|
||||
manifest = json.loads((HERE / "data/les_flow/source_pins.json").read_text())
|
||||
EXPERIMENTS = {
|
||||
"historical": "source_pins.json",
|
||||
"dev8403956": "source_pins_dev8403956.json",
|
||||
"a135252": "source_pins_a135252.json",
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
return dict(
|
||||
identity = dict(
|
||||
base_commit=manifest["base_commit"],
|
||||
production_origin_commit=manifest["production_origin_commit"],
|
||||
source_experiment=manifest["source_experiment"],
|
||||
@@ -80,12 +87,22 @@ def source_guard(root, variant):
|
||||
header_sha256=hashlib.sha256((root / HEADER).read_bytes()).hexdigest(),
|
||||
variant=variant,
|
||||
)
|
||||
if experiment != "historical":
|
||||
identity.update(
|
||||
experiment=experiment,
|
||||
source_manifest_sha256=hashlib.sha256(manifest_path.read_bytes()).hexdigest(),
|
||||
)
|
||||
return identity
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--root", type=Path, required=True)
|
||||
ap.add_argument("--variant", choices=["baseline", "corrected"], required=True)
|
||||
ap.add_argument(
|
||||
"--experiment", choices=tuple(EXPERIMENTS), default="historical",
|
||||
help="Explicit source contract; historical pins remain the default",
|
||||
)
|
||||
ap.add_argument("--output", type=Path, required=True)
|
||||
ap.add_argument(
|
||||
"--device", type=int, default=0, help="Logical device in CUDA_VISIBLE_DEVICES"
|
||||
@@ -100,7 +117,7 @@ def main():
|
||||
ap.error("Output exists; choose a new evidence path")
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
root = args.root.resolve()
|
||||
pins = source_guard(root, args.variant)
|
||||
pins = source_guard(root, args.variant, args.experiment)
|
||||
# Refuse installed-package shadowing; import only after explicit GPU opt-in.
|
||||
sys.path.insert(0, str(root / "src"))
|
||||
import CelerisLab
|
||||
|
||||
@@ -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,434 @@
|
||||
"""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):
|
||||
from CelerisLab.cuda import compiler_v2
|
||||
|
||||
for size in (128, 256):
|
||||
cfg = replace(run_case.config(size, 100), nx=10, ny=10)
|
||||
cfg.validate()
|
||||
root = compiler_v2.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_v2.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()
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Measurement-only wall timing for the matched MRT refined DFG case. No numerical edits.
|
||||
|
||||
Variants over EQUAL simulated duration (one refined base step = two uniform D80 steps):
|
||||
A refined: step(1) + read_raw_exchange per base step -- runner-like force-read workload
|
||||
B refined: step(1) only, no host reads -- timing-only, no force history
|
||||
C refined: one step(N) call -- timing-only; ledger keeps final base interval only
|
||||
E uniform: sim.run(<=100) + read_force -- runner-like force-read workload
|
||||
E' uniform: one sim.run(2N) call -- timing-only; at most a whole-window force
|
||||
average (different summation order), no history
|
||||
A/E read forces but discard them: no retained force history, no interval aggregation/validation,
|
||||
and no macro/probe/CSV cost, so they are not full benchmark throughput.
|
||||
observe_raw_exchange stays True for A/B/C: B/C omit host reads only.
|
||||
Status lines: CONSTRUCTED = backend/simulation built (JIT done); WARM_READY = first warm-up and
|
||||
health check passed for that case.
|
||||
Parity compares A/B/C with each other and E/E' with each other, never uniform with refined.
|
||||
A-B and B-C are measured comparative differences, not exact causal decompositions.
|
||||
Each repeat starts from the same fresh initial state with the same warm-up; construction,
|
||||
compilation, parity and health checks are outside timed regions. The actual stepping stream is
|
||||
synchronized explicitly immediately before the timer starts and after the timed loop. Wall time
|
||||
(perf_counter) is the only timing source; no CUDA-event span is reported as kernel time. An outer
|
||||
--max-wall-seconds deadline is checked between variants and inside per-step loops.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import statistics
|
||||
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 r # noqa: E402 (published runner: same setup functions)
|
||||
|
||||
VARIANTS = {
|
||||
"A": dict(case="refined", force_history_valid=False, force_reads=True,
|
||||
force_note="runner-like force-read workload; values discarded, no aggregation"),
|
||||
"B": dict(case="refined", force_history_valid=False, timing_only=True,
|
||||
force_note="no host reads"),
|
||||
"C": dict(case="refined", force_history_valid=False, timing_only=True,
|
||||
force_note="raw ledger retains only the final base interval"),
|
||||
"E": dict(case="uniform", force_history_valid=False, force_reads=True,
|
||||
force_note="runner-like force-read workload; values discarded, no aggregation"),
|
||||
"E'": dict(case="uniform", force_history_valid=False, timing_only=True,
|
||||
force_note="at most a whole-window accumulated average; different summation order"),
|
||||
}
|
||||
|
||||
|
||||
def schedule(n_base, warmup_base):
|
||||
"""Steps per case for equal simulated duration (refined dt_root = 2 dt_uniform)."""
|
||||
if n_base <= 0 or warmup_base < 0:
|
||||
raise ValueError("n_base must be positive and warmup_base nonnegative")
|
||||
return dict(refined=(warmup_base, n_base), uniform=(2 * warmup_base, 2 * n_base),
|
||||
tstar_window=n_base * r.SPEED / 40.0)
|
||||
|
||||
|
||||
# Existing run_refined_dfg safety limits (rho in [.5, 1.5], sampled max Mach <= .2).
|
||||
RHO_LIMITS, MAX_MACH = (0.5, 1.5), 0.2
|
||||
|
||||
|
||||
def same_state(reference, values):
|
||||
"""Bitwise populations and identical NaN masks; returns a short description or None.
|
||||
|
||||
Matching NaN is accepted only as a storage mask (inactive/ghost/solid slots); physical
|
||||
leaf health is checked separately. Any infinity in either state is a mismatch.
|
||||
"""
|
||||
reference, values = list(reference), list(values)
|
||||
if len(reference) != len(values):
|
||||
return f"level count {len(reference)} != {len(values)}"
|
||||
for level, (a, b) in enumerate(zip(reference, values)):
|
||||
a, b = np.asarray(a), np.asarray(b)
|
||||
if a.shape != b.shape:
|
||||
return f"level {level}: shape {a.shape} != {b.shape}"
|
||||
if np.isinf(a).any() or np.isinf(b).any():
|
||||
return f"level {level}: infinite population"
|
||||
if not np.array_equal(np.isnan(a), np.isnan(b)):
|
||||
return f"level {level}: NaN mask differs"
|
||||
finite = np.isfinite(a)
|
||||
if a.dtype != b.dtype or not np.array_equal(a[finite].view(np.uint8), b[finite].view(np.uint8)):
|
||||
return f"level {level}: finite bits differ"
|
||||
return None
|
||||
|
||||
|
||||
def uniform_populations_finite(ddf, fluid):
|
||||
"""Simulation.get_ddf() may be flat (9*ny*nx); view it as (Q, cells) before masking."""
|
||||
ddf, fluid = np.asarray(ddf), np.asarray(fluid, bool).ravel()
|
||||
if fluid.size == 0 or ddf.size % fluid.size:
|
||||
raise RuntimeError(f"DDF size {ddf.size} incompatible with {fluid.size} cells")
|
||||
return bool(np.isfinite(ddf.reshape(ddf.size // fluid.size, fluid.size)[:, fluid]).all())
|
||||
|
||||
|
||||
def physical_health(fields):
|
||||
"""fields: (rho, ux, uy) arrays already restricted to physical leaves/fluid."""
|
||||
if not fields or any(not np.isfinite(v).all() or v.size == 0 for f in fields for v in f):
|
||||
raise RuntimeError("nonfinite or empty physical macroscopic field")
|
||||
rho_min = float(min(f[0].min() for f in fields))
|
||||
rho_max = float(max(f[0].max() for f in fields))
|
||||
max_mach = float(max(np.sqrt(3 * (f[1] ** 2 + f[2] ** 2)).max() for f in fields))
|
||||
if not (RHO_LIMITS[0] <= rho_min <= rho_max <= RHO_LIMITS[1] and max_mach <= MAX_MACH):
|
||||
raise RuntimeError(f"health limits violated: rho [{rho_min}, {rho_max}], Mach {max_mach}")
|
||||
return dict(rho_min=rho_min, rho_max=rho_max, max_mach=max_mach)
|
||||
|
||||
|
||||
def refined_health(backend, masks):
|
||||
arrays = backend.download() # physical FP64 populations; non-leaves NaN by contract
|
||||
if any(not np.isfinite(g[:, m]).all() for g, m in zip(arrays, masks)):
|
||||
raise RuntimeError("nonfinite physical leaf population")
|
||||
return physical_health([tuple(v[m] for v in r.macros(g)) for g, m in zip(arrays, masks)])
|
||||
|
||||
|
||||
def uniform_health(sim, fluid):
|
||||
m = sim.get_macroscopic()
|
||||
return physical_health([tuple(np.asarray(m[k], np.float64).reshape(fluid.shape)[fluid]
|
||||
for k in ("rho", "ux", "uy"))])
|
||||
|
||||
|
||||
def check_deadline(deadline):
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError("timing harness exceeded --max-wall-seconds")
|
||||
|
||||
|
||||
def sync_refined(backend):
|
||||
with backend.ctx.activate():
|
||||
backend._stream.synchronize()
|
||||
|
||||
|
||||
def sync_uniform(sim):
|
||||
with sim.ctx.activate():
|
||||
sim.stream.synchronize()
|
||||
|
||||
|
||||
def refined_loop(backend, variant, n, deadline):
|
||||
if variant == "C":
|
||||
backend.step(n)
|
||||
return
|
||||
for i in range(n):
|
||||
backend.step(1)
|
||||
if variant == "A":
|
||||
backend.read_raw_exchange(0, level=1, window="last_base_step")
|
||||
if i % 100 == 99:
|
||||
check_deadline(deadline)
|
||||
|
||||
|
||||
def uniform_loop(sim, variant, n, deadline):
|
||||
if variant == "E'":
|
||||
sim.run(n)
|
||||
return
|
||||
done = 0
|
||||
while done < n:
|
||||
batch = min(100, n - done)
|
||||
sim.run(batch)
|
||||
sim.read_force(0)
|
||||
done += batch
|
||||
check_deadline(deadline)
|
||||
|
||||
|
||||
def time_refined(a, h, sched):
|
||||
from contextlib import ExitStack
|
||||
from CelerisLab.cuda.context import CudaContext
|
||||
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
|
||||
from CelerisLab.lbm.channel import channel_masks
|
||||
from CelerisLab.lbm.refinement import ACTIVE
|
||||
from CelerisLab.lbm.descriptors import SOLID
|
||||
|
||||
warm, n = sched["refined"]
|
||||
rows, reference = [], {}
|
||||
with ExitStack() as stack:
|
||||
cfg, pair, body, host = r.host_geometry(a.source, h, "MRT")
|
||||
context = stack.enter_context(CudaContext(0))
|
||||
backend = stack.enter_context(FixedPatchStepper(
|
||||
cfg, r.BOUNDS, context=context, coarse_boundary="channel", base_block=r.BASE_BLOCK,
|
||||
fine_block=r.FINE_BLOCK, fine_objects=(body,), observe_raw_exchange=True))
|
||||
masks = [channel_masks(pair.coarse_shape)[0] & ~pair.covered, # as run_refined_dfg
|
||||
(pair.fine_kind == ACTIVE) & ((host.flags.reshape(pair.fine_shape) & SOLID) == 0)]
|
||||
fresh = (r.initial_state(pair.coarse_shape, (0.0, 0.0), 1.0, root=True),
|
||||
r.initial_state(pair.fine_shape, pair.fine_origin, 0.5))
|
||||
print(json.dumps(dict(status="CONSTRUCTED", case="refined")), flush=True)
|
||||
for repeat in range(a.repeats):
|
||||
for variant in ("A", "B", "C"):
|
||||
check_deadline(a.deadline)
|
||||
backend.initialize(*fresh) # resets step count; same fresh state every time
|
||||
refined_loop(backend, variant, warm, a.deadline)
|
||||
warm_health = refined_health(backend, masks) # untimed
|
||||
if repeat == 0 and variant == "A":
|
||||
print(json.dumps(dict(status="WARM_READY", case="refined")), flush=True)
|
||||
sync_refined(backend)
|
||||
start = time.perf_counter()
|
||||
refined_loop(backend, variant, n, a.deadline)
|
||||
sync_refined(backend)
|
||||
seconds = time.perf_counter() - start
|
||||
end_health = refined_health(backend, masks) # untimed
|
||||
values = [np.array(v, copy=True) for v in backend._download_raw().values]
|
||||
if not all(np.isfinite(v[:, m]).all() for v, m in zip(values, backend._state)):
|
||||
raise RuntimeError(f"refined {variant} repeat {repeat}: nonfinite leaf populations")
|
||||
mismatch = None
|
||||
if repeat == 0 and variant == "A":
|
||||
reference["A"] = values
|
||||
else:
|
||||
mismatch = same_state(reference["A"], values)
|
||||
rows.append(dict(variant=variant, repeat=repeat, seconds=seconds,
|
||||
base_steps=n, step_count=backend.step_count,
|
||||
health_after_warmup=warm_health, health_after_window=end_health,
|
||||
parity_vs_A=mismatch or "identical", **VARIANTS[variant]))
|
||||
if mismatch:
|
||||
raise RuntimeError(f"refined {variant} repeat {repeat}: {mismatch}")
|
||||
return rows
|
||||
|
||||
|
||||
def time_uniform(a, h, sched):
|
||||
from CelerisLab.simulation import Simulation
|
||||
from CelerisLab.lbm.descriptors import FLUID
|
||||
|
||||
warm, n = sched["uniform"]
|
||||
rows, reference = [], {}
|
||||
cfgdict, bodydict, params = r.config(h, 80, "MRT")
|
||||
with TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
(tmp / "lbm.json").write_text(json.dumps(cfgdict))
|
||||
(tmp / "body.json").write_text(json.dumps(bodydict))
|
||||
with Simulation(str(tmp / "lbm.json"), str(tmp / "body.json"), device_id=0) as sim:
|
||||
sim.initialize()
|
||||
sim.snapshot() # fresh state; restore keeps clock and streaming parity
|
||||
fluid = (sim.get_flags() & FLUID) != 0
|
||||
ny, nx = params["ny"], params["nx"]
|
||||
fluid = fluid.reshape(ny, nx)
|
||||
print(json.dumps(dict(status="CONSTRUCTED", case="uniform")), flush=True)
|
||||
for repeat in range(a.repeats):
|
||||
for variant in ("E", "E'"):
|
||||
check_deadline(a.deadline)
|
||||
sim.restore()
|
||||
if sim.stepper.step_count % 2:
|
||||
raise RuntimeError("uniform restore parity changed between variants")
|
||||
uniform_loop(sim, variant, warm, a.deadline)
|
||||
warm_health = uniform_health(sim, fluid) # untimed
|
||||
if repeat == 0 and variant == "E":
|
||||
print(json.dumps(dict(status="WARM_READY", case="uniform")), flush=True)
|
||||
sync_uniform(sim)
|
||||
start = time.perf_counter()
|
||||
uniform_loop(sim, variant, n, a.deadline)
|
||||
sync_uniform(sim)
|
||||
seconds = time.perf_counter() - start
|
||||
end_health = uniform_health(sim, fluid) # untimed
|
||||
values = [sim.get_ddf()]
|
||||
if not uniform_populations_finite(values[0], fluid):
|
||||
raise RuntimeError(f"uniform {variant} repeat {repeat}: nonfinite fluid populations")
|
||||
mismatch = None
|
||||
if repeat == 0 and variant == "E":
|
||||
reference["E"] = values
|
||||
else:
|
||||
mismatch = same_state(reference["E"], values)
|
||||
rows.append(dict(variant=variant, repeat=repeat, seconds=seconds,
|
||||
uniform_steps=n, parity_vs_E=mismatch or "identical",
|
||||
health_after_warmup=warm_health, health_after_window=end_health,
|
||||
**VARIANTS[variant]))
|
||||
if mismatch:
|
||||
raise RuntimeError(f"uniform {variant} repeat {repeat}: {mismatch}")
|
||||
return rows
|
||||
|
||||
|
||||
def summarize(rows, tstar_window):
|
||||
summary = {}
|
||||
for variant in VARIANTS:
|
||||
seconds = [row["seconds"] for row in rows if row["variant"] == variant]
|
||||
if seconds:
|
||||
median = statistics.median(seconds)
|
||||
summary[variant] = dict(median_seconds=median, seconds=seconds,
|
||||
seconds_per_tstar=median / tstar_window,
|
||||
**VARIANTS[variant])
|
||||
if {"A", "B", "C"} <= summary.keys():
|
||||
summary["A_minus_B_seconds"] = summary["A"]["median_seconds"] - summary["B"]["median_seconds"]
|
||||
summary["B_minus_C_seconds"] = summary["B"]["median_seconds"] - summary["C"]["median_seconds"]
|
||||
if {"A", "E"} <= summary.keys():
|
||||
summary["A_over_E"] = summary["A"]["median_seconds"] / summary["E"]["median_seconds"]
|
||||
if {"C", "E'"} <= summary.keys():
|
||||
summary["C_over_Eprime"] = summary["C"]["median_seconds"] / summary["E'"]["median_seconds"]
|
||||
summary["interpretation"] = ("Measured comparative differences under GPU variability; "
|
||||
"not exact causal decompositions or performance claims.")
|
||||
return summary
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--source", type=Path, default=r.DEFAULT_SOURCE)
|
||||
p.add_argument("--output", type=Path, required=True)
|
||||
p.add_argument("--base-steps", type=int, default=2000)
|
||||
p.add_argument("--warmup-base-steps", type=int, default=200)
|
||||
p.add_argument("--repeats", type=int, default=3)
|
||||
p.add_argument("--max-wall-seconds", type=float, help="Required outer timeout for GPU runs")
|
||||
p.add_argument("--plan", action="store_true")
|
||||
a = p.parse_args()
|
||||
if a.repeats < 1 or a.base_steps > 20000:
|
||||
p.error("repeats must be >= 1 and base-steps <= 20000")
|
||||
a.source = a.source.resolve()
|
||||
manifest = r.verify_source(a.source, "MRT")
|
||||
sched = schedule(a.base_steps, a.warmup_base_steps)
|
||||
plan = dict(collision="MRT", manifest_commit=manifest["commit"], schedule=sched,
|
||||
repeats=a.repeats, variants=VARIANTS, timer="time.perf_counter wall",
|
||||
excluded="construction/JIT, warm-up, state download and parity checks")
|
||||
if a.plan:
|
||||
print(json.dumps(plan, indent=2))
|
||||
return 0
|
||||
if a.max_wall_seconds is None or not math.isfinite(a.max_wall_seconds) or a.max_wall_seconds <= 0:
|
||||
p.error("GPU timing requires a finite positive --max-wall-seconds")
|
||||
a.deadline = time.monotonic() + a.max_wall_seconds
|
||||
if not os.environ.get("CUDA_VISIBLE_DEVICES") or "," in os.environ["CUDA_VISIBLE_DEVICES"]:
|
||||
p.error("Root must select exactly one verified idle GPU via CUDA_VISIBLE_DEVICES")
|
||||
if a.output.exists() and any(a.output.iterdir()):
|
||||
p.error("Output must be empty/new")
|
||||
a.output.mkdir(parents=True, exist_ok=True)
|
||||
h, _ = r.helpers(a.source)
|
||||
rows = time_uniform(a, h, sched) + time_refined(a, h, sched)
|
||||
result = dict(plan=plan, rows=rows, summary=summarize(rows, sched["tstar_window"]))
|
||||
if not all(math.isfinite(row["seconds"]) for row in rows):
|
||||
raise RuntimeError("Nonfinite timing")
|
||||
(a.output / "timing.json").write_text(json.dumps(result, indent=2))
|
||||
print(json.dumps(result["summary"], indent=2), flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Measurement-only A-versus-window timing for the matched shifted-MRT refined DFG setup.
|
||||
|
||||
Candidate source (window ledger) is recorded by checkout revision, dirty flag and file hashes;
|
||||
the frozen refined-DFG manifests/results are NOT used or modified (they pin pre-window source).
|
||||
Equal simulated duration, same fresh state and warm-up per variant, one observation interval =
|
||||
80 root base steps (the runner's dense-phase interval):
|
||||
A per base step: step(1) + read_raw_exchange, summed per interval (current runner workload)
|
||||
W per interval: start_exchange_window(); step(80); read_exchange_window()
|
||||
W1 per interval: start_exchange_window(); 80 x step(1); read_exchange_window()
|
||||
All variants keep observe_raw_exchange=True; the backend is built once with
|
||||
observe_window_exchange=True (a closed window adds no launch). Populations (bitwise) and
|
||||
per-interval total impulses are compared OUTSIDE timing; the impulse tolerance is a
|
||||
conservative engineering tolerance (see INTERVAL_RTOL); the tighter substep-scaled engineering
|
||||
tolerance is exercised by the capability test tests/validation/multigrid/test_exchange_window.py.
|
||||
Wall time (perf_counter) with explicit stream synchronization is the only timing source.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import statistics
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
import time_refined_dfg as t # noqa: E402 (reuses run_refined_dfg setup via t.r)
|
||||
|
||||
r = t.r
|
||||
INTERVAL = 80
|
||||
VARIANTS = ("A", "W", "W1")
|
||||
# Interval impulses differ only by FP64 summation order (per link over time vs per step over
|
||||
# links). Engineering tolerance: 1e-9 of the interval's summed absolute per-step totals.
|
||||
INTERVAL_RTOL = 1e-9
|
||||
CANDIDATE_FILES = ("src/CelerisLab/lbm/refinement_stepper.py", "src/CelerisLab/lbm/raw_exchange.py",
|
||||
"src/CelerisLab/lbm/refinement_tables.py", "src/CelerisLab/lbm/kernels/blocks.cu")
|
||||
|
||||
|
||||
def components(obs):
|
||||
o = obs["total"]
|
||||
return np.array([o["impulse"][0], o["impulse"][1], o["angular_impulse"], o["mass_into_fluid"]])
|
||||
|
||||
|
||||
def candidate_identity(source):
|
||||
from source_provenance import checkout_provenance
|
||||
return dict(checkout_provenance(source),
|
||||
sha256={f: hashlib.sha256((source / f).read_bytes()).hexdigest()
|
||||
for f in CANDIDATE_FILES})
|
||||
|
||||
|
||||
def run_variant(backend, variant, n_intervals, deadline):
|
||||
"""Returns per-interval totals and, for A, per-interval summed |per-step totals|."""
|
||||
totals, scales = [], []
|
||||
for _ in range(n_intervals):
|
||||
if variant == "A":
|
||||
impulse, scale = np.zeros(4), np.zeros(4)
|
||||
for _ in range(INTERVAL):
|
||||
backend.step(1)
|
||||
c = components(backend.read_raw_exchange(0, level=1, window="last_base_step"))
|
||||
impulse += c
|
||||
scale += np.abs(c)
|
||||
totals.append(impulse)
|
||||
scales.append(scale)
|
||||
else:
|
||||
backend.start_exchange_window()
|
||||
if variant == "W":
|
||||
backend.step(INTERVAL)
|
||||
else:
|
||||
for _ in range(INTERVAL):
|
||||
backend.step(1)
|
||||
totals.append(components(backend.read_exchange_window(0)))
|
||||
t.check_deadline(deadline)
|
||||
return np.array(totals), (np.array(scales) if scales else None)
|
||||
|
||||
|
||||
def compare_intervals(reference, scales, totals):
|
||||
"""Max violation ratio; <= 1 passes. Scale includes a floor so zero cancels cannot divide."""
|
||||
bound = INTERVAL_RTOL * scales + 1e-300
|
||||
return float(np.max(np.abs(totals - reference) / bound))
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--source", type=Path, default=r.DEFAULT_SOURCE)
|
||||
p.add_argument("--output", type=Path, required=True)
|
||||
p.add_argument("--base-steps", type=int, default=2000)
|
||||
p.add_argument("--warmup-base-steps", type=int, default=200)
|
||||
p.add_argument("--repeats", type=int, default=3)
|
||||
p.add_argument("--max-wall-seconds", type=float)
|
||||
p.add_argument("--plan", action="store_true")
|
||||
a = p.parse_args()
|
||||
if a.base_steps <= 0 or a.base_steps % INTERVAL or a.base_steps > 20000 or a.repeats < 1:
|
||||
p.error(f"base-steps must be a positive multiple of {INTERVAL} (<= 20000); repeats >= 1")
|
||||
a.source = a.source.resolve()
|
||||
identity = candidate_identity(a.source)
|
||||
plan = dict(collision="MRT", shifted_storage="as run_refined_dfg MRT config",
|
||||
candidate_source=identity, frozen_manifests_used=False, interval_base_steps=INTERVAL,
|
||||
base_steps=a.base_steps, warmup_base_steps=a.warmup_base_steps, repeats=a.repeats,
|
||||
variants=VARIANTS, tstar_window=a.base_steps * r.SPEED / 40.0,
|
||||
timer="time.perf_counter wall with explicit stream sync",
|
||||
excluded="construction/JIT, warm-up, health, downloads, parity/impulse comparison")
|
||||
if a.plan:
|
||||
print(json.dumps(plan, indent=2))
|
||||
return 0
|
||||
if a.max_wall_seconds is None or not math.isfinite(a.max_wall_seconds) or a.max_wall_seconds <= 0:
|
||||
p.error("GPU timing requires a finite positive --max-wall-seconds")
|
||||
if not os.environ.get("CUDA_VISIBLE_DEVICES") or "," in os.environ["CUDA_VISIBLE_DEVICES"]:
|
||||
p.error("Root must select exactly one verified idle GPU via CUDA_VISIBLE_DEVICES")
|
||||
if a.output.exists() and any(a.output.iterdir()):
|
||||
p.error("Output must be empty/new")
|
||||
a.output.mkdir(parents=True, exist_ok=True)
|
||||
deadline = time.monotonic() + a.max_wall_seconds
|
||||
h, _ = r.helpers(a.source)
|
||||
|
||||
from contextlib import ExitStack
|
||||
from CelerisLab.cuda.context import CudaContext
|
||||
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
|
||||
from CelerisLab.lbm.channel import channel_masks
|
||||
from CelerisLab.lbm.refinement import ACTIVE
|
||||
from CelerisLab.lbm.descriptors import SOLID
|
||||
|
||||
rows, ref = [], {}
|
||||
n_intervals = a.base_steps // INTERVAL
|
||||
with ExitStack() as stack:
|
||||
cfg, pair, body, host = r.host_geometry(a.source, h, "MRT")
|
||||
context = stack.enter_context(CudaContext(0))
|
||||
backend = stack.enter_context(FixedPatchStepper(
|
||||
cfg, r.BOUNDS, context=context, coarse_boundary="channel", base_block=r.BASE_BLOCK,
|
||||
fine_block=r.FINE_BLOCK, fine_objects=(body,), observe_raw_exchange=True,
|
||||
observe_window_exchange=True))
|
||||
masks = [channel_masks(pair.coarse_shape)[0] & ~pair.covered,
|
||||
(pair.fine_kind == ACTIVE) & ((host.flags.reshape(pair.fine_shape) & SOLID) == 0)]
|
||||
fresh = (r.initial_state(pair.coarse_shape, (0.0, 0.0), 1.0, root=True),
|
||||
r.initial_state(pair.fine_shape, pair.fine_origin, 0.5))
|
||||
print(json.dumps(dict(status="CONSTRUCTED", case="refined")), flush=True)
|
||||
for repeat in range(a.repeats):
|
||||
for variant in VARIANTS:
|
||||
t.check_deadline(deadline)
|
||||
backend.initialize(*fresh)
|
||||
for _ in range(a.warmup_base_steps):
|
||||
backend.step(1)
|
||||
warm = t.refined_health(backend, masks)
|
||||
if repeat == 0 and variant == "A":
|
||||
print(json.dumps(dict(status="WARM_READY", case="refined")), flush=True)
|
||||
t.sync_refined(backend)
|
||||
start = time.perf_counter()
|
||||
totals, scales = run_variant(backend, variant, n_intervals, deadline)
|
||||
t.sync_refined(backend)
|
||||
seconds = time.perf_counter() - start
|
||||
end = t.refined_health(backend, masks)
|
||||
values = [np.array(v, copy=True) for v in backend._download_raw().values]
|
||||
if variant == "A" and repeat == 0:
|
||||
ref.update(values=values, totals=totals, scales=scales)
|
||||
parity, ratio = "identical", 0.0
|
||||
else:
|
||||
parity = t.same_state(ref["values"], values) or "identical"
|
||||
ratio = compare_intervals(ref["totals"], ref["scales"], totals)
|
||||
rows.append(dict(variant=variant, repeat=repeat, seconds=seconds,
|
||||
intervals=n_intervals, population_parity_vs_A=parity,
|
||||
interval_impulse_violation_ratio=ratio,
|
||||
health_after_warmup=warm, health_after_window=end))
|
||||
if parity != "identical" or not ratio <= 1:
|
||||
raise RuntimeError(f"{variant} repeat {repeat}: parity {parity}, impulse ratio {ratio}")
|
||||
summary = {v: dict(median_seconds=statistics.median(x["seconds"] for x in rows if x["variant"] == v),
|
||||
seconds=[x["seconds"] for x in rows if x["variant"] == v]) for v in VARIANTS}
|
||||
summary.update(W_over_A=summary["W"]["median_seconds"] / summary["A"]["median_seconds"],
|
||||
W1_over_A=summary["W1"]["median_seconds"] / summary["A"]["median_seconds"],
|
||||
interpretation="Measured comparative timings under GPU variability; W also "
|
||||
"batches step calls, W1 isolates the read pattern. Not a "
|
||||
"benchmark-throughput or causal claim.")
|
||||
(a.output / "window_timing.json").write_text(json.dumps(dict(plan=plan, rows=rows, summary=summary),
|
||||
indent=2))
|
||||
print(json.dumps(summary, indent=2), flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,12 +0,0 @@
|
||||
unittest.case.FunctionTestCase (test_bounded_state_evidence) ... ok
|
||||
unittest.case.FunctionTestCase (test_disjoint_complete_categories) ... ok
|
||||
unittest.case.FunctionTestCase (test_exact_config_and_source) ... ok
|
||||
unittest.case.FunctionTestCase (test_inactive_nonfinite_does_not_fail) ... ok
|
||||
unittest.case.FunctionTestCase (test_original_safety_limits) ... ok
|
||||
unittest.case.FunctionTestCase (test_population_macro_force_separate) ... ok
|
||||
unittest.case.FunctionTestCase (test_source_guard_and_deadline) ... ok
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Ran 7 tests in 0.039s
|
||||
|
||||
OK
|
||||
@@ -1,312 +0,0 @@
|
||||
"""Read-only observations of the exact failed SRT control, at most 320 steps.
|
||||
|
||||
Default is CPU preflight. Execution requires root admission and an external watchdog.
|
||||
No solver or numerical parameter modifications. --without-cylinder removes only the body;
|
||||
all other geometry and the production initialization remain unchanged.
|
||||
"""
|
||||
import argparse
|
||||
from copy import deepcopy
|
||||
import hashlib
|
||||
import importlib
|
||||
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
|
||||
DEFAULT_SOURCE=HERE.parents[2]
|
||||
LIMIT_BYTES=128*1024**2
|
||||
HISTORY_LIMIT_BYTES=12*1024**2
|
||||
HISTORY_RESERVE_BYTES=64*1024
|
||||
|
||||
|
||||
class InletHistory:
|
||||
"""Optional copies of existing snapshots only; never changes solver/health stop."""
|
||||
def __init__(self, flags, parameters, limit=HISTORY_LIMIT_BYTES):
|
||||
self.flags = np.array(flags[:, :4], copy=True)
|
||||
self.parameters = parameters
|
||||
self.limit = limit
|
||||
self.frames = []
|
||||
self.steps = []
|
||||
planned_bytes = 197 * (9 * self.flags.shape[0] * 4 * 4 + 24) + self.flags.nbytes
|
||||
if planned_bytes + HISTORY_RESERVE_BYTES > self.limit:
|
||||
raise ValueError('Requested history window exceeds 12 MiB budget')
|
||||
|
||||
def record(self, state):
|
||||
if int(state['step']) > 196:
|
||||
return
|
||||
raw = state['raw']
|
||||
if (raw.dtype != np.float32 or raw.ndim != 3 or raw.shape[0] != 9
|
||||
or raw.shape[1] != self.flags.shape[0] or raw.shape[2] < 4):
|
||||
raise ValueError('History requires raw shifted FP32 D2Q9 with four inlet columns')
|
||||
step = int(state['step'])
|
||||
if step != len(self.steps):
|
||||
raise ValueError('History requires contiguous completed steps starting at zero')
|
||||
frame = raw[:, :, :4]
|
||||
self.frames.append(frame.copy())
|
||||
self.steps.append(step)
|
||||
|
||||
def save(self, output, terminal_step):
|
||||
steps = np.asarray(self.steps, dtype=np.int64)
|
||||
raw = np.stack(self.frames) if self.frames else np.empty((0, 9, self.flags.shape[0], 4), np.float32)
|
||||
path = output / 'inlet_history.npz'
|
||||
np.savez(path, raw_shifted=raw, flags=self.flags, steps=steps,
|
||||
time_s=steps * self.parameters['dt'],
|
||||
tstar=steps * self.parameters['mean_speed'] / self.parameters['diameter'],
|
||||
x_indices=np.arange(4), y_indices=np.arange(self.flags.shape[0]),
|
||||
requested_step_range=np.array([0,196],np.int64),
|
||||
diagnostic_terminal_step=np.array(terminal_step,np.int64))
|
||||
size = path.stat().st_size
|
||||
if size > self.limit:
|
||||
raise RuntimeError('History exceeded its reserved artifact bound')
|
||||
return dict(mode='inlet_raw_history', requested_first_step=0, requested_last_step=196,
|
||||
complete_for_requested_window=len(steps)==197,
|
||||
diagnostic_terminal_step=int(terminal_step), frames=len(steps),
|
||||
first_step=None if not len(steps) else int(steps[0]),
|
||||
last_step=None if not len(steps) else int(steps[-1]),
|
||||
bytes=size, limit_bytes=self.limit,
|
||||
sha256=hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||
layout='time,q,y,x; active postcollision raw shifted FP32; step zero is initialization',
|
||||
window_policy='Recording stops after step196; evolution and health stopping remain unchanged')
|
||||
|
||||
|
||||
def record_history(history, state):
|
||||
if history is not None:
|
||||
history.record(state)
|
||||
|
||||
|
||||
def dump(path,value):
|
||||
path.write_text(json.dumps(value,indent=2,allow_nan=False))
|
||||
|
||||
|
||||
def number(value):
|
||||
value=float(value)
|
||||
return value if math.isfinite(value) else ('NaN' if math.isnan(value) else ('+Inf' if value>0 else '-Inf'))
|
||||
|
||||
|
||||
def source_config(source, manifest_path=None):
|
||||
manifest=json.loads((HERE/'source_manifest.json' if manifest_path is None else manifest_path).read_text())
|
||||
for name,digest in {**manifest['sha256'],**manifest.get('runtime_sha256',{})}.items():
|
||||
if hashlib.sha256((source/name).read_bytes()).hexdigest()!=digest:
|
||||
raise ValueError('Pinned source mismatch: '+name)
|
||||
sys.path.insert(0,str(source/'src')); sys.path.insert(0,str(source/'tests/validation'))
|
||||
import CelerisLab
|
||||
if Path(CelerisLab.__file__).resolve()!=source/'src/CelerisLab/__init__.py':
|
||||
raise ValueError('Wrong CelerisLab import')
|
||||
runner=importlib.import_module('run_refined_dfg')
|
||||
if Path(runner.__file__).resolve()!=source/'tests/validation/run_refined_dfg.py':
|
||||
raise ValueError('Wrong runner import')
|
||||
h,_=runner.helpers(source)
|
||||
cfg,body,p=runner.config(h,80) # Exact original config factory, not a reimplementation.
|
||||
return cfg,body,p,manifest
|
||||
|
||||
|
||||
def select_body(body, without_cylinder=False):
|
||||
selected = deepcopy(body)
|
||||
if without_cylinder:
|
||||
if len(selected["objects"]) != 1 or selected["objects"][0]["type"] != "cylinder":
|
||||
raise ValueError("Bodyless control requires exactly the original cylinder")
|
||||
selected["objects"] = []
|
||||
return selected
|
||||
|
||||
|
||||
def body_force(sim, body):
|
||||
# No body means no body-id 0 and no force observable; never fabricate zeros.
|
||||
return sim.read_force(0).astype(np.float64) if body["objects"] else None
|
||||
|
||||
|
||||
def categories(flags,p):
|
||||
"""Disjoint physical categories; subtype nibble is not a bitfield."""
|
||||
from CelerisLab.lbm.descriptors import FLUID,SOLID,OBSTACLE
|
||||
y,x=np.indices(flags.shape); fluid=(flags&FLUID)!=0
|
||||
masks={}; left=fluid.copy()
|
||||
for name,test in (
|
||||
('body_near',(x-p['center'][0])**2+(y-p['center'][1])**2 <= (p['radius']+3)**2),
|
||||
('outlet_near',x>=flags.shape[1]-4),('inlet_near',x<=3),
|
||||
('wall_near',(y<=3)|(y>=flags.shape[0]-4))):
|
||||
masks[name]=left&test; left &= ~test
|
||||
masks['bulk']=left
|
||||
subtype=flags&0xf0
|
||||
masks['inlet_ghost']=(~fluid)&(subtype==0x20)
|
||||
masks['outlet_ghost']=(~fluid)&(subtype==0x30)
|
||||
masks['wall_ghost']=(~fluid)&(subtype==0x10)
|
||||
assigned=np.logical_or.reduce(list(masks.values()))
|
||||
masks['body_solid']=(~assigned)&(((flags&OBSTACLE)!=0)|((flags&SOLID)!=0))
|
||||
assigned|=masks['body_solid']; masks['other_inactive']=~assigned
|
||||
return fluid,masks
|
||||
|
||||
|
||||
def finite_extrema(a,mask):
|
||||
good=mask&np.isfinite(a)
|
||||
if not good.any(): return dict(min=None,max=None)
|
||||
idx=np.flatnonzero(good); values=a.ravel()[idx]
|
||||
lo=int(idx[np.argmin(values)]); hi=int(idx[np.argmax(values)])
|
||||
return dict(min=float(a.ravel()[lo]),max=float(a.ravel()[hi]),
|
||||
min_yx=list(map(int,np.unravel_index(lo,a.shape))),
|
||||
max_yx=list(map(int,np.unravel_index(hi,a.shape))))
|
||||
|
||||
|
||||
def diagnose(g,macro,force,flags,p,step):
|
||||
fluid,masks=categories(flags,p)
|
||||
badpop=~np.isfinite(g); badnode=badpop.any(axis=0)
|
||||
badmacro=np.logical_or.reduce([~np.isfinite(macro[k]) for k in ('rho','ux','uy')])
|
||||
with np.errstate(over='ignore',invalid='ignore'):
|
||||
mach=np.sqrt(3*(np.asarray(macro['ux'],np.float64)**2+np.asarray(macro['uy'],np.float64)**2))
|
||||
rho=macro['rho']
|
||||
# Exactly the original physical macro/reaction conditions, now sampled each step.
|
||||
nonfinite_macro=bool(np.any(badmacro&fluid))
|
||||
force_bad=force is not None and not np.isfinite(force).all()
|
||||
density_bad=bool(np.any(fluid&((rho<.5)|(rho>1.5))))
|
||||
mach_bad=bool(np.any(fluid&(mach>.2)))
|
||||
population_bad=bool(np.any(badnode&fluid))
|
||||
original_failure=nonfinite_macro or force_bad or density_bad or mach_bad
|
||||
offending=fluid&(badnode|badmacro|(rho<.5)|(rho>1.5)|(mach>.2))
|
||||
examples=[]
|
||||
for y,x in np.argwhere(offending)[:16]:
|
||||
examples.append(dict(y=int(y),x=int(x),physical_xy_m=[float(x*p['dx']),float((y-.5)*p['dx'])],
|
||||
category=next(name for name,mask in masks.items() if mask[y,x]),flag=int(flags[y,x]),
|
||||
nonfinite_q=np.flatnonzero(badpop[:,y,x]).tolist(),
|
||||
populations=[number(v) for v in g[:,y,x]],
|
||||
rho=number(rho[y,x]),ux=number(macro['ux'][y,x]),uy=number(macro['uy'][y,x])))
|
||||
neighborhoods=[]
|
||||
for node in examples[:4]:
|
||||
y,x=node['y'],node['x']; y0,y1=max(0,y-2),min(flags.shape[0],y+3); x0,x1=max(0,x-2),min(flags.shape[1],x+3)
|
||||
neighborhoods.append(dict(bounds_xy=[x0,y0,x1,y1],flags=flags[y0:y1,x0:x1].tolist(),
|
||||
physical_populations=[[[number(v) for v in row] for row in plane] for plane in g[:,y0:y1,x0:x1]]))
|
||||
return dict(step=step,tstar=step*p['mean_speed']/p['diameter'],
|
||||
physical_failure=bool(original_failure or population_bad),
|
||||
original_health_failure=bool(original_failure),
|
||||
physical_population_nonfinite=population_bad,physical_macro_nonfinite=nonfinite_macro,
|
||||
force_nonfinite=bool(force_bad),density_limit=density_bad,mach_limit=mach_bad,
|
||||
force=None if force is None else [number(v) for v in force],
|
||||
categories={name:dict(nodes=int(mask.sum()),nonfinite_population_nodes=int((mask&badnode).sum()),
|
||||
nonfinite_populations=int(badpop[:,mask].sum()),nonfinite_macro_nodes=int((mask&badmacro).sum()))
|
||||
for name,mask in masks.items()},
|
||||
rho=finite_extrema(rho,fluid),mach=finite_extrema(mach,fluid),
|
||||
first_physical_examples=examples,local_neighborhoods=neighborhoods)
|
||||
|
||||
|
||||
def snapshot(sim,p):
|
||||
# Public physical decode and original production macro path. No host reconstruction
|
||||
# substitutes for the original health check. get_ddf shares its completed-step cache.
|
||||
with np.errstate(all='ignore'):
|
||||
macro={k:np.asarray(v).copy() for k,v in sim.get_macroscopic().items()}
|
||||
g=sim.get_ddf().reshape(9,p['ny'],p['nx'])
|
||||
import pycuda.driver as cuda
|
||||
raw=np.empty_like(g,dtype=np.float32)
|
||||
with sim.ctx.activate():
|
||||
cuda.memcpy_dtoh(raw,sim.field.ddf_gpu)
|
||||
return dict(raw=raw,g=g,macro=macro,step=int(sim.stepper.step_count))
|
||||
|
||||
|
||||
def save_states(dest,previous,current,flags,diagnostic):
|
||||
"""Two states only; raw shifted bits and decoded observations, no trajectory."""
|
||||
arrays={'flags':flags}
|
||||
for name,state in (('previous',previous),('current',current)):
|
||||
if state is None: continue
|
||||
arrays[name+'_raw_shifted']=state['raw']
|
||||
arrays[name+'_physical']=state['g']
|
||||
arrays[name+'_step']=np.array(state['step'],np.int64)
|
||||
# At D80, raw+physical for two states ~84 MiB; reserve >4 MiB for JSON/NPZ overhead.
|
||||
if sum(v.nbytes for v in arrays.values())>LIMIT_BYTES-4*1024**2:
|
||||
raise ValueError('Evidence arrays exceed bounded artifact budget')
|
||||
np.savez(dest/'states.npz',**arrays)
|
||||
dump(dest/'diagnostics.json',diagnostic)
|
||||
size=sum(p.stat().st_size for p in dest.iterdir() if p.is_file())
|
||||
if size>LIMIT_BYTES: raise RuntimeError('Evidence directory exceeds 128 MiB')
|
||||
return size
|
||||
|
||||
|
||||
def deadline_check(deadline):
|
||||
if time.monotonic()>=deadline: raise TimeoutError('Diagnostic wall ceiling reached')
|
||||
|
||||
|
||||
def execute(a,cfg,body,p):
|
||||
from CelerisLab.simulation import Simulation
|
||||
started=time.monotonic(); deadline=started+a.wall_seconds
|
||||
previous=None; prior_report=None; initial_report=None
|
||||
with TemporaryDirectory() as temp:
|
||||
temp=Path(temp); dump(temp/'lbm.json',cfg); dump(temp/'body.json',body)
|
||||
with Simulation(str(temp/'lbm.json'),str(temp/'body.json'),device_id=0) as sim:
|
||||
sim.initialize() # Exact original production initialization, unchanged.
|
||||
flags=sim.get_flags().reshape(p['ny'],p['nx'])
|
||||
current=snapshot(sim,p)
|
||||
history=InletHistory(flags,p) if a.record_inlet_history else None
|
||||
record_history(history,current)
|
||||
initial_report=diagnose(current['g'],current['macro'],None,flags,p,0)
|
||||
report=initial_report; status='initial_physical_failure' if report['physical_failure'] else 'step_cap_reached'
|
||||
try:
|
||||
deadline_check(deadline)
|
||||
if not report['physical_failure']:
|
||||
for _ in range(320):
|
||||
deadline_check(deadline)
|
||||
previous=current; prior_report=report
|
||||
sim.run(1) # Same solver; observation buffers reset/read once per step.
|
||||
force=body_force(sim,body)
|
||||
current=snapshot(sim,p)
|
||||
record_history(history,current)
|
||||
report=diagnose(current['g'],current['macro'],force,flags,p,current['step'])
|
||||
if current['step']==1:
|
||||
print('READY '+json.dumps(dict(**a.identity,step=1,all_observation_paths_exercised=True,
|
||||
physical_healthy=not report['physical_failure']),allow_nan=False),flush=True)
|
||||
if report['physical_failure']:
|
||||
status='first_physical_failure'; break
|
||||
deadline_check(deadline)
|
||||
except TimeoutError:
|
||||
status='wall_time_limit'
|
||||
details=dict(**a.identity,status=status,executed_steps=int(sim.stepper.step_count),
|
||||
observation_step=current['step'],previous_observation_step=None if previous is None else previous['step'],
|
||||
initial=initial_report,preceding=prior_report,current=report,
|
||||
wall_seconds=time.monotonic()-started,
|
||||
storage='raw_shifted FP32 is exact active-buffer data; physical is production FP32 decoded G. Both are diagnostic snapshots, not complete restart checkpoints.',
|
||||
health_scope='Physical FLUID nodes and force only; ghost/body categories cannot independently trigger physical failure.',
|
||||
interpretation='Near-region labels locate symptoms, not the root cause. Per-step inspection changes observation cadence only. No physical acceptance.')
|
||||
if history is not None:
|
||||
details['inlet_history']=history.save(a.output,current['step'])
|
||||
size=save_states(a.output,previous,current,flags,details)
|
||||
print(json.dumps(dict(**a.identity,status=status,executed_steps=int(sim.stepper.step_count),evidence_bytes=size),allow_nan=False),flush=True)
|
||||
return 0 if status=='step_cap_reached' else 2
|
||||
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--source',type=Path,default=DEFAULT_SOURCE)
|
||||
parser.add_argument('--output',type=Path,required=True)
|
||||
parser.add_argument('--execute',action='store_true')
|
||||
parser.add_argument('--without-cylinder',action='store_true',
|
||||
help='Remove only the cylinder for the bounded inlet control')
|
||||
parser.add_argument('--record-inlet-history',action='store_true',
|
||||
help='Record bounded raw x0..3 history without changing health stopping')
|
||||
parser.add_argument('--gpu-admitted',action='store_true')
|
||||
parser.add_argument('--wall-seconds',type=float)
|
||||
a=parser.parse_args(); a.source=a.source.resolve()
|
||||
cfg,body,p,manifest=source_config(a.source)
|
||||
body=select_body(body,a.without_cylinder)
|
||||
from source_provenance import checkout_provenance
|
||||
a.identity=dict(observation_mode='inlet_raw_history' if a.record_inlet_history else 'first_health_pair',
|
||||
history_limit_bytes=HISTORY_LIMIT_BYTES if a.record_inlet_history else None,
|
||||
history_requested_steps=[0,196] if a.record_inlet_history else None,
|
||||
control="without_cylinder" if a.without_cylinder else "baseline_cylinder",
|
||||
force_observation="not_applicable_no_body" if a.without_cylinder else "per_step_body_reaction",
|
||||
baseline_diagnostic_commit="8e378aa047bb35ae4ec46037eff7256bd2f074e7",
|
||||
diagnostic_checkout=checkout_provenance(DEFAULT_SOURCE),
|
||||
numerical_checkout=checkout_provenance(a.source),
|
||||
diagnostic_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest())
|
||||
if a.output.exists() and any(a.output.iterdir()): raise ValueError('Use a new/empty output directory')
|
||||
if a.execute and (not a.gpu_admitted or not os.environ.get('CUDA_VISIBLE_DEVICES') or
|
||||
a.wall_seconds is None or not math.isfinite(a.wall_seconds) or a.wall_seconds<=0):
|
||||
raise ValueError('Explicit root admission, device selection and finite wall ceiling required')
|
||||
a.output.mkdir(parents=True,exist_ok=True)
|
||||
dump(a.output/'preflight.json',dict(**a.identity,source=manifest,config=cfg,body=body,parameters=p,
|
||||
max_steps=320,
|
||||
max_evidence_bytes=LIMIT_BYTES,executed=False))
|
||||
if not a.execute: return 0
|
||||
return execute(a,cfg,body,p)
|
||||
|
||||
|
||||
if __name__=='__main__': raise SystemExit(main())
|
||||
@@ -1,132 +0,0 @@
|
||||
"""CPU-only synthetic diagnostic and source-contract tests; no simulation results."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
import numpy as np
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parent))
|
||||
import diagnose_uniform_srt as d
|
||||
cfg,body,p,manifest=d.source_config(d.DEFAULT_SOURCE)
|
||||
|
||||
|
||||
def fixture():
|
||||
flags=np.ones((12,20),np.uint16)
|
||||
flags[:,0]=2|0x20; flags[:,-1]=2|0x30
|
||||
flags[[0,-1]]=2|0x10; flags[5,6]=2|0x40|0x200
|
||||
g=np.full((9,12,20),1/9,np.float32)
|
||||
macro=dict(rho=np.ones((12,20)),ux=np.zeros((12,20)),uy=np.zeros((12,20)))
|
||||
params=dict(center=[6,5],radius=1,dx=.1,mean_speed=.01,diameter=80)
|
||||
return g,macro,flags,params
|
||||
|
||||
|
||||
def test_exact_config_and_source():
|
||||
assert manifest['commit']=='9805383f9c50d867e8555471baf5dbda8333a652'
|
||||
assert p['diameter']==80 and p['mean_speed']==.01 and p['nx']==1761 and p['ny']==330
|
||||
assert cfg['method']['collision']=='SRT' and cfg['method']['ddf_shifting']
|
||||
assert cfg['method']['outlet']['srt_neq_damp']==.5
|
||||
assert np.isclose(3*p['viscosity']+.5,.524)
|
||||
assert body['objects'][0]['center']==[160,160.5]
|
||||
|
||||
|
||||
def test_inactive_nonfinite_does_not_fail():
|
||||
g,m,f,p=fixture(); fluid,_=d.categories(f,p)
|
||||
g[:,~fluid]=np.nan
|
||||
for a in m.values(): a[~fluid]=np.nan
|
||||
r=d.diagnose(g,m,np.zeros(2),f,p,1)
|
||||
assert not r['physical_failure']
|
||||
assert r['categories']['body_solid']['nonfinite_population_nodes']==1
|
||||
assert r['categories']['outlet_ghost']['nodes']==10
|
||||
assert r['categories']['inlet_ghost']['nodes']==10
|
||||
json.dumps(r,allow_nan=False)
|
||||
|
||||
|
||||
def test_population_macro_force_separate():
|
||||
g,m,f,p=fixture(); g[4,6,17]=np.inf
|
||||
r=d.diagnose(g,m,np.zeros(2),f,p,27)
|
||||
assert r['physical_failure'] and r['physical_population_nonfinite']
|
||||
assert not r['original_health_failure'] and not r['force_nonfinite']
|
||||
assert r['first_physical_examples'][0]['category']=='outlet_near'
|
||||
assert r['first_physical_examples'][0]['nonfinite_q']==[4]
|
||||
assert r['first_physical_examples'][0]['populations'][4]=='+Inf'
|
||||
json.dumps(r,allow_nan=False)
|
||||
g,m,f,p=fixture(); m['ux'][6,10]=np.nan
|
||||
r=d.diagnose(g,m,np.zeros(2),f,p,28)
|
||||
assert r['physical_macro_nonfinite'] and not r['physical_population_nonfinite']
|
||||
assert not r['force_nonfinite'] and r['original_health_failure']
|
||||
g,m,f,p=fixture(); r=d.diagnose(g,m,np.array([np.nan,0]),f,p,29)
|
||||
assert r['force_nonfinite'] and not r['physical_macro_nonfinite']
|
||||
assert not r['first_physical_examples']
|
||||
|
||||
|
||||
def test_original_safety_limits():
|
||||
g,m,f,p=fixture(); m['rho'][5,7]=.49
|
||||
r=d.diagnose(g,m,[0,0],f,p,1)
|
||||
assert r['density_limit'] and r['first_physical_examples'][0]['category']=='body_near'
|
||||
g,m,f,p=fixture(); m['ux'][6,10]=.2
|
||||
assert d.diagnose(g,m,[0,0],f,p,1)['mach_limit']
|
||||
|
||||
|
||||
def test_disjoint_complete_categories():
|
||||
_,_,f,p=fixture(); fluid,masks=d.categories(f,p)
|
||||
np.testing.assert_array_equal(sum(mask.astype(int) for mask in masks.values()),np.ones(f.shape))
|
||||
physical=sum(masks[k].astype(int) for k in ('body_near','inlet_near','outlet_near','wall_near','bulk'))
|
||||
np.testing.assert_array_equal(physical,fluid.astype(int))
|
||||
|
||||
|
||||
def test_bounded_state_evidence():
|
||||
g,m,f,p=fixture(); state=dict(raw=g.copy(),g=g,macro=m,step=1)
|
||||
with TemporaryDirectory() as tmp:
|
||||
dest=Path(tmp); size=d.save_states(dest,state,state,f,{'status':'synthetic_test'})
|
||||
assert size<d.LIMIT_BYTES
|
||||
with np.load(dest/'states.npz') as saved:
|
||||
np.testing.assert_array_equal(saved['previous_raw_shifted'],g)
|
||||
np.testing.assert_array_equal(saved['current_physical'],g)
|
||||
assert sorted(x.name for x in dest.iterdir())==['diagnostics.json','states.npz']
|
||||
actual_bytes=4*(9*330*1761*4)+(330*1761*2)+16
|
||||
assert actual_bytes<d.LIMIT_BYTES-4*1024**2
|
||||
|
||||
|
||||
def test_source_guard_and_deadline():
|
||||
with TemporaryDirectory() as tmp:
|
||||
with unittest.TestCase().assertRaises((ValueError,FileNotFoundError)):
|
||||
d.source_config(Path(tmp))
|
||||
with unittest.TestCase().assertRaises(TimeoutError): d.deadline_check(0)
|
||||
|
||||
|
||||
def test_bodyless_selection_preserves_baseline():
|
||||
from copy import deepcopy
|
||||
baseline_config, baseline_body, baseline_parameters = deepcopy((cfg,body,p))
|
||||
selected = d.select_body(body,True)
|
||||
assert selected == {"objects": []}
|
||||
assert (cfg,body,p) == (baseline_config,baseline_body,baseline_parameters)
|
||||
assert d.select_body(body) == baseline_body
|
||||
assert d.select_body(body) is not body
|
||||
assert p['nx']==1761 and p['ny']==330 and p['mean_speed']==.01
|
||||
assert np.isclose(3*p['viscosity']+.5,.524)
|
||||
for invalid in ({"objects":[]},{"objects":body['objects']*2},
|
||||
{"objects":[{"type":"other"}]}):
|
||||
with unittest.TestCase().assertRaises(ValueError): d.select_body(invalid,True)
|
||||
|
||||
|
||||
def test_absent_force_is_not_zero_or_read():
|
||||
class FakeSimulation:
|
||||
def __init__(self): self.calls=[]
|
||||
def read_force(self,index):
|
||||
self.calls.append(index)
|
||||
return np.array([1.,2.],np.float32)
|
||||
sim=FakeSimulation()
|
||||
assert d.body_force(sim,{"objects":[]}) is None and not sim.calls
|
||||
np.testing.assert_array_equal(d.body_force(sim,body),[1.,2.])
|
||||
assert sim.calls==[0]
|
||||
g,m,f,params=fixture()
|
||||
report=d.diagnose(g,m,None,f,params,1)
|
||||
assert report['force'] is None and not report['force_nonfinite']
|
||||
m['ux'][3,1]=.2
|
||||
assert d.diagnose(g,m,None,f,params,2)['physical_failure']
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
result=unittest.TextTestRunner(verbosity=2).run(unittest.TestSuite(
|
||||
unittest.FunctionTestCase(fn) for name,fn in sorted(globals().items()) if name.startswith('test_')))
|
||||
raise SystemExit(not result.wasSuccessful())
|
||||
@@ -1,91 +0,0 @@
|
||||
"""CPU-only fixed-window observation tests; no GPU or simulated acceptance."""
|
||||
from contextlib import redirect_stdout
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from types import SimpleNamespace, ModuleType
|
||||
from unittest.mock import patch
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
import numpy as np
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parent))
|
||||
import diagnose_uniform_srt as d
|
||||
|
||||
|
||||
def test_raw_bits_copy_and_prefix():
|
||||
flags=np.ones((12,20),np.uint16);p=dict(dt=.25,mean_speed=.01,diameter=80)
|
||||
h=d.InletHistory(flags,p)
|
||||
raw=np.arange(9*12*20,dtype=np.uint32).reshape(9,12,20).view(np.float32)
|
||||
raw[0,0,0]=np.float32(-0.)
|
||||
expected=raw[:,:,:4].view(np.uint32).copy()
|
||||
h.record(dict(raw=raw,step=0)); raw.fill(7); flags.fill(0)
|
||||
h.record(dict(raw=raw,step=1))
|
||||
with TemporaryDirectory() as temp:
|
||||
receipt=h.save(Path(temp),1);z=np.load(Path(temp)/'inlet_history.npz')
|
||||
np.testing.assert_array_equal(z['raw_shifted'][0].view(np.uint32),expected)
|
||||
assert np.all(z['flags']==1)
|
||||
np.testing.assert_array_equal(z['steps'],[0,1])
|
||||
np.testing.assert_array_equal(z['time_s'],[0,.25])
|
||||
np.testing.assert_array_equal(z['tstar'],[0,.01/80])
|
||||
assert not receipt['complete_for_requested_window'] and receipt['diagnostic_terminal_step']==1
|
||||
|
||||
|
||||
def test_fixed_window_bound_and_late_stop():
|
||||
flags=np.ones((330,1761),np.uint16);p=dict(dt=.1,mean_speed=.01,diameter=80)
|
||||
h=d.InletHistory(flags,p);raw=np.zeros((9,330,4),np.float32)
|
||||
for step in range(321):h.record(dict(raw=raw,step=step))
|
||||
assert len(h.frames)==197
|
||||
with TemporaryDirectory() as temp:
|
||||
r=h.save(Path(temp),320)
|
||||
assert r['complete_for_requested_window'] and r['last_step']==196 and r['diagnostic_terminal_step']==320
|
||||
assert r['bytes']<d.HISTORY_LIMIT_BYTES
|
||||
with unittest.TestCase().assertRaises(ValueError):d.InletHistory(flags,p,limit=100)
|
||||
with unittest.TestCase().assertRaises(ValueError):
|
||||
d.InletHistory(flags,p).record(dict(raw=np.zeros((9,331,4),np.float32),step=0))
|
||||
|
||||
|
||||
def test_off_by_default():
|
||||
import subprocess
|
||||
with TemporaryDirectory() as temp:
|
||||
subprocess.run([sys.executable,str(Path(d.__file__)),'--output',temp],check=True,capture_output=True)
|
||||
r=json.loads((Path(temp)/'preflight.json').read_text())
|
||||
assert r['observation_mode']=='first_health_pair' and r['history_requested_steps'] is None
|
||||
assert not (Path(temp)/'inlet_history.npz').exists()
|
||||
# Off-path must not inspect or modify observation state.
|
||||
d.record_history(None,object())
|
||||
|
||||
|
||||
def test_execution_identical_and_failure_included():
|
||||
class Sim:
|
||||
def __init__(self,*args,**kwargs):self.stepper=SimpleNamespace(step_count=0);self.calls=[]
|
||||
def __enter__(self):instances.append(self);return self
|
||||
def __exit__(self,*args):pass
|
||||
def initialize(self):self.calls.append('initialize')
|
||||
def get_flags(self):return np.ones((12,20),np.uint16)
|
||||
def run(self,n):self.calls.append(('run',n));self.stepper.step_count+=n
|
||||
module=ModuleType('CelerisLab.simulation');module.Simulation=Sim
|
||||
def snapshot(sim,p):
|
||||
raw=np.full((9,12,20),sim.stepper.step_count,np.float32)
|
||||
return dict(raw=raw,g=raw.copy(),macro={},step=sim.stepper.step_count)
|
||||
def diagnose(g,m,f,flags,p,step):return dict(physical_failure=step==3)
|
||||
p=dict(ny=12,nx=20,dt=.1,mean_speed=.01,diameter=80);instances=[];details=[]
|
||||
def save(output,prev,current,flags,report):details.append((prev['step'],current['step'],report));return 0
|
||||
with patch.dict(sys.modules,{'CelerisLab.simulation':module}),patch.object(d,'snapshot',snapshot),patch.object(d,'diagnose',diagnose),patch.object(d,'save_states',save),redirect_stdout(StringIO()):
|
||||
for enabled in [False,True]:
|
||||
with TemporaryDirectory() as temp:
|
||||
a=SimpleNamespace(wall_seconds=100,output=Path(temp),identity={},record_inlet_history=enabled)
|
||||
assert d.execute(a,{},dict(objects=[]),p)==2
|
||||
if enabled:
|
||||
z=np.load(Path(temp)/'inlet_history.npz')
|
||||
np.testing.assert_array_equal(z['steps'],[0,1,2,3])
|
||||
assert np.all(z['raw_shifted'][-1]==3)
|
||||
assert details[-1][2]['inlet_history']['diagnostic_terminal_step']==3
|
||||
assert instances[0].calls==instances[1].calls==['initialize',('run',1),('run',1),('run',1)]
|
||||
assert [(p,c) for p,c,_ in details]==[(2,3),(2,3)]
|
||||
assert details[0][2]['status']==details[1][2]['status']=='first_physical_failure'
|
||||
|
||||
if __name__=='__main__':
|
||||
result=unittest.TextTestRunner(verbosity=2).run(unittest.TestSuite(
|
||||
unittest.FunctionTestCase(fn) for name,fn in sorted(globals().items()) if name.startswith('test_')))
|
||||
raise SystemExit(not result.wasSuccessful())
|
||||
Reference in New Issue
Block a user