Compare commits

...
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 a454f02355 Jets: compute jet link shapes once per boundary preparation
surface_link_velocity already runs jet_link_shapes; CurvedLinkSoA.
assign_surface and _prepare_blocked_boundary then ran it again with the
same jets and hits to fill the host jet cache. A private _surface_links
returns (velocity, aperture, shape, jet_id) from the one call; both
callers use it. surface_link_velocity keeps its two-value public
contract (now a thin wrapper). Same values, same validation order, same
cache contents; host geometry preparation only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 05:57:11 +00:00
ClaudeandCodex 750e8a483e test: DFG Re20 steady rule as agreed (10 % range, 2 % slope, window-mean verdict)
Supersedes 71221837. Samples every t* 0.5 (interval-mean forces), trailing
t* 50..60 window (21 samples); range <= 10 % and |slope*window| <= 2 % of
each official band width. Verdict on the window mean with the distance to
the nearest band edge. Our engineering rule, frozen before any run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 869d60d78e)
2026-10-20 21:59:15 +08:00
ClaudeandCodex 6a03f463bc test: DFG Re20 steady rule per review (1 % band, range and slope, 0.5 s samples)
Samples every 0.5 s at synchronized root steps; trailing 5 s window (11
samples); range and |slope*window| each at most 1 % of the official band
width. States the fluid-on-cylinder force sign, reports cd_positive,
defaults to the refined case and uses the review's label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 71221837f9)
2026-10-20 21:59:15 +08:00
ClaudeandCodex 46dc6ba086 test: add DFG 2D-1 Re20 runner against official reference intervals
Reuses the pinned Re100 runner and helpers unchanged; sets only the
lattice viscosity for Re 20. Reports Cd, Cl and p_diff (dp * 0.04 for
U = 0.2 m/s), a steady rule frozen before any run (window ranges at most
a tenth of each official interval width) and no interval verdict when
not steady. Declares the pressure-NEQ outlet and Zou-He inlet deviations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 9b8ff90609)
2026-10-20 21:59:15 +08:00
ClaudeandCodex e1efc18274 test: follow the recursive _advance in three-level feed checks
baba5e0 replaced _advance_child with _advance(index, parent_buffer) driven
from _hop; the two feed-order tests still patched the removed method.
Patch _advance and record only index 2 (L1 hop feeding L2); assertions,
expected order and the stale-feed negative control are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 3f47436073)
2026-10-20 21:57:57 +08:00
7 changed files with 387 additions and 24 deletions
+49
View File
@@ -0,0 +1,49 @@
# DFG 2D-1 (Re 20) against the official reference intervals
`tests/validation/run_dfg_re20.py` runs the steady DFG 2D-1 case on the refined
channel root (D40 root, D80 wide fine patch) and on the uniform D80 grid. Geometry,
inflow, initial state, layout and observation are those of the pinned Re100 runner
(`run_refined_dfg.py`, `re100_helpers.py`, both unchanged); only the lattice
viscosity is set for Re 20 (root tau 0.56, fine 0.62, uniform 0.62, lattice speed 0.01).
Label: "compared against the official reference bands", not a full benchmark
certification: the outlet is pressure-NEQ (`neq_extrap`), not do-nothing, and the
inlet is local Zou-He.
## Quantities
- Cd = 2 Fx / (rho U^2 D), Cl = 2 Fy / (rho U^2 D), interval means over each sample.
- p_diff = p(0.15, 0.2) - p(0.25, 0.2), linear extrapolation to the exact surface
points (`re100_helpers.pressure_probes`). The helper returns dp / (rho U^2) in
scaled units; p_diff = dp * 0.04 for U = 0.2 m/s, rho = 1. Both are reported.
- Official intervals (Schaefer & Turek 1996): Cd [5.57, 5.59], Cl [0.0104, 0.0110],
p_diff [0.1172, 0.1176].
## Bounds and steady rule (frozen before any run)
- End time t* = 60 (30 s physical): 240,000 refined root steps, 480,000 uniform steps.
- One sample every t* = 0.5 (0.25 s) at the end of a synchronized root step; Cd and
Cl are interval means. Trailing window t* 50..60 inclusive (21 samples).
- Steady rule (our engineering choice, not official): in the window, for each of Cd,
Cl, p_diff, max - min <= 10 % and |least-squares slope x window| <= 2 % of its
official interval width (range 2e-3 / 6e-5 / 4e-5; slope 4e-4 / 1.2e-5 / 8e-6).
Absolute limits, so the small Cl is never a divisor. Never relaxed after a run.
- The verdict uses the window mean; the distance to the nearest interval edge is
reported for each quantity (negative outside).
- Force sign: fluid on cylinder, x along the inflow, y up from the bottom wall; Cd > 0
and the official Cl > 0 (`cd_positive` is reported).
- Not steady at t* = 60: status `NOT_STEADY`, no interval verdict. Health limits
(Mach <= 0.2, rho in [0.5, 1.5]) stop the run with `UNHEALTHY`.
- Steady: `WITHIN_INTERVALS` or `OUTSIDE_INTERVALS`, with the per-quantity verdict.
## Run
```
python tests/validation/run_dfg_re20.py --output <new-dir> --plan
CUDA_VISIBLE_DEVICES=<n> python tests/validation/run_dfg_re20.py --case both \
--collision SRT --output <new-dir> --max-wall-seconds <cap>
```
The uniform D80 control separates method error from refinement error. `--plan` writes `plan.json` only. A GPU run writes `plan.json` and `result.json`
(per case: lattice parameters, every sample, window ranges, status, verdict).
Host contract tests: `tests/unit/test_dfg_re20.py`.
+14 -3
View File
@@ -207,19 +207,30 @@ def surface_link_velocity(objects, cfg, flags, fluid_idx, dir, body_id, rx, ry,
jets=(), hits=None, *, _shifted_blocked=False):
"""Per-link prescribed surface velocity (n, 2) FP32 and aperture mask.
See _surface_links, which also returns the unit-amplitude jet shapes.
"""
return _surface_links(objects, cfg, flags, fluid_idx, dir, body_id, rx, ry, jets, hits,
_shifted_blocked=_shifted_blocked)[:2]
def _surface_links(objects, cfg, flags, fluid_idx, dir, body_id, rx, ry,
jets=(), hits=None, *, _shifted_blocked=False):
"""(velocity, aperture, jet shape, jet id); the last two are None without jets.
Host-only and storage-neutral: rows follow the given physical cut-link
order. Returns (None, None) when neither an object carries surface
order. Returns all None when neither an object carries surface
actuation nor a jet is given. Jets need true FP64 ``hits`` (n, 2) and may
not share a link with each other or with a legacy aperture.
Shifted storage is privately enabled only by the all-blocked stepper.
"""
enabled = [obj for obj in objects if obj.surface_actuation is not None]
if not enabled and not jets:
return None, None
return None, None, None, None
from .preprocess.flag_overlay import validate_cut_link_ownership
validate_cut_link_ownership(flags, fluid_idx, dir, cfg.nq)
surface_velocity = np.zeros((len(dir), 2), np.float32)
surface_aperture = np.zeros(len(dir), bool)
shape = jet_id = None
for obj in enabled:
obj.validate_surface_actuation(cfg)
selected = body_id == obj.obj_id
@@ -252,4 +263,4 @@ def surface_link_velocity(objects, cfg, flags, fluid_idx, dir, body_id, rx, ry,
raise ValueError("Jet overlaps a SurfaceActuation aperture")
surface_velocity[driven] = jet_link_velocity(jets, shape, jet_id)[driven]
surface_aperture |= driven
return surface_velocity, surface_aperture
return surface_velocity, surface_aperture, shape, jet_id
+2 -4
View File
@@ -94,12 +94,10 @@ class CurvedLinkSoA:
"""
self.surface_velocity = self.aperture = None
self.jets, self.jet_shape, self.jet_id = tuple(jets), None, None
self.surface_velocity, self.aperture = surface_link_velocity(
from ..body.jets import _surface_links
self.surface_velocity, self.aperture, self.jet_shape, self.jet_id = _surface_links(
objects, cfg, flags, self.fluid_idx, self.dir, self.body_id, self.rx, self.ry,
jets=self.jets, hits=hits)
if self.jets:
from ..body.jets import jet_link_shapes
self.jet_shape, self.jet_id = jet_link_shapes(self.jets, hits)
def set_jet_amplitude(self, index, amplitude, stream=None):
"""Rewrite jet rows from cached shapes; re-upload only the velocity column.
+6 -4
View File
@@ -8,7 +8,7 @@ from dataclasses import dataclass, replace
import numpy as np
from .blocks import blocked_link_tables
from ..body.jets import jet_link_shapes, surface_link_velocity
from ..body.jets import _surface_links
from .descriptors import D2Q9_EX, D2Q9_EY
from .refinement import ACTIVE, OUTER_GHOST
@@ -142,11 +142,13 @@ def _prepare_blocked_boundary(boundary, layout, cfg, jets, *, _shifted_blocked=F
tables.update(body_id=np.asarray(body_id, np.int32), rx=np.asarray(rx, np.float32),
ry=np.asarray(ry, np.float32), aperture=np.zeros(len(dirs), bool))
actuated = bool(jets) or any(o.surface_actuation is not None for o in boundary.objects)
jet_cache = None
if actuated:
cfg = replace(cfg)
cfg.validate()
tables["surface_velocity"], tables["aperture"] = surface_link_velocity(
tables["surface_velocity"], tables["aperture"], shape, jet_id = _surface_links(
boundary.objects, cfg, boundary.flags, fluid_idx, dirs, body_id, rx, ry,
jets=jets, hits=boundary.curved_hits, _shifted_blocked=_shifted_blocked)
return _BlockedBoundary(boundary, tables, jets, actuated,
jet_link_shapes(jets, boundary.curved_hits) if jets else None)
if jets:
jet_cache = (shape, jet_id)
return _BlockedBoundary(boundary, tables, jets, actuated, jet_cache)
+79
View File
@@ -0,0 +1,79 @@
"""CPU-only contract tests for run_dfg_re20. Not CUDA or DFG acceptance evidence."""
import sys
from pathlib import Path
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "validation"))
import run_dfg_re20 as v # noqa: E402
import run_refined_dfg as rr # noqa: E402
h, _ = rr.helpers(rr.DEFAULT_SOURCE)
def rows(values, tstar_end=v.TSTAR_MAX):
n = int(round(tstar_end / v.SAMPLE_TSTAR))
return [dict(tstar=(i + 1) * v.SAMPLE_TSTAR, **values(i)) for i in range(n)]
class Re20Contract(unittest.TestCase):
def test_lattice_parameters_and_bounds(self):
root, uniform = v.lattice_parameters(40), v.lattice_parameters(80)
self.assertAlmostEqual(root["tau"], 0.56, places=12)
self.assertAlmostEqual(uniform["tau"], 0.62, places=12)
self.assertEqual((root["steps"], root["interval"]), (240000, 2000))
self.assertEqual((uniform["steps"], uniform["interval"]), (480000, 4000))
self.assertAlmostEqual(v.plan()["refined_fine_tau"], 0.62, places=12)
def test_config_only_changes_viscosity(self):
base = v._re100_config(h, 40, "SRT", "none")
cfg, body, p = v.config(h, 40, "SRT")
self.assertAlmostEqual(cfg["physics"]["viscosity"], 0.01 * 40 / 20, places=15)
self.assertEqual((p["re"], p["viscosity"]), (20.0, cfg["physics"]["viscosity"]))
base[0]["physics"]["viscosity"] = cfg["physics"]["viscosity"]
self.assertEqual(base[0], cfg)
self.assertEqual(base[1], body)
with self.assertRaisesRegex(ValueError, "no Jets"):
v.config(h, 40, "MRT", "zero")
def test_units(self):
s = v.sample([2.0, -0.5], 4, 40, 3.0)
den = rr.SPEED**2 * 40 / 2
self.assertAlmostEqual(s["cd"], 2.0 / 4 / den)
self.assertAlmostEqual(s["cl"], -0.5 / 4 / den)
self.assertAlmostEqual(s["p_diff"], 3.0 * 0.04)
self.assertEqual(s["dp_scaled"], 3.0)
def test_steady_and_verdict(self):
inside = dict(cd=5.58, cl=0.0106, p_diff=0.1174)
out = v.summarize(rows(lambda i: inside))
self.assertEqual((out["status"], out["steady"], out["window_samples"]),
("WITHIN_INTERVALS", True, 21))
self.assertTrue(out["cd_positive"])
self.assertAlmostEqual(out["edge_distance"]["cl"], 0.0002)
self.assertTrue(all(out["verdict"].values()))
low = v.summarize(rows(lambda i: dict(inside, cl=0.0100)))
self.assertEqual(low["status"], "OUTSIDE_INTERVALS")
self.assertFalse(low["verdict"]["cl"])
def test_slow_drift_gives_no_verdict(self):
# Range 2e-5 passes (limit 6e-5); slope * window 2e-5 exceeds 1.2e-5.
out = v.summarize(rows(lambda i: dict(cd=5.58, cl=0.0106 + 1e-6 * i, p_diff=0.1174)))
self.assertEqual((out["status"], out["verdict"]), ("NOT_STEADY", None))
self.assertLessEqual(out["ranges"]["cl"], out["range_limits"]["cl"])
self.assertGreater(out["drifts"]["cl"], out["slope_limits"]["cl"])
def test_noise_range(self):
quiet = v.summarize(rows(lambda i: dict(cd=5.58, cl=0.0106 + 2e-5 * (i % 2), p_diff=0.1174)))
self.assertTrue(quiet["steady"]) # within the range limit, no trend
noisy = v.summarize(rows(lambda i: dict(cd=5.58, cl=0.0106 + 1e-4 * (i % 2), p_diff=0.1174)))
self.assertEqual(noisy["status"], "NOT_STEADY")
self.assertGreater(noisy["ranges"]["cl"], noisy["range_limits"]["cl"])
def test_incomplete_run_gives_no_verdict(self):
out = v.summarize(rows(lambda i: dict(cd=5.58, cl=0.0106, p_diff=0.1174), tstar_end=30))
self.assertEqual((out["status"], out["verdict"]), ("INCOMPLETE", None))
if __name__ == "__main__":
unittest.main()
@@ -80,11 +80,12 @@ class ThreeLevelCPU(unittest.TestCase):
f, g = run._levels[1:]
b = g.boundary
events, feeds = [], []
advance, gather = run._advance_child, run._gather_halo
def record_feed(buffer, **kw):
self.assertIs(buffer, (f.current, f.spare)[len(feeds)])
feeds.append(buffer)
advance(buffer, **kw)
advance, gather = run._advance, run._gather_halo
def record_feed(index, buffer, **kw): # index 2: L1 hop feeds L2
if index == 2:
self.assertIs(buffer, (f.current, f.spare)[len(feeds)])
feeds.append(buffer)
advance(index, buffer, **kw)
def record_halo(level, buffer, **kw):
if level is g:
events.append(("halo", buffer))
@@ -95,7 +96,7 @@ class ThreeLevelCPU(unittest.TestCase):
self.assertIs(kind, g.kind)
events.append(("step", src))
g.kernel.side_effect = record_step
with patch.object(run, "_advance_child", side_effect=record_feed), \
with patch.object(run, "_advance", side_effect=record_feed), \
patch.object(run, "_gather_halo", side_effect=record_halo):
run.step()
self.assertEqual(feeds, [f.current, f.spare])
@@ -105,8 +106,8 @@ class ThreeLevelCPU(unittest.TestCase):
self.assertGreater(len(np.unique(b.tables["block"])), 1)
# Negative control: reusing L1's old source at the midpoint must fail.
feeds.clear()
with patch.object(run, "_advance_child", side_effect=lambda buffer, **kw:
record_feed(f.current, **kw)):
with patch.object(run, "_advance", side_effect=lambda index, buffer, **kw:
record_feed(index, f.current if index == 2 else buffer, **kw)):
with self.assertRaises(AssertionError):
run.step()
run.close()
@@ -172,11 +173,12 @@ class ThreeLevelGPU(unittest.TestCase):
p1, p2 = run.patch, run.child_patch
run.initialize(state(16, 16, 1), state(*p1.fine_shape, 2), state(*p2.fine_shape, 3))
f = run._levels[1]
feeds, original = [], run._advance_child
def record(buffer, **kw):
feeds.append(int(buffer))
original(buffer, **kw)
with patch.object(run, "_advance_child", side_effect=record):
feeds, original = [], run._advance
def record(index, buffer, **kw): # index 2: L1 hop feeds L2
if index == 2:
feeds.append(int(buffer))
original(index, buffer, **kw)
with patch.object(run, "_advance", side_effect=record):
cur, spare = int(f.current), int(f.spare)
run.step(1)
self.assertEqual(feeds, [cur, spare]) # current G, then the new G of hop 1
+222
View File
@@ -0,0 +1,222 @@
"""Steady DFG 2D-1 (Re 20): refined and uniform runs against the official reference intervals.
Not a full benchmark certification. Declared deviations from the official setup:
pressure-NEQ outlet (``neq_extrap``) instead of do-nothing, local Zou-He inlet.
Geometry, inflow profile, initial state, refined layout and observation reuse the
pinned Re100 runner (``run_refined_dfg``) and ``re100_helpers`` unchanged; only the
lattice viscosity is set for Re 20 (nu = U_lattice * D / 20 at the root level).
Units. The helpers use scaled DFG units with mean inflow speed 1, so
Cd = 2 Fx / (rho U^2 D) and Cl = 2 Fy / (rho U^2 D) are scale-free, and
``pressure_probes`` returns dp / (rho U^2). The official p_diff = p(0.15, 0.2) -
p(0.25, 0.2) uses U = 0.2 m/s, rho = 1, so p_diff = dp * 0.04. Both are reported;
only p_diff is compared with the official interval. Forces use the existing sign
convention: F is the force of the fluid on the cylinder, i.e. the refined raw
exchange impulse (window ledger, ``total.impulse``) or the uniform ``read_force``,
with x along the inflow and y up from the bottom wall. Cd must be positive, and the
official Cl is positive (centre 0.20 below the centreline 0.205), so a flipped sign
shows up as Cl outside its interval.
Sampling. One sample every SAMPLE_TSTAR (t* = U t / D; 0.25 s), taken at the end of
a completed root step with all levels synchronized; the step index is recorded. Cd
and Cl are means over the sample interval (window exchange ledger refined, per-batch
forces uniform), which filters acoustic and step-parity oscillation; p_diff is the
end-of-interval value at the exact surface points.
Steady rule (our engineering choice, frozen before any run; not official). Over the
trailing WINDOW_TSTAR (t* 50..60 inclusive, 21 samples), for each of Cd, Cl and
p_diff: range (max - min) <= 10 % and |least-squares slope * window| <= 2 % of the
width of its official interval. Absolute limits, so the small Cl is never a divisor;
the slope term bounds slow monotone drift that the range alone would pass. If the
rule fails at TSTAR_MAX the run reports "not steady" and gives no interval verdict;
limits are never relaxed after a run. The verdict uses the window mean, and the
distance of each mean to its nearest interval edge is reported (negative outside).
"""
import argparse
import json
import math
import os
from pathlib import Path
import sys
import time
from tempfile import TemporaryDirectory
import numpy as np
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
import run_refined_dfg as rr # noqa: E402
RE = 20.0
U_PHYS = 0.2 # m/s mean inflow; rho = 1
TSTAR_MAX = 60.0 # 30 s physical
SAMPLE_TSTAR = 0.5 # 0.25 s
WINDOW_TSTAR = 10.0 # trailing 5 s, 21 samples
# Schaefer & Turek (1996), 2D-1 lower/upper bounds.
BANDS = dict(cd=(5.57, 5.59), cl=(0.0104, 0.0110), p_diff=(0.1172, 0.1176))
STEADY_RANGE = {k: (hi - lo) / 10 for k, (lo, hi) in BANDS.items()}
STEADY_SLOPE = {k: (hi - lo) / 50 for k, (lo, hi) in BANDS.items()}
DEVIATIONS = ("pressure-NEQ outlet (neq_extrap), not do-nothing", "local Zou-He inlet")
_re100_config = rr.config
def config(h, diameter, collision="SRT", jet_scenario="none"):
"""The pinned Re100 configuration with the lattice viscosity set for Re 20."""
if jet_scenario != "none":
raise ValueError("DFG 2D-1 runs have no Jets")
cfg, body, p = _re100_config(h, diameter, collision, jet_scenario)
nu = p["mean_speed"] * p["diameter"] / RE
cfg["physics"]["viscosity"] = nu
return cfg, body, dict(p, viscosity=nu, re=RE)
def lattice_parameters(diameter, speed=rr.SPEED):
nu = speed * diameter / RE
return dict(viscosity=nu, tau=0.5 + 3 * nu, steps=math.ceil(TSTAR_MAX * diameter / speed),
interval=round(SAMPLE_TSTAR * diameter / speed))
def sample(impulse, n, diameter, dp):
den = rr.SPEED**2 * diameter / 2
return dict(cd=float(impulse[0] / n / den), cl=float(impulse[1] / n / den),
dp_scaled=float(dp), p_diff=float(dp) * U_PHYS**2)
def summarize(rows):
"""Steady rule over the trailing window, then the interval verdict (or none)."""
if not rows or rows[-1]["tstar"] < TSTAR_MAX - 1e-9:
return dict(status="INCOMPLETE", steady=False, verdict=None)
window = [r for r in rows if r["tstar"] >= TSTAR_MAX - WINDOW_TSTAR - 1e-9]
t = np.array([r["tstar"] for r in window])
ranges = {k: max(r[k] for r in window) - min(r[k] for r in window) for k in BANDS}
drifts = {k: abs(np.polyfit(t, [r[k] for r in window], 1)[0]) * WINDOW_TSTAR for k in BANDS}
steady = all(ranges[k] <= STEADY_RANGE[k] and drifts[k] <= STEADY_SLOPE[k] for k in BANDS)
final = {k: float(np.mean([r[k] for r in window])) for k in BANDS}
edge = {k: min(final[k] - BANDS[k][0], BANDS[k][1] - final[k]) for k in BANDS}
result = dict(window_samples=len(window), ranges=ranges, drifts=drifts,
range_limits=STEADY_RANGE, slope_limits=STEADY_SLOPE, steady=steady,
window_mean=final, edge_distance=edge, bands=BANDS,
deviations=DEVIATIONS, cd_positive=final["cd"] > 0)
if not steady:
return dict(result, status="NOT_STEADY", verdict=None)
inside = {k: BANDS[k][0] <= final[k] <= BANDS[k][1] for k in BANDS}
return dict(result, status="WITHIN_INTERVALS" if all(inside.values()) else "OUTSIDE_INTERVALS",
verdict=inside)
def healthy(row):
return row["max_mach"] <= 0.2 and row["rho_min"] >= 0.5 and row["rho_max"] <= 1.5
def run_case(a, h, case):
from contextlib import ExitStack
from CelerisLab.cuda.context import CudaContext
from CelerisLab.lbm.descriptors import FLUID
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
from CelerisLab.simulation import Simulation
refined = case == "refined"
cfgdict, bodydict, p = config(h, 40 if refined else 80, a.collision)
lp = lattice_parameters(p["diameter"])
rows = []
with ExitStack() as stack:
if refined:
cfg, pair, body, host = rr.host_geometry(a.source, h, a.collision)
context = stack.enter_context(CudaContext(0))
backend = stack.enter_context(FixedPatchStepper(
cfg, rr.PLACEMENTS["wide"], context=context, coarse_boundary="channel",
base_block=rr.BASE_BLOCK, fine_block=rr.FINE_BLOCK, fine_objects=(body,),
observe_raw_exchange=True, observe_window_exchange=True))
from CelerisLab.lbm.channel import channel_masks
from CelerisLab.lbm.refinement import ACTIVE
from CelerisLab.lbm.descriptors import SOLID
masks = [channel_masks(pair.coarse_shape)[0] & ~pair.covered,
(pair.fine_kind == ACTIVE)
& ((host.flags.reshape(pair.fine_shape) & SOLID) == 0)]
backend.initialize(rr.initial_state(pair.coarse_shape, (0.0, 0.0), 1.0, root=True),
rr.initial_state(pair.fine_shape, pair.fine_origin, 0.5))
probe_p = dict(p, center=[(80.5 - pair.fine_origin[0]) / 0.5 - 0.5,
(81.0 - pair.fine_origin[1]) / 0.5 - 0.5], radius=40.0)
else:
tmp = Path(stack.enter_context(TemporaryDirectory()))
(tmp / "lbm.json").write_text(json.dumps(cfgdict))
(tmp / "body.json").write_text(json.dumps(bodydict))
sim = stack.enter_context(Simulation(str(tmp / "lbm.json"), str(tmp / "body.json"),
device_id=0))
sim.initialize()
probe_p = p
fluid = (sim.field.flag.reshape(p["ny"], p["nx"]) & FLUID) != 0
begin = 0
while begin < lp["steps"]:
rr.check_deadline(a.deadline)
n = min(lp["interval"], lp["steps"] - begin)
if refined:
impulse = rr.advance_refined_interval(backend, n, a.deadline, "window")
fields = [rr.macros(g) for g in backend.download()]
rho, ux, uy = fields[1]
fluid = masks[1]
vals = [tuple(v[m] for v in f) for m, f in zip(masks, fields)]
else:
impulse, done = np.zeros(2), 0
while done < n:
batch = min(100, n - done)
sim.run(batch)
impulse += sim.read_force(0).astype(np.float64) * batch
done += batch
m = sim.get_macroscopic()
rho, ux, uy = [np.asarray(m[k], np.float64).reshape(p["ny"], p["nx"])
for k in ("rho", "ux", "uy")]
vals = [(rho[fluid], ux[fluid], uy[fluid])]
begin += n
row = dict(step=begin, tstar=begin * rr.SPEED / p["diameter"],
**sample(impulse, n, p["diameter"],
h.pressure_probes(rho, fluid, probe_p)["dp"]),
rho_min=float(min(v[0].min() for v in vals)),
rho_max=float(max(v[0].max() for v in vals)),
max_mach=float(max(np.sqrt(3 * (v[1]**2 + v[2]**2)).max() for v in vals)))
rr.ensure_finite(row)
rows.append(row)
if not healthy(row):
break
summary = summarize(rows) if healthy(rows[-1]) else dict(status="UNHEALTHY", steady=False,
verdict=None)
return dict(case=case, collision=a.collision, lattice=lp, rows=rows, **summary)
def plan(collision="SRT"):
return dict(re=RE, collision=collision, tstar_max=TSTAR_MAX, sample_tstar=SAMPLE_TSTAR,
window_tstar=WINDOW_TSTAR, bands=BANDS, range_limits=STEADY_RANGE, slope_limits=STEADY_SLOPE,
deviations=DEVIATIONS, pressure_scale=U_PHYS**2,
refined_root=lattice_parameters(40), refined_fine_tau=0.5 + 3 * 2 * rr.SPEED * 40 / RE,
uniform=lattice_parameters(80),
label="compared against the official reference bands (non-canonical neq_extrap outlet, zou_he_local inlet); not a full benchmark certification")
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--case", choices=["refined", "uniform", "both"], default="refined")
parser.add_argument("--collision", choices=["SRT", "MRT"], default="SRT")
parser.add_argument("--source", type=Path, default=rr.DEFAULT_SOURCE)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--plan", action="store_true", help="Write the frozen plan; no CUDA")
parser.add_argument("--max-wall-seconds", type=float)
a = parser.parse_args()
a.source = a.source.resolve()
a.output.mkdir(parents=True, exist_ok=False)
(a.output / "plan.json").write_text(json.dumps(plan(a.collision), indent=2, allow_nan=False))
if a.plan:
return
if not a.max_wall_seconds or a.max_wall_seconds <= 0 or not os.environ.get("CUDA_VISIBLE_DEVICES"):
raise SystemExit("GPU run needs --max-wall-seconds > 0 and explicit CUDA_VISIBLE_DEVICES")
h, _ = rr.helpers(a.source)
rr.config = config # host_geometry builds its root configuration through this name
a.deadline = time.monotonic() + a.max_wall_seconds
cases = ["refined", "uniform"] if a.case == "both" else [a.case]
results = [run_case(a, h, case) for case in cases]
(a.output / "result.json").write_text(json.dumps(results, indent=2, allow_nan=False))
if __name__ == "__main__":
main()