codex/lagrange-interpolation-cardinals.py

lagrange-interpolation-cardinals.py
"""Plot three Lagrange cardinal polynomials and their interpolant; PNG to cwd.

Tested with Python 3.14.4, NumPy 2.3.5, Matplotlib 3.10.7.
"""

from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np


def cardinals(x):
    x = np.asarray(x, dtype=float)
    return np.stack(((x - 1) * (x - 2) / 2,
                     -x * (x - 2), x * (x - 1) / 2))


def main():
    nodes = np.array([0.0, 1.0, 2.0])
    values = np.array([1.0, 2.0, 5.0])
    x = np.linspace(0, 2, 801)
    basis = cardinals(x)
    p = values @ basis
    np.testing.assert_array_equal(cardinals(nodes), np.eye(3))
    np.testing.assert_allclose(basis.sum(axis=0), 1, rtol=0, atol=5e-16)
    np.testing.assert_allclose(p, x**2 + 1, rtol=0, atol=2e-15)
    np.testing.assert_array_equal(values @ cardinals(nodes), values)
    np.testing.assert_array_equal(cardinals(0.5), [3 / 8, 3 / 4, -1 / 8])
    assert values @ cardinals(0.5) == 1.25
    # The Newton coefficients are 1, 1, 1, from divided differences 1, 3, 1.
    first = np.diff(values) / np.diff(nodes)
    second = (first[1] - first[0]) / (nodes[2] - nodes[0])
    np.testing.assert_array_equal(first, [1.0, 3.0])
    assert second == 1.0
    np.testing.assert_allclose(1 + x + x * (x - 1), p, rtol=0, atol=2e-15)

    plt.rcParams.update({"font.size": 11, "axes.titlesize": 13})
    fig, axes = plt.subplots(1, 2, figsize=(10.4, 4.6), layout="constrained")
    fig.patch.set_facecolor("white")
    colors = ["#2166ac", "#25834a", "#b43b35"]
    ax = axes[0]
    for i, color in enumerate(colors):
        ax.plot(x, basis[i], color=color, lw=2.2, label=rf"$\ell_{i}(x)$")
        ax.scatter(nodes, cardinals(nodes)[i], color=color, s=45, zorder=5,
                   marker=["o", "s", "^"][i])
    ax.axhline(1, color="0.45", ls="--", lw=1.2,
               label=r"$\ell_0+\ell_1+\ell_2=1$")
    ax.axhline(0, color="0.6", lw=0.8)
    ax.set(ylim=(-0.25, 1.38), ylabel="Cardinal value",
           title="Each basis polynomial selects one node")
    ax.legend(loc="upper center", ncols=2, fontsize=10, framealpha=1)
    ax.annotate("Negative between nodes", (0.5, -0.125),
                xytext=(0.95, -0.23), fontsize=9,
                arrowprops={"arrowstyle": "->", "color": "0.4"})

    ax = axes[1]
    for i, color in enumerate(colors):
        ax.plot(x, values[i] * basis[i], color=color, lw=1.5, ls="--",
                label=rf"${int(values[i])}\ell_{i}(x)$")
    ax.plot(x, p, color="#292929", lw=2.7, label=r"Sum: $p(x)=x^2+1$")
    ax.scatter(nodes, values, color="black", s=55, zorder=6)
    for a, b in zip(nodes, values):
        ax.annotate(f"({int(a)}, {int(b)})", (a, b), xytext=(5, 8),
                    textcoords="offset points", fontsize=10)
    ax.scatter([0.5], [1.25], color="#7952a3", s=45, zorder=6)
    ax.annotate(r"$p(1/2)=5/4$", (0.5, 1.25), xytext=(0.68, 2.75),
                fontsize=11, color="#7952a3",
                arrowprops={"arrowstyle": "->", "color": "#7952a3"})
    ax.set(ylim=(-0.85, 5.65), ylabel="Weighted contribution or sum",
           title="Data values are the basis coefficients")
    ax.legend(loc="upper left", fontsize=10, framealpha=1)
    for ax in axes:
        ax.set(xlim=(-0.06, 2.16), xticks=[0, 0.5, 1, 1.5, 2], xlabel="$x$")
        ax.set_facecolor("white")
        ax.grid(alpha=0.18)
        ax.spines[["top", "right"]].set_visible(False)
    fig.savefig(Path.cwd() / "lagrange-interpolation-cardinals.png", dpi=120,
                facecolor="white", transparent=False)
    plt.close(fig)
    print("Cardinal identity, sum-to-one, nodal values, p(1/2)=5/4, and Newton form verified.")


if __name__ == "__main__":
    main()