inire/inire/tests/test_astar.py

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