Research-Stack/4-Infrastructure/shim/taylor_green_betti.py
allaun a247dcb1b6 feat: complete all four interconnected solves
1. NKHodgeFAMM ↔ BurgersPDE bridge — dq_energy_satisfies_scar_condition, burgers_embedding, applyViscosityN induction, scarDensityFromDQ. Build: 8316 jobs, 0 errors.

2. Lonely Runner sim — lonely_runner_sim.py with circular Betti tracking. All k≤4 test cases confirm beta_0 > 0 (lonely times exist). Fixed uint8 wrap-around bug.

3. Taylor-Green Betti tracker — taylor_green_betti.py with 6 scenarios. beta_2 correctly detects voids: TG smooth=0, TG+void>0, noise=77. Fixed int64 serialization in betti_tracker.py.

4. Q1/Q2 vorticity decomposition — VorticityDecomposition.lean: Q1_is_dilatational, Q2_is_solenoidal, enstrophy_proportional_to_Q2, velocity_hodge_decomposition. All rfl proofs + native_decide witnesses. Build: 8598 jobs, 0 errors.
2026-06-16 22:21:55 -05:00

301 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""taylor_green_betti.py — Betti tracking on Taylor-Green vortex.
Tests whether β₂ of FAMM scar support correctly identifies enclosed
voids in a Navier-Stokes-like velocity field.
"""
from __future__ import annotations
import json, sys, warnings
from pathlib import Path
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
sys.path.insert(0, str(Path(__file__).resolve().parent))
from betti_tracker import (
velocity_gradient,
scar_density,
threshold_scar_support,
compute_betti_numbers,
)
warnings.filterwarnings("ignore", category=UserWarning)
# ── Field generators ─────────────────────────────────────────────────
def taylor_green_field(
N: int = 32,
nu: float = 0.1,
t: float = 0.0,
void_center: tuple[float, float, float] | None = None,
void_radius: float = 0.0,
void_velocity: tuple[float, float, float] | None = None,
) -> np.ndarray:
"""Taylor-Green vortex: (N,N,N,3) velocity field.
u = sin(x) cos(y) cos(z) e^{-2νt}
v = -cos(x) sin(y) cos(z) e^{-2νt}
w = 0
Void parameters use grid-index coordinates.
"""
x = np.linspace(0, 2 * np.pi, N, endpoint=False)
y = np.linspace(0, 2 * np.pi, N, endpoint=False)
z = np.linspace(0, 2 * np.pi, N, endpoint=False)
X, Y, Z = np.meshgrid(x, y, z, indexing="ij")
decay = np.exp(-2 * nu * t)
u = np.zeros((N, N, N, 3), dtype=np.float64)
u[..., 0] = np.sin(X) * np.cos(Y) * np.cos(Z) * decay
u[..., 1] = -np.cos(X) * np.sin(Y) * np.cos(Z) * decay
if void_center is not None and void_radius > 0:
cx, cy, cz = void_center
ix, iy, iz = np.meshgrid(np.arange(N), np.arange(N), np.arange(N),
indexing="ij")
dist2 = (ix - cx) ** 2 + (iy - cy) ** 2 + (iz - cz) ** 2
sphere = dist2 <= void_radius ** 2
if void_velocity is not None:
u[sphere] = np.array(void_velocity, dtype=np.float64)
else:
u[sphere] = 0.0
return u
def uniform_field(
N: int = 32,
void_center: tuple[float, float, float] | None = None,
void_radius: float = 0.0,
) -> np.ndarray:
"""Constant velocity field with optional spherical void."""
u = np.ones((N, N, N, 3), dtype=np.float64) * 0.5
if void_center is not None and void_radius > 0:
cx, cy, cz = void_center
ix, iy, iz = np.meshgrid(np.arange(N), np.arange(N), np.arange(N),
indexing="ij")
dist2 = (ix - cx) ** 2 + (iy - cy) ** 2 + (iz - cz) ** 2
sphere = dist2 <= void_radius ** 2
u[sphere] = 3.0
return u
def noise_field(N: int = 32, seed: int = 42) -> np.ndarray:
"""Random velocity field (uniform in [-1, 1])."""
return np.random.default_rng(seed).uniform(-1.0, 1.0, (N, N, N, 3))
# ── Analysis ─────────────────────────────────────────────────────────
def analyze_field(u: np.ndarray, percentile: float = 75.0,
dx: float = 1.0) -> dict:
"""Run NK-Hodge-FAMM pipeline on a single velocity field."""
grad = velocity_gradient(u, dx)
mu = scar_density(grad)
mask = threshold_scar_support(mu, percentile)
betti_raw = compute_betti_numbers(mask, max_dim=2)
betti = {int(k): int(v) for k, v in betti_raw.items()}
return {
"mask": mask,
"mu": mu,
"betti": betti,
"scar_density_mean": float(mu.mean()),
"scar_density_max": float(mu.max()),
"scar_support_fraction": float(mask.mean()),
}
# ── Plotting ─────────────────────────────────────────────────────────
def make_plot(results: list[dict], names: list[str], save_path: str):
"""Layout: 2×3 scar mid-slices, then bar+table+interpretation, then histogram."""
n = len(results)
fig = plt.figure(figsize=(14, 14))
gs = fig.add_gridspec(4, 3, hspace=0.35, wspace=0.25)
# Rows 0-1: scar support mid-slices (2 rows × 3 cols)
img_axes = []
for r in range(2):
row = []
for c in range(3):
ax = fig.add_subplot(gs[r, c])
row.append(ax)
img_axes.append(row)
for i, (res, name) in enumerate(zip(results, names)):
r, c = divmod(i, 3)
ax = img_axes[r][c]
mid = res["mask"].shape[2] // 2
ax.imshow(res["mask"][:, :, mid], cmap="Reds", interpolation="nearest")
b = res["betti"]
ax.set_title(f"{name}\nβ₀={b[0]} β₁={b[1]} β₂={b[2]}", fontsize=9)
ax.axis("off")
# Row 2: bar chart (col 0), table (col 1), interpretation (col 2)
ax_bar = fig.add_subplot(gs[2, 0])
ax_tab = fig.add_subplot(gs[2, 1])
ax_int = fig.add_subplot(gs[2, 2])
labels = names
x = np.arange(n)
w = 0.25
ax_bar.bar(x - w, [r["betti"][0] for r in results], w, label="β₀", color="C0")
ax_bar.bar(x, [r["betti"][1] for r in results], w, label="β₁", color="C1")
ax_bar.bar(x + w, [r["betti"][2] for r in results], w, label="β₂", color="C2")
ax_bar.set_xticks(x)
ax_bar.set_xticklabels(labels, fontsize=8)
ax_bar.set_ylabel("Betti number")
ax_bar.legend(fontsize=8)
ax_bar.set_title("Betti numbers")
cell_text = [[str(r["betti"][d]) for d in (0, 1, 2)] for r in results]
ax_tab.axis("off")
tbl = ax_tab.table(cellText=cell_text, rowLabels=labels,
colLabels=["β₀", "β₁", "β₂"],
loc="center", cellLoc="center")
tbl.auto_set_font_size(False)
tbl.set_fontsize(8)
tbl.scale(1, 1.4)
ax_tab.set_title("Betti table")
lines = ["β₂ Interpretation:", "" * 30]
for name, r in zip(names, results):
b2 = r["betti"][2]
lines.append(f" {name:>15s}: β₂={b2}{'voids' if b2 > 0 else 'no voids'}")
lines.append("")
lines.append("Void detection:")
lines.append(" β₂>0 ⇔ scar support encloses cavity")
lines.append(" Uniform+void: clean shell → β₂>0")
lines.append(" TG+void: depends on void vs percolation")
ax_int.axis("off")
ax_int.text(0, 1.0, "\n".join(lines), fontsize=9,
verticalalignment="top", family="monospace")
ax_int.set_title("Interpretation")
# Row 3: histogram (full width)
ax_det = fig.add_subplot(gs[3, :])
mu_flat = np.concatenate([r["mu"].ravel() for r in results])
ax_det.hist(mu_flat, bins=80, density=True, alpha=0.4, color="gray",
label="all scenarios")
for i, (r, name) in enumerate(zip(results, names)):
ax_det.axvline(x=r["scar_density_max"], color=f"C{i}", ls="--",
lw=1, label=f"{name} σ_max={r['scar_density_max']:.4f}")
ax_det.set_xlabel("scar density μ(x)")
ax_det.set_ylabel("density")
ax_det.set_title("Scar density distributions")
ax_det.legend(fontsize=6)
fig.suptitle("Betti Tracker — Taylor-Green Vortex Analysis", fontsize=13)
fig.savefig(save_path, dpi=150, bbox_inches="tight")
plt.close(fig)
print(f"Wrote {save_path}")
# ── Scenarios ─────────────────────────────────────────────────────────
N = 32
VOID_CENTER = (24, 16, 16) # off-center where TG velocity ≠ 0
SCENARIOS = [
("Uniform flow",
lambda: uniform_field(N=N)),
("Uniform+void",
lambda: uniform_field(N=N, void_center=VOID_CENTER, void_radius=5)),
("TG smooth",
lambda: taylor_green_field(N=N, t=0.0)),
("TG+void r=5",
lambda: taylor_green_field(N=N, t=0.0, void_center=VOID_CENTER,
void_radius=5)),
("TG+void r=6",
lambda: taylor_green_field(N=N, t=0.0, void_center=VOID_CENTER,
void_radius=6)),
("Noise",
lambda: noise_field(N=N)),
]
def main():
outdir = Path(__file__).resolve().parent
results, entries = [], []
print("=" * 60)
print("Betti Tracker — Taylor-Green Vortex Analysis")
print("=" * 60)
for name, gen_fn in SCENARIOS:
print(f"\n [{name}]")
u = gen_fn()
res = analyze_field(u, percentile=75.0)
b = res["betti"]
b2 = b[2]
interp = f"β₂={b2} — indicating voids in scar support" if b2 > 0 \
else f"β₂={b2} — no voids in scar support"
print(f" β₀={b[0]} β₁={b[1]} β₂={b2}")
print(f" scar fraction={res['scar_support_fraction']:.3f}")
print(f" {interp}")
entries.append({
"scenario": name,
"betti_0": b[0], "betti_1": b[1], "betti_2": b2,
"interpretation": interp,
"scar_density_mean": res["scar_density_mean"],
"scar_density_max": res["scar_density_max"],
"scar_support_fraction": res["scar_support_fraction"],
})
results.append(res)
report = dict(
schema="taylor_green_betti_v1",
description="Betti number analysis of Taylor-Green vortex with voids",
grid=f"{N}x{N}x{N}",
percentile=75.0,
scenarios=entries,
summary={entries[i]["scenario"]: entries[i]["betti_2"]
for i in range(len(entries))},
)
json_path = outdir / "taylor_green_betti.json"
with open(json_path, "w") as f:
json.dump(report, f, indent=2)
print(f"\nWrote {json_path}")
make_plot(results, [s[0] for s in SCENARIOS],
str(outdir / "taylor_green_betti.png"))
print("\n" + "=" * 60)
print("DONE")
print("=" * 60)
# ── Entry point ───────────────────────────────────────────────────────
if __name__ == "__main__":
test = "--test" in sys.argv[1:]
if test:
sys.argv.remove("--test")
main()
if test:
with open(Path(__file__).resolve().parent / "taylor_green_betti.json") as f:
rpt = json.load(f)
s = rpt["summary"]
ok = True
if s["Uniform flow"] != 0:
print(f"\nFAIL: uniform flow expected β₂=0, got β₂={s['Uniform flow']}")
ok = False
if s["Uniform+void"] <= 0:
print(f"\nFAIL: uniform+void expected β₂>0, got β₂={s['Uniform+void']}")
ok = False
if s["TG smooth"] != 0:
print(f"\nWARN: smooth TG has β₂={s['TG smooth']} (expected 0)")
if s["TG+void r=5"] <= 0:
print(f"\nWARN: TG+void r=5 has β₂={s['TG+void r=5']} (expected >0)")
else:
print(f"\n✓ TG+void r=5: β₂={s['TG+void r=5']} > 0 — void detected")
if ok:
print("✓ ALL HARD EXPECTATIONS MET")
else:
print("✗ SOME HARD EXPECTATIONS FAILED")
sys.exit(1)