Files
CelerisLab/research/duetsch_re100_kc5/protocol.py
T
ClaudeandClaude Opus 5.5 6f95c34c0b research: one-shot Dütsch Re100/KC5 prescribed-oscillation driver (no production change)
protocol.py holds the pure arithmetic (phase/cycle bookkeeping from the right
turning point, 1-degree bins, interval-averaged Morison bases and fit with
added-mass c_i, solid-aware bilinear sampling, settling and common-window
rules). run.py drives the existing Simulation API (set_body + run(1) +
read_force + moving_diagnostics; macroscopic fields and flags only inside the
three phase bins) and writes raw data outside the checkout. analyze.py builds
the cycle ledger, K_case, the common window, Morison fits, Fy sanity and the
cross-resolution waveform difference. CPU tests cover the §6a arithmetic.
Carries the protocol doc from claude/duetsch-protocol 8c1f3c4d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:54:59 +00:00

108 lines
3.7 KiB
Python

"""Pure arithmetic of the Dütsch Re 100 / KC 5 protocol (numpy only, no CUDA).
docs/validation/duetsch_re100_kc5_protocol.md is the contract: elapsed time s
in native steps from the right turning point, paper phase
phi = (360 s / T - 90) mod 360, paper cycle k >= 1 spans
s/T in [0.25 + (k-1), 0.25 + k); cycle 0 is the startup quarter.
"""
from __future__ import annotations
import math
import numpy as np
KC, RE = 5.0, 100.0
STATIONS = (-0.6, 0.0, 0.6, 1.2) # lab-fixed x/D from the mean position
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
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)
def amplitude(D):
return KC * D / (2.0 * math.pi)
def position(s, T, A):
"""Centre position relative to the mean: x = A cos(2 pi s / T)."""
return A * math.cos(2.0 * math.pi * s / T)
def phase(s, T):
return (360.0 * s / T - 90.0) % 360.0
def cycle(s, T):
return 0 if s < 0.25 * T else int(math.floor(s / T - 0.25)) + 1
def bin_phase(s, T):
"""Scored phase whose 1-degree bin [p, p + 1) contains time s, else None."""
phi = phase(s, T)
for p in PHASES:
if p <= phi < p + 1.0:
return p
return None
def interval_bases(n, T, points=16):
"""Single-step interval averages over [n, n + 1] of cos phi |cos phi| and sin phi
(phi in radians = 2 pi s / T - pi / 2), Gauss-Legendre quadrature."""
x, w = np.polynomial.legendre.leggauss(points)
s = n + 0.5 * (x + 1.0)
phi = 2.0 * np.pi * s / T - 0.5 * np.pi
c = np.cos(phi)
return 0.5 * float(np.sum(w * c * np.abs(c))), 0.5 * float(np.sum(w * np.sin(phi)))
def morison_fit(f_norm, b1, b2):
"""Least squares of f_norm = c_d b1 - (pi^2 c_i / KC) b2 (paper phase, no shift)."""
a = np.column_stack([b1, -(math.pi ** 2 / KC) * np.asarray(b2)])
(c_d, c_i), *_ = np.linalg.lstsq(a, np.asarray(f_norm), rcond=None)
return float(c_d), float(c_i)
def bilinear_fluid(field, fluid, x, y):
"""Bilinear value at (x, y) in cell coordinates from fluid cells only.
Returns (value, ((ix, iy, weight), ...)) with weights renormalized over the
fluid corners, or (None, ()) when no corner is fluid (MISSING, never zero).
"""
x0, y0 = int(math.floor(x)), int(math.floor(y))
fx, fy = x - x0, y - y0
corners = ((x0, y0, (1 - fx) * (1 - fy)), (x0 + 1, y0, fx * (1 - fy)),
(x0, y0 + 1, (1 - fx) * fy), (x0 + 1, y0 + 1, fx * fy))
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)
if total == 0.0:
return None, ()
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
def settled_start(pairs_pass):
"""K_case from {k: pass of pair (k-1 -> k)}: first cycle after three consecutive
passing pairs among cycles > DISCARD, or None."""
run = 0
for k in sorted(pairs_pass):
if k - 1 <= DISCARD:
continue
run = run + 1 if pairs_pass[k] else 0
if run == N_PAIRS:
return k + 1
return None
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
case never settled, K + 2 exceeds the cap, or a case fails a window pair."""
if any(k is None for k in k_cases):
return None
K = max(k_cases)
if K + 2 > LAST_SCORED:
return None
for pairs in pairs_pass_by_case:
if not all(pairs.get(k, False) for k in (K, K + 1, K + 2)):
return None
return K, K + 1, K + 2