research: Dütsch driver — continuous settling, domain criteria, profile coverage

common_window requires every pair from each case's own K_case to K+2;
a-priori domain criteria against the 60 D reference (separate from Table 1);
bilinear sampling returns the original fluid corner weight sum; analysis
time-averages bin samples, reports coverage/missing points and fails closed
on an empty common profile set; cases run to the end of cycle 11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-30 10:58:05 +00:00
1 parent 6f95c34c0b
commit b31a6141b3
5 files changed
+79 -32

No files matched your search

+21 -14
View File
@@ -87,8 +87,16 @@ Order (closed):
3. Optional labelled BC sensitivity: one x-periodic / y-slip case at D = 40, 3. Optional labelled BC sensitivity: one x-periodic / y-slip case at D = 40,
40 D; reported as a delta against native only. 40 D; reported as a delta against native only.
4. Only after step 2 passes: the D = 20 / 40 / 80 ladder on the chosen BC and 4. Only after step 2 passes: the D = 20 / 40 / 80 ladder on the chosen BC and
domain (default 40 D if, against 60 D, `RMS(dFx)/F_ref` and |dc_d|, |dc_i| domain.
stay inside the section 6 bands).
Domain criteria (a-priori, ENGINEERING; separate from the Table 1 bands): the
reference is 60 D at D = 40; 20 D and 40 D are compared with it on the common
window (same cycle indices) and the common valid point set:
- `RMS(Fx_L - Fx_60D) / F_ref ≤ .01` over the window (per-step waveforms);
- `|c_d,L - c_d,60D| ≤ .02` and `|c_i,L - c_i,60D| ≤ .02` (window fits);
- `max(|u_L - u_60D|, |v_L - v_60D|)` over common points `/ U_max ≤ .02`.
The chosen domain is the smallest of {20 D, 40 D} passing all three; if
neither passes, the domain verdict is FAIL and the ladder is not run.
- Memory/runtime: measured by a smoke run before each new size; measurements - Memory/runtime: measured by a smoke run before each new size; measurements
inform scheduling only, never tolerances. inform scheduling only, never tolerances.
@@ -144,21 +152,20 @@ quarter `s/T < 0.25`. Pair p_k compares cycle k−1 with cycle k.
first cycle AFTER the last of those pairs. Earliest possible: pairs 3→4, first cycle AFTER the last of those pairs. Earliest possible: pairs 3→4,
4→5, 5→6 pass ⇒ K_case = 7. 4→5, 5→6 pass ⇒ K_case = 7.
- Common window: K = max over compared cases of K_case; every case scores the - Common window: K = max over compared cases of K_case; every case scores the
same three complete cycles K, K+1, K+2 and must stay settled throughout: same three complete cycles K, K+1, K+2 and must stay settled continuously:
pairs (K−1→K), (K→K+1), (K+1→K+2) must pass on every case, otherwise that every pair p_j for j = its own K_case .. K+2 must pass (including the wait
case is FAIL-CLOSED (an earlier local settle does not waive this). between its local settle and the common K), otherwise that case is
FAIL-CLOSED.
- Cap: cycle k ends at s/T = k + 0.25, so the last admissible scored cycle is - Cap: cycle k ends at s/T = k + 0.25, so the last admissible scored cycle is
11 (ends 11.25 T) and K ≤ 9. Examples: K = 7 → cycles 7–9 (ends 9.25 T); 11 (ends 11.25 T) and K ≤ 9. Examples: K = 7 → cycles 7–9 (ends 9.25 T);
K = 9 → cycles 9–11 (ends 11.25 T); any K_case > 9 → FAIL-CLOSED. K = 9 → cycles 9–11 (ends 11.25 T); any K_case > 9 → FAIL-CLOSED.
- Every case is run to the end of cycle K + 2 (proportionally more native - Run length: cases run sequentially; each runs to the end of cycle 11
steps on finer grids). (11.25 T: 28,350 / 56,700 / 113,400 steps for D = 20 / 40 / 80; the 12 T
- Discard: the initial quarter cycle plus the first 2 full paper cycles cap is 30,240 / 60,480 / 120,960) and keeps its scalar and profile data, then
(s/T < 2.25). Settling is searched from cycle 3; cap 12 periods from s = 0; the common K is chosen. An unscorable partial cycle 12 is not run.
unsettled → FAIL-CLOSED (no literature, cross-resolution or VIV statistic). - Profiles: an empty common valid point set is FAIL-CLOSED; coverage, missing
- Common window: K = the largest first-settled cycle index over all compared points, bin time weights (equal per step) and the original fluid corner
cases; every case is scored on the same 3 cycles K .. K + 2 and must reach weight sums are reported.
them within the cap (proportionally more native steps on finer grids), or
the comparison is FAIL-CLOSED.
- The earlier G4 timing check (100 coarse vs 400 diffusive-fine steps) was a - The earlier G4 timing check (100 coarse vs 400 diffusive-fine steps) was a
matched short transient, not periodic evidence. matched short transient, not periodic evidence.
+36 -2
View File
@@ -36,9 +36,30 @@ def coarse_wave(case, k, r=1):
def profiles(case, k): def profiles(case, k):
"""{(phase, station, x2): (u, v)} of cycle k, time-averaged over the bin steps
(equal weights per step) from valid samples only."""
s = case["samples"]
acc = {}
for _, _, ph, xs, x2, u, v, _ in s[s[:, 1] == k]:
if not (math.isnan(u) or math.isnan(v)):
acc.setdefault((ph, xs, x2), []).append((u, v))
return {key: tuple(np.mean(vals, axis=0)) for key, vals in acc.items()}
def coverage(case, k):
s = case["samples"] s = case["samples"]
rows = s[s[:, 1] == k] rows = s[s[:, 1] == k]
return {(ph, xs, x2): (u, v) for _, _, ph, xs, x2, u, v in rows} missing = int(np.isnan(rows[:, 5]).sum())
return dict(samples=len(rows), missing=missing, bin_steps=len(set(rows[:, 0])),
min_fluid_weight=float(np.nanmin(np.where(rows[:, 7] > 0, rows[:, 7], np.nan)))
if len(rows) else math.nan)
def common_points(dicts):
keys = set(dicts[0])
for d in dicts[1:]:
keys &= set(d)
return sorted(keys)
def cycle_morison(case, ks): def cycle_morison(case, ks):
@@ -51,7 +72,7 @@ def cycle_morison(case, ks):
def pair_metrics(case, k): def pair_metrics(case, k):
a, b = coarse_wave(case, k - 1), coarse_wave(case, k) a, b = coarse_wave(case, k - 1), coarse_wave(case, k)
pa, pb = profiles(case, k - 1), profiles(case, k) pa, pb = profiles(case, k - 1), profiles(case, k)
common = [key for key in pa if key in pb and not any(map(math.isnan, pa[key] + pb[key]))] common = common_points([pa, pb]) # empty set -> inf -> pair fails (FAIL-CLOSED)
prof = max((max(abs(pa[c][0] - pb[c][0]), abs(pa[c][1] - pb[c][1])) for c in common), prof = max((max(abs(pa[c][0] - pb[c][0]), abs(pa[c][1] - pb[c][1])) for c in common),
default=math.inf) / case["u_max"] default=math.inf) / case["u_max"]
(cd0, ci0), (cd1, ci1) = cycle_morison(case, [k - 1]), cycle_morison(case, [k]) (cd0, ci0), (cd1, ci1) = cycle_morison(case, [k - 1]), cycle_morison(case, [k])
@@ -80,6 +101,7 @@ def main(paths):
entry = {key: case[key] for key in ("path", "D", "T", "box", "n", "u_max", "nu", "tau", "A", entry = {key: case[key] for key in ("path", "D", "T", "box", "n", "u_max", "nu", "tau", "A",
"steps", "cycles", "F_ref")} "steps", "cycles", "F_ref")}
entry.update(duration_T=case["steps"] / case["T"], K_case=k_case, pairs=pairs, entry.update(duration_T=case["steps"] / case["T"], K_case=k_case, pairs=pairs,
coverage={k: coverage(case, k) for k in sorted(cycle_slices(case)) if k > p.DISCARD},
no_donor=float(case["diag"][:, 13].sum()), eq_only=float(case["diag"][:, 12].sum())) no_donor=float(case["diag"][:, 13].sum()), eq_only=float(case["diag"][:, 12].sum()))
if window: if window:
entry["morison"] = cycle_morison(case, window) entry["morison"] = cycle_morison(case, window)
@@ -94,6 +116,18 @@ def main(paths):
ref_c = ref.reshape(-1, r).mean(axis=1) ref_c = ref.reshape(-1, r).mean(axis=1)
own = np.concatenate([coarse_wave(case, k) for k in window]) own = np.concatenate([coarse_wave(case, k) for k in window])
entry["wave_rms_vs_finest"] = float(np.sqrt(np.mean((own - ref_c) ** 2))) entry["wave_rms_vs_finest"] = float(np.sqrt(np.mean((own - ref_c) ** 2)))
for k in window: # profiles on the common valid point set of all compared cases
per_case = [profiles(c, k) for c in cases]
keys = common_points(per_case)
report.setdefault("common_profile_points", {})[k] = len(keys)
if not keys:
report["verdict"] = "FAIL-CLOSED: empty common profile set"
continue
fine_p = per_case[cases.index(fine)]
for case, entry, prof in zip(cases, report["cases"], per_case):
d = max(max(abs(prof[key][0] - fine_p[key][0]), abs(prof[key][1] - fine_p[key][1]))
for key in keys) / case["u_max"]
entry.setdefault("profile_max_vs_finest", {})[k] = float(d)
print(json.dumps(report, indent=1, default=float)) print(json.dumps(report, indent=1, default=float))
+10 -7
View File
@@ -17,6 +17,7 @@ PHASES = (180.0, 210.0, 330.0) # degrees; bins [p, p + 1)
X2 = tuple(np.round(np.arange(-1.5, 1.5001, 0.05), 10)) # declared x2/D points X2 = tuple(np.round(np.arange(-1.5, 1.5001, 0.05), 10)) # declared x2/D points
DISCARD, LAST_SCORED, N_PAIRS = 2, 11, 3 # cycles 0..2 discarded, cap 12 T DISCARD, LAST_SCORED, N_PAIRS = 2, 11, 3 # cycles 0..2 discarded, cap 12 T
SETTLE = dict(wave=.02, scalar=.01, profile=.03, morison=.02) SETTLE = dict(wave=.02, scalar=.01, profile=.03, morison=.02)
DOMAIN = dict(wave=.01, morison=.02, profile=.02) # a-priori, vs the 60 D reference
def amplitude(D): def amplitude(D):
@@ -65,8 +66,9 @@ def morison_fit(f_norm, b1, b2):
def bilinear_fluid(field, fluid, x, y): def bilinear_fluid(field, fluid, x, y):
"""Bilinear value at (x, y) in cell coordinates from fluid cells only. """Bilinear value at (x, y) in cell coordinates from fluid cells only.
Returns (value, ((ix, iy, weight), ...)) with weights renormalized over the Returns (value, ((ix, iy, weight), ...), fluid_weight_sum) with weights
fluid corners, or (None, ()) when no corner is fluid (MISSING, never zero). renormalized over the fluid corners and the original sum of their bilinear
weights, or (None, (), 0.0) when no corner is fluid (MISSING, never zero).
""" """
x0, y0 = int(math.floor(x)), int(math.floor(y)) x0, y0 = int(math.floor(x)), int(math.floor(y))
fx, fy = x - x0, y - y0 fx, fy = x - x0, y - y0
@@ -75,9 +77,9 @@ def bilinear_fluid(field, fluid, x, y):
used = [(i, j, w) for i, j, w in corners if w > 0.0 and fluid[j, i]] used = [(i, j, w) for i, j, w in corners if w > 0.0 and fluid[j, i]]
total = sum(w for _, _, w in used) total = sum(w for _, _, w in used)
if total == 0.0: if total == 0.0:
return None, () return None, (), 0.0
used = tuple((i, j, w / total) for i, j, w in used) used = tuple((i, j, w / total) for i, j, w in used)
return float(sum(w * field[j, i] for i, j, w in used)), used return float(sum(w * field[j, i] for i, j, w in used)), used, float(total)
def settled_start(pairs_pass): def settled_start(pairs_pass):
@@ -95,13 +97,14 @@ def settled_start(pairs_pass):
def common_window(k_cases, pairs_pass_by_case): def common_window(k_cases, pairs_pass_by_case):
"""Common scored cycles K..K+2 with K = max K_case, or None (FAIL-CLOSED) when a """Common scored cycles K..K+2 with K = max K_case, or None (FAIL-CLOSED) when a
case never settled, K + 2 exceeds the cap, or a case fails a window pair.""" case never settled, K + 2 exceeds the cap, or a case fails any pair p_j for
j = its own K_case .. K + 2 (continuous settling, including the wait for K)."""
if any(k is None for k in k_cases): if any(k is None for k in k_cases):
return None return None
K = max(k_cases) K = max(k_cases)
if K + 2 > LAST_SCORED: if K + 2 > LAST_SCORED:
return None return None
for pairs in pairs_pass_by_case: for k_case, pairs in zip(k_cases, pairs_pass_by_case):
if not all(pairs.get(k, False) for k in (K, K + 1, K + 2)): if not all(pairs.get(j, False) for j in range(k_case, K + 3)):
return None return None
return K, K + 1, K + 2 return K, K + 1, K + 2
+5 -5
View File
@@ -73,18 +73,18 @@ def main():
for xs in p.STATIONS: for xs in p.STATIONS:
for x2 in p.X2: for x2 in p.X2:
x, y = xm + xs * D, ym + x2 * D x, y = xm + xs * D, ym + x2 * D
u, wu = p.bilinear_fluid(ux, fluid, x, y) u, wu, wsum = p.bilinear_fluid(ux, fluid, x, y)
v, _ = p.bilinear_fluid(uy, fluid, x, y) v, _, _ = p.bilinear_fluid(uy, fluid, x, y)
samples.append((s, p.cycle(s, T), ph, xs, x2, samples.append((s, p.cycle(s, T), ph, xs, x2,
math.nan if u is None else u, math.nan if v is None else v, math.nan if u is None else u, math.nan if v is None else v,
json.dumps(wu))) wsum, json.dumps(wu)))
np.savez(os.path.join(args.out, "raw.npz"), force=force, diag=np.asarray(diag), np.savez(os.path.join(args.out, "raw.npz"), force=force, diag=np.asarray(diag),
samples=np.asarray([r[:7] for r in samples], float)) samples=np.asarray([r[:8] for r in samples], float))
with open(os.path.join(args.out, "case.json"), "w") as fh: with open(os.path.join(args.out, "case.json"), "w") as fh:
json.dump(dict(D=D, T=T, box=args.box, n=n, u_max=u_max, nu=cfg["physics"]["viscosity"], json.dump(dict(D=D, T=T, box=args.box, n=n, u_max=u_max, nu=cfg["physics"]["viscosity"],
tau=3 * cfg["physics"]["viscosity"] + 0.5, A=A, steps=steps, tau=3 * cfg["physics"]["viscosity"] + 0.5, A=A, steps=steps,
cycles=args.cycles, F_ref=0.5 * u_max ** 2 * D, cycles=args.cycles, F_ref=0.5 * u_max ** 2 * D,
weights=[r[7] for r in samples]), fh) weights=[r[8] for r in samples]), fh)
finally: finally:
sim.close() sim.close()
+7 -4
View File
@@ -38,19 +38,22 @@ def test_settling_and_common_window():
assert p.common_window([7, 10], [passes, passes]) is None # K + 2 = 12 beyond the cap assert p.common_window([7, 10], [passes, passes]) is None # K + 2 = 12 beyond the cap
assert p.common_window([7, None], [passes, passes]) is None assert p.common_window([7, None], [passes, passes]) is None
assert p.common_window([7, 7], [passes, dict(passes, **{8: False})]) is None # must stay settled assert p.common_window([7, 7], [passes, dict(passes, **{8: False})]) is None # must stay settled
# a case settled early must keep passing while waiting for the common K
assert p.common_window([7, 9], [dict(passes, **{8: False}), passes]) is None
def test_solid_aware_bilinear(): def test_solid_aware_bilinear():
field = np.arange(16.0).reshape(4, 4) field = np.arange(16.0).reshape(4, 4)
fluid = np.ones((4, 4), bool) fluid = np.ones((4, 4), bool)
value, used = p.bilinear_fluid(field, fluid, 1.25, 1.5) value, used, total = p.bilinear_fluid(field, fluid, 1.25, 1.5)
assert value == pytest.approx(0.75 * 0.5 * (5 + 9) + 0.25 * 0.5 * (6 + 10)) assert total == pytest.approx(1.0) and value == pytest.approx(0.75 * 0.5 * (5 + 9) + 0.25 * 0.5 * (6 + 10))
fluid[1, 1] = False # one solid corner fluid[1, 1] = False # one solid corner
value, used = p.bilinear_fluid(field, fluid, 1.25, 1.5) value, used, total = p.bilinear_fluid(field, fluid, 1.25, 1.5)
assert total == pytest.approx(1.0 - 0.75 * 0.5)
assert all(not (i == 1 and j == 1) for i, j, _ in used) assert all(not (i == 1 and j == 1) for i, j, _ in used)
assert sum(w for *_, w in used) == pytest.approx(1.0) assert sum(w for *_, w in used) == pytest.approx(1.0)
fluid[:] = False fluid[:] = False
assert p.bilinear_fluid(field, fluid, 1.25, 1.5) == (None, ()) assert p.bilinear_fluid(field, fluid, 1.25, 1.5) == (None, (), 0.0)
def test_interval_bases_and_morison_fit_recover_known_coefficients(): def test_interval_bases_and_morison_fit_recover_known_coefficients():