codex/change-of-variables-area.py

change-of-variables-area.py
"""Compare Jacobian area scaling and orientation, tested on Python 3.14.4.

Run from the desired output directory; uses the root pyproject dependencies.
"""
from pathlib import Path

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Polygon
import numpy as np


def signed_area(points):
    following = np.roll(points, -1, axis=0)
    return .5 * np.sum(points[:, 0] * following[:, 1]
                       - points[:, 1] * following[:, 0])


def main():
    square = np.array([[0, 0], [1, 0], [1, 1], [0, 1]], dtype=float)
    maps = [np.eye(2), np.array([[2, 1], [0, 1]]),
            np.array([[2, 1], [0, -1]])]
    colors = ["#3b8963", "#277da8", "#b37028"]
    plt.rcParams.update({"font.size": 10, "axes.spines.top": False,
                         "axes.spines.right": False})
    fig, axes = plt.subplots(1, 3, figsize=(10.5, 4.4), dpi=130,
                             constrained_layout=True, facecolor="white")
    titles = ["Original unit square", r"$T(u,v)=(2u+v,v)$",
              r"$\widetilde T(u,v)=(2u+v,-v)$"]
    for ax, matrix, color, title in zip(axes, maps, colors, titles):
        points = square @ matrix.T
        determinant = np.linalg.det(matrix)
        area = signed_area(points)
        assert np.isclose(area, determinant)
        ax.set_facecolor("white")
        ax.add_patch(Polygon(points, facecolor=color, alpha=.18,
                             edgecolor=color, linewidth=2))
        for value in np.linspace(0, 1, 5):
            for line in (np.array([[value, 0], [value, 1]]),
                         np.array([[0, value], [1, value]])):
                image = line @ matrix.T
                ax.plot(image[:, 0], image[:, 1], color=color, alpha=.55, lw=1)
        # Follow the same ordered vertices: reflection reverses the arrows.
        for start, end in zip(points, np.roll(points, -1, axis=0)):
            ax.annotate("", xy=start + .66 * (end - start),
                        xytext=start + .35 * (end - start),
                        arrowprops={"arrowstyle": "->", "color": color, "lw": 2})
        ax.scatter(points[:, 0], points[:, 1], color=color, s=18, zorder=3)
        ax.set(xlim=(-.3, 3.3), ylim=(-1.9, 1.4), title=title,
               xlabel="u" if ax is axes[0] else "x",
               ylabel="v" if ax is axes[0] else "y")
        ax.set_aspect("equal", adjustable="box")
        ax.set_xticks([0, 1, 2, 3])
        ax.set_yticks([-1, 0, 1])
        ax.grid(alpha=.15)
        ax.text(.5, .07, f"determinant = {determinant:g}\n"
                f"geometric area = {abs(area):g}", ha="center",
                transform=ax.transAxes, color=color, fontsize=11)
    # Check the two worked integrals independently by quadrature.
    nodes, weights = np.polynomial.legendre.leggauss(24)
    nodes, weights = (nodes + 1) / 2, weights / 2
    log_integral = np.sum(weights * 2 * nodes / (1 + nodes**2))
    weighted_area = np.sum(weights[:, None] * weights[None, :]
                           * 2 * (2 * nodes[:, None] + nodes[None, :]))
    assert abs(log_integral - np.log(2)) < 1e-14
    assert abs(weighted_area - 3) < 1e-14
    fig.suptitle("Area uses the absolute determinant; arrows record orientation", fontsize=12)
    fig.savefig(Path.cwd() / "change-of-variables-area.png",
                facecolor="white", transparent=False)
    plt.close(fig)
    print(f"Signed polygon areas: {[signed_area(square @ m.T) for m in maps]}; "
          f"substitution: {log_integral:.10f}; integral of x over T(S): {weighted_area:.10f}")


if __name__ == "__main__":
    main()