mirror of
https://github.com/allaunthefox/Research-Stack.git
synced 2026-07-31 03:05:21 +00:00
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.
480 lines
16 KiB
Python
480 lines
16 KiB
Python
#!/usr/bin/env python3
|
||
"""lonely_runner_sim.py — Lonely Runner Betti simulation.
|
||
|
||
Simulates k runners on S¹, computes scar (uncovered) region M_t,
|
||
tracks β₀(M_t) — connected components of uncovered set.
|
||
β₀(M_t) > 0 ⇔ lonely runner exists at time t.
|
||
|
||
Imports scar_density, threshold_scar_support, compute_betti_numbers
|
||
from betti_tracker for compatibility; uses direct 1D circular β₀
|
||
calculator for correctness on S¹ topology.
|
||
|
||
Usage:
|
||
python3 lonely_runner_sim.py --test
|
||
python3 lonely_runner_sim.py --k 3 --speeds 0 1 2 --time 5 --steps 1000
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
|
||
try:
|
||
import matplotlib
|
||
matplotlib.use("Agg")
|
||
import matplotlib.pyplot as plt
|
||
HAVE_MPL = True
|
||
except ImportError:
|
||
HAVE_MPL = False
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||
from betti_tracker import threshold_scar_support, compute_betti_numbers
|
||
|
||
# ── simulation core ──────────────────────────────────────────────────
|
||
|
||
|
||
def runner_positions(
|
||
speeds: list[float], times: np.ndarray
|
||
) -> np.ndarray:
|
||
"""Runner positions on S¹ at each time step.
|
||
|
||
Returns (T, k) array in [0, 1).
|
||
"""
|
||
speeds_arr = np.array(speeds, dtype=np.float64)
|
||
pos = np.outer(times, speeds_arr) % 1.0
|
||
return pos
|
||
|
||
|
||
def coverage_density(
|
||
positions: np.ndarray, delta: float, N: int = 512
|
||
) -> np.ndarray:
|
||
"""Coverage density Φ(t,θ) = Σᵢ 𝟙(|θ - vᵢt| < δ mod 1).
|
||
|
||
Returns (T, N) integer count array.
|
||
"""
|
||
T, k = positions.shape
|
||
theta = np.linspace(0, 1, N, endpoint=False)
|
||
phi = np.zeros((T, N), dtype=np.int32)
|
||
|
||
for t in range(T):
|
||
diff = np.abs(theta[:, np.newaxis] - positions[t, np.newaxis, :])
|
||
dist = np.minimum(diff, 1.0 - diff)
|
||
phi[t, :] = (dist < delta).sum(axis=1)
|
||
|
||
return phi
|
||
|
||
|
||
def scar_field(phi: np.ndarray) -> np.ndarray:
|
||
"""Scar (loneliness) field μ(t,θ) = 1 - min(Φ(t,θ), 1).
|
||
|
||
Returns (T, N) float64 array where 1 = uncovered, 0 = covered.
|
||
"""
|
||
return (1.0 - np.minimum(phi, 1)).astype(np.float64)
|
||
|
||
|
||
# ── 1D circular Betti-0 ─────────────────────────────────────────────
|
||
|
||
|
||
def betti_1d_circular(mask: np.ndarray) -> int:
|
||
"""β₀ of a 1D binary mask on S¹ (circular topology).
|
||
|
||
Counts connected components of the foreground (1 = scar),
|
||
accounting for wrap-around adjacency (first and last elements
|
||
are neighbours on the circle).
|
||
"""
|
||
if not mask.any():
|
||
return 0
|
||
if mask.all():
|
||
return 1
|
||
signed = 2 * mask.astype(np.int8) - 1
|
||
padded = np.concatenate([[signed[-1]], signed])
|
||
trans_up = int(((padded[1:] - padded[:-1]) > 0).sum())
|
||
return trans_up
|
||
|
||
|
||
# ── simulate ────────────────────────────────────────────────────────
|
||
|
||
|
||
SIM_CASES: dict[str, dict] = {
|
||
"k2_simple": {
|
||
"k": 2,
|
||
"speeds": [0, 1],
|
||
"description": "k=2 with speeds [0, 1]",
|
||
},
|
||
"k3_012": {
|
||
"k": 3,
|
||
"speeds": [0, 1, 2],
|
||
"description": "k=3 with speeds [0, 1, 2]",
|
||
},
|
||
"k4_0134": {
|
||
"k": 4,
|
||
"speeds": [0, 1, 3, 4],
|
||
"description": "k=4 with speeds [0, 1, 3, 4] — known lonely times",
|
||
},
|
||
"k3_rational": {
|
||
"k": 3,
|
||
"speeds": [0, 0.5, 1.0],
|
||
"description": "k=3 with rational multiples [0, 0.5, 1.0]",
|
||
},
|
||
"k4_random": {
|
||
"k": 4,
|
||
"speeds": [0, 1.0, 2.71, 3.14],
|
||
"description": "k=4 with random-ish speeds",
|
||
},
|
||
}
|
||
|
||
|
||
def simulate(
|
||
speeds: list[float],
|
||
total_time: float,
|
||
time_steps: int,
|
||
N: int = 512,
|
||
) -> dict:
|
||
"""Run a Lonely Runner simulation.
|
||
|
||
Returns a results dict with positions, scar data, and Betti series.
|
||
"""
|
||
k = len(speeds)
|
||
delta = 1.0 / (k + 1.0)
|
||
times = np.linspace(0, total_time, time_steps, endpoint=False)
|
||
|
||
pos = runner_positions(speeds, times)
|
||
phi = coverage_density(pos, delta, N=N)
|
||
mu = scar_field(phi)
|
||
masks = (mu > 0).astype(np.uint8)
|
||
|
||
betti_series: list[int] = []
|
||
scar_fractions: list[float] = []
|
||
for t in range(time_steps):
|
||
b0 = betti_1d_circular(masks[t])
|
||
betti_series.append(b0)
|
||
scar_fractions.append(float(masks[t].mean()))
|
||
|
||
theta = np.linspace(0, 1, N, endpoint=False)
|
||
|
||
return {
|
||
"k": k,
|
||
"speeds": speeds,
|
||
"delta": delta,
|
||
"total_time": total_time,
|
||
"time_steps": time_steps,
|
||
"N": N,
|
||
"times": times.tolist(),
|
||
"theta": theta.tolist(),
|
||
"positions": pos.tolist(),
|
||
"phi": phi.tolist(),
|
||
"mu": mu.tolist(),
|
||
"masks": masks.tolist(),
|
||
"betti_series": betti_series,
|
||
"scar_fractions": scar_fractions,
|
||
"min_betti_0": min(betti_series),
|
||
"max_betti_0": max(betti_series),
|
||
"lonely_times_found": max(betti_series) > 0,
|
||
"always_covered": max(betti_series) == 0,
|
||
"fraction_with_scar": sum(1 for b in betti_series if b > 0) / time_steps,
|
||
}
|
||
|
||
|
||
def build_report(results: dict) -> dict:
|
||
"""Build a JSON-serialisable report with concise summary."""
|
||
b0 = results["betti_series"]
|
||
summary_parts = []
|
||
if results["lonely_times_found"]:
|
||
summary_parts.append(
|
||
f"β₀(M_t) > 0 at some t (max={results['max_betti_0']})"
|
||
)
|
||
else:
|
||
summary_parts.append("β₀(M_t) = 0 ∀t — complete coverage")
|
||
summary_parts.append(
|
||
f"scar present {results['fraction_with_scar']*100:.1f}% of time"
|
||
)
|
||
summary_parts.append(
|
||
f"min β₀ = {results['min_betti_0']}, max β₀ = {results['max_betti_0']}"
|
||
)
|
||
|
||
return {
|
||
"schema": "lonely_runner_sim_v1",
|
||
"claim_boundary": "numerical-simulation;no-lean-proof",
|
||
"k": results["k"],
|
||
"speeds": results["speeds"],
|
||
"delta": results["delta"],
|
||
"total_time": results["total_time"],
|
||
"time_steps": results["time_steps"],
|
||
"N": results["N"],
|
||
"summary": "; ".join(summary_parts),
|
||
"lonely_times_found": results["lonely_times_found"],
|
||
"always_covered": results["always_covered"],
|
||
"min_betti_0": results["min_betti_0"],
|
||
"max_betti_0": results["max_betti_0"],
|
||
"fraction_with_scar": results["fraction_with_scar"],
|
||
"scar_fraction_mean": float(np.mean(results["scar_fractions"])),
|
||
"betti_series": b0,
|
||
"scar_fractions": results["scar_fractions"],
|
||
}
|
||
|
||
|
||
# ── plotting ────────────────────────────────────────────────────────
|
||
|
||
|
||
def plot_results(
|
||
results: dict, outdir: str, prefix: str = "lonely_runner"
|
||
) -> str | None:
|
||
"""Generate three-panel diagnostic plot.
|
||
|
||
Returns path to saved PNG, or None if matplotlib unavailable.
|
||
"""
|
||
if not HAVE_MPL:
|
||
return None
|
||
|
||
out_path = Path(outdir)
|
||
out_path.mkdir(parents=True, exist_ok=True)
|
||
ts = np.array(results["times"])
|
||
theta = np.array(results["theta"])
|
||
pos = np.array(results["positions"])
|
||
phi = np.array(results["phi"])
|
||
mu = np.array(results["mu"])
|
||
masks = np.array(results["masks"])
|
||
b0 = results["betti_series"]
|
||
|
||
fig, axes = plt.subplots(4, 1, figsize=(12, 11), sharex=False)
|
||
|
||
# Panel 1: runner trajectories + uncovered regions
|
||
ax = axes[0]
|
||
for i in range(results["k"]):
|
||
ax.plot(ts, pos[:, i], linewidth=0.8, label=f"R{i} (v={results['speeds'][i]})")
|
||
scar_t, scar_theta = np.where(masks > 0)
|
||
if len(scar_t) > 0:
|
||
ax.scatter(
|
||
ts[scar_t], theta[scar_theta],
|
||
s=0.5, c="red", alpha=0.3, label="uncovered",
|
||
)
|
||
ax.set_ylabel("position on S¹")
|
||
ax.set_title(
|
||
f"k={results['k']} speeds={results['speeds']} δ={results['delta']:.4f}"
|
||
)
|
||
ax.legend(fontsize=7, ncol=min(results["k"], 4))
|
||
ax.set_ylim(0, 1)
|
||
|
||
# Panel 2: coverage heatmap
|
||
ax = axes[1]
|
||
extent = [0, results["total_time"], 0, 1]
|
||
im = ax.imshow(
|
||
phi.T, aspect="auto", origin="lower", extent=extent,
|
||
cmap="YlOrRd", interpolation="nearest",
|
||
)
|
||
ax.set_ylabel("angle θ")
|
||
ax.set_title("Coverage density Φ(t,θ)")
|
||
plt.colorbar(im, ax=ax)
|
||
|
||
# Panel 3: scar field heatmap
|
||
ax = axes[2]
|
||
im2 = ax.imshow(
|
||
mu.T, aspect="auto", origin="lower", extent=extent,
|
||
cmap="Blues", interpolation="nearest", vmin=0, vmax=1,
|
||
)
|
||
ax.set_ylabel("angle θ")
|
||
ax.set_title("Scar (loneliness) field μ(t,θ)")
|
||
plt.colorbar(im2, ax=ax)
|
||
|
||
# Panel 4: β₀ over time
|
||
ax = axes[3]
|
||
ax.plot(ts, b0, "o-", color="C0", markersize=2, linewidth=0.8)
|
||
ax.axhline(y=0, color="gray", linestyle="--", alpha=0.5)
|
||
ax.set_ylabel("β₀")
|
||
ax.set_xlabel("time")
|
||
ax.set_title(f"β₀(M_t) — {'lonely times found' if results['lonely_times_found'] else 'always covered'}")
|
||
ax.set_ylim(-0.5, max(b0) + 1.5)
|
||
|
||
fig.suptitle(
|
||
f"Lonely Runner Simulation (k={results['k']}, δ={results['delta']:.4f})",
|
||
fontsize=13,
|
||
)
|
||
plt.tight_layout(rect=[0, 0, 1, 0.97])
|
||
png_path = out_path / f"{prefix}_k{results['k']}.png"
|
||
fig.savefig(png_path, dpi=150)
|
||
plt.close(fig)
|
||
return str(png_path)
|
||
|
||
|
||
# ── case runner ─────────────────────────────────────────────────────
|
||
|
||
|
||
def run_case(
|
||
name: str,
|
||
config: dict,
|
||
total_time: float = 5.0,
|
||
time_steps: int = 1000,
|
||
N: int = 512,
|
||
outdir: str = ".",
|
||
) -> dict:
|
||
"""Run one simulation case and write output files."""
|
||
out_path = Path(outdir) / name
|
||
out_path.mkdir(parents=True, exist_ok=True)
|
||
|
||
print(f"\n Case: {name} ({config['description']})")
|
||
print(f" k={config['k']} speeds={config['speeds']} δ={1.0/(config['k']+1):.4f}")
|
||
|
||
results = simulate(
|
||
config["speeds"],
|
||
total_time=total_time,
|
||
time_steps=time_steps,
|
||
N=N,
|
||
)
|
||
report = build_report(results)
|
||
|
||
png = plot_results(results, str(out_path), prefix=f"lonely_runner_{name}")
|
||
|
||
json_path = out_path / f"{name}_report.json"
|
||
with open(json_path, "w") as f:
|
||
json.dump(report, f, indent=2)
|
||
|
||
print(f" min β₀ = {report['min_betti_0']}, max β₀ = {report['max_betti_0']}")
|
||
print(f" scar fraction: {report['scar_fraction_mean']:.4f}")
|
||
print(f" lonely times: {report['lonely_times_found']}")
|
||
if png:
|
||
print(f" plot: {png}")
|
||
print(f" JSON: {json_path}")
|
||
|
||
return report
|
||
|
||
|
||
# ── tests ────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_betti_1d():
|
||
"""Unit tests for the 1D circular Betti-0 calculator."""
|
||
# all zeros → β₀ = 0
|
||
assert betti_1d_circular(np.zeros(10, dtype=np.uint8)) == 0
|
||
# all ones → β₀ = 1 (whole circle)
|
||
assert betti_1d_circular(np.ones(10, dtype=np.uint8)) == 1
|
||
# single block, no wrap
|
||
assert betti_1d_circular(np.array([1, 1, 0, 0, 0], dtype=np.uint8)) == 1
|
||
# two blocks, no wrap
|
||
assert betti_1d_circular(np.array([1, 1, 0, 0, 1], dtype=np.uint8)) == 1
|
||
# two blocks, wraps around
|
||
assert betti_1d_circular(np.array([1, 0, 1, 0, 1], dtype=np.uint8)) == 2
|
||
# alternating single
|
||
assert betti_1d_circular(np.array([1, 0, 1, 0, 1, 0], dtype=np.uint8)) == 3
|
||
# single isolated 1
|
||
assert betti_1d_circular(np.array([0, 0, 1, 0, 0], dtype=np.uint8)) == 1
|
||
print(" ✓ betti_1d_circular — all unit tests passed")
|
||
|
||
|
||
def test_coverage_sanity():
|
||
"""Sanity checks on coverage density calculation."""
|
||
# k=3, at t=0 each runner covers 2δ = 0.5 of circle
|
||
# with runners at 0, overlap means total covered < 3*0.5
|
||
speeds = [0, 1, 2]
|
||
delta = 1.0 / (3 + 1) # 0.25
|
||
times = np.array([0.0])
|
||
pos = runner_positions(speeds, times)
|
||
phi = coverage_density(pos, delta, N=100)
|
||
frac_covered = (phi[0] > 0).mean()
|
||
# At t=0 all runners at 0 → exactly δ on each side → 0.5
|
||
assert abs(frac_covered - 0.5) < 0.06, f"Expected ~0.5, got {frac_covered}"
|
||
# At t=0, coverage count at θ=0 should be 3 (all runners)
|
||
assert phi[0, 0] == 3, f"Expected phi=3 at origin, got {phi[0, 0]}"
|
||
print(f" ✓ coverage_sanity — k=3 at t=0 covers {frac_covered:.3f} (expected ~0.5)")
|
||
|
||
|
||
def test_small_k_cases():
|
||
"""Check well-known small-k cases produce lonely times."""
|
||
cases = [
|
||
("k=2 [0,1]", [0, 1]),
|
||
("k=3 [0,1,2]", [0, 1, 2]),
|
||
("k=4 [0,1,3,4]", [0, 1, 3, 4]),
|
||
]
|
||
for label, speeds in cases:
|
||
k = len(speeds)
|
||
res = simulate(speeds, total_time=5.0, time_steps=1000, N=512)
|
||
assert res["lonely_times_found"], f"{label}: expected lonely times, got always covered"
|
||
print(f" ✓ {label}: β₀ max = {res['max_betti_0']}, scar {res['fraction_with_scar']*100:.1f}%")
|
||
|
||
|
||
def _test():
|
||
"""Full test suite."""
|
||
print("=" * 60)
|
||
print("lonely_runner_sim — test suite")
|
||
print("=" * 60)
|
||
|
||
# Unit tests
|
||
print("\n--- Unit tests ---")
|
||
test_betti_1d()
|
||
test_coverage_sanity()
|
||
|
||
# Small-k verification
|
||
print("\n--- Small-k lonely times ---")
|
||
test_small_k_cases()
|
||
|
||
# Rational-multiple case (more coverage overlap)
|
||
print("\n--- Rational speeds (more overlap expected) ---")
|
||
r = run_case(
|
||
"k3_rational",
|
||
SIM_CASES["k3_rational"],
|
||
total_time=5.0, time_steps=1000, N=512,
|
||
outdir="/tmp/lonely_runner_test",
|
||
)
|
||
print(f" (rational case: scar {r['fraction_with_scar']*100:.1f}% of time)")
|
||
|
||
# Random speeds
|
||
print("\n--- Random-ish speeds ---")
|
||
r = run_case(
|
||
"k4_random",
|
||
SIM_CASES["k4_random"],
|
||
total_time=5.0, time_steps=1000, N=512,
|
||
outdir="/tmp/lonely_runner_test",
|
||
)
|
||
|
||
print("\n" + "=" * 60)
|
||
print("ALL TESTS PASSED")
|
||
print("=" * 60)
|
||
|
||
|
||
# ── CLI ─────────────────────────────────────────────────────────────
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(
|
||
description="Lonely Runner Betti simulation"
|
||
)
|
||
ap.add_argument("--k", type=int, default=3, help="Number of runners")
|
||
ap.add_argument("--speeds", type=float, nargs="+", default=None, help="Runner speeds")
|
||
ap.add_argument("--time", type=float, default=5.0, help="Total simulation time")
|
||
ap.add_argument("--steps", type=int, default=1000, help="Number of time steps")
|
||
ap.add_argument("--N", type=int, default=512, help="Spatial discretisation points")
|
||
ap.add_argument("--outdir", default=".", help="Output directory")
|
||
ap.add_argument("--name", default="lonely_runner", help="Run name")
|
||
ap.add_argument("--test", action="store_true", help="Run test suite")
|
||
args = ap.parse_args()
|
||
|
||
if args.test:
|
||
_test()
|
||
return
|
||
|
||
speeds = args.speeds
|
||
if speeds is None:
|
||
speeds = list(range(args.k))
|
||
elif len(speeds) != args.k:
|
||
print(f"Error: --speeds must have {args.k} values for k={args.k}")
|
||
sys.exit(1)
|
||
|
||
config = {
|
||
"k": args.k,
|
||
"speeds": speeds,
|
||
"description": f"k={args.k} speeds={speeds}",
|
||
}
|
||
run_case(
|
||
args.name, config,
|
||
total_time=args.time,
|
||
time_steps=args.steps,
|
||
N=args.N,
|
||
outdir=args.outdir,
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|