320 lines
12 KiB
Python
320 lines
12 KiB
Python
import math
|
|
|
|
import pytest
|
|
from shapely.geometry import Polygon
|
|
|
|
from inire import RoutingProblem, RoutingOptions, RoutingResult, SearchOptions
|
|
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, SearchRunConfig
|
|
from inire.router._search import route_astar
|
|
from inire.router.cost import CostEvaluator
|
|
from inire.router.danger_map import DangerMap
|
|
|
|
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 _build_options(**search_overrides: object) -> RoutingOptions:
|
|
return RoutingOptions(search=SearchOptions(**search_overrides))
|
|
|
|
|
|
def _build_context(
|
|
evaluator: CostEvaluator,
|
|
*,
|
|
bounds: tuple[float, float, float, float],
|
|
**search_overrides: object,
|
|
) -> AStarContext:
|
|
return AStarContext(
|
|
evaluator,
|
|
RoutingProblem(bounds=bounds),
|
|
_build_options(**search_overrides),
|
|
)
|
|
|
|
|
|
def _route(context: AStarContext, start: Port, target: Port, **config_overrides: object):
|
|
return route_astar(
|
|
start,
|
|
target,
|
|
net_width=2.0,
|
|
context=context,
|
|
config=SearchRunConfig.from_options(context.options, **config_overrides),
|
|
)
|
|
|
|
|
|
def _validate_routing_result(
|
|
result: RoutingResult,
|
|
static_obstacles: list[Polygon],
|
|
clearance: float,
|
|
expected_start: Port | None = None,
|
|
expected_end: Port | None = None,
|
|
) -> dict[str, object]:
|
|
if not result.path:
|
|
return {"is_valid": False, "reason": "No path found"}
|
|
|
|
connectivity_errors: list[str] = []
|
|
if expected_start:
|
|
first_port = result.path[0].start_port
|
|
dist_to_start = math.hypot(first_port.x - expected_start.x, first_port.y - expected_start.y)
|
|
if dist_to_start > 0.005:
|
|
connectivity_errors.append(f"Initial port position mismatch: {dist_to_start*1000:.2f}nm")
|
|
if abs(first_port.r - expected_start.r) > 0.1:
|
|
connectivity_errors.append(f"Initial port orientation mismatch: {first_port.r} vs {expected_start.r}")
|
|
|
|
if expected_end:
|
|
last_port = result.path[-1].end_port
|
|
dist_to_end = math.hypot(last_port.x - expected_end.x, last_port.y - expected_end.y)
|
|
if dist_to_end > 0.005:
|
|
connectivity_errors.append(f"Final port position mismatch: {dist_to_end*1000:.2f}nm")
|
|
if abs(last_port.r - expected_end.r) > 0.1:
|
|
connectivity_errors.append(f"Final port orientation mismatch: {last_port.r} vs {expected_end.r}")
|
|
|
|
engine = RoutingWorld(clearance=clearance)
|
|
for obstacle in static_obstacles:
|
|
engine.add_static_obstacle(obstacle)
|
|
report = engine.verify_path_report("validation", result.path)
|
|
is_valid = report.is_valid and not connectivity_errors
|
|
|
|
reasons = []
|
|
if report.static_collision_count:
|
|
reasons.append(f"Found {report.static_collision_count} obstacle collisions.")
|
|
if report.dynamic_collision_count:
|
|
reasons.append(f"Found {report.dynamic_collision_count} dynamic-net collisions.")
|
|
if report.self_collision_count:
|
|
reasons.append(f"Found {report.self_collision_count} self-intersections.")
|
|
reasons.extend(connectivity_errors)
|
|
|
|
return {
|
|
"is_valid": is_valid,
|
|
"reason": " ".join(reasons),
|
|
"obstacle_collisions": report.static_collision_count,
|
|
"dynamic_collisions": report.dynamic_collision_count,
|
|
"self_intersections": report.self_collision_count,
|
|
"total_length": report.total_length,
|
|
"connectivity_ok": not connectivity_errors,
|
|
}
|
|
|
|
|
|
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(context, start, target)
|
|
|
|
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(context, start, target)
|
|
|
|
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(context, start, target)
|
|
|
|
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(context, start, target)
|
|
|
|
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 = CostEvaluator(
|
|
basic_evaluator.collision_engine,
|
|
basic_evaluator.danger_map,
|
|
bend_penalty=120.0,
|
|
sbend_penalty=240.0,
|
|
)
|
|
context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(5.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,
|
|
config=SearchRunConfig.from_options(
|
|
context.options,
|
|
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(context, start, target, return_partial=True)
|
|
no_partial_path = _route(context, start, target, 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(context, start, target)
|
|
|
|
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(context, start, target)
|
|
|
|
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,
|
|
RoutingProblem(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(context, start, target)
|
|
assert path is not None
|
|
assert path[-1].end_port == target
|