362 lines
12 KiB
Python
362 lines
12 KiB
Python
import pytest
|
|
from shapely.geometry import Polygon
|
|
|
|
import inire.router.astar as astar_module
|
|
from inire.geometry.components import SBend, Straight
|
|
from inire.geometry.collision import CollisionEngine
|
|
from inire.geometry.primitives import Port
|
|
from inire.router.astar import AStarContext, route_astar
|
|
from inire.router.config import CostConfig
|
|
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
|
|
|
|
|
|
@pytest.fixture
|
|
def basic_evaluator() -> CostEvaluator:
|
|
engine = CollisionEngine(clearance=2.0)
|
|
danger_map = DangerMap(bounds=(0, -50, 150, 150))
|
|
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 = AStarContext(basic_evaluator)
|
|
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, is_valid=True, collisions=0)
|
|
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 = AStarContext(basic_evaluator, 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, is_valid=True, collisions=0)
|
|
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 = AStarContext(basic_evaluator, 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, is_valid=True, collisions=0)
|
|
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 = AStarContext(basic_evaluator)
|
|
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, is_valid=True, collisions=0)
|
|
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_astar_context_keeps_cost_config_separate(basic_evaluator: CostEvaluator) -> None:
|
|
context = AStarContext(basic_evaluator, bend_radii=[5.0], bend_penalty=120.0, sbend_penalty=240.0)
|
|
|
|
assert isinstance(basic_evaluator.config, CostConfig)
|
|
assert basic_evaluator.config is not context.config
|
|
assert basic_evaluator.config.bend_penalty == 120.0
|
|
assert basic_evaluator.config.sbend_penalty == 240.0
|
|
assert basic_evaluator.config.min_bend_radius == 5.0
|
|
|
|
|
|
def test_route_astar_bend_collision_override_does_not_persist(basic_evaluator: CostEvaluator) -> None:
|
|
context = AStarContext(basic_evaluator, 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.config.bend_collision_type == "arc"
|
|
|
|
|
|
def test_expand_moves_only_shortens_consecutive_straights(
|
|
basic_evaluator: CostEvaluator,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
context = AStarContext(basic_evaluator, min_straight_length=5.0, max_straight_length=100.0)
|
|
prev_result = Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)
|
|
current = astar_module.AStarNode(
|
|
prev_result.end_port,
|
|
g_cost=prev_result.length,
|
|
h_cost=0.0,
|
|
component_result=prev_result,
|
|
)
|
|
|
|
emitted: list[tuple[str, tuple]] = []
|
|
|
|
def fake_process_move(*args, **kwargs) -> None:
|
|
emitted.append((args[9], args[10]))
|
|
|
|
monkeypatch.setattr(astar_module, "process_move", fake_process_move)
|
|
|
|
astar_module.expand_moves(
|
|
current,
|
|
Port(80, 0, 0),
|
|
net_width=2.0,
|
|
net_id="test",
|
|
open_set=[],
|
|
closed_set={},
|
|
context=context,
|
|
metrics=astar_module.AStarMetrics(),
|
|
congestion_cache={},
|
|
)
|
|
|
|
straight_lengths = [params[0] for move_class, params in emitted if move_class == "S"]
|
|
assert straight_lengths
|
|
assert all(length < prev_result.length for length in straight_lengths)
|
|
|
|
|
|
def test_expand_moves_does_not_chain_sbends(
|
|
basic_evaluator: CostEvaluator,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
context = AStarContext(basic_evaluator, sbend_radii=[10.0], sbend_offsets=[5.0], max_straight_length=100.0)
|
|
prev_result = SBend.generate(Port(0, 0, 0), 5.0, 10.0, width=2.0, dilation=1.0)
|
|
current = astar_module.AStarNode(
|
|
prev_result.end_port,
|
|
g_cost=prev_result.length,
|
|
h_cost=0.0,
|
|
component_result=prev_result,
|
|
)
|
|
|
|
emitted: list[str] = []
|
|
|
|
def fake_process_move(*args, **kwargs) -> None:
|
|
emitted.append(args[9])
|
|
|
|
monkeypatch.setattr(astar_module, "process_move", fake_process_move)
|
|
|
|
astar_module.expand_moves(
|
|
current,
|
|
Port(60, 10, 0),
|
|
net_width=2.0,
|
|
net_id="test",
|
|
open_set=[],
|
|
closed_set={},
|
|
context=context,
|
|
metrics=astar_module.AStarMetrics(),
|
|
congestion_cache={},
|
|
)
|
|
|
|
assert "SB" not in emitted
|
|
assert emitted
|
|
|
|
|
|
def test_add_node_rejects_self_collision_against_ancestor(
|
|
basic_evaluator: CostEvaluator,
|
|
) -> None:
|
|
context = AStarContext(basic_evaluator)
|
|
metrics = astar_module.AStarMetrics()
|
|
target = Port(100, 0, 0)
|
|
|
|
root = astar_module.AStarNode(Port(0, 0, 0), g_cost=0.0, h_cost=0.0)
|
|
ancestor = Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)
|
|
ancestor_node = astar_module.AStarNode(
|
|
ancestor.end_port,
|
|
g_cost=ancestor.length,
|
|
h_cost=0.0,
|
|
parent=root,
|
|
component_result=ancestor,
|
|
)
|
|
parent_result = Straight.generate(Port(30, 0, 0), 10.0, width=2.0, dilation=1.0)
|
|
parent_node = astar_module.AStarNode(
|
|
parent_result.end_port,
|
|
g_cost=ancestor.length + parent_result.length,
|
|
h_cost=0.0,
|
|
parent=ancestor_node,
|
|
component_result=parent_result,
|
|
)
|
|
overlapping_move = Straight.generate(Port(5, 0, 0), 10.0, width=2.0, dilation=1.0)
|
|
|
|
open_set: list[astar_module.AStarNode] = []
|
|
astar_module.add_node(
|
|
parent_node,
|
|
overlapping_move,
|
|
target,
|
|
net_width=2.0,
|
|
net_id="test",
|
|
open_set=open_set,
|
|
closed_set={},
|
|
context=context,
|
|
metrics=metrics,
|
|
congestion_cache={},
|
|
move_type="S",
|
|
cache_key=("self_collision",),
|
|
self_collision_check=True,
|
|
)
|
|
|
|
assert not open_set
|
|
assert metrics.moves_added == 0
|
|
|
|
|
|
def test_expand_moves_adds_sbend_aligned_straight_stop_points(
|
|
basic_evaluator: CostEvaluator,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
context = AStarContext(
|
|
basic_evaluator,
|
|
bend_radii=[10.0],
|
|
sbend_radii=[10.0],
|
|
max_straight_length=150.0,
|
|
)
|
|
current = astar_module.AStarNode(Port(0, 0, 0), g_cost=0.0, h_cost=0.0)
|
|
|
|
emitted: list[tuple[str, tuple]] = []
|
|
|
|
def fake_process_move(*args, **kwargs) -> None:
|
|
emitted.append((args[9], args[10]))
|
|
|
|
monkeypatch.setattr(astar_module, "process_move", fake_process_move)
|
|
|
|
astar_module.expand_moves(
|
|
current,
|
|
Port(100, 10, 0),
|
|
net_width=2.0,
|
|
net_id="test",
|
|
open_set=[],
|
|
closed_set={},
|
|
context=context,
|
|
metrics=astar_module.AStarMetrics(),
|
|
congestion_cache={},
|
|
)
|
|
|
|
straight_lengths = {params[0] for move_class, params in emitted if move_class == "S"}
|
|
sbend_span = astar_module._sbend_forward_span(10.0, 10.0)
|
|
assert sbend_span is not None
|
|
assert int(round(100.0 - sbend_span)) in straight_lengths
|
|
assert int(round(100.0 - 2.0 * sbend_span)) in straight_lengths
|
|
|
|
|
|
def test_expand_moves_adds_exact_corner_visibility_stop_points(
|
|
basic_evaluator: CostEvaluator,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
context = AStarContext(
|
|
basic_evaluator,
|
|
bend_radii=[10.0],
|
|
max_straight_length=150.0,
|
|
visibility_guidance="exact_corner",
|
|
)
|
|
current = astar_module.AStarNode(Port(0, 0, 0), g_cost=0.0, h_cost=0.0)
|
|
|
|
monkeypatch.setattr(
|
|
astar_module.VisibilityManager,
|
|
"get_corner_visibility",
|
|
lambda self, origin, max_dist=0.0: [(40.0, 10.0, 41.23), (75.0, -15.0, 76.48)],
|
|
)
|
|
|
|
emitted: list[tuple[str, tuple]] = []
|
|
|
|
def fake_process_move(*args, **kwargs) -> None:
|
|
emitted.append((args[9], args[10]))
|
|
|
|
monkeypatch.setattr(astar_module, "process_move", fake_process_move)
|
|
|
|
astar_module.expand_moves(
|
|
current,
|
|
Port(120, 20, 0),
|
|
net_width=2.0,
|
|
net_id="test",
|
|
open_set=[],
|
|
closed_set={},
|
|
context=context,
|
|
metrics=astar_module.AStarMetrics(),
|
|
congestion_cache={},
|
|
)
|
|
|
|
straight_lengths = {params[0] for move_class, params in emitted if move_class == "S"}
|
|
assert 40 in straight_lengths
|
|
assert 75 in straight_lengths
|
|
|
|
|
|
def test_expand_moves_adds_tangent_corner_visibility_stop_points(
|
|
basic_evaluator: CostEvaluator,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
class DummyCornerIndex:
|
|
def intersection(self, bounds: tuple[float, float, float, float]) -> list[int]:
|
|
return [0, 1]
|
|
|
|
context = AStarContext(
|
|
basic_evaluator,
|
|
bend_radii=[10.0],
|
|
sbend_radii=[],
|
|
max_straight_length=150.0,
|
|
visibility_guidance="tangent_corner",
|
|
)
|
|
current = astar_module.AStarNode(Port(0, 0, 0), g_cost=0.0, h_cost=0.0)
|
|
|
|
monkeypatch.setattr(astar_module.VisibilityManager, "_ensure_current", lambda self: None)
|
|
context.visibility_manager.corners = [(50.0, 10.0), (80.0, -10.0)]
|
|
context.visibility_manager.corner_index = DummyCornerIndex()
|
|
monkeypatch.setattr(
|
|
type(context.cost_evaluator.collision_engine),
|
|
"ray_cast",
|
|
lambda self, origin, angle_deg, max_dist=2000.0, net_width=None: max_dist,
|
|
)
|
|
|
|
emitted: list[tuple[str, tuple]] = []
|
|
|
|
def fake_process_move(*args, **kwargs) -> None:
|
|
emitted.append((args[9], args[10]))
|
|
|
|
monkeypatch.setattr(astar_module, "process_move", fake_process_move)
|
|
|
|
astar_module.expand_moves(
|
|
current,
|
|
Port(120, 20, 0),
|
|
net_width=2.0,
|
|
net_id="test",
|
|
open_set=[],
|
|
closed_set={},
|
|
context=context,
|
|
metrics=astar_module.AStarMetrics(),
|
|
congestion_cache={},
|
|
)
|
|
|
|
straight_lengths = {params[0] for move_class, params in emitted if move_class == "S"}
|
|
assert 40 in straight_lengths
|
|
assert 70 in straight_lengths
|