Paper 27 — Knm Phase Dynamics in scpn-control¶
Demonstrates the integration of SCPN Paper 27 (The Knm Matrix: A Simulation Framework for Modelling Multi-Scale Bidirectional Causality) into the scpn-control tokamak control repo.
What this notebook covers:
- Kuramoto–Sakaguchi + ζ sin(Ψ−θ) global driver — single-layer mean-field
- Paper 27 Knm matrix — 16×16 coupling with calibration anchors
- Multi-layer UPDE — 16 SCPN layers coupled through Knm
- FusionKernel.phase_sync_step() — reviewer's ζ injection into fusion path
- Convergence analysis — R(t) trajectories and per-layer coherence
References: - Paper 27 (Academia) - arXiv:2004.06344 — Generalized Kuramoto–Sakaguchi
Copyright 1996–2026 Miroslav Šotek · MIT OR Apache-2.0
import numpy as np
import matplotlib.pyplot as plt
from scpn_control.phase.kuramoto import (
kuramoto_sakaguchi_step, order_parameter, wrap_phase,
)
from scpn_control.phase.knm import KnmSpec, build_knm_paper27, OMEGA_N_16
from scpn_control.phase.upde import UPDESystem
SEED = 42
rng = np.random.default_rng(SEED)
plt.rcParams.update({"figure.dpi": 120, "figure.facecolor": "white"})
print(f"OMEGA_N_16 shape: {OMEGA_N_16.shape}")
print(f"Frequencies (rad/s): {OMEGA_N_16}")
1. Knm Coupling Matrix (Paper 27)¶
The 16×16 Knm matrix encodes inter-layer coupling with exponential distance decay, calibration anchors from Paper 27 Table 2, and cross-hierarchy boosts (L1↔L16, L5↔L7).
spec = build_knm_paper27(L=16)
fig, ax = plt.subplots(figsize=(7, 6))
im = ax.imshow(spec.K, cmap="YlOrRd", origin="upper")
ax.set_xlabel("Target layer m")
ax.set_ylabel("Source layer n")
ax.set_title("Paper 27 — Knm Coupling Matrix (16×16)")
ax.set_xticks(range(16))
ax.set_yticks(range(16))
ax.set_xticklabels([f"L{i+1}" for i in range(16)], fontsize=7, rotation=45)
ax.set_yticklabels([f"L{i+1}" for i in range(16)], fontsize=7)
plt.colorbar(im, ax=ax, label="Coupling strength")
plt.tight_layout()
plt.show()
# Verify calibration anchors
print(f"K[L1,L2] = {spec.K[0,1]:.3f} (expected 0.302)")
print(f"K[L2,L3] = {spec.K[1,2]:.3f} (expected 0.201)")
print(f"K[L3,L4] = {spec.K[2,3]:.3f} (expected 0.252)")
print(f"K[L4,L5] = {spec.K[3,4]:.3f} (expected 0.154)")
print(f"K[L1,L16] = {spec.K[0,15]:.3f} (cross-hierarchy ≥ 0.05)")
print(f"K[L5,L7] = {spec.K[4,6]:.3f} (cross-hierarchy ≥ 0.15)")
2. Single-Layer Kuramoto–Sakaguchi: Effect of ζ sin(Ψ−θ)¶
Compare three regimes on a population of 200 oscillators: - K only — standard Kuramoto mean-field coupling - K + ζ — coupling plus exogenous global driver at Ψ = 0 - ζ only — pure intention/carrier pull (no inter-oscillator coupling)
The ζ sin(Ψ−θ) term acts as a Lagrangian pull toward the carrier phase Ψ without its own dynamics (no ˙Ψ equation).
N = 200
n_steps = 800
dt = 0.01
omega = rng.normal(0, 0.8, N)
theta0 = rng.uniform(-np.pi, np.pi, N)
configs = {
"K=3, ζ=0": dict(K=3.0, zeta=0.0, psi_mode="mean_field"),
"K=3, ζ=2": dict(K=3.0, zeta=2.0, psi_driver=0.0, psi_mode="external"),
"K=0, ζ=3": dict(K=0.0, zeta=3.0, psi_driver=0.0, psi_mode="external"),
}
fig, axes = plt.subplots(1, 3, figsize=(14, 4), sharey=True)
R_traces = {}
for ax, (label, kw) in zip(axes, configs.items()):
theta = theta0.copy()
R_hist = np.empty(n_steps)
for t in range(n_steps):
out = kuramoto_sakaguchi_step(theta, omega, dt=dt, **kw)
theta = out["theta1"]
R_hist[t] = out["R"]
R_traces[label] = R_hist
ax.plot(np.arange(n_steps) * dt, R_hist, lw=1.5)
ax.set_title(label, fontsize=11)
ax.set_xlabel("Time (s)")
ax.set_ylim(0, 1.05)
ax.axhline(0.95, color="red", ls="--", lw=0.8, label="Theurgic threshold")
ax.legend(fontsize=8)
axes[0].set_ylabel("Order parameter R")
fig.suptitle("Kuramoto–Sakaguchi: ζ sin(Ψ−θ) Global Driver Effect", fontsize=13)
plt.tight_layout()
plt.show()
for label, R in R_traces.items():
print(f" {label}: R_final = {R[-1]:.3f}")
3. Sakaguchi Phase-Lag α: Frustration Effect¶
With α > 0, the coupling term becomes sin(ψ_r − θ_i − α), introducing a phase-lag frustration that prevents full synchronisation (R < 1 at equilibrium). This models finite-size effects per arXiv:2004.06344.
alphas = [0.0, 0.3, 0.6, 0.9, 1.2]
theta0_alpha = rng.uniform(-np.pi, np.pi, N)
fig, ax = plt.subplots(figsize=(8, 4))
for alpha in alphas:
theta = theta0_alpha.copy()
R_hist = np.empty(n_steps)
for t in range(n_steps):
out = kuramoto_sakaguchi_step(
theta, omega, dt=dt, K=4.0, alpha=alpha, psi_mode="mean_field",
)
theta = out["theta1"]
R_hist[t] = out["R"]
ax.plot(np.arange(n_steps) * dt, R_hist, lw=1.5, label=f"α={alpha:.1f}")
ax.set_xlabel("Time (s)")
ax.set_ylabel("Order parameter R")
ax.set_title("Sakaguchi Phase-Lag Frustration (K=4)")
ax.legend(fontsize=9)
ax.set_ylim(0, 1.05)
plt.tight_layout()
plt.show()
4. Multi-Layer UPDE — 16 SCPN Layers¶
Full Paper 27 UPDE: each of 16 layers has its own oscillator population, coupled through Knm (intra-layer diagonal + inter-layer off-diagonal).
We add a uniform ζ = 1.0 global driver at Ψ = 0 and track per-layer coherence R_m(t) and global R(t) over 2000 steps.
L = 16
N_per = 50
n_upde_steps = 2000
dt_upde = 0.005
spec16 = build_knm_paper27(L=16, zeta_uniform=1.0)
upde = UPDESystem(spec=spec16, dt=dt_upde, psi_mode="external")
theta_layers = [rng.uniform(-np.pi, np.pi, N_per) for _ in range(L)]
omega_layers = [OMEGA_N_16[m] + rng.normal(0, 0.2, N_per) for m in range(L)]
result = upde.run(
n_upde_steps, theta_layers, omega_layers,
psi_driver=0.0,
)
R_layer = result["R_layer_hist"] # (n_steps, 16)
R_global = result["R_global_hist"] # (n_steps,)
t_axis = np.arange(n_upde_steps) * dt_upde
print(f"R_global: {R_global[0]:.3f} → {R_global[-1]:.3f}")
for m in range(L):
print(f" L{m+1:2d}: R = {R_layer[-1, m]:.3f}")
fig, axes = plt.subplots(2, 1, figsize=(12, 7), gridspec_kw={"height_ratios": [2, 3]})
# Top: global R
axes[0].plot(t_axis, R_global, color="black", lw=2)
axes[0].axhline(0.95, color="red", ls="--", lw=1, label="Theurgic threshold (R=0.95)")
axes[0].set_ylabel("R_global")
axes[0].set_title("16-Layer UPDE — Global Order Parameter")
axes[0].legend(fontsize=9)
axes[0].set_ylim(0, 1.05)
# Bottom: per-layer R heatmap
im = axes[1].imshow(
R_layer.T, aspect="auto", cmap="inferno",
extent=[0, t_axis[-1], 16.5, 0.5], vmin=0, vmax=1,
)
axes[1].set_xlabel("Time (s)")
axes[1].set_ylabel("SCPN Layer")
axes[1].set_title("Per-Layer Coherence R_m(t)")
axes[1].set_yticks(range(1, 17))
axes[1].set_yticklabels([f"L{i}" for i in range(1, 17)], fontsize=7)
plt.colorbar(im, ax=axes[1], label="R_m")
plt.tight_layout()
plt.show()
5. PAC Gating — Cross-Frequency Coupling¶
Paper 27 §4.3 describes phase-amplitude coupling (PAC) between layers.
The pac_gamma parameter modulates inter-layer coupling by
(1 + γ·(1 − R_source)), boosting coupling when the source layer is
less coherent (low R → stronger drive).
gammas = [0.0, 0.5, 1.0, 2.0]
spec_pac = build_knm_paper27(L=8, zeta_uniform=0.5)
n_pac = 1500
fig, ax = plt.subplots(figsize=(8, 4))
for gamma in gammas:
upde_pac = UPDESystem(spec=spec_pac, dt=0.005, psi_mode="external")
th = [rng.uniform(-np.pi, np.pi, 40) for _ in range(8)]
om = [OMEGA_N_16[m] + rng.normal(0, 0.15, 40) for m in range(8)]
res = upde_pac.run(n_pac, th, om, psi_driver=0.0, pac_gamma=gamma)
ax.plot(
np.arange(n_pac) * 0.005, res["R_global_hist"],
lw=1.5, label=f"γ={gamma:.1f}",
)
ax.set_xlabel("Time (s)")
ax.set_ylabel("R_global")
ax.set_title("PAC Gating Effect on Global Synchronisation")
ax.legend(fontsize=9)
ax.set_ylim(0, 1.05)
plt.tight_layout()
plt.show()
6. Plasma Sync via FusionKernel.phase_sync_step()¶
The reviewer requested ζ sin(Ψ−θ) inside the fusion kernel path. Here we simulate a population of 128 plasma mode oscillators (representing ELM pacing, tearing modes, edge oscillations) and show the phase-reduction kernel converging under the global driver.
This uses FusionKernel.phase_sync_step() directly — the same
entry point a real control loop would call.
import json, tempfile
from scpn_control.core.fusion_kernel import FusionKernel
# Minimal config just to instantiate the kernel
cfg = {
"reactor_name": "phase_sync_demo",
"dimensions": {"R_min": 0.5, "R_max": 2.5, "Z_min": -1.5, "Z_max": 1.5},
"grid_resolution": [9, 9],
"coils": {"positions": [], "currents": [], "turns": []},
"physics": {},
"solver": {"method": "sor", "max_iterations": 5, "tol": 1e-4},
"phase_sync": {
"K": 3.0,
"alpha": 0.0,
"zeta": 1.5,
"psi_mode": "external",
"actuation_gain": 1.0,
},
}
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f:
json.dump(cfg, f)
cfg_path = f.name
fk = FusionKernel(cfg_path)
# Simulate plasma mode ensemble
N_modes = 128
theta_plasma = rng.uniform(-np.pi, np.pi, N_modes)
omega_plasma = rng.normal(0, 0.6, N_modes)
Psi_intention = 0.0 # carrier phase
n_fk_steps = 600
dt_fk = 0.01
R_fk = np.empty(n_fk_steps)
theta_hist = np.empty((n_fk_steps, N_modes))
for t in range(n_fk_steps):
out = fk.phase_sync_step(
theta_plasma, omega_plasma, dt=dt_fk, psi_driver=Psi_intention,
)
theta_plasma = out["theta1"]
R_fk[t] = out["R"]
theta_hist[t] = theta_plasma
print(f"FusionKernel phase_sync: R = {R_fk[0]:.3f} → {R_fk[-1]:.3f}")
fig, axes = plt.subplots(1, 2, figsize=(13, 5))
# Left: R(t) convergence
axes[0].plot(np.arange(n_fk_steps) * dt_fk, R_fk, color="navy", lw=2)
axes[0].axhline(0.95, color="red", ls="--", lw=1, label="Theurgic threshold")
axes[0].set_xlabel("Time (s)")
axes[0].set_ylabel("R (plasma mode coherence)")
axes[0].set_title("FusionKernel.phase_sync_step() — Plasma Sync")
axes[0].legend(fontsize=9)
axes[0].set_ylim(0, 1.05)
# Right: phase portrait (unit circle snapshots)
for snap_idx, color, label in [
(0, "gray", "t=0"),
(n_fk_steps // 4, "orange", f"t={n_fk_steps//4 * dt_fk:.1f}s"),
(-1, "green", f"t={n_fk_steps * dt_fk:.1f}s"),
]:
th = theta_hist[snap_idx]
axes[1].scatter(
np.cos(th), np.sin(th), s=8, alpha=0.6, color=color, label=label,
)
circle = np.linspace(0, 2 * np.pi, 200)
axes[1].plot(np.cos(circle), np.sin(circle), "k-", lw=0.5, alpha=0.3)
axes[1].arrow(0, 0, np.cos(Psi_intention) * 0.85, np.sin(Psi_intention) * 0.85,
head_width=0.06, color="red", lw=2, label="Ψ carrier")
axes[1].set_xlim(-1.3, 1.3)
axes[1].set_ylim(-1.3, 1.3)
axes[1].set_aspect("equal")
axes[1].set_title("Phase Portrait — Mode Oscillators on Unit Circle")
axes[1].legend(fontsize=8, loc="upper left")
plt.tight_layout()
plt.show()
7. Actuation Gain Sweep — Control Authority¶
The actuation_gain parameter scales both K and ζ, modelling how
the controller's authority over the plasma mode ensemble increases.
This maps directly to SNN or PID output amplitude in a closed-loop
control scenario.
gains = [0.2, 0.5, 1.0, 2.0, 5.0]
fig, ax = plt.subplots(figsize=(8, 4))
for gain in gains:
theta_g = rng.uniform(-np.pi, np.pi, 128)
omega_g = rng.normal(0, 0.6, 128)
R_g = np.empty(400)
for t in range(400):
out = fk.phase_sync_step(
theta_g, omega_g, dt=0.01, psi_driver=0.0, actuation_gain=gain,
)
theta_g = out["theta1"]
R_g[t] = out["R"]
ax.plot(np.arange(400) * 0.01, R_g, lw=1.5, label=f"gain={gain:.1f}")
ax.set_xlabel("Time (s)")
ax.set_ylabel("R")
ax.set_title("Actuation Gain Sweep — FusionKernel Phase Sync")
ax.legend(fontsize=9)
ax.set_ylim(0, 1.05)
plt.tight_layout()
plt.show()
8. Lyapunov Stability — ½Σ(1 − cos(θ_i − Ψ))¶
A natural Lyapunov candidate for the ζ-driven system is:
V → 0 as all oscillators lock to the carrier Ψ. Monotonic decrease of V confirms stability of the phase-locked state.
def lyapunov_V(theta, psi):
"""V = (1/N) Σ (1 - cos(θ_i - Ψ)). V=0 at full lock."""
return float(np.mean(1.0 - np.cos(theta - psi)))
N_ly = 200
theta_ly = rng.uniform(-np.pi, np.pi, N_ly)
omega_ly = rng.normal(0, 0.5, N_ly)
Psi_ly = 0.0
n_ly = 1000
V_hist = np.empty(n_ly)
R_ly_hist = np.empty(n_ly)
for t in range(n_ly):
V_hist[t] = lyapunov_V(theta_ly, Psi_ly)
out = kuramoto_sakaguchi_step(
theta_ly, omega_ly, dt=0.01, K=3.0, zeta=2.0,
psi_driver=Psi_ly, psi_mode="external",
)
theta_ly = out["theta1"]
R_ly_hist[t] = out["R"]
fig, ax1 = plt.subplots(figsize=(8, 4))
t_ly = np.arange(n_ly) * 0.01
color_v = "tab:blue"
ax1.plot(t_ly, V_hist, color=color_v, lw=2, label="V(t) Lyapunov")
ax1.set_xlabel("Time (s)")
ax1.set_ylabel("V(t)", color=color_v)
ax1.tick_params(axis="y", labelcolor=color_v)
ax2 = ax1.twinx()
color_r = "tab:red"
ax2.plot(t_ly, R_ly_hist, color=color_r, lw=2, ls="--", label="R(t)")
ax2.set_ylabel("R(t)", color=color_r)
ax2.tick_params(axis="y", labelcolor=color_r)
fig.suptitle("Lyapunov V(t) Decay ↔ Order Parameter R(t) Rise", fontsize=12)
lines1, labels1 = ax1.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax1.legend(lines1 + lines2, labels1 + labels2, fontsize=9, loc="center right")
plt.tight_layout()
plt.show()
# Monotonicity check (allow small numerical noise)
dV = np.diff(V_hist)
violations = int(np.sum(dV > 1e-6))
print(f"Lyapunov monotonicity violations (dV > 1e-6): {violations}/{n_ly-1}")
print(f"V: {V_hist[0]:.4f} → {V_hist[-1]:.6f}")
9. SNN Controller Output → Phase Sync (Closed-Loop Sketch)¶
In a real control loop, the SNN controller output maps to actuation_gain
and optionally to psi_driver (carrier phase tracking a reference).
Below is a toy sketch showing how a sinusoidal SNN output modulates
the phase sync kernel's authority over plasma modes.
n_cl = 1000
dt_cl = 0.01
theta_cl = rng.uniform(-np.pi, np.pi, 128)
omega_cl = rng.normal(0, 0.6, 128)
R_cl = np.empty(n_cl)
gain_cl = np.empty(n_cl)
for t in range(n_cl):
# Simulated SNN controller output: ramps up, then oscillates
snn_out = 0.5 + 0.5 * np.tanh((t - 200) / 50) + 0.15 * np.sin(0.05 * t)
gain_cl[t] = max(snn_out, 0.1)
out = fk.phase_sync_step(
theta_cl, omega_cl, dt=dt_cl,
psi_driver=0.0, actuation_gain=gain_cl[t],
)
theta_cl = out["theta1"]
R_cl[t] = out["R"]
fig, ax1 = plt.subplots(figsize=(10, 4))
t_cl = np.arange(n_cl) * dt_cl
ax1.plot(t_cl, R_cl, color="navy", lw=2, label="R(t) plasma coherence")
ax1.set_xlabel("Time (s)")
ax1.set_ylabel("R(t)", color="navy")
ax1.set_ylim(0, 1.05)
ax2 = ax1.twinx()
ax2.plot(t_cl, gain_cl, color="darkorange", lw=1.5, ls="--", label="SNN gain(t)")
ax2.set_ylabel("actuation_gain", color="darkorange")
lines1, labels1 = ax1.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax1.legend(lines1 + lines2, labels1 + labels2, fontsize=9, loc="center right")
ax1.set_title("Closed-Loop Sketch: SNN Output → Phase Sync Gain → Plasma R(t)")
plt.tight_layout()
plt.show()
10. Full PAC Cross-Layer SNN Closed-Loop¶
A dual-pool SNN controls two plasma mode ensembles (low-frequency MHD modes on L2, high-frequency edge oscillations on L7). The SNN error signal comes from the per-layer R deficit. PAC gating cross-couples the layers: when L2 decoherence rises, L7 coupling strengthens and vice versa — modelling the cross-frequency coupling in Paper 27 §4.3.
# Two-layer UPDE with PAC cross-coupling + dual SNN gains
L_pac = 4 # L1 (slow MHD), L2, L5 (edge), L7 (symbolic)
N_osc = 60
spec_snn = build_knm_paper27(L=L_pac, zeta_uniform=1.0)
upde_snn = UPDESystem(spec=spec_snn, dt=0.005, psi_mode="external")
th_snn = [rng.uniform(-np.pi, np.pi, N_osc) for _ in range(L_pac)]
om_snn = [OMEGA_N_16[m] + rng.normal(0, 0.2, N_osc) for m in range(L_pac)]
n_snn = 1500
R_target = 0.85
Kp = 3.0 # SNN proportional gain
R_per_layer = np.empty((n_snn, L_pac))
gains_snn = np.empty((n_snn, L_pac))
for t in range(n_snn):
out = upde_snn.step(th_snn, om_snn, psi_driver=0.0, pac_gamma=1.5)
R_m = out["R_layer"]
th_snn = out["theta1"]
# SNN error → gain per layer (proportional controller)
for m in range(L_pac):
error = R_target - R_m[m]
gains_snn[t, m] = max(0.2, 1.0 + Kp * error)
R_per_layer[t, m] = R_m[m]
# Apply gains as actuation for next step (scale omega)
om_snn = [
(OMEGA_N_16[m] + rng.normal(0, 0.2, N_osc)) * gains_snn[t, m]
for m in range(L_pac)
]
t_snn = np.arange(n_snn) * 0.005
fig, axes = plt.subplots(2, 1, figsize=(11, 6), sharex=True)
layer_labels = [f"L{i+1}" for i in range(L_pac)]
for m in range(L_pac):
axes[0].plot(t_snn, R_per_layer[:, m], lw=1.5, label=layer_labels[m])
axes[0].axhline(R_target, color="red", ls="--", lw=1, label=f"R_target={R_target}")
axes[0].set_ylabel("R_m(t)")
axes[0].set_title("PAC Cross-Layer SNN — Per-Layer Coherence")
axes[0].legend(fontsize=8, ncol=5)
axes[0].set_ylim(0, 1.05)
for m in range(L_pac):
axes[1].plot(t_snn, gains_snn[:, m], lw=1.2, label=layer_labels[m])
axes[1].set_xlabel("Time (s)")
axes[1].set_ylabel("SNN gain")
axes[1].set_title("SNN Proportional Controller — Actuation Gains")
axes[1].legend(fontsize=8, ncol=5)
plt.tight_layout()
plt.show()
Summary¶
| Component | Location | Status |
|---|---|---|
| Kuramoto–Sakaguchi + ζ sin(Ψ−θ) | scpn_control.phase.kuramoto |
Verified |
| Paper 27 Knm (16×16) | scpn_control.phase.knm |
Calibration anchors match |
| Multi-layer UPDE | scpn_control.phase.upde |
16-layer convergence shown |
| FusionKernel hook | FusionKernel.phase_sync_step() |
Config-driven, non-invasive |
| Lyapunov stability | V(t) = (1/N)Σ(1−cos(θ−Ψ)) | Monotonic decay confirmed |
| Rust Kuramoto kernel | control-math::kuramoto |
Rayon-parallelised, 7/7 tests |
| PAC cross-layer SNN | Notebook §10 | Dual-pool, per-layer gain control |
| Markdown export | docs/paper27_phase_dynamics.md |
Auto-generated from notebook |