test: apply shear-wave review corrections
Modal magnitude R=hypot(a,b) with amplitude error and separate phase; free-intercept ln R rate fit with per-interval rates, log-fit residual, t* units and finite-positive check; spurious-pressure naming with raw rms; INVALID_CONTROL vs CONTROL_SCREEN_FAIL (refined results kept), mixed-source check, raw ratios to both controls; docs narrow the cubic-term scope, startup and placement limitations. oracle.py still byte-identical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ffff742b69
commit
10069feadb
3 files changed
+197
-101
No files matched your search
@@ -22,8 +22,10 @@ INITIALIZATION = (
|
||||
)
|
||||
# Per-row screens. phase_drift_abs, residual_l2 and the run-level rate screen
|
||||
# use eps(n) and are preregistered diagnostic choices, not error bounds.
|
||||
LIMITS = dict(velocity_l2=0.05, pressure_rel=0.02, mass_relative=1e-5)
|
||||
FLOORS = dict(rate_error=0.002, residual_l2=0.002, pressure_rel=0.02)
|
||||
LIMITS = dict(velocity_l2=0.05, spurious_pressure_scaled=0.02, mass_relative=1e-5)
|
||||
# With equal absolute ceiling and floor (0.02) the comparative pressure budget
|
||||
# adds no constraint unless the root control exceeds 0.01; raw ratios are kept.
|
||||
FLOORS = dict(rate_error=0.002, residual_l2=0.002, spurious_pressure_scaled=0.02)
|
||||
BASELINE_FACTOR = 2.0
|
||||
|
||||
|
||||
@@ -103,7 +105,8 @@ def metrics(arrays, masks, t, length, viscosity, initial_mass=None):
|
||||
fit = a_hat * s + b_hat * c
|
||||
longitudinal = (ux * k[0] + uy * k[1]) / math.sqrt(k @ k)
|
||||
amplitude = U * math.exp(-decay_rate(length, viscosity) * t)
|
||||
scale = amplitude * amplitude / 4 # fixed pressure scale, see docs
|
||||
scale = amplitude * amplitude / 4 # continuum reference scale, not the TG norm
|
||||
modal = math.hypot(a_hat, b_hat)
|
||||
p = (rho - 1) / 3
|
||||
p = p - w @ p / volume # one global gauge
|
||||
mass = float(w @ rho)
|
||||
@@ -117,6 +120,8 @@ def metrics(arrays, masks, t, length, viscosity, initial_mass=None):
|
||||
b_hat=float(b_hat),
|
||||
amplitude_exact=amplitude,
|
||||
amplitude_ratio=float(a_hat / amplitude),
|
||||
modal_amplitude=modal,
|
||||
amplitude_error=modal / amplitude - 1,
|
||||
phase_drift=float(math.atan2(b_hat, a_hat)),
|
||||
phase_drift_abs=abs(float(math.atan2(b_hat, a_hat))),
|
||||
velocity_l2=float(
|
||||
@@ -126,9 +131,10 @@ def metrics(arrays, masks, t, length, viscosity, initial_mass=None):
|
||||
np.sqrt(w @ ((ux - fit * E[0]) ** 2 + (uy - fit * E[1]) ** 2) / exact_norm)
|
||||
),
|
||||
longitudinal_l2=float(np.sqrt(w @ (longitudinal**2) / exact_norm)),
|
||||
pressure_rel=float(np.sqrt(w @ (p * p) / volume) / scale),
|
||||
pressure_max_rel=float(np.max(np.abs(p)) / scale),
|
||||
pressure_rel_by_level=[
|
||||
spurious_pressure_rms=float(np.sqrt(w @ (p * p) / volume)),
|
||||
spurious_pressure_scaled=float(np.sqrt(w @ (p * p) / volume) / scale),
|
||||
spurious_pressure_max_scaled=float(np.max(np.abs(p)) / scale),
|
||||
spurious_pressure_scaled_by_level=[
|
||||
float(np.sqrt(w[lev == i] @ (p[lev == i] ** 2) / w[lev == i].sum()) / scale)
|
||||
for i in range(len(masks))
|
||||
],
|
||||
@@ -140,15 +146,33 @@ def metrics(arrays, masks, t, length, viscosity, initial_mass=None):
|
||||
|
||||
|
||||
def fit_rate(rows, length, viscosity):
|
||||
"""Least-squares decay rate of ln(a_hat) over late samples."""
|
||||
"""Free-intercept least squares of ln R, R = hypot(a_hat, b_hat), late samples.
|
||||
|
||||
The intercept absorbs only a constant amplitude prefactor once a single
|
||||
exponential dominates; per-interval rates and the fit residual are kept so
|
||||
a lingering transient stays visible. Rates are per root step of the record
|
||||
(uniform 2N uses its own nu and doubled step counts).
|
||||
"""
|
||||
late = [r for r in rows if r["tstar"] >= FIT_FROM_TSTAR - 1e-12]
|
||||
if len(late) < 2 or any(r["a_hat"] <= 0 for r in late):
|
||||
raise ValueError("need >= 2 late samples with positive amplitude")
|
||||
amp = np.array([math.hypot(r["a_hat"], r["b_hat"]) for r in late])
|
||||
if len(late) < 3 or not (np.isfinite(amp).all() and (amp > 0).all()):
|
||||
raise ValueError("need >= 3 late samples with finite positive R")
|
||||
t = np.array([r["step"] for r in late], dtype=float)
|
||||
slope = np.polyfit(t, np.log([r["a_hat"] for r in late]), 1)[0]
|
||||
(slope, intercept) = np.polyfit(t, np.log(amp), 1)
|
||||
residual = np.log(amp) - (slope * t + intercept)
|
||||
exact_rate = decay_rate(length, viscosity)
|
||||
return dict(rate=float(-slope), exact_rate=exact_rate,
|
||||
rate_error=abs(-slope / exact_rate - 1))
|
||||
to_tstar = length / U # root steps per unit t*
|
||||
return dict(
|
||||
rate=float(-slope), exact_rate=exact_rate,
|
||||
rate_tstar=float(-slope * to_tstar),
|
||||
exact_rate_tstar=exact_rate * to_tstar, # = 5 (2 pi)^2 / Re
|
||||
rate_error=abs(-slope / exact_rate - 1),
|
||||
interval_rate_errors=[
|
||||
float(-np.log(amp[i + 1] / amp[i]) / (t[i + 1] - t[i]) / exact_rate - 1)
|
||||
for i in range(len(t) - 1)
|
||||
],
|
||||
log_fit_residual_max=float(np.max(np.abs(residual))),
|
||||
)
|
||||
|
||||
|
||||
def summarize(record):
|
||||
@@ -160,7 +184,9 @@ def summarize(record):
|
||||
complete=record["completed"] and len(rows) == len(TIMES),
|
||||
residual_l2=max(r["residual_l2"] for r in rows),
|
||||
phase_drift_abs=max(r["phase_drift_abs"] for r in rows),
|
||||
pressure_rel=max(r["pressure_rel"] for r in rows),
|
||||
spurious_pressure_scaled=max(r["spurious_pressure_scaled"] for r in rows),
|
||||
spurious_pressure_rms=max(r["spurious_pressure_rms"] for r in rows),
|
||||
amplitude_error_abs=max(abs(r["amplitude_error"]) for r in rows),
|
||||
velocity_l2=max(r["velocity_l2"] for r in rows),
|
||||
mass_relative=max(r["mass_relative"] for r in rows),
|
||||
)
|
||||
@@ -170,25 +196,47 @@ def summarize(record):
|
||||
return out
|
||||
|
||||
|
||||
def _usable(record):
|
||||
try:
|
||||
out = summarize(record)
|
||||
except (KeyError, ValueError):
|
||||
return None
|
||||
finite = all(math.isfinite(v) for v in out.values() if isinstance(v, float))
|
||||
return out if out["complete"] and finite else None
|
||||
|
||||
|
||||
def assess(base_control, fine_control, refined):
|
||||
"""Uniform root-spacing and finest-spacing controls versus refined runs."""
|
||||
"""Uniform root-spacing and finest-spacing controls versus refined runs.
|
||||
|
||||
INVALID_CONTROL: missing, incomplete, nonfinite or mixed-source controls.
|
||||
CONTROL_SCREEN_FAIL: complete finite controls that fail a screen; refined
|
||||
results are still computed and kept.
|
||||
"""
|
||||
if base_control is None or fine_control is None:
|
||||
return dict(status="INVALID_CONTROL", reason="missing control")
|
||||
n = base_control["n"]
|
||||
if (base_control["depth"], fine_control["depth"], fine_control["n"]) != (1, 1, 2 * n):
|
||||
raise ValueError("controls must be uniform N and uniform 2N")
|
||||
if any(r["depth"] != 2 or r["n"] != n for r in refined):
|
||||
raise ValueError("refined runs must be depth 2 at root N")
|
||||
base, fine = summarize(base_control), summarize(fine_control)
|
||||
sources = {repr(r.get("source")) for r in [base_control, fine_control, *refined]}
|
||||
base, fine = _usable(base_control), _usable(fine_control)
|
||||
if base is None or fine is None or len(sources) != 1:
|
||||
return dict(status="INVALID_CONTROL",
|
||||
reason="incomplete/nonfinite control or mixed source")
|
||||
report = dict(base_control=base, fine_control=fine, refined={}, status="PASS")
|
||||
if not (base["absolute_pass"] and fine["absolute_pass"]):
|
||||
report["status"] = "INVALID_CONTROL"
|
||||
report["status"] = "CONTROL_SCREEN_FAIL"
|
||||
for r in refined:
|
||||
s = summarize(r)
|
||||
s["budget"] = {
|
||||
key: max(BASELINE_FACTOR * base[key], floor) for key, floor in FLOORS.items()
|
||||
}
|
||||
s["budget_pass"] = all(s[key] <= s["budget"][key] for key in FLOORS)
|
||||
s["fine_control_ratio"] = {
|
||||
key: (s[key] / fine[key] if fine[key] > 0 else None) for key in FLOORS
|
||||
s["control_ratios"] = {
|
||||
name: {key: (s[key] / c[key] if c[key] > 0 else None)
|
||||
for key in (*FLOORS, "spurious_pressure_rms")}
|
||||
for name, c in (("root", base), ("finest", fine))
|
||||
}
|
||||
s["pass"] = s["absolute_pass"] and s["budget_pass"]
|
||||
report["refined"][r["explosion"]] = s
|
||||
|
||||
@@ -120,13 +120,15 @@ class MetricTests(unittest.TestCase):
|
||||
self.assertLess(r["longitudinal_l2"], 1e-12)
|
||||
self.assertLess(r["phase_drift_abs"], 1e-12)
|
||||
self.assertAlmostEqual(r["amplitude_ratio"], 1.0, places=12)
|
||||
self.assertLess(r["pressure_rel"], 1e-10)
|
||||
self.assertEqual(len(r["pressure_rel_by_level"]), 2)
|
||||
self.assertLess(abs(r["amplitude_error"]), 1e-12)
|
||||
self.assertLess(r["spurious_pressure_scaled"], 1e-10)
|
||||
self.assertEqual(len(r["spurious_pressure_scaled_by_level"]), 2)
|
||||
self.assertEqual(r["leaf_cells"], int(sum(m.sum() for m in self.masks)))
|
||||
|
||||
def test_phase_control(self):
|
||||
r = self.sample(exact_field(phase=0.01), 0.0)
|
||||
self.assertAlmostEqual(r["phase_drift"], 0.01, delta=1e-9)
|
||||
self.assertLess(abs(r["amplitude_error"]), 1e-12) # R = hypot(a, b) is phase-free
|
||||
self.assertLess(r["residual_l2"], 1e-12) # a pure phase shift stays modal
|
||||
|
||||
def test_rate_control_and_fit(self):
|
||||
@@ -138,16 +140,24 @@ class MetricTests(unittest.TestCase):
|
||||
rows.append(dict(self.sample(field, step), step=step))
|
||||
fit = sw.fit_rate(rows, N, NU)
|
||||
self.assertAlmostEqual(fit["rate_error"], expected, delta=1e-9)
|
||||
np.testing.assert_allclose(fit["interval_rate_errors"], expected, atol=1e-9)
|
||||
self.assertLess(fit["log_fit_residual_max"], 1e-9)
|
||||
self.assertAlmostEqual(fit["exact_rate_tstar"], 5 * (2 * math.pi) ** 2 / sw.RE)
|
||||
self.assertAlmostEqual(fit["exact_rate_tstar"], 1.97392088, places=8)
|
||||
|
||||
def test_fit_ignores_startup_sample(self):
|
||||
rows = [dict(tstar=0.0, step=0, a_hat=2 * U)]
|
||||
rows += [dict(tstar=s, step=round(s * N / U),
|
||||
rows = [dict(tstar=0.0, step=0, a_hat=2 * U, b_hat=0.0)]
|
||||
rows += [dict(tstar=s, step=round(s * N / U), b_hat=0.0,
|
||||
a_hat=U * math.exp(-sw.decay_rate(N, NU) * round(s * N / U)))
|
||||
for s in TIMES[1:]]
|
||||
self.assertLess(sw.fit_rate(rows, N, NU)["rate_error"], 1e-12)
|
||||
rows[-1]["a_hat"] = float("nan")
|
||||
with self.assertRaises(ValueError):
|
||||
sw.fit_rate(rows, N, NU)
|
||||
|
||||
def test_wrong_mode_control(self):
|
||||
# Swapping (2,1) for (1,2), i.e. 26.6 vs 63.4 deg, is not silently modal.
|
||||
# Composite quadrature need not give an exactly zero projection.
|
||||
r = self.sample(exact_field(mode=(1, 2)), 0.0)
|
||||
self.assertLess(abs(r["amplitude_ratio"]), 0.05)
|
||||
self.assertGreater(r["residual_l2"], 0.95)
|
||||
@@ -155,18 +165,23 @@ class MetricTests(unittest.TestCase):
|
||||
def test_longitudinal_control(self):
|
||||
r = self.sample(exact_field(longitudinal=1e-3), 0.0)
|
||||
self.assertAlmostEqual(r["longitudinal_l2"], 1e-3 / U, delta=1e-9)
|
||||
# The full-vector residual and velocity error see it too.
|
||||
self.assertAlmostEqual(r["residual_l2"], 1e-3 / U, delta=1e-9)
|
||||
self.assertAlmostEqual(r["velocity_l2"], 1e-3 / U, delta=1e-9)
|
||||
self.assertLess(r["phase_drift_abs"], 1e-12)
|
||||
self.assertAlmostEqual(r["amplitude_ratio"], 1.0, places=12)
|
||||
|
||||
def test_pressure_gauge_and_ripple(self):
|
||||
r = self.sample(exact_field(offset=1e-4), 0.0)
|
||||
self.assertLess(r["pressure_rel"], 1e-10) # one global gauge removes offsets
|
||||
self.assertLess(r["spurious_pressure_scaled"], 1e-10) # one global gauge
|
||||
ripple = 3e-5
|
||||
r = self.sample(exact_field(ripple=ripple), 0.0)
|
||||
# p = ripple/3 cos(theta); rms over a whole number of periods = ripple/(3 sqrt 2).
|
||||
# Composite midpoint quadrature is not exactly periodic: 3% tolerance.
|
||||
expected = ripple / (3 * math.sqrt(2)) / (U * U / 4)
|
||||
self.assertAlmostEqual(r["pressure_rel"], expected, delta=0.03 * expected)
|
||||
self.assertAlmostEqual(r["spurious_pressure_scaled"], expected, delta=0.03 * expected)
|
||||
self.assertAlmostEqual(r["spurious_pressure_rms"], expected * U * U / 4,
|
||||
delta=0.03 * expected * U * U / 4)
|
||||
|
||||
def test_volume_weighting_and_double_count(self):
|
||||
bad = [np.ones_like(m) for m in self.masks]
|
||||
@@ -177,9 +192,9 @@ class MetricTests(unittest.TestCase):
|
||||
self.assertEqual(r["leaf_cells"], N * N - self.masks[1].sum() // 4 + self.masks[1].sum())
|
||||
|
||||
|
||||
def record(n, depth, rows, explosion="constant", completed=True):
|
||||
def record(n, depth, rows, explosion="constant", completed=True, source="s"):
|
||||
return dict(n=n, depth=depth, viscosity=U * n / sw.RE, rows=rows, completed=completed,
|
||||
explosion=explosion)
|
||||
explosion=explosion, source=source)
|
||||
|
||||
|
||||
def rows_for(n, rate_error=0.0, residual=1e-4, pressure=1e-3, phase=0.0):
|
||||
@@ -187,8 +202,10 @@ def rows_for(n, rate_error=0.0, residual=1e-4, pressure=1e-3, phase=0.0):
|
||||
out = []
|
||||
for tstar in TIMES:
|
||||
step = round(tstar * n / U)
|
||||
out.append(dict(tstar=tstar, step=step, a_hat=U * math.exp(-rate * step),
|
||||
residual_l2=residual, phase_drift_abs=phase, pressure_rel=pressure,
|
||||
out.append(dict(tstar=tstar, step=step, a_hat=U * math.exp(-rate * step), b_hat=0.0,
|
||||
amplitude_error=0.0, residual_l2=residual, phase_drift_abs=phase,
|
||||
spurious_pressure_scaled=pressure,
|
||||
spurious_pressure_rms=pressure * U * U / 4,
|
||||
velocity_l2=residual, mass_relative=0.0))
|
||||
return out
|
||||
|
||||
@@ -203,15 +220,30 @@ class AssessTests(unittest.TestCase):
|
||||
self.assertEqual(report["status"], "FAIL")
|
||||
self.assertAlmostEqual(report["refined"]["constant"]["budget"]["rate_error"], 0.002)
|
||||
# pressure budget = max(2*0.015, 0.02) = 0.03 (control-dominated).
|
||||
self.assertAlmostEqual(report["refined"]["constant"]["budget"]["pressure_rel"], 0.03)
|
||||
self.assertAlmostEqual(
|
||||
report["refined"]["constant"]["budget"]["spurious_pressure_scaled"], 0.03)
|
||||
self.assertIn("finest", report["refined"]["constant"]["control_ratios"])
|
||||
ok = record(128, 2, rows_for(128, rate_error=0.0015), explosion="projected_linear")
|
||||
self.assertEqual(sw.assess(base, fine, [ok])["status"], "PASS")
|
||||
|
||||
def test_invalid_control_and_wrong_shapes(self):
|
||||
base = record(128, 1, rows_for(128, residual=0.5)) # > eps(128)
|
||||
def test_control_outcomes_are_distinct(self):
|
||||
fine = record(256, 1, rows_for(256))
|
||||
refined = record(128, 2, rows_for(128))
|
||||
failing = record(128, 1, rows_for(128, residual=0.5)) # complete, > eps(128)
|
||||
report = sw.assess(failing, fine, [refined])
|
||||
self.assertEqual(report["status"], "CONTROL_SCREEN_FAIL")
|
||||
self.assertIn("constant", report["refined"]) # refined results preserved
|
||||
good = record(128, 1, rows_for(128))
|
||||
self.assertEqual(sw.assess(None, fine, [refined])["status"], "INVALID_CONTROL")
|
||||
incomplete = record(128, 1, rows_for(128), completed=False)
|
||||
self.assertEqual(sw.assess(incomplete, fine, [refined])["status"], "INVALID_CONTROL")
|
||||
mixed = record(256, 1, rows_for(256), source="other")
|
||||
self.assertEqual(sw.assess(good, mixed, [refined])["status"], "INVALID_CONTROL")
|
||||
|
||||
def test_wrong_shapes(self):
|
||||
base = record(128, 1, rows_for(128))
|
||||
fine = record(256, 1, rows_for(256))
|
||||
refined = record(128, 2, rows_for(128))
|
||||
self.assertEqual(sw.assess(base, fine, [refined])["status"], "INVALID_CONTROL")
|
||||
with self.assertRaises(ValueError):
|
||||
sw.assess(base, record(128, 1, rows_for(128)), [refined])
|
||||
with self.assertRaises(ValueError):
|
||||
|
||||
Reference in new issue
Block a user