235 lines
8.9 KiB
Python
235 lines
8.9 KiB
Python
import pytest
|
|
from shapely.geometry import Polygon
|
|
|
|
from inire import RoutingResult
|
|
from inire.geometry.components import Bend90, Straight
|
|
from inire.geometry.collision import RoutingWorld
|
|
from inire.geometry.primitives import Port
|
|
from inire.router._astar_types import AStarContext
|
|
from inire.router._search import route_astar
|
|
from inire.router.cost import CostEvaluator
|
|
from inire.router.danger_map import DangerMap
|
|
from inire.tests.support import build_context, build_options, build_problem
|
|
from inire.utils.validation import validate_routing_result
|
|
|
|
BOUNDS = (0, -50, 150, 150)
|
|
|
|
|
|
@pytest.fixture
|
|
def basic_evaluator() -> CostEvaluator:
|
|
engine = RoutingWorld(clearance=2.0)
|
|
danger_map = DangerMap(bounds=BOUNDS)
|
|
danger_map.precompute([])
|
|
return CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0)
|
|
|
|
|
|
def test_astar_straight(basic_evaluator: CostEvaluator) -> None:
|
|
context = build_context(basic_evaluator, bounds=BOUNDS)
|
|
start = Port(0, 0, 0)
|
|
target = Port(50, 0, 0)
|
|
path = route_astar(start, target, net_width=2.0, context=context)
|
|
|
|
assert path is not None
|
|
result = RoutingResult(net_id="test", path=path, reached_target=True)
|
|
validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target)
|
|
|
|
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
|
|
assert validation["connectivity_ok"]
|
|
# Path should be exactly 50um (or slightly more if it did weird things, but here it's straight)
|
|
assert abs(validation["total_length"] - 50.0) < 1e-6
|
|
|
|
|
|
def test_astar_bend(basic_evaluator: CostEvaluator) -> None:
|
|
context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0])
|
|
start = Port(0, 0, 0)
|
|
# 20um right, 20um up. Needs a 10um bend and a 10um bend.
|
|
target = Port(20, 20, 0)
|
|
path = route_astar(start, target, net_width=2.0, context=context)
|
|
|
|
assert path is not None
|
|
result = RoutingResult(net_id="test", path=path, reached_target=True)
|
|
validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target)
|
|
|
|
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
|
|
assert validation["connectivity_ok"]
|
|
|
|
|
|
def test_astar_obstacle(basic_evaluator: CostEvaluator) -> None:
|
|
# Add an obstacle in the middle of a straight path
|
|
# Obstacle from x=20 to 40, y=-20 to 20
|
|
obstacle = Polygon([(20, -20), (40, -20), (40, 20), (20, 20)])
|
|
basic_evaluator.collision_engine.add_static_obstacle(obstacle)
|
|
basic_evaluator.danger_map.precompute([obstacle])
|
|
|
|
context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0], node_limit=1000000)
|
|
start = Port(0, 0, 0)
|
|
target = Port(60, 0, 0)
|
|
path = route_astar(start, target, net_width=2.0, context=context)
|
|
|
|
assert path is not None
|
|
result = RoutingResult(net_id="test", path=path, reached_target=True)
|
|
validation = validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target)
|
|
|
|
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
|
|
# Path should have detoured, so length > 50
|
|
assert validation["total_length"] > 50.0
|
|
|
|
|
|
def test_astar_uses_integerized_ports(basic_evaluator: CostEvaluator) -> None:
|
|
context = build_context(basic_evaluator, bounds=BOUNDS)
|
|
start = Port(0, 0, 0)
|
|
target = Port(10.1, 0, 0)
|
|
path = route_astar(start, target, net_width=2.0, context=context)
|
|
|
|
assert path is not None
|
|
result = RoutingResult(net_id="test", path=path, reached_target=True)
|
|
assert target.x == 10
|
|
validation = validate_routing_result(result, [], clearance=2.0, expected_start=start, expected_end=target)
|
|
|
|
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
|
|
|
|
|
|
def test_validate_routing_result_checks_expected_start() -> None:
|
|
path = [Straight.generate(Port(100, 0, 0), 10.0, width=2.0, dilation=1.0)]
|
|
result = RoutingResult(net_id="test", path=path, reached_target=True)
|
|
|
|
validation = validate_routing_result(
|
|
result,
|
|
[],
|
|
clearance=2.0,
|
|
expected_start=Port(0, 0, 0),
|
|
expected_end=Port(110, 0, 0),
|
|
)
|
|
|
|
assert not validation["is_valid"]
|
|
assert "Initial port position mismatch" in validation["reason"]
|
|
|
|
|
|
def test_validate_routing_result_uses_exact_component_geometry() -> None:
|
|
bend = Bend90.generate(Port(0, 0, 0), 10.0, 2.0, direction="CCW", collision_type="bbox", dilation=1.0)
|
|
result = RoutingResult(net_id="test", path=[bend], reached_target=True)
|
|
obstacle = Polygon([(2.0, 7.0), (4.0, 7.0), (4.0, 9.0), (2.0, 9.0)])
|
|
|
|
validation = validate_routing_result(
|
|
result,
|
|
[obstacle],
|
|
clearance=2.0,
|
|
expected_start=Port(0, 0, 0),
|
|
expected_end=bend.end_port,
|
|
)
|
|
|
|
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
|
|
|
|
|
|
def test_astar_context_keeps_evaluator_weights_separate(basic_evaluator: CostEvaluator) -> None:
|
|
basic_evaluator.bend_penalty = 120.0
|
|
basic_evaluator.sbend_penalty = 240.0
|
|
context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[5.0])
|
|
|
|
assert basic_evaluator.bend_penalty == 120.0
|
|
assert basic_evaluator.sbend_penalty == 240.0
|
|
assert context.options.search.bend_radii == (5.0,)
|
|
assert basic_evaluator.h_manhattan(Port(0, 0, 0), Port(10, 10, 0)) > 0.0
|
|
|
|
|
|
def test_route_astar_bend_collision_override_does_not_persist(basic_evaluator: CostEvaluator) -> None:
|
|
context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0], bend_collision_type="arc")
|
|
|
|
route_astar(
|
|
Port(0, 0, 0),
|
|
Port(30, 10, 0),
|
|
net_width=2.0,
|
|
context=context,
|
|
bend_collision_type="clipped_bbox",
|
|
return_partial=True,
|
|
)
|
|
|
|
assert context.options.search.bend_collision_type == "arc"
|
|
|
|
|
|
def test_route_astar_returns_partial_path_when_node_limited(basic_evaluator: CostEvaluator) -> None:
|
|
obstacle = Polygon([(20, -20), (40, -20), (40, 20), (20, 20)])
|
|
basic_evaluator.collision_engine.add_static_obstacle(obstacle)
|
|
basic_evaluator.danger_map.precompute([obstacle])
|
|
context = build_context(basic_evaluator, bounds=BOUNDS, bend_radii=[10.0], node_limit=2)
|
|
start = Port(0, 0, 0)
|
|
target = Port(60, 0, 0)
|
|
|
|
partial_path = route_astar(start, target, net_width=2.0, context=context, return_partial=True)
|
|
no_partial_path = route_astar(start, target, net_width=2.0, context=context, return_partial=False)
|
|
|
|
assert partial_path is not None
|
|
assert partial_path
|
|
assert partial_path[-1].end_port != target
|
|
assert no_partial_path is None
|
|
|
|
|
|
def test_route_astar_uses_single_sbend_for_same_orientation_offset(basic_evaluator: CostEvaluator) -> None:
|
|
context = build_context(
|
|
basic_evaluator,
|
|
bounds=BOUNDS,
|
|
bend_radii=[10.0],
|
|
sbend_radii=[10.0],
|
|
sbend_offsets=[10.0],
|
|
max_straight_length=150.0,
|
|
)
|
|
start = Port(0, 0, 0)
|
|
target = Port(100, 10, 0)
|
|
|
|
path = route_astar(start, target, net_width=2.0, context=context)
|
|
|
|
assert path is not None
|
|
assert path[-1].end_port == target
|
|
assert sum(1 for component in path if component.move_type == "sbend") == 1
|
|
assert not any(
|
|
first.move_type == second.move_type == "sbend"
|
|
for first, second in zip(path, path[1:], strict=False)
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("visibility_guidance", ["off", "exact_corner", "tangent_corner"])
|
|
def test_route_astar_supports_all_visibility_guidance_modes(
|
|
basic_evaluator: CostEvaluator,
|
|
visibility_guidance: str,
|
|
) -> None:
|
|
obstacle = Polygon([(30, 10), (50, 10), (50, 40), (30, 40)])
|
|
basic_evaluator.collision_engine.add_static_obstacle(obstacle)
|
|
basic_evaluator.danger_map.precompute([obstacle])
|
|
context = build_context(
|
|
basic_evaluator,
|
|
bounds=BOUNDS,
|
|
bend_radii=[10.0],
|
|
sbend_radii=[],
|
|
max_straight_length=150.0,
|
|
visibility_guidance=visibility_guidance,
|
|
)
|
|
start = Port(0, 0, 0)
|
|
target = Port(80, 50, 0)
|
|
|
|
path = route_astar(start, target, net_width=2.0, context=context)
|
|
|
|
assert path is not None
|
|
result = RoutingResult(net_id="test", path=path, reached_target=True)
|
|
validation = validate_routing_result(result, [obstacle], clearance=2.0, expected_start=start, expected_end=target)
|
|
|
|
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
|
|
assert validation["connectivity_ok"]
|
|
|
|
|
|
def test_route_astar_repeated_searches_succeed_with_small_cache_limit(basic_evaluator: CostEvaluator) -> None:
|
|
context = AStarContext(
|
|
basic_evaluator,
|
|
build_problem(bounds=BOUNDS),
|
|
build_options(
|
|
min_straight_length=1.0,
|
|
max_straight_length=100.0,
|
|
),
|
|
max_cache_size=2,
|
|
)
|
|
start = Port(0, 0, 0)
|
|
targets = [Port(length, 0, 0) for length in range(10, 70, 10)]
|
|
|
|
for target in targets:
|
|
path = route_astar(start, target, net_width=2.0, context=context)
|
|
assert path is not None
|
|
assert path[-1].end_port == target
|