codex/jacobi-tridiagonal-error-modes.py

jacobi-tridiagonal-error-modes.py
"""Compute and plot Jacobi errors and eigenmodes for an original system.

Tested with Python 3.14, Matplotlib 3.10.7 and NumPy 2.3.5.
Run from _media; emits this script's basename.png into the current directory.
"""
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np


def main():
    a = np.array([[4., -1., 0.], [-1., 4., -1.], [0., -1., 4.]])
    xstar = np.array([1., 2., 3.])
    b = a @ xstar
    d = np.diag(a)
    iteration = np.eye(3) - a / d[:, None]
    q = np.sqrt(2.) / 4.
    modes = np.array([[.5, 1/np.sqrt(2), .5],
                      [1/np.sqrt(2), 0, -1/np.sqrt(2)],
                      [.5, -1/np.sqrt(2), .5]])
    k = np.arange(17)
    x = np.zeros(3)
    errors = []
    for _ in k:
        errors.append(x - xstar)
        # Evaluate every coordinate from the same old vector.
        x = x + (b - a @ x) / d
    errors = np.asarray(errors)
    coefficients = errors @ modes.T
    norms = np.linalg.norm(errors, axis=1)
    exact_norms = 2*np.sqrt(3)*q**k[1:]
    np.testing.assert_allclose(norms[1:], exact_norms, rtol=2e-8, atol=1e-15)
    np.testing.assert_allclose(iteration @ modes.T,
                               modes.T * np.array([q, 0, -q]), atol=2e-16)
    plt.rcParams.update({'font.family': 'DejaVu Sans', 'font.size': 12})
    fig, axes = plt.subplots(1, 2, figsize=(11, 5.5), dpi=120, facecolor='white')
    fig.subplots_adjust(left=.085, right=.97, top=.76, bottom=.19, wspace=.28)
    fig.suptitle('Jacobi convergence: error size and persistent mode shapes',
                 fontsize=19, y=.97)
    fig.text(.5, .865, r'$A=\mathrm{tridiag}(-1,4,-1),\quad x_*=(1,2,3)^T,\quad x^{(0)}=0$'
             '\nComputed iterates; no experimental measurements', ha='center', va='center', fontsize=13)
    left, right = axes
    colors = ['#2563a6', '#6b7280', '#ba4e17']
    left.semilogy(k, norms, 'o', ms=5, color='#111827', label=r'computed $\|e^{(k)}\|_2$')
    left.semilogy(k[1:], exact_norms, '--', color='#111827', lw=1.6,
                  label=r'exact $2\sqrt{3}\,q^k$ ($k\geq1$)')
    for j, label in [(0, r'$|c_+^{(k)}|$'), (2, r'$|c_-^{(k)}|$')]:
        left.semilogy(k, np.abs(coefficients[:, j]), '-', color=colors[j],
                      lw=1.8, label=label)
    left.set(xlabel='Sweep k', ylabel='Absolute error / mode coefficient',
             title=r'$q=\sqrt{2}/4\approx0.3536$', xlim=(-.2, 16.2))
    left.set_xticks(np.arange(0, 17, 4))
    left.grid(True, which='major', alpha=.22)
    left.legend(fontsize=10, loc='upper right')
    left.text(.02, .05, 'Zero mode disappears after sweep 1.',
              transform=left.transAxes, fontsize=10,
              bbox={'facecolor': 'white', 'edgecolor': 'none', 'alpha': .9})
    labels = [r'$u_+$: factor $+q$', r'$u_0$: factor $0$', r'$u_-$: factor $-q$']
    for j, (color, label, marker) in enumerate(zip(colors, labels, ['o', 's', '^'])):
        right.plot([1, 2, 3], modes[j], marker=marker, color=color, lw=2,
                   ms=7, label=label)
    right.axhline(0, color='#64748b', lw=.8)
    right.set(xlabel='Coordinate j', ylabel='Normalized eigenvector component',
              title='Shapes stay fixed; coefficients change',
              xticks=[1, 2, 3], xlim=(.8, 3.2), ylim=(-.86, .86))
    right.grid(True, alpha=.22)
    right.text(2, .76, labels[0], ha='center', color=colors[0], fontsize=11)
    right.text(2, -.80, labels[2], ha='center', color=colors[2], fontsize=11)
    right.text(1.35, -.75, labels[1], ha='center', color=colors[1], fontsize=11)
    fig.text(.5, .05, 'The negative mode changes sign each sweep.  '
             'After sweep 1, the total error shrinks by q per sweep.',
             ha='center', fontsize=12)
    fig.savefig(Path.cwd() / (Path(__file__).stem + '.png'), facecolor='white')
    plt.close(fig)


if __name__ == '__main__':
    main()