run_refined_dfg, run_dfg_re20, time_window_dfg, time_refined_dfg, run_jet_circle_cv and examples/fixed_patch_circle pass refinements / level_blocks / level_objects / level_jets instead of bounds, base_block, fine_block, fine_objects and jets. One-to-one with _legacy_levels: same rectangle (FixedPatch2D frame), same blocks, bodies and Jets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
223 lines
11 KiB
Python
223 lines
11 KiB
Python
"""Steady DFG 2D-1 (Re 20): refined and uniform runs against the official reference intervals.
|
|
|
|
Not a full benchmark certification. Declared deviations from the official setup:
|
|
pressure-NEQ outlet (``neq_extrap``) instead of do-nothing, local Zou-He inlet.
|
|
Geometry, inflow profile, initial state, refined layout and observation reuse the
|
|
pinned Re100 runner (``run_refined_dfg``) and ``re100_helpers`` unchanged; only the
|
|
lattice viscosity is set for Re 20 (nu = U_lattice * D / 20 at the root level).
|
|
|
|
Units. The helpers use scaled DFG units with mean inflow speed 1, so
|
|
Cd = 2 Fx / (rho U^2 D) and Cl = 2 Fy / (rho U^2 D) are scale-free, and
|
|
``pressure_probes`` returns dp / (rho U^2). The official p_diff = p(0.15, 0.2) -
|
|
p(0.25, 0.2) uses U = 0.2 m/s, rho = 1, so p_diff = dp * 0.04. Both are reported;
|
|
only p_diff is compared with the official interval. Forces use the existing sign
|
|
convention: F is the force of the fluid on the cylinder, i.e. the refined raw
|
|
exchange impulse (window ledger, ``total.impulse``) or the uniform ``read_force``,
|
|
with x along the inflow and y up from the bottom wall. Cd must be positive, and the
|
|
official Cl is positive (centre 0.20 below the centreline 0.205), so a flipped sign
|
|
shows up as Cl outside its interval.
|
|
|
|
Sampling. One sample every SAMPLE_TSTAR (t* = U t / D; 0.25 s), taken at the end of
|
|
a completed root step with all levels synchronized; the step index is recorded. Cd
|
|
and Cl are means over the sample interval (window exchange ledger refined, per-batch
|
|
forces uniform), which filters acoustic and step-parity oscillation; p_diff is the
|
|
end-of-interval value at the exact surface points.
|
|
|
|
Steady rule (our engineering choice, frozen before any run; not official). Over the
|
|
trailing WINDOW_TSTAR (t* 50..60 inclusive, 21 samples), for each of Cd, Cl and
|
|
p_diff: range (max - min) <= 10 % and |least-squares slope * window| <= 2 % of the
|
|
width of its official interval. Absolute limits, so the small Cl is never a divisor;
|
|
the slope term bounds slow monotone drift that the range alone would pass. If the
|
|
rule fails at TSTAR_MAX the run reports "not steady" and gives no interval verdict;
|
|
limits are never relaxed after a run. The verdict uses the window mean, and the
|
|
distance of each mean to its nearest interval edge is reported (negative outside).
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
import sys
|
|
import time
|
|
from tempfile import TemporaryDirectory
|
|
|
|
import numpy as np
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
sys.path.insert(0, str(HERE))
|
|
import run_refined_dfg as rr # noqa: E402
|
|
|
|
RE = 20.0
|
|
U_PHYS = 0.2 # m/s mean inflow; rho = 1
|
|
TSTAR_MAX = 60.0 # 30 s physical
|
|
SAMPLE_TSTAR = 0.5 # 0.25 s
|
|
WINDOW_TSTAR = 10.0 # trailing 5 s, 21 samples
|
|
# Schaefer & Turek (1996), 2D-1 lower/upper bounds.
|
|
BANDS = dict(cd=(5.57, 5.59), cl=(0.0104, 0.0110), p_diff=(0.1172, 0.1176))
|
|
STEADY_RANGE = {k: (hi - lo) / 10 for k, (lo, hi) in BANDS.items()}
|
|
STEADY_SLOPE = {k: (hi - lo) / 50 for k, (lo, hi) in BANDS.items()}
|
|
DEVIATIONS = ("pressure-NEQ outlet (neq_extrap), not do-nothing", "local Zou-He inlet")
|
|
|
|
_re100_config = rr.config
|
|
|
|
|
|
def config(h, diameter, collision="SRT", jet_scenario="none"):
|
|
"""The pinned Re100 configuration with the lattice viscosity set for Re 20."""
|
|
if jet_scenario != "none":
|
|
raise ValueError("DFG 2D-1 runs have no Jets")
|
|
cfg, body, p = _re100_config(h, diameter, collision, jet_scenario)
|
|
nu = p["mean_speed"] * p["diameter"] / RE
|
|
cfg["physics"]["viscosity"] = nu
|
|
return cfg, body, dict(p, viscosity=nu, re=RE)
|
|
|
|
|
|
def lattice_parameters(diameter, speed=rr.SPEED):
|
|
nu = speed * diameter / RE
|
|
return dict(viscosity=nu, tau=0.5 + 3 * nu, steps=math.ceil(TSTAR_MAX * diameter / speed),
|
|
interval=round(SAMPLE_TSTAR * diameter / speed))
|
|
|
|
|
|
def sample(impulse, n, diameter, dp):
|
|
den = rr.SPEED**2 * diameter / 2
|
|
return dict(cd=float(impulse[0] / n / den), cl=float(impulse[1] / n / den),
|
|
dp_scaled=float(dp), p_diff=float(dp) * U_PHYS**2)
|
|
|
|
|
|
def summarize(rows):
|
|
"""Steady rule over the trailing window, then the interval verdict (or none)."""
|
|
if not rows or rows[-1]["tstar"] < TSTAR_MAX - 1e-9:
|
|
return dict(status="INCOMPLETE", steady=False, verdict=None)
|
|
window = [r for r in rows if r["tstar"] >= TSTAR_MAX - WINDOW_TSTAR - 1e-9]
|
|
t = np.array([r["tstar"] for r in window])
|
|
ranges = {k: max(r[k] for r in window) - min(r[k] for r in window) for k in BANDS}
|
|
drifts = {k: abs(np.polyfit(t, [r[k] for r in window], 1)[0]) * WINDOW_TSTAR for k in BANDS}
|
|
steady = all(ranges[k] <= STEADY_RANGE[k] and drifts[k] <= STEADY_SLOPE[k] for k in BANDS)
|
|
final = {k: float(np.mean([r[k] for r in window])) for k in BANDS}
|
|
edge = {k: min(final[k] - BANDS[k][0], BANDS[k][1] - final[k]) for k in BANDS}
|
|
result = dict(window_samples=len(window), ranges=ranges, drifts=drifts,
|
|
range_limits=STEADY_RANGE, slope_limits=STEADY_SLOPE, steady=steady,
|
|
window_mean=final, edge_distance=edge, bands=BANDS,
|
|
deviations=DEVIATIONS, cd_positive=final["cd"] > 0)
|
|
if not steady:
|
|
return dict(result, status="NOT_STEADY", verdict=None)
|
|
inside = {k: BANDS[k][0] <= final[k] <= BANDS[k][1] for k in BANDS}
|
|
return dict(result, status="WITHIN_INTERVALS" if all(inside.values()) else "OUTSIDE_INTERVALS",
|
|
verdict=inside)
|
|
|
|
|
|
def healthy(row):
|
|
return row["max_mach"] <= 0.2 and row["rho_min"] >= 0.5 and row["rho_max"] <= 1.5
|
|
|
|
|
|
def run_case(a, h, case):
|
|
from contextlib import ExitStack
|
|
from CelerisLab.cuda.context import CudaContext
|
|
from CelerisLab.lbm.descriptors import FLUID
|
|
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
|
|
from CelerisLab.simulation import Simulation
|
|
|
|
refined = case == "refined"
|
|
cfgdict, bodydict, p = config(h, 40 if refined else 80, a.collision)
|
|
lp = lattice_parameters(p["diameter"])
|
|
rows = []
|
|
with ExitStack() as stack:
|
|
if refined:
|
|
cfg, pair, body, host = rr.host_geometry(a.source, h, a.collision)
|
|
context = stack.enter_context(CudaContext(0))
|
|
backend = stack.enter_context(FixedPatchStepper(
|
|
cfg, context=context, coarse_boundary="channel", refinements=[rr.PLACEMENTS["wide"]],
|
|
level_blocks=[rr.BASE_BLOCK, rr.FINE_BLOCK], level_objects=[(), (body,)],
|
|
observe_raw_exchange=True, observe_window_exchange=True))
|
|
from CelerisLab.lbm.channel import channel_masks
|
|
from CelerisLab.lbm.refinement import ACTIVE
|
|
from CelerisLab.lbm.descriptors import SOLID
|
|
masks = [channel_masks(pair.coarse_shape)[0] & ~pair.covered,
|
|
(pair.fine_kind == ACTIVE)
|
|
& ((host.flags.reshape(pair.fine_shape) & SOLID) == 0)]
|
|
backend.initialize(rr.initial_state(pair.coarse_shape, (0.0, 0.0), 1.0, root=True),
|
|
rr.initial_state(pair.fine_shape, pair.fine_origin, 0.5))
|
|
probe_p = dict(p, center=[(80.5 - pair.fine_origin[0]) / 0.5 - 0.5,
|
|
(81.0 - pair.fine_origin[1]) / 0.5 - 0.5], radius=40.0)
|
|
else:
|
|
tmp = Path(stack.enter_context(TemporaryDirectory()))
|
|
(tmp / "lbm.json").write_text(json.dumps(cfgdict))
|
|
(tmp / "body.json").write_text(json.dumps(bodydict))
|
|
sim = stack.enter_context(Simulation(str(tmp / "lbm.json"), str(tmp / "body.json"),
|
|
device_id=0))
|
|
sim.initialize()
|
|
probe_p = p
|
|
fluid = (sim.field.flag.reshape(p["ny"], p["nx"]) & FLUID) != 0
|
|
begin = 0
|
|
while begin < lp["steps"]:
|
|
rr.check_deadline(a.deadline)
|
|
n = min(lp["interval"], lp["steps"] - begin)
|
|
if refined:
|
|
impulse = rr.advance_refined_interval(backend, n, a.deadline, "window")
|
|
fields = [rr.macros(g) for g in backend.download()]
|
|
rho, ux, uy = fields[1]
|
|
fluid = masks[1]
|
|
vals = [tuple(v[m] for v in f) for m, f in zip(masks, fields)]
|
|
else:
|
|
impulse, done = np.zeros(2), 0
|
|
while done < n:
|
|
batch = min(100, n - done)
|
|
sim.run(batch)
|
|
impulse += sim.read_force(0).astype(np.float64) * batch
|
|
done += batch
|
|
m = sim.get_macroscopic()
|
|
rho, ux, uy = [np.asarray(m[k], np.float64).reshape(p["ny"], p["nx"])
|
|
for k in ("rho", "ux", "uy")]
|
|
vals = [(rho[fluid], ux[fluid], uy[fluid])]
|
|
begin += n
|
|
row = dict(step=begin, tstar=begin * rr.SPEED / p["diameter"],
|
|
**sample(impulse, n, p["diameter"],
|
|
h.pressure_probes(rho, fluid, probe_p)["dp"]),
|
|
rho_min=float(min(v[0].min() for v in vals)),
|
|
rho_max=float(max(v[0].max() for v in vals)),
|
|
max_mach=float(max(np.sqrt(3 * (v[1]**2 + v[2]**2)).max() for v in vals)))
|
|
rr.ensure_finite(row)
|
|
rows.append(row)
|
|
if not healthy(row):
|
|
break
|
|
summary = summarize(rows) if healthy(rows[-1]) else dict(status="UNHEALTHY", steady=False,
|
|
verdict=None)
|
|
return dict(case=case, collision=a.collision, lattice=lp, rows=rows, **summary)
|
|
|
|
|
|
def plan(collision="SRT"):
|
|
return dict(re=RE, collision=collision, tstar_max=TSTAR_MAX, sample_tstar=SAMPLE_TSTAR,
|
|
window_tstar=WINDOW_TSTAR, bands=BANDS, range_limits=STEADY_RANGE, slope_limits=STEADY_SLOPE,
|
|
deviations=DEVIATIONS, pressure_scale=U_PHYS**2,
|
|
refined_root=lattice_parameters(40), refined_fine_tau=0.5 + 3 * 2 * rr.SPEED * 40 / RE,
|
|
uniform=lattice_parameters(80),
|
|
label="compared against the official reference bands (non-canonical neq_extrap outlet, zou_he_local inlet); not a full benchmark certification")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
parser.add_argument("--case", choices=["refined", "uniform", "both"], default="refined")
|
|
parser.add_argument("--collision", choices=["SRT", "MRT"], default="SRT")
|
|
parser.add_argument("--source", type=Path, default=rr.DEFAULT_SOURCE)
|
|
parser.add_argument("--output", type=Path, required=True)
|
|
parser.add_argument("--plan", action="store_true", help="Write the frozen plan; no CUDA")
|
|
parser.add_argument("--max-wall-seconds", type=float)
|
|
a = parser.parse_args()
|
|
a.source = a.source.resolve()
|
|
a.output.mkdir(parents=True, exist_ok=False)
|
|
(a.output / "plan.json").write_text(json.dumps(plan(a.collision), indent=2, allow_nan=False))
|
|
if a.plan:
|
|
return
|
|
if not a.max_wall_seconds or a.max_wall_seconds <= 0 or not os.environ.get("CUDA_VISIBLE_DEVICES"):
|
|
raise SystemExit("GPU run needs --max-wall-seconds > 0 and explicit CUDA_VISIBLE_DEVICES")
|
|
h, _ = rr.helpers(a.source)
|
|
rr.config = config # host_geometry builds its root configuration through this name
|
|
a.deadline = time.monotonic() + a.max_wall_seconds
|
|
cases = ["refined", "uniform"] if a.case == "both" else [a.case]
|
|
results = [run_case(a, h, case) for case in cases]
|
|
(a.output / "result.json").write_text(json.dumps(results, indent=2, allow_nan=False))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|