11 — Neural-Seeded Predictive Equilibrium (Validated Warm Start)¶

Open In Colab Binder

This tutorial walks through the compiled predictive free-boundary equilibrium solver and the DeepONet predictive warm-starter that seeds it with a validated neural prediction.

The free-boundary Grad–Shafranov equilibrium is the fixed point of

$$\Delta^{*}\psi \;=\; -\mu_0 R^{2}\,p'(\psi)\;-\;F(\psi)F'(\psi),$$

with the plasma boundary determined self-consistently by the external poloidal-field coils. The predictive solver takes the coil currents together with the $p'(\psi)$ and $FF'(\psi)$ profile knots and iterates to the equilibrium that holds a target plasma current $I_p$.

The warm-starter does not replace this mechanistic solve. It provides an initial guess $\psi_{\text{init}}$ from a trained DeepONet, validates it against the mechanistic fixed point, and — if the neural inference is unstable or out of contract — falls back to the real cold solver. The neural component therefore can never degrade correctness: it is an accelerator candidate that is always checked, never trusted blindly.

License: © 1996–2026 Miroslav Šotek. GNU AGPL-3.0-or-later (commercial licence available).

Evidence boundary. This notebook is tutorial and onboarding material, not production-parity evidence. For accepted parity claims use the tracked reports under validation/reports/.

0 · Environment¶

The cell below installs the package when the notebook runs on a fresh Colab/Binder kernel and enables 64-bit JAX (required for the equilibrium fixed point).

The DeepONet warm-starter ships after the 4.0.0 release. When the installed wheel predates it, Sections 2–3 report that cleanly and are skipped; Section 1 (the compiled predictive solver, part of 4.0.0) runs everywhere.

In [1]:
# --- Colab/Binder bootstrap: no-op in a repo checkout or CI ---
try:
    import scpn_fusion  # noqa: F401
except ImportError:
    import subprocess, sys
    subprocess.run([sys.executable, "-m", "pip", "install", "-q", "scpn-fusion"], check=True)

import importlib.util
from typing import Any, cast
import jax
cast(Any, jax.config).update("jax_enable_x64", True)
import jax.numpy as jnp
import numpy as np

# The warm-starter is post-4.0.0; degrade gracefully on an older wheel.
HAS_WARM_START = importlib.util.find_spec(
    "scpn_fusion.core.deeponet_solver_warm_start"
) is not None
print("scpn_fusion warm-start API available:", HAS_WARM_START)
scpn_fusion warm-start API available: True

1 · Compiled predictive free-boundary solve¶

We use the synthetic diverted case from the predictive test suite verbatim: six poloidal-field coils on a $33\times33$ $(R,Z)$ grid, five-knot $p'$ and $FF'$ profiles, and a $1\,\mathrm{MA}$ target. solve_predictive_equilibrium_compiled returns the equilibrium flux $\psi(R,Z)$ and, with return_iterations=True, the number of fixed-point iterations to convergence.

In [2]:
from scpn_fusion.core.jax_free_boundary_predictive import build_response_matrix
from scpn_fusion.core.jax_predictive_forward_compiled import (
    solve_predictive_equilibrium_compiled,
)

R = jnp.linspace(1.0, 2.5, 33)
Z = jnp.linspace(-1.4, 1.4, 33)
COIL_R = jnp.asarray([1.2, 2.3, 1.2, 2.3, 1.6, 1.5])
COIL_Z = jnp.asarray([0.9, 0.9, -0.9, -0.9, 1.3, -1.35])
COIL_I = jnp.asarray([-3.0e5, -3.0e5, -3.0e5, -3.0e5, -1.0e5, -6.0e5])
COIL_NAMES = ("C1", "C2", "C3", "C4", "C5", "C6")
PSIN = jnp.linspace(0.0, 1.0, 6)
PPRIME = jnp.asarray([-8.0e4, -6.0e4, -4.0e4, -2.0e4, -0.7e4, 0.0])
FFPRIME = jnp.asarray([-1.2, -0.9, -0.6, -0.3, -0.1, 0.0])
IP_TARGET = 1.0e6

response_matrix, wall_idx, source_idx = build_response_matrix(R, Z)

psi_cold, iters_cold = cast(
    tuple,
    solve_predictive_equilibrium_compiled(
        COIL_I, PPRIME, FFPRIME, R, Z, COIL_R, COIL_Z, PSIN, IP_TARGET,
        response_matrix, wall_idx, source_idx,
        n_iter=150, return_iterations=True,
    ),
)
psi_cold.block_until_ready()
flux_span = float(jnp.ptp(psi_cold))
print(f"cold predictive solve: {int(iters_cold)} iterations, flux span = {flux_span:.4g} Wb/rad")
WARNING:2026-08-29 20:27:31,340:jax._src.xla_bridge:864: An NVIDIA GPU may be present on this machine, but a CUDA-enabled jaxlib is not installed. Falling back to cpu.
cold predictive solve: 131 iterations, flux span = 0.7617 Wb/rad
In [3]:
import matplotlib.pyplot as plt

psi_np = np.asarray(psi_cold)
Rg, Zg = np.meshgrid(np.asarray(R), np.asarray(Z), indexing="xy")
fig, ax = plt.subplots(figsize=(4.6, 5.2))
cf = ax.contourf(Rg, Zg, psi_np, levels=40, cmap="viridis")
ax.contour(Rg, Zg, psi_np, levels=14, colors="white", linewidths=0.4, alpha=0.6)
ax.scatter(np.asarray(COIL_R), np.asarray(COIL_Z), c="crimson", marker="s", s=40,
           label="PF coils", zorder=5)
ax.set_aspect("equal")
ax.set_xlabel("R  [m]"); ax.set_ylabel("Z  [m]")
ax.set_title("Predictive free-boundary equilibrium  ψ(R, Z)")
ax.legend(loc="upper right", fontsize=8)
fig.colorbar(cf, ax=ax, label="ψ  [Wb/rad]", shrink=0.85)
plt.tight_layout(); plt.show()
No description has been provided for this image

2 · Validated neural warm start¶

The warm-starter binds one authenticated DeepONet artifact to a machine manifest, predicts a seed field, and hands it to the same compiled solver as psi_init. Here we build a self-contained artifact whose predicted field is the equilibrium of Section 1 — an exact seed — so we can show that the neural path reaches the same mechanistic fixed point (relative difference below the solver tolerance). No external weights are required.

In [4]:
if not HAS_WARM_START:
    print("Warm-start API not in this wheel (pre-release). "
          "Install scpn-fusion from a checkout of main to run Sections 2-3.")
else:
    import tempfile
    from pathlib import Path
    from scpn_fusion.core import (
        DeepONetPredictiveWarmStarter, DeepONetWarmStartResult,
    )

    SHA = "b" * 64
    FEATURES = (
        "plasma_current_target_a",
        *(f"coil_current_a.{n}" for n in COIL_NAMES),
        *(f"pprime_knot_{i}" for i in range(5)),
        *(f"ffprime_knot_{i}" for i in range(5)),
    )

    def write_seed_artifact(path, field_mean, *, unstable=False):
        gr, gz = np.meshgrid(np.asarray(R), np.asarray(Z), indexing="xy")
        coords = np.column_stack((gr.ravel(), gz.ravel()))
        branch_w = np.zeros((len(FEATURES), 1))
        if unstable:
            branch_w.fill(np.finfo(np.float64).max)
        np.savez(
            path,
            artifact_schema=np.asarray(["scpn-fusion.equilibrium-deeponet.v1"]),
            branch_n_layers=np.asarray([1]), branch_0_W=branch_w, branch_0_b=np.zeros(1),
            trunk_n_layers=np.asarray([1]), trunk_0_W=np.ones((2, 1)), trunk_0_b=np.zeros(1),
            input_mean=np.zeros(len(FEATURES)), input_std=np.ones(len(FEATURES)),
            coordinates_rz_m=coords, coordinate_mean=np.mean(coords, 0),
            coordinate_std=np.std(coords, 0),
            field_mean=np.asarray(field_mean).ravel(), field_scale=np.asarray([1.0]),
            basis_width=np.asarray([1]), grid_nh=np.asarray([len(Z)]),
            grid_nw=np.asarray([len(R)]), feature_names=np.asarray(FEATURES),
            dataset_manifest_sha256=np.asarray([SHA]),
        )

    solve_kwargs = dict(
        machine_manifest_sha256=SHA, coil_names=COIL_NAMES, coil_i=COIL_I,
        pprime_vals=PPRIME, ffprime_vals=FFPRIME, r_grid=R, z_grid=Z,
        coil_r=COIL_R, coil_z=COIL_Z, psin_knots=PSIN, ip_target=IP_TARGET,
        response_matrix=response_matrix, wall_idx=wall_idx, source_idx=source_idx,
        n_iter=150,
    )

    tmp = Path(tempfile.mkdtemp())
    write_seed_artifact(tmp / "exact_seed.npz", psi_cold)
    starter = DeepONetPredictiveWarmStarter(tmp / "exact_seed.npz", prefer_rust=False)
    result = cast(DeepONetWarmStartResult, starter.solve(**solve_kwargs))
    result.equilibrium.block_until_ready()

    rel = float(jnp.max(jnp.abs(result.equilibrium - psi_cold))) / flux_span
    print(f"used_neural_seed   : {result.used_neural_seed}")
    print(f"fallback_reason    : {result.fallback_reason}")
    print(f"iterations         : {result.iterations}")
    print(f"neural_seed_weight : {result.neural_seed_weight}")
    print(f"rel. diff to cold  : {rel:.2e}  (< solver tolerance)")
used_neural_seed   : True
fallback_reason    : None
iterations         : 131
neural_seed_weight : 1.0
rel. diff to cold  : 4.37e-16  (< solver tolerance)

3 · Guaranteed mechanistic fallback¶

Safety is the point of the design. We now hand the warm-starter a corrupt artifact whose branch weights are non-finite. The neural inference fails its finiteness contract, the warm-starter reports used_neural_seed = False with a machine-readable reason, and still returns the correct mechanistic equilibrium by running the cold solver. A broken or adversarial surrogate cannot make the answer worse than the mechanistic baseline.

In [5]:
if HAS_WARM_START:
    write_seed_artifact(tmp / "bad_seed.npz", psi_cold, unstable=True)
    bad_starter = DeepONetPredictiveWarmStarter(tmp / "bad_seed.npz", prefer_rust=False)
    fallback = cast(DeepONetWarmStartResult, bad_starter.solve(**solve_kwargs))
    fallback.equilibrium.block_until_ready()

    rel_fb = float(jnp.max(jnp.abs(fallback.equilibrium - psi_cold))) / flux_span
    print(f"used_neural_seed : {fallback.used_neural_seed}")
    print(f"fallback_reason  : {fallback.fallback_reason!r}")
    print(f"rel. diff to cold: {rel_fb:.2e}  (mechanistic equilibrium preserved)")
else:
    print("Skipped: warm-start API not available in this wheel.")
used_neural_seed : False
fallback_reason  : 'neural_inference_failed'
rel. diff to cold: 0.00e+00  (mechanistic equilibrium preserved)

What this shows — and what it does not¶

  • Shown. The compiled predictive solver reaches the free-boundary Grad–Shafranov fixed point that holds the target $I_p$; a validated DeepONet seed reaches the same fixed point to well below solver tolerance; and a failed/adversarial seed transparently falls back to the mechanistic solve.
  • Not claimed here. This tutorial makes no runtime-speedup claim. The synthetic case is dominated by the separatrix-continuation schedule, so iteration counts are comparable by design; the warm-starter's contribution demonstrated here is validated seeding with a guaranteed fallback, not acceleration. Accepted performance and parity claims live in the tracked reports under validation/reports/.

See also notebook 03_grad_shafranov_equilibrium for the eager solver contract and 06_inverse_and_transport_benchmarks for the inverse/transport validation.