Files
janik b62e45a9b4 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>
2026-07-15 15:24:21 +07:00

140 lines
5.3 KiB
Python

import numpy as np
import pytest
from fill_resistance import config, raster, solver
from fill_resistance.errors import (ConnectivityError, ElectrodeError,
GridSizeError)
from tests.util import NM, make_problem, strip_problem
def _stack(problem, h_mm):
return raster.rasterize_stack(problem, h_mm * NM)
def test_exact_cell_count_square_with_hole():
# 10x10 mm square, centered 4x4 mm hole, h=1 mm: cell centers at
# half-integers, no boundary ambiguity -> exactly 100 - 16 cells
p = make_problem(
[([(0, 0), (10, 0), (10, 10), (0, 10)],
[[(3, 3), (7, 3), (7, 7), (3, 7)]])],
rect1_mm=(0, 0, 1, 10), rect2_mm=(9, 0, 10, 10))
stack = _stack(p, 1.0)
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)
m = stack.masks[0]
assert not m[0, :].any() and not m[-1, :].any()
assert not m[:, 0].any() and not m[:, -1].any()
def test_electrode_masks_and_counts():
p = strip_problem(length=50, width=10, e_len=5)
stack = _stack(p, 0.5)
e1, e2 = raster.electrode_masks(stack, p)
# electrodes: 5 mm / 0.5 mm = 10 columns x 20 rows on 1 layer
assert int(e1.sum()) == 200 and int(e2.sum()) == 200
def test_electrode_off_copper_raises():
p = make_problem([([(0, 0), (10, 0), (10, 10), (0, 10)], [])],
rect1_mm=(20, 20, 25, 25), rect2_mm=(8, 0, 10, 10))
stack = _stack(p, 0.5)
with pytest.raises(ElectrodeError, match="does not overlap"):
raster.electrode_masks(stack, p)
def test_touching_electrodes_raise_equipotential():
p = make_problem([([(0, 0), (10, 0), (10, 10), (0, 10)], [])],
rect1_mm=(0, 0, 5, 10), rect2_mm=(5, 0, 10, 10))
stack = _stack(p, 0.5)
e1, e2 = raster.electrode_masks(stack, p) # touch is fine at mask level
with pytest.raises(ElectrodeError, match="touch"):
solver.run_solve(p, stack, e1, e2, 1.0,
contact_model="equipotential")
def test_disconnected_regions_raise():
p = make_problem(
[([(0, 0), (10, 0), (10, 10), (0, 10)], []),
([(20, 0), (30, 0), (30, 10), (20, 10)], [])],
rect1_mm=(0, 0, 2, 10), rect2_mm=(28, 0, 30, 10))
stack = _stack(p, 0.5)
e1, e2 = raster.electrode_masks(stack, p)
with pytest.raises(ConnectivityError, match="not connected"):
solver.run_solve(p, stack, e1, e2, 1.0,
contact_model="equipotential")
def test_islands_dropped():
# island square not touching the main strip disappears from the mask
p = make_problem(
[([(0, 0), (50, 0), (50, 10), (0, 10)], []),
([(20, 20), (30, 20), (30, 30), (20, 30)], [])],
rect1_mm=(0, 0, 5, 10), rect2_mm=(45, 0, 50, 10))
stack = _stack(p, 0.5)
e1, e2 = raster.electrode_masks(stack, p)
before = int(stack.masks.sum())
solver.run_solve(p, stack, e1, e2, 1.0, contact_model="equipotential")
after = int(stack.masks.sum())
assert after < before
assert after == 100 * 20 # only the strip remains
def test_hard_max_cells_guard(monkeypatch):
monkeypatch.setattr(config, "CELL_UM_OVERRIDE", 1.0) # 1 um cells
p = strip_problem()
with pytest.raises(GridSizeError, match="M cells"):
raster.choose_cell_size(p.copper_bbox(), len(p.layers))
def test_cell_override_nonpositive_raises(monkeypatch):
p = strip_problem()
for bad in (0.0, -50.0):
monkeypatch.setattr(config, "CELL_UM_OVERRIDE", bad)
with pytest.raises(GridSizeError, match="positive"):
raster.choose_cell_size(p.copper_bbox(), len(p.layers))
def test_auto_cell_size_hits_target():
# large plane: unclamped regime, cell count tracks TARGET_CELLS
p = strip_problem(length=200, width=100, e_len=5)
h = raster.choose_cell_size(p.copper_bbox(), 1)
ncells = (200.0 * NM / h) * (100.0 * NM / h)
assert 0.5 * config.TARGET_CELLS < ncells < 2.0 * config.TARGET_CELLS
# small board: MIN_CELL_UM clamp kicks in instead
q = strip_problem() # 50x10 mm
hq = raster.choose_cell_size(q.copper_bbox(), 1)
assert hq == pytest.approx(config.MIN_CELL_UM * 1000)