Speed up rasterization ~40x and large solves ~2x

- Hybrid rasterizer: PIL scanline fill for the bulk, with cells in a
  ~2 px band around each ring edge re-tested exactly against the
  polygon - cell-for-cell identical to the old center-in-polygon pass
  (equivalence test added) but O(vertices + cells) instead of
  O(vertices x cells). Measured 4.5 s -> 0.11 s at 1.45M cells with
  8.8k polygon vertices.
- AMG-preconditioned CG (pyamg, new requirement) above 500k unknowns:
  measured 7.0 s vs 15.3 s spsolve at 1.4M unknowns at a fraction of
  the memory, R identical to 1e-6; the old Jacobi-CG (kept as fallback
  when pyamg is missing) needed tens of minutes there. spsolve stays
  the default below 500k where it is exact and fastest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
janik
2026-07-15 15:24:21 +07:00
parent 6e989fc5f8
commit b62e45a9b4
9 changed files with 124 additions and 23 deletions
+29
View File
@@ -22,6 +22,35 @@ def test_exact_cell_count_square_with_hole():
assert int(stack.masks[0].sum()) == 100 - 16
def test_hybrid_raster_matches_exact_point_test():
"""The PIL-fill + exact-edge-band rasterizer must be cell-for-cell
identical to a pure center-in-polygon pass, including awkward
fractional offsets, concave lobes and a hole."""
from matplotlib.path import Path as MplPath
ang = np.linspace(0, 2 * np.pi, 257, endpoint=False)
r = 7.3 + 1.7 * np.sin(5 * ang) + 0.9 * np.cos(9 * ang + 0.4)
blob = np.stack([20.05 + r * np.cos(ang), 20.13 + r * np.sin(ang)],
axis=1)
hole = np.stack([20.4 + 2.1 * np.cos(ang), 19.8 + 2.2 * np.sin(ang)],
axis=1)
p = make_problem([(blob.tolist(), [hole.tolist()])],
rect1_mm=(14, 19, 16, 21), rect2_mm=(24, 19, 26, 21))
stack = _stack(p, 0.25)
ny, nx = stack.shape2d
xg, yg = stack.cell_centers(0, ny, 0, nx)
pts = np.column_stack([xg.ravel(), yg.ravel()])
def exact(ring):
verts = np.vstack([ring, ring[:1]])
return MplPath(verts, closed=True).contains_points(pts).reshape(
ny, nx)
poly = p.layers[0].polygons[0]
ref = exact(poly.outline) & ~exact(poly.holes[0])
assert np.array_equal(stack.masks[0], ref)
def test_margin_cells_are_empty():
p = strip_problem()
stack = _stack(p, 0.5)
+12 -1
View File
@@ -79,10 +79,21 @@ def test_hole_increases_resistance_and_converges():
assert abs(r_a.R_ohm - r_b.R_ohm) < 0.01 * r_b.R_ohm
def test_cg_path_matches_direct(monkeypatch):
def test_iterative_paths_match_direct(monkeypatch):
"""AMG-CG (default iterative) and Jacobi-CG (pyamg-missing fallback)
both reproduce the direct solve."""
p = strip_problem(length=50, width=10, e_len=5)
r_direct, _ = _solve(p, 0.25)
monkeypatch.setattr(config, "SPSOLVE_MAX_UNKNOWNS", 0)
r_amg, _ = _solve(p, 0.25)
assert r_amg.solve_info.method == "amg+cg"
assert r_amg.R_ohm == pytest.approx(r_direct.R_ohm, rel=1e-6)
assert r_amg.mismatch_rel < 1e-5
def no_pyamg(A, b):
raise ImportError("pyamg unavailable")
monkeypatch.setattr(solver, "_solve_amg", no_pyamg)
r_cg, _ = _solve(p, 0.25)
assert r_cg.solve_info.method == "cg+jacobi"
assert r_cg.R_ohm == pytest.approx(r_direct.R_ohm, rel=1e-6)