From 750e8a483ec47dcf472e528edc1cf2936f0e7e83 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:41:41 +0000 Subject: [PATCH] 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 (cherry picked from commit 869d60d78e417349ac13ed03742137fcbd1ee956) --- docs/validation/dfg_re20.md | 17 ++++++++------ tests/unit/test_dfg_re20.py | 24 ++++++++++++-------- tests/validation/run_dfg_re20.py | 39 ++++++++++++++++++-------------- 3 files changed, 47 insertions(+), 33 deletions(-) diff --git a/docs/validation/dfg_re20.md b/docs/validation/dfg_re20.md index 4662f26..26d56bf 100644 --- a/docs/validation/dfg_re20.md +++ b/docs/validation/dfg_re20.md @@ -22,11 +22,14 @@ inlet is local Zou-He. ## 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* = 1 (0.5 s) at the end of a synchronized root step; trailing - window t* = 10 (last 5 s, 11 samples). -- Steady: in the window, both max - min and |least-squares slope x window| of each - of Cd, Cl, p_diff are at most 1 % of its official interval width (2e-4, 6e-6, - 4e-6). Absolute limits, so the small Cl is never a divisor. Never relaxed after a run. +- 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 @@ -37,10 +40,10 @@ inlet is local Zou-He. ``` python tests/validation/run_dfg_re20.py --output --plan -CUDA_VISIBLE_DEVICES= python tests/validation/run_dfg_re20.py --case refined \ +CUDA_VISIBLE_DEVICES= python tests/validation/run_dfg_re20.py --case both \ --collision SRT --output --max-wall-seconds ``` -Run `--case uniform` only if the refined result is outside a band. `--plan` writes `plan.json` only. A GPU run writes `plan.json` and `result.json` +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`. diff --git a/tests/unit/test_dfg_re20.py b/tests/unit/test_dfg_re20.py index 6a3de8c..05f8800 100644 --- a/tests/unit/test_dfg_re20.py +++ b/tests/unit/test_dfg_re20.py @@ -21,8 +21,8 @@ class Re20Contract(unittest.TestCase): 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, 4000)) - self.assertEqual((uniform["steps"], uniform["interval"]), (480000, 8000)) + 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): @@ -48,21 +48,27 @@ class Re20Contract(unittest.TestCase): 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, 11)) + ("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_drift_gives_no_verdict(self): - # Cl drifts by more than 1 % of its interval width (6e-6) inside the window. + 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.assertGreater(out["ranges"]["cl"], out["steady_limits"]["cl"]) - self.assertGreater(out["drifts"]["cl"], out["steady_limits"]["cl"]) - small = v.summarize(rows(lambda i: dict(cd=5.58, cl=0.0106 + 2e-6 * (i % 2), p_diff=0.1174))) - self.assertTrue(small["steady"]) # 2e-6 alternation, no trend: within both limits + 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)) diff --git a/tests/validation/run_dfg_re20.py b/tests/validation/run_dfg_re20.py index 414a43b..fdf43b3 100644 --- a/tests/validation/run_dfg_re20.py +++ b/tests/validation/run_dfg_re20.py @@ -17,18 +17,20 @@ with x along the inflow and y up from the bottom wall. Cd must be positive, and 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.5 s), taken at the end of a -completed root step with all levels synchronized; the step index is recorded. Cd -and Cl are the mean over the sample interval (smooths step-parity/acoustic -oscillation; equal to the instantaneous value at steady state); p_diff is the +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 (frozen before any run). Over the trailing WINDOW_TSTAR (last 5 s, 11 -samples), both the range (max - min) and |least-squares slope * window| of each of -Cd, Cl and p_diff must not exceed 1 % of the width of its official interval. These -are absolute limits, so the small Cl is never a divisor, and the slope term catches -slow monotone drift. If the rule fails at TSTAR_MAX the run reports "not steady" and -gives no interval verdict; the limits are never relaxed after a run. +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 @@ -49,11 +51,12 @@ 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 = 1.0 # 0.5 s -WINDOW_TSTAR = 10.0 # trailing 5 s +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_LIMIT = {k: (hi - lo) / 100 for k, (lo, hi) in BANDS.items()} +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 @@ -89,10 +92,12 @@ def summarize(rows): 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_LIMIT[k] and drifts[k] <= STEADY_LIMIT[k] for k in BANDS) - final = {k: rows[-1][k] 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, - steady_limits=STEADY_LIMIT, steady=steady, final=final, bands=BANDS, + 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) @@ -182,7 +187,7 @@ def run_case(a, h, case): def plan(collision="SRT"): return dict(re=RE, collision=collision, tstar_max=TSTAR_MAX, sample_tstar=SAMPLE_TSTAR, - window_tstar=WINDOW_TSTAR, bands=BANDS, steady_limits=STEADY_LIMIT, + 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),