codexchange-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()