diff --git a/inire/geometry/components.py b/inire/geometry/components.py index 0808694..0d61862 100644 --- a/inire/geometry/components.py +++ b/inire/geometry/components.py @@ -49,7 +49,7 @@ class ComponentResult: _offsets: list[int] | None = None, _coords_cache: numpy.ndarray | None = None, _base_result: ComponentResult | None = None, - _offset: list[float] | None = None, + _offset: numpy.ndarray | None = None, snap_size: float = SEARCH_GRID_SNAP_UM, rel_gx: int | None = None, rel_gy: int | None = None, @@ -216,17 +216,18 @@ class ComponentResult: base = self._base_result if base is not None: dx, dy = self._offset + shift = numpy.array([dx, dy, dx, dy]) # Vectorized addition is faster if we avoid creating new lists/arrays in the loop if base._bounds is not None: - self._bounds = base._bounds + [dx, dy, dx, dy] + self._bounds = base._bounds + shift if base._total_bounds is not None: b = base._total_bounds - self._total_bounds = numpy.array([b[0]+dx, b[1]+dy, b[2]+dx, b[3]+dy]) + self._total_bounds = b + shift if base._dilated_bounds is not None: - self._dilated_bounds = base._dilated_bounds + [dx, dy, dx, dy] + self._dilated_bounds = base._dilated_bounds + shift if base._total_dilated_bounds is not None: b = base._total_dilated_bounds - self._total_dilated_bounds = numpy.array([b[0]+dx, b[1]+dy, b[2]+dx, b[3]+dy]) + self._total_dilated_bounds = b + shift self._bounds_cached = True def translate(self, dx: float, dy: float, rel_gx: int | None = None, rel_gy: int | None = None, rel_go: int | None = None) -> ComponentResult: @@ -241,10 +242,10 @@ class ComponentResult: if self._base_result: base = self._base_result current_offset = self._offset - new_offset = [current_offset[0] + dx, current_offset[1] + dy] + new_offset = numpy.array([current_offset[0] + dx, current_offset[1] + dy]) else: base = self - new_offset = [dx, dy] + new_offset = numpy.array([dx, dy]) return ComponentResult( end_port=new_port, @@ -607,6 +608,11 @@ class SBend: actual_radius = abs(local_dy) / denom + # Safety Check: Reject SBends with tiny radii that would cause self-overlap + # (inner_radius becomes negative if actual_radius < width/2) + if actual_radius < width: + raise ValueError(f"SBend actual_radius {actual_radius:.3f} is too small (width={width})") + # Limit radius to prevent giant arcs if actual_radius > 100000.0: actual_len = numpy.sqrt(local_dx**2 + local_dy**2) diff --git a/inire/router/astar.py b/inire/router/astar.py index abc0e84..7f874b3 100644 --- a/inire/router/astar.py +++ b/inire/router/astar.py @@ -56,7 +56,8 @@ class AStarRouter: """ __slots__ = ('cost_evaluator', 'config', 'node_limit', 'visibility_manager', '_hard_collision_set', '_congestion_cache', '_static_safe_cache', - '_move_cache', 'total_nodes_expanded', 'last_expanded_nodes', 'metrics') + '_move_cache', 'total_nodes_expanded', 'last_expanded_nodes', 'metrics', + '_self_collision_check') def __init__(self, cost_evaluator: CostEvaluator, node_limit: int | None = None, **kwargs) -> None: self.cost_evaluator = cost_evaluator @@ -121,11 +122,25 @@ class AStarRouter: return_partial: bool = False, store_expanded: bool = False, skip_congestion: bool = False, - max_cost: float | None = None + max_cost: float | None = None, + self_collision_check: bool = False, ) -> list[ComponentResult] | None: """ Route a single net using A*. + + Args: + start: Starting port. + target: Target port. + net_width: Waveguide width. + net_id: Identifier for the net. + bend_collision_type: Type of collision model to use for bends. + return_partial: If True, returns the best-effort path if target not reached. + store_expanded: If True, keep track of all expanded nodes for visualization. + skip_congestion: If True, ignore other nets' paths (greedy mode). + max_cost: Hard limit on f_cost to prune search. + self_collision_check: If True, prevent the net from crossing its own path. """ + self._self_collision_check = self_collision_check self._congestion_cache.clear() if store_expanded: self.last_expanded_nodes = [] @@ -440,6 +455,22 @@ class AStarRouter: total_overlaps = self.cost_evaluator.collision_engine.check_move_congestion(result, net_id) self._congestion_cache[cache_key] = total_overlaps + # SELF-COLLISION CHECK (Optional for performance) + if getattr(self, '_self_collision_check', False): + curr_p = parent + new_tb = result.total_bounds + while curr_p and curr_p.parent: + ancestor_res = curr_p.component_result + if ancestor_res: + anc_tb = ancestor_res.total_bounds + if (new_tb[0] < anc_tb[2] and new_tb[2] > anc_tb[0] and + new_tb[1] < anc_tb[3] and new_tb[3] > anc_tb[1]): + for p_anc in ancestor_res.geometry: + for p_new in result.geometry: + if p_new.intersects(p_anc) and not p_new.touches(p_anc): + return + curr_p = curr_p.parent + penalty = 0.0 if 'SB' in move_type: penalty = self.config.sbend_penalty elif 'B' in move_type: penalty = self.config.bend_penalty diff --git a/inire/router/pathfinder.py b/inire/router/pathfinder.py index 193853c..55e4b14 100644 --- a/inire/router/pathfinder.py +++ b/inire/router/pathfinder.py @@ -144,6 +144,31 @@ class PathFinder: logger.info(f"PathFinder: Greedy Warm-Start finished. Seeding {len(greedy_paths)}/{len(netlist)} nets.") return greedy_paths + def _has_self_collision(self, path: list[ComponentResult]) -> bool: + """ + Quickly check if a path intersects itself. + """ + if not path: + return False + + num_components = len(path) + for i in range(num_components): + comp_i = path[i] + tb_i = comp_i.total_bounds + for j in range(i + 2, num_components): # Skip immediate neighbors + comp_j = path[j] + tb_j = comp_j.total_bounds + + # AABB Check + if (tb_i[0] < tb_j[2] and tb_i[2] > tb_j[0] and + tb_i[1] < tb_j[3] and tb_i[3] > tb_j[1]): + # Real geometry check + for p_i in comp_i.geometry: + for p_j in comp_j.geometry: + if p_i.intersects(p_j) and not p_i.touches(p_j): + return True + return False + def route_all( self, netlist: dict[str, tuple[Port, Port]], @@ -180,6 +205,7 @@ class PathFinder: session_timeout = max(60.0, 10.0 * num_nets * self.max_iterations) all_net_ids = list(netlist.keys()) + needs_sc = set() # Nets requiring self-collision avoidance # Determine initial paths (Warm Start) if initial_paths is None: @@ -249,7 +275,7 @@ class PathFinder: original_limit = self.router.node_limit self.router.node_limit = current_node_limit - path = self.router.route(start, target, width, net_id=net_id, bend_collision_type=coll_model, return_partial=True, store_expanded=store_expanded, skip_congestion=skip_cong) + path = self.router.route(start, target, width, net_id=net_id, bend_collision_type=coll_model, return_partial=True, store_expanded=store_expanded, skip_congestion=skip_cong, self_collision_check=(net_id in needs_sc)) if store_expanded and self.router.last_expanded_nodes: self.accumulated_expanded_nodes.extend(self.router.last_expanded_nodes) @@ -258,6 +284,13 @@ class PathFinder: logger.debug(f' Net {net_id} routed in {time.monotonic() - net_start:.4f}s using {coll_model}') if path: + # Check for self-collision if not already handled by router + if net_id not in needs_sc: + if self._has_self_collision(path): + logger.info(f' Net {net_id} detected self-collision. Enabling protection for next iteration.') + needs_sc.add(net_id) + any_congestion = True + # Check if reached exactly last_p = path[-1].end_port reached = (abs(last_p.x - target.x) < 1e-6 and diff --git a/inire/tests/test_failed_net_congestion.py b/inire/tests/test_failed_net_congestion.py new file mode 100644 index 0000000..cc40951 --- /dev/null +++ b/inire/tests/test_failed_net_congestion.py @@ -0,0 +1,74 @@ + +import pytest +import numpy +from inire.geometry.primitives import Port +from inire.geometry.collision import CollisionEngine +from inire.router.cost import CostEvaluator +from inire.router.astar import AStarRouter +from inire.router.pathfinder import PathFinder +from inire.router.danger_map import DangerMap + +def test_failed_net_visibility(): + """ + Verifies that nets that fail to reach their target (return partial paths) + ARE added to the collision engine, making them visible to other nets + for negotiated congestion. + """ + # 1. Setup + engine = CollisionEngine(clearance=2.0) + + # Create a simple danger map (bounds 0-100) + # We don't strictly need obstacles in it for this test. + dm = DangerMap(bounds=(0, 0, 100, 100)) + + evaluator = CostEvaluator(engine, dm) + + # 2. Configure Router with low limit to FORCE failure + # node_limit=5 is extremely low, likely allowing only a few moves. + # Start (0,0) -> Target (100,0) is 100um away. + # If snap is 1.0, direct jump S100 might be tried. + # If direct jump works, it might succeed in 1 expansion. + # So we need to block the direct jump or make the limit VERY small (0?). + # Or place a static obstacle that forces a search. + + # Let's add a static obstacle that blocks the direct path. + from shapely.geometry import box + obstacle = box(40, -10, 60, 10) # Wall at x=50 + engine.add_static_obstacle(obstacle) + + # With obstacle, direct jump fails. A* must search around. + # Limit=10 should be enough to fail to find a path around. + router = AStarRouter(evaluator, node_limit=10) + + # 3. Configure PathFinder + # max_iterations=1 because we only need to check the state after the first attempt. + pf = PathFinder(router, evaluator, max_iterations=1, warm_start=None) + + netlist = { + "net1": (Port(0, 0, 0), Port(100, 0, 0)) + } + net_widths = {"net1": 1.0} + + # 4. Route + print("\nStarting Route...") + results = pf.route_all(netlist, net_widths) + + res = results["net1"] + print(f"Result: is_valid={res.is_valid}, reached={res.reached_target}, path_len={len(res.path)}") + + # 5. Verify Failure Condition + # We expect reached_target to be False because of node_limit + obstacle + assert not res.reached_target, "Test setup failed: Net reached target despite low limit!" + assert len(res.path) > 0, "Test setup failed: No partial path returned!" + + # 6. Verify Visibility + # Check if net1 is in the collision engine + found_nets = set() + # CollisionEngine.dynamic_geometries: dict[obj_id, (net_id, poly)] + for obj_id, (nid, poly) in engine.dynamic_geometries.items(): + found_nets.add(nid) + + print(f"Nets found in engine: {found_nets}") + + # The FIX Expectation: "net1" SHOULD be present + assert "net1" in found_nets, "Bug present: Net1 is invisible despite having partial path!"