Plot the mu-trajectory for any non-empty 2D quadric

The poles, branches and mu range now follow the eigenvalues (one or two
poles of either sign), so hyperbolas, parabolas, line pairs, single
lines and points are supported, not only ellipses.

- Find every root of f from sign changes, including across inactive
  poles where quadproj's f is 0/0; mu* is the root between the active
  poles surrounding 0 and the other roots get their own marker.
- Mark the apex of conical quadrics as a degenerate KKT point.
- Draw the quadric as the contour psi = 0 and frame the trajectory
  panel on x0, the KKT points and the vertices.
- Use the correct asymptote direction for repeated eigenvalues.
- Test every quadric type against brute-force sampling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
loicvhandClaude Opus 5 committed 2026-09-15 21:45:51 +02:00
1 parent 5b118416be
commit bdcf0e0b02
3 files changed
+272 -130

No files matched your search

+86 -18
View File
@@ -1,44 +1,100 @@
import contourpy
import matplotlib
import matplotlib.pyplot as plt
import numpy as np
import pytest
from quadproj.fun import Fun
from quadproj.project import project
from quadproj.quadrics import Quadric
from plot_mu_trajectories import degenerate_point, ellipse_boundary, kkt_roots, pole_line
from plot_mu_trajectories import degenerate_point, kkt_roots, off_pole, plot_trajectories, pole_line
ROTATED_ELLIPSE = Quadric(np.array([[3.0, 1.0], [1.0, 2.0]]), np.array([1.0, -1.0]), -4.0)
matplotlib.use('Agg')
GRID = np.linspace(-4, 4, 4001)
# (A, b, c, x0): generic points, and points with a zero normalized component (degenerate)
QUADRICS = {
'ellipse': ([[3, 1], [1, 2]], [1, -1], -4, [0.3, 0.2]),
'ellipse_outside': ([[3, 1], [1, 2]], [1, -1], -4, [2.0, -1.5]),
'ellipse_degenerate': ([[2, 0], [0, 1]], [0, 0], -1, [0.0, -0.1]),
'ellipse_degenerate_2': ([[2, 0], [0, 1]], [0, 0], -1, [0.0, 0.5]),
'hyperbola': ([[1, 2], [2, -1]], [1, 0], -1, [0.3, 0.2]),
'hyperbola_degenerate': ([[1, 0], [0, -1]], [0, 0], -1, [0.0, 0.5]),
'parabola': ([[1, 1], [1, 1]], [1, -1], 0, [0.3, 0.2]),
'parabola_degenerate': ([[1, 0], [0, 0]], [0, -1], 0, [0.0, 0.5]),
'crossing_lines': ([[1, 0], [0, -1]], [0, 0], 0, [0.3, 0.2]),
'parallel_lines': ([[1, 0], [0, 0]], [0, 0], -1, [0.3, 0.2]),
}
# Quadrics without finite roots of f: the projection is the limit of x(mu) as |mu| -> inf.
LIMIT_QUADRICS = {
'single_line': ([[1, 0], [0, 0]], [0, 0], 0, [0.3, 0.2]),
'point': ([[1, 0], [0, 1]], [0, 0], 0, [0.3, 0.2]),
}
def make_quadric(A, b, c):
return Quadric(np.array(A, dtype=float), np.array(b, dtype=float), float(c))
ROTATED_ELLIPSE = make_quadric(*QUADRICS['ellipse'][:3])
def trajectory(Q, x0):
return Fun(Q, Q.to_normalized(x0)).x_not_std
return off_pole(Fun(Q, Q.to_normalized(x0)).x_not_std)
def boundary_distances(Q, x0):
return np.linalg.norm(ellipse_boundary(Q, 200_000) - x0, axis=1)
def ellipse_boundary(Q, n=1000):
"""Return n points of the ellipse Q as an (n, 2) array."""
t = np.linspace(-np.pi, np.pi, n)
u = np.column_stack((np.cos(t), np.sin(t))) / np.sqrt(Q.eig)
return Q.d + np.sqrt(abs(Q.gamma)) * u @ Q.V.T
def distances_to_quadric(Q, x0):
"""Distances from x0 to the points of the contour psi = 0 on a fine grid."""
X, Y = np.meshgrid(GRID, GRID)
P = np.stack((X, Y), axis=-1)
psi = np.einsum('...i,ij,...j->...', P, Q.A, P) + P @ Q.b + Q.c
return np.linalg.norm(np.vstack(contourpy.contour_generator(X, Y, psi).lines(0)) - x0, axis=1)
@pytest.mark.parametrize('name', QUADRICS)
def test_kkt_points_contain_the_projection(name):
*quadric, x0 = QUADRICS[name]
Q, x0 = make_quadric(*quadric), np.array(x0)
mu_star, other_roots = kkt_roots(Q, x0)
x = trajectory(Q, x0)
points = [x(mu) for mu in other_roots + ([] if mu_star is None else [mu_star])]
points += [xd for k in range(Q.dim) if (xd := degenerate_point(Q, x0, k)) is not None]
if Q.is_conical:
points.append(Q.d)
assert all(abs(Q.evaluate_point(point)) < 1e-8 for point in points)
nearest = min(np.linalg.norm(point - x0) for point in points)
assert nearest == pytest.approx(distances_to_quadric(Q, x0).min(), abs=1e-4)
@pytest.mark.parametrize('x0', [[0.3, 0.2], [2.0, -1.5], [-0.1, 0.4]])
def test_ellipse_roots_give_nearest_and_farthest_points(x0):
Q, x0 = ROTATED_ELLIPSE, np.array(x0)
mu_star, other_roots = kkt_roots(Q, x0)
x = trajectory(Q, x0)
distances = np.linalg.norm(ellipse_boundary(Q, 200_000) - x0, axis=1)
assert np.linalg.norm(x(mu_star) - x0) == pytest.approx(distances.min(), abs=1e-6)
assert max(np.linalg.norm(x(mu) - x0) for mu in other_roots) == pytest.approx(distances.max(), abs=1e-6)
def test_boundary_is_on_the_ellipse():
assert np.all([abs(ROTATED_ELLIPSE.evaluate_point(x)) < 1e-10 for x in ellipse_boundary(ROTATED_ELLIPSE)])
@pytest.mark.parametrize('x0', [[0.3, 0.2], [2.0, -1.5], [-0.1, 0.4]])
def test_kkt_roots_give_nearest_and_farthest_points(x0):
Q, x0 = ROTATED_ELLIPSE, np.array(x0)
mu_star, mu_star_star = kkt_roots(Q, x0)
x = trajectory(Q, x0)
distances = boundary_distances(Q, x0)
assert abs(Q.evaluate_point(x(mu_star_star))) < 1e-8
assert np.linalg.norm(x(mu_star) - x0) == pytest.approx(distances.min(), abs=1e-6)
assert np.linalg.norm(x(mu_star_star) - x0) == pytest.approx(distances.max(), abs=1e-6)
@pytest.mark.parametrize('eig, x0', [([2.0, 1.0], [0.0, -0.1]), ([2.0, 0.75], [0.0, -0.6])])
def test_degenerate_point_is_the_projection(eig, x0):
Q, x0 = Quadric(np.diag(eig), np.zeros(2), -1.0), np.array(x0)
xd = degenerate_point(Q, x0, 0)
np.testing.assert_allclose(xd, project(Q, x0))
assert np.linalg.norm(xd - x0) == pytest.approx(boundary_distances(Q, x0).min(), abs=1e-6)
assert degenerate_point(Q, x0, 1) is None
@@ -46,5 +102,17 @@ def test_pole_line_is_the_asymptote():
Q, x0 = ROTATED_ELLIPSE, np.array([0.3, 0.2])
point, direction = pole_line(Q, x0, 1)
x = trajectory(Q, x0)(-1 / Q.eig[1] + 1e-9)
direction = direction / np.linalg.norm(direction)
residual = (x - point) - ((x - point) @ direction) * direction
np.testing.assert_allclose(residual, 0, atol=1e-6)
@pytest.mark.parametrize('name', [*QUADRICS, *LIMIT_QUADRICS])
def test_every_quadric_type_can_be_plotted(name):
*quadric, x0 = {**QUADRICS, **LIMIT_QUADRICS}[name]
plt.close(plot_trajectories(make_quadric(*quadric), np.array(x0, dtype=float)))
def test_empty_quadric_is_rejected():
with pytest.raises(ValueError):
plot_trajectories(make_quadric([[1, 0], [0, 1]], [0, 0], 1), np.zeros(2))