inire/inire/tests/test_astar.py

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