Initial buildout

This commit is contained in:
Jan Petykiewicz 2026-03-07 08:26:29 -08:00
commit f600b52f32
25 changed files with 1856 additions and 23 deletions

View file

@ -0,0 +1,56 @@
import time
from inire.geometry.primitives import Port
from inire.geometry.collision import CollisionEngine
from inire.router.danger_map import DangerMap
from inire.router.cost import CostEvaluator
from inire.router.astar import AStarRouter
from inire.router.pathfinder import PathFinder
def benchmark_scaling() -> None:
print("Starting Scalability Benchmark...")
# 1. Memory Verification (20x20mm)
# Resolution 1um -> 20000 x 20000 grid
bounds = (0, 0, 20000, 20000)
print(f"Initializing DangerMap for {bounds} area...")
dm = DangerMap(bounds=bounds, resolution=1.0)
# nbytes for float32: 20000 * 20000 * 4 bytes = 1.6 GB
mem_gb = dm.grid.nbytes / (1024**3)
print(f"DangerMap memory usage: {mem_gb:.2f} GB")
assert mem_gb < 2.0
# 2. Node Expansion Rate (50 nets)
engine = CollisionEngine(clearance=2.0)
# Use a smaller area for routing benchmark to keep it fast
routing_bounds = (0, 0, 1000, 1000)
danger_map = DangerMap(bounds=routing_bounds)
danger_map.precompute([])
evaluator = CostEvaluator(engine, danger_map)
router = AStarRouter(evaluator)
pf = PathFinder(router, evaluator)
num_nets = 50
netlist = {}
for i in range(num_nets):
# Parallel nets spaced by 10um
netlist[f"net{i}"] = (Port(0, i * 10, 0), Port(100, i * 10, 0))
print(f"Routing {num_nets} nets...")
start_time = time.monotonic()
results = pf.route_all(netlist, dict.fromkeys(netlist, 2.0))
end_time = time.monotonic()
total_time = end_time - start_time
print(f"Total routing time: {total_time:.2f} s")
print(f"Time per net: {total_time/num_nets:.4f} s")
if total_time > 0:
nodes_per_sec = router.total_nodes_expanded / total_time
print(f"Node expansion rate: {nodes_per_sec:.2f} nodes/s")
# Success rate
successes = sum(1 for r in results.values() if r.is_valid)
print(f"Success rate: {successes/num_nets * 100:.1f}%")
if __name__ == "__main__":
benchmark_scaling()

71
inire/tests/test_astar.py Normal file
View file

@ -0,0 +1,71 @@
import pytest
import numpy as np
from inire.geometry.primitives import Port
from inire.geometry.collision import CollisionEngine
from inire.router.danger_map import DangerMap
from inire.router.cost import CostEvaluator
from inire.router.astar import AStarRouter
from shapely.geometry import Polygon
@pytest.fixture
def basic_evaluator():
engine = CollisionEngine(clearance=2.0)
danger_map = DangerMap(bounds=(0, 0, 100, 100))
danger_map.precompute([])
return CostEvaluator(engine, danger_map)
def test_astar_straight(basic_evaluator) -> None:
router = AStarRouter(basic_evaluator)
start = Port(0, 0, 0)
target = Port(50, 0, 0)
path = router.route(start, target, net_width=2.0)
assert path is not None
assert len(path) > 0
# Final port should be target
assert abs(path[-1].end_port.x - 50.0) < 1e-6
assert path[-1].end_port.y == 0.0
def test_astar_bend(basic_evaluator) -> None:
router = AStarRouter(basic_evaluator)
start = Port(0, 0, 0)
target = Port(20, 20, 90)
path = router.route(start, target, net_width=2.0)
assert path is not None
assert any("Bend90" in str(res) or hasattr(res, 'geometry') for res in path) # Loose check
assert abs(path[-1].end_port.x - 20.0) < 1e-6
assert abs(path[-1].end_port.y - 20.0) < 1e-6
assert path[-1].end_port.orientation == 90.0
def test_astar_obstacle(basic_evaluator) -> None:
# Add an obstacle in the middle of a straight path
obstacle = Polygon([(20, -5), (30, -5), (30, 5), (20, 5)])
basic_evaluator.collision_engine.add_static_obstacle(obstacle)
basic_evaluator.danger_map.precompute([obstacle])
router = AStarRouter(basic_evaluator)
start = Port(0, 0, 0)
target = Port(50, 0, 0)
path = router.route(start, target, net_width=2.0)
assert path is not None
# Path should have diverted (check that it's not a single straight)
# The path should go around the 5um half-width obstacle.
# Total wire length should be > 50.
sum(np.sqrt((p.end_port.x - p.geometry[0].bounds[0])**2 + (p.end_port.y - p.geometry[0].bounds[1])**2) for p in path)
# That's a rough length estimate.
# Better: check that no part of the path collides.
for res in path:
for poly in res.geometry:
assert not poly.intersects(obstacle)
def test_astar_snap_to_target_lookahead(basic_evaluator) -> None:
router = AStarRouter(basic_evaluator)
# Target is NOT on 1um grid
start = Port(0, 0, 0)
target = Port(10.005, 0, 0)
path = router.route(start, target, net_width=2.0)
assert path is not None
assert abs(path[-1].end_port.x - 10.005) < 1e-6

View file

@ -0,0 +1,59 @@
from shapely.geometry import Polygon
from inire.geometry.primitives import Port
from inire.geometry.collision import CollisionEngine
def test_collision_detection() -> None:
# Clearance = 2um
engine = CollisionEngine(clearance=2.0)
# Static obstacle at (10, 10) with size 5x5
obstacle = Polygon([(10,10), (15,10), (15,15), (10,15)])
engine.add_static_obstacle(obstacle)
# Net width = 2um
# Dilation = (W+C)/2 = (2+2)/2 = 2.0um
# 1. Direct hit
test_poly = Polygon([(12,12), (13,12), (13,13), (12,13)])
assert engine.is_collision(test_poly, net_width=2.0) is True
# 2. Far away
test_poly_far = Polygon([(0,0), (5,0), (5,5), (0,5)])
assert engine.is_collision(test_poly_far, net_width=2.0) is False
# 3. Near hit (within clearance)
# Obstacle is at (10,10).
# test_poly is at (8,10) to (9,15).
# Centerline at 8.5. Distance to 10 is 1.5.
# Required distance (Wi+C)/2 = 2.0. Collision!
test_poly_near = Polygon([(8,10), (9,10), (9,15), (8,15)])
assert engine.is_collision(test_poly_near, net_width=2.0) is True
def test_safety_zone() -> None:
# Use zero clearance for this test to verify the 2nm port safety zone
# against the physical obstacle boundary.
engine = CollisionEngine(clearance=0.0)
obstacle = Polygon([(10,10), (15,10), (15,15), (10,15)])
engine.add_static_obstacle(obstacle)
# Port exactly on the boundary (x=10)
start_port = Port(10.0, 12.0, 0.0)
# A very narrow waveguide (1nm width) that overlaps by 1nm.
# Overlap is from x=10 to x=10.001, y=11.9995 to 12.0005.
# This fits entirely within a 2nm radius of (10.0, 12.0).
test_poly = Polygon([(9.999, 11.9995), (10.001, 11.9995), (10.001, 12.0005), (9.999, 12.0005)])
assert engine.is_collision(test_poly, net_width=0.001, start_port=start_port) is False
def test_configurable_max_net_width() -> None:
# Large max_net_width (10.0) -> large pre-dilation (6.0)
engine = CollisionEngine(clearance=2.0, max_net_width=10.0)
obstacle = Polygon([(20, 20), (25, 20), (25, 25), (20, 25)])
engine.add_static_obstacle(obstacle)
test_poly = Polygon([(15, 20), (16, 20), (16, 25), (15, 25)])
# physical check: dilated test_poly by C/2 = 1.0.
# Dilated test_poly bounds: (14, 19, 17, 26).
# obstacle: (20, 20, 25, 25). No physical collision.
assert engine.is_collision(test_poly, net_width=2.0) is False

View file

@ -0,0 +1,75 @@
import pytest
from inire.geometry.primitives import Port
from inire.geometry.components import Straight, Bend90, SBend
def test_straight_generation() -> None:
start = Port(0, 0, 0)
length = 10.0
width = 2.0
result = Straight.generate(start, length, width)
# End port check
assert result.end_port.x == 10.0
assert result.end_port.y == 0.0
assert result.end_port.orientation == 0.0
# Geometry check
poly = result.geometry[0]
assert poly.area == length * width
# Check bounds
minx, miny, maxx, maxy = poly.bounds
assert minx == 0.0
assert maxx == 10.0
assert miny == -1.0
assert maxy == 1.0
def test_bend90_generation() -> None:
start = Port(0, 0, 0)
radius = 10.0
width = 2.0
# CW bend (0 -> 270)
result_cw = Bend90.generate(start, radius, width, direction='CW')
# End port (center is at (0, -10))
# End port is at (10, -10) relative to center if it was 90-degree turn?
# No, from center (0, -10), start is (0, 0) which is 90 deg.
# Turn -90 deg -> end is at 0 deg from center -> (10, -10)
assert result_cw.end_port.x == 10.0
assert result_cw.end_port.y == -10.0
assert result_cw.end_port.orientation == 270.0
# CCW bend (0 -> 90)
result_ccw = Bend90.generate(start, radius, width, direction='CCW')
assert result_ccw.end_port.x == 10.0
assert result_ccw.end_port.y == 10.0
assert result_ccw.end_port.orientation == 90.0
def test_sbend_generation() -> None:
start = Port(0, 0, 0)
offset = 5.0
radius = 10.0
width = 2.0
result = SBend.generate(start, offset, radius, width)
# End port check
assert result.end_port.y == 5.0
assert result.end_port.orientation == 0.0
# Geometry check (two arcs)
assert len(result.geometry) == 2
# Verify failure for large offset
with pytest.raises(ValueError):
SBend.generate(start, 25.0, 10.0, 2.0)
def test_bend_snapping() -> None:
# Radius that results in non-integer coords
radius = 10.1234
start = Port(0, 0, 0)
result = Bend90.generate(start, radius, 2.0, direction='CCW')
# End port should be snapped to 1µm (SEARCH_GRID_SNAP_UM)
# ex = 10.1234, ey = 10.1234
# snapped: ex = 10.0, ey = 10.0 if we round to nearest 1.0?
# SEARCH_GRID_SNAP_UM = 1.0
assert result.end_port.x == 10.0
assert result.end_port.y == 10.0

View file

@ -0,0 +1,70 @@
import pytest
from inire.geometry.primitives import Port
from inire.geometry.collision import CollisionEngine
from inire.router.danger_map import DangerMap
from inire.router.cost import CostEvaluator
from inire.router.astar import AStarRouter
from inire.router.pathfinder import PathFinder
from shapely.geometry import Polygon
@pytest.fixture
def basic_evaluator():
engine = CollisionEngine(clearance=2.0)
# Wider bounds to allow going around (y from -40 to 40)
danger_map = DangerMap(bounds=(0, -40, 100, 40))
danger_map.precompute([])
return CostEvaluator(engine, danger_map)
def test_astar_sbend(basic_evaluator) -> None:
router = AStarRouter(basic_evaluator)
# Start at (0,0), target at (50, 3) -> 3um lateral offset
start = Port(0, 0, 0)
target = Port(50, 3, 0)
path = router.route(start, target, net_width=2.0)
assert path is not None
# Check if any component in the path is an SBend
found_sbend = False
for res in path:
# SBend should align us with the target y=3
if abs(res.end_port.y - 3.0) < 1e-6 and res.end_port.orientation == 0:
found_sbend = True
assert found_sbend
def test_pathfinder_negotiated_congestion_resolution(basic_evaluator) -> None:
router = AStarRouter(basic_evaluator)
pf = PathFinder(router, basic_evaluator)
pf.max_iterations = 10
netlist = {
"net1": (Port(0, 0, 0), Port(50, 0, 0)),
"net2": (Port(0, 10, 0), Port(50, 10, 0))
}
net_widths = {"net1": 2.0, "net2": 2.0}
# Tiny obstacles to block net1 and net2 direct paths?
# No, let's block the space BETWEEN them so they must choose
# to either stay far apart or squeeze together.
# Actually, let's block their direct paths and force them
# into a narrow corridor that only fits ONE.
# Obstacles creating a wide wall with a narrow 2um gap at y=5.
# Gap y: 4 to 6. Center y=5.
# Net 1 (y=0) and Net 2 (y=10) both want to go to y=5 to pass.
# But only ONE fits at y=5.
obs_top = Polygon([(20, 6), (30, 6), (30, 30), (20, 30)])
obs_bottom = Polygon([(20, 4), (30, 4), (30, -30), (20, -30)])
basic_evaluator.collision_engine.add_static_obstacle(obs_top)
basic_evaluator.collision_engine.add_static_obstacle(obs_bottom)
basic_evaluator.danger_map.precompute([obs_top, obs_bottom])
# Increase base penalty to force detour immediately
pf.base_congestion_penalty = 1000.0
results = pf.route_all(netlist, net_widths)
assert results["net1"].is_valid
assert results["net2"].is_valid
assert results["net1"].collisions == 0
assert results["net2"].collisions == 0

36
inire/tests/test_cost.py Normal file
View file

@ -0,0 +1,36 @@
from shapely.geometry import Polygon
from inire.geometry.collision import CollisionEngine
from inire.router.danger_map import DangerMap
from inire.router.cost import CostEvaluator
from inire.geometry.primitives import Port
def test_cost_calculation() -> None:
engine = CollisionEngine(clearance=2.0)
# 50x50 um area, 1um resolution
danger_map = DangerMap(bounds=(0, 0, 50, 50), resolution=1.0, safety_threshold=10.0, k=1.0)
# Add a central obstacle
# Grid cells are indexed from self.minx.
obstacle = Polygon([(20,20), (30,20), (30,30), (20,30)])
danger_map.precompute([obstacle])
evaluator = CostEvaluator(engine, danger_map)
# 1. Cost far from obstacle
cost_far = evaluator.g_proximity(5.0, 5.0)
assert cost_far == 0.0
# 2. Cost near obstacle (d=1.0)
# Cell center (20.5, 20.5) is inside. Cell (19.5, 20.5) center to boundary (20, 20.5) is 0.5.
# Scipy EDT gives distance to mask=False.
cost_near = evaluator.g_proximity(19.0, 25.0)
assert cost_near > 0.0
# 3. Collision cost
engine.add_static_obstacle(obstacle)
test_poly = Polygon([(22, 22), (23, 22), (23, 23), (22, 23)])
# end_port at (22.5, 22.5)
move_cost = evaluator.evaluate_move(
[test_poly], Port(22.5, 22.5, 0), net_width=2.0, net_id="net1"
)
assert move_cost == 1e9

63
inire/tests/test_fuzz.py Normal file
View file

@ -0,0 +1,63 @@
import pytest
from hypothesis import given, settings, strategies as st
from shapely.geometry import Polygon
from inire.geometry.collision import CollisionEngine
from inire.geometry.primitives import Port
from inire.router.astar import AStarRouter
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
from inire.router.pathfinder import RoutingResult
from inire.utils.validation import validate_routing_result
@st.composite
def random_obstacle(draw):
x = draw(st.floats(min_value=0, max_value=20))
y = draw(st.floats(min_value=0, max_value=20))
w = draw(st.floats(min_value=1, max_value=5))
h = draw(st.floats(min_value=1, max_value=5))
return Polygon([(x, y), (x + w, y), (x + w, y + h), (x, y + h)])
@st.composite
def random_port(draw):
x = draw(st.floats(min_value=0, max_value=20))
y = draw(st.floats(min_value=0, max_value=20))
orientation = draw(st.sampled_from([0, 90, 180, 270]))
return Port(x, y, orientation)
@settings(max_examples=3, deadline=None)
@given(obstacles=st.lists(random_obstacle(), min_size=0, max_size=3), start=random_port(), target=random_port())
def test_fuzz_astar_no_crash(obstacles, start, target) -> None:
engine = CollisionEngine(clearance=2.0)
for obs in obstacles:
engine.add_static_obstacle(obs)
danger_map = DangerMap(bounds=(0, 0, 30, 30))
danger_map.precompute(obstacles)
evaluator = CostEvaluator(engine, danger_map)
router = AStarRouter(evaluator)
router.node_limit = 5000 # Lower limit for fuzzing stability
# Check if start/target are inside obstacles (safety zone check)
# The router should handle this gracefully (either route or return None)
try:
path = router.route(start, target, net_width=2.0)
# Analytic Correctness: if path is returned, verify it's collision-free
if path:
result = RoutingResult(net_id="default", path=path, is_valid=True, collisions=0)
validation = validate_routing_result(
result,
obstacles,
clearance=2.0,
start_port_coord=(start.x, start.y),
end_port_coord=(target.x, target.y),
)
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
except Exception as e:
# Unexpected exceptions are failures
pytest.fail(f"Router crashed with {type(e).__name__}: {e}")

View file

@ -0,0 +1,51 @@
import pytest
from inire.geometry.primitives import Port
from inire.geometry.collision import CollisionEngine
from inire.router.danger_map import DangerMap
from inire.router.cost import CostEvaluator
from inire.router.astar import AStarRouter
from inire.router.pathfinder import PathFinder
@pytest.fixture
def basic_evaluator():
engine = CollisionEngine(clearance=2.0)
danger_map = DangerMap(bounds=(0, 0, 100, 100))
danger_map.precompute([])
return CostEvaluator(engine, danger_map)
def test_pathfinder_parallel(basic_evaluator) -> None:
router = AStarRouter(basic_evaluator)
pf = PathFinder(router, basic_evaluator)
netlist = {
"net1": (Port(0, 0, 0), Port(50, 0, 0)),
"net2": (Port(0, 10, 0), Port(50, 10, 0))
}
net_widths = {"net1": 2.0, "net2": 2.0}
results = pf.route_all(netlist, net_widths)
assert results["net1"].is_valid
assert results["net2"].is_valid
assert results["net1"].collisions == 0
assert results["net2"].collisions == 0
def test_pathfinder_congestion(basic_evaluator) -> None:
router = AStarRouter(basic_evaluator)
pf = PathFinder(router, basic_evaluator)
# Net1 blocks Net2
netlist = {
"net1": (Port(0, 0, 0), Port(50, 0, 0)),
"net2": (Port(25, -10, 90), Port(25, 10, 90))
}
net_widths = {"net1": 2.0, "net2": 2.0}
results = pf.route_all(netlist, net_widths)
# Verify both nets are valid and collision-free
assert results["net1"].is_valid
assert results["net2"].is_valid
assert results["net1"].collisions == 0
assert results["net2"].collisions == 0

View file

@ -0,0 +1,43 @@
from hypothesis import given, strategies as st
from inire.geometry.primitives import Port, translate_port, rotate_port
@st.composite
def port_strategy(draw):
x = draw(st.floats(min_value=-1e6, max_value=1e6))
y = draw(st.floats(min_value=-1e6, max_value=1e6))
orientation = draw(st.sampled_from([0, 90, 180, 270]))
return Port(x, y, orientation)
def test_port_snapping() -> None:
p = Port(0.123456, 0.654321, 90)
assert p.x == 0.123
assert p.y == 0.654
assert p.orientation == 90.0
@given(p=port_strategy())
def test_port_transform_invariants(p) -> None:
# Rotating 90 degrees 4 times should return to same orientation
p_rot = p
for _ in range(4):
p_rot = rotate_port(p_rot, 90)
assert p_rot.orientation == p.orientation
# Coordinates should be close (floating point error) but snapped to 1nm
assert abs(p_rot.x - p.x) < 1e-9
assert abs(p_rot.y - p.y) < 1e-9
@given(p=port_strategy(), dx=st.floats(min_value=-1000, max_value=1000), dy=st.floats(min_value=-1000, max_value=1000))
def test_translate_snapping(p, dx, dy) -> None:
p_trans = translate_port(p, dx, dy)
# Check that snapped result is indeed multiple of GRID_SNAP_UM (0.001 um = 1nm)
# Multiplication is more stable for this check
assert abs(p_trans.x * 1000 - round(p_trans.x * 1000)) < 1e-6
assert abs(p_trans.y * 1000 - round(p_trans.y * 1000)) < 1e-6
def test_orientation_normalization() -> None:
p = Port(0, 0, 360)
assert p.orientation == 0.0
p2 = Port(0, 0, -90)
assert p2.orientation == 270.0
p3 = Port(0, 0, 95) # Should snap to 90
assert p3.orientation == 90.0

View file

@ -0,0 +1,60 @@
from inire.geometry.primitives import Port
from inire.geometry.collision import CollisionEngine
from inire.router.danger_map import DangerMap
from inire.router.cost import CostEvaluator
from inire.router.astar import AStarRouter
from inire.router.pathfinder import PathFinder
from inire.geometry.components import Bend90
def test_arc_resolution_sagitta() -> None:
start = Port(0, 0, 0)
# R=10, 90 deg bend.
# High tolerance (0.5um) -> few segments
res_coarse = Bend90.generate(start, radius=10.0, width=2.0, sagitta=0.5)
# Low tolerance (0.001um = 1nm) -> many segments
res_fine = Bend90.generate(start, radius=10.0, width=2.0, sagitta=0.001)
# Check number of points in the polygon exterior
# (num_segments + 1) * 2 points usually
pts_coarse = len(res_coarse.geometry[0].exterior.coords)
pts_fine = len(res_fine.geometry[0].exterior.coords)
assert pts_fine > pts_coarse
def test_locked_paths() -> None:
engine = CollisionEngine(clearance=2.0)
danger_map = DangerMap(bounds=(0, -50, 100, 50))
danger_map.precompute([])
evaluator = CostEvaluator(engine, danger_map)
router = AStarRouter(evaluator)
pf = PathFinder(router, evaluator)
# 1. Route Net A
netlist_a = {"netA": (Port(0, 0, 0), Port(50, 0, 0))}
results_a = pf.route_all(netlist_a, {"netA": 2.0})
assert results_a["netA"].is_valid
# 2. Lock Net A
engine.lock_net("netA")
# 3. Route Net B through the same space. It should detour or fail.
# We'll place Net B's start/target such that it MUST cross Net A's physical path.
netlist_b = {"netB": (Port(0, -5, 0), Port(50, 5, 0))}
# Route Net B
results_b = pf.route_all(netlist_b, {"netB": 2.0})
# Net B should be is_valid (it detoured) or at least not have collisions
# with Net A in the dynamic set (because netA is now static).
# Since netA is static, netB will see it as a HARD collision if it tries to cross.
# Our A* will find a detour around the static obstacle.
assert results_b["netB"].is_valid
# Verify geometry doesn't intersect locked netA (physical check)
poly_a = [p.geometry[0] for p in results_a["netA"].path]
poly_b = [p.geometry[0] for p in results_b["netB"].path]
for pa in poly_a:
for pb in poly_b:
# Check physical clearance
assert not pa.buffer(1.0).intersects(pb.buffer(1.0))