diff --git a/DOCS.md b/DOCS.md index b6c8a1b..ea6292c 100644 --- a/DOCS.md +++ b/DOCS.md @@ -6,23 +6,23 @@ This document describes the user-tunable parameters for the `inire` auto-router. The `AStarRouter` is the core pathfinding engine. It can be configured directly through its constructor. -| Parameter | Type | Default | Description | -| :-------------------- | :------------ | :----------------- | :------------------------------------------------------------------------------------ | -| `node_limit` | `int` | 1,000,000 | Maximum number of states to explore per net. Increase for very complex paths. | -| `straight_lengths` | `list[float]` | `[1.0, 5.0, 25.0]` | Discrete step sizes for straight waveguides (µm). Larger steps speed up search. | -| `bend_radii` | `list[float]` | `[10.0]` | Available radii for 90-degree turns (µm). Multiple values allow best-fit selection. | -| `sbend_offsets` | `list[float]` | `[-5, -2, 2, 5]` | Lateral offsets for parametric S-bends (µm). | -| `sbend_radii` | `list[float]` | `[10.0]` | Available radii for S-bends (µm). | -| `snap_to_target_dist` | `float` | 20.0 | Distance (µm) at which the router attempts an exact bridge to the target port. | -| `bend_penalty` | `float` | 50.0 | Flat cost added for every 90-degree bend. Higher values favor straight lines. | -| `sbend_penalty` | `float` | 100.0 | Flat cost added for every S-bend. Usually higher than `bend_penalty`. | -| `bend_collision_type` | `str` | `"arc"` | Collision model for bends: `"arc"`, `"bbox"`, or `"clipped_bbox"`. | -| `bend_clip_margin` | `float` | 10.0 | Extra space (µm) around the waveguide before the bounding box corners are clipped. | +| Parameter | Type | Default | Description | +| :--- | :--- | :--- | :--- | +| `node_limit` | `int` | 1,000,000 | Maximum number of states to explore per net. Increase for very complex paths. | +| `straight_lengths` | `list[float]` | `[1.0, 5.0, 25.0]` | Discrete step sizes for straight waveguides (µm). Larger steps speed up search in open space. | +| `bend_radii` | `list[float]` | `[10.0]` | Available radii for 90-degree turns (µm). Multiple values allow the router to pick the best fit. | +| `sbend_offsets` | `list[float]` | `[-5, -2, 2, 5]` | Lateral offsets for parametric S-bends (µm). | +| `sbend_radii` | `list[float]` | `[10.0]` | Available radii for S-bends (µm). | +| `snap_to_target_dist`| `float` | 20.0 | Distance (µm) at which the router attempts an exact bridge to the target port. | +| `bend_penalty` | `float` | 50.0 | Flat cost added for every 90-degree bend. Higher values favor straight lines. | +| `sbend_penalty` | `float` | 100.0 | Flat cost added for every S-bend. Usually higher than `bend_penalty`. | +| `bend_collision_type`| `str` | `"arc"` | Collision model for bends: `"arc"`, `"bbox"`, or `"clipped_bbox"`. | +| `bend_clip_margin` | `float` | 10.0 | Margin (µm) for the `"clipped_bbox"` collision model. | ### Bend Collision Models * `"arc"`: High-fidelity model following the exact curved waveguide geometry. * `"bbox"`: Conservative model using the axis-aligned bounding box of the bend. Fast but blocks more space. -* `"clipped_bbox"`: A middle ground that starts with the bounding box but applies 45-degree linear cuts to the inner and outer corners. The `bend_clip_margin` defines the extra safety distance from the waveguide edge to the cut line. +* `"clipped_bbox"`: A middle ground that uses the bounding box but clips corners that are far from the waveguide. --- @@ -30,11 +30,11 @@ The `AStarRouter` is the core pathfinding engine. It can be configured directly The `CostEvaluator` defines the "goodness" of a path. -| Parameter | Type | Default | Description | -| :------------------- | :------ | :--------- | :--------------------------------------------------------------------------------------- | -| `unit_length_cost` | `float` | 1.0 | Cost per µm of wire length. | -| `greedy_h_weight` | `float` | 1.1 | Heuristic weight. `1.0` is optimal; higher values (e.g. `1.5`) speed up search. | -| `congestion_penalty` | `float` | 10,000.0 | Multiplier for overlaps in the multi-net Negotiated Congestion loop. | +| Parameter | Type | Default | Description | +| :--- | :--- | :--- | :--- | +| `unit_length_cost` | `float` | 1.0 | Cost per µm of wire length. | +| `greedy_h_weight` | `float` | 1.1 | Heuristic weight. `1.0` is optimal; higher values (e.g., `1.5`) are faster but may produce longer paths. | +| `congestion_penalty`| `float` | 10,000.0 | Multiplier for overlaps in the multi-net Negotiated Congestion loop. | --- @@ -42,19 +42,19 @@ The `CostEvaluator` defines the "goodness" of a path. The `PathFinder` orchestrates multi-net routing using the Negotiated Congestion algorithm. -| Parameter | Type | Default | Description | -| :------------------------ | :------ | :------ | :-------------------------------------------------------------------------------------- | -| `max_iterations` | `int` | 10 | Maximum number of rip-up and reroute iterations to resolve congestion. | -| `base_congestion_penalty` | `float` | 100.0 | Starting penalty for overlaps. Multiplied by `1.5` each iteration if congestion remains.| +| Parameter | Type | Default | Description | +| :--- | :--- | :--- | :--- | +| `max_iterations` | `int` | 10 | Maximum number of rip-up and reroute iterations to resolve congestion. | +| `base_congestion_penalty` | `float` | 100.0 | Starting penalty for overlaps. This value is multiplied by `1.5` each iteration if congestion persists. | --- ## 4. CollisionEngine Parameters -| Parameter | Type | Default | Description | -| :------------------- | :------ | :--------- | :------------------------------------------------------------------------------------ | -| `clearance` | `float` | (Required) | Minimum required distance between any two waveguides or obstacles (µm). | -| `safety_zone_radius` | `float` | 0.0021 | Radius (µm) around ports where collisions are ignored for PDK boundary incidence. | +| Parameter | Type | Default | Description | +| :--- | :--- | :--- | :--- | +| `clearance` | `float` | (Required) | Minimum required distance between any two waveguides or obstacles (µm). | +| `safety_zone_radius`| `float` | 0.0021 | Radius (µm) around ports where collisions are ignored to allow PDK boundary incidence. | --- @@ -62,7 +62,6 @@ The `PathFinder` orchestrates multi-net routing using the Negotiated Congestion - **Coordinates**: Micrometers (µm). - **Grid Snapping**: The router internally operates on a **1nm** grid for final ports and a **1µm** lattice for expansion moves. - **Search Space**: Assumptions are optimized for design areas up to **20mm x 20mm**. -- **Design Bounds**: The boundary limits defined in `DangerMap` strictly constrain the **physical edges** (dilated geometry) of the waveguide. Any move that would cause the waveguide or its required clearance to extend beyond these bounds is rejected with an infinite cost. --- diff --git a/README.md b/README.md index dffee6d..af1915f 100644 --- a/README.md +++ b/README.md @@ -74,17 +74,12 @@ Check the `examples/` directory for ready-to-run scripts demonstrating core feat * **`examples/03_locked_paths.py`**: Incremental workflow using `lock_net()` to route around previously fixed paths. Generates `03_locked_paths.png`. * **`examples/04_sbends_and_radii.py`**: Complex paths using parametric S-bends and multiple bend radii. Generates `04_sbends_and_radii.png`. * **`examples/05_orientation_stress.py`**: Stress test for various port orientation combinations (U-turns, opposite directions). Generates `05_orientation_stress.png`. -* **`examples/06_bend_collision_models.py`**: Comparison of different collision models for bends (Arc vs. BBox vs. Clipped BBox). Generates `06_bend_collision_models.png`. Run an example: ```bash python3 examples/01_simple_route.py ``` -## Documentation - -Full documentation for all user-tunable parameters, cost functions, and collision models can be found in **[DOCS.md](DOCS.md)**. - ## Architecture `inire` operates on a **State-Lattice** defined by $(x, y, \theta)$. From any state, the router expands via three primary "Move" types: diff --git a/examples/02_congestion_resolution.py b/examples/02_congestion_resolution.py index 3485eb9..34e39f5 100644 --- a/examples/02_congestion_resolution.py +++ b/examples/02_congestion_resolution.py @@ -28,7 +28,7 @@ def main() -> None: "vertical_up": (Port(45, 10, 90), Port(45, 90, 90)), "vertical_down": (Port(55, 90, 270), Port(55, 10, 270)), } - net_widths = dict.fromkeys(netlist, 2.0) + net_widths = {nid: 2.0 for nid in netlist} # 3. Route with Negotiated Congestion # We increase the base penalty to encourage faster divergence diff --git a/examples/03_locked_paths.py b/examples/03_locked_paths.py index 4937783..ad630ed 100644 --- a/examples/03_locked_paths.py +++ b/examples/03_locked_paths.py @@ -28,7 +28,7 @@ def main() -> None: "bus_2": (Port(10, 60, 0), Port(110, 65, 0)), } print("Phase 1: Routing bus (3 nets)...") - results_p1 = pf.route_all(netlist_p1, dict.fromkeys(netlist_p1, 2.0)) + results_p1 = pf.route_all(netlist_p1, {nid: 2.0 for nid in netlist_p1}) # Lock all Phase 1 nets path_polys = [] @@ -50,10 +50,10 @@ def main() -> None: "cross_left": (Port(30, 10, 90), Port(30, 110, 90)), "cross_right": (Port(80, 110, 270), Port(80, 10, 270)), # Top to bottom } - + print("Phase 2: Routing crossing nets around locked bus...") # We use a slightly different width for variety - results_p2 = pf.route_all(netlist_p2, dict.fromkeys(netlist_p2, 1.5)) + results_p2 = pf.route_all(netlist_p2, {nid: 1.5 for nid in netlist_p2}) # 4. Check Results for nid, res in results_p2.items(): diff --git a/examples/04_sbends_and_radii.py b/examples/04_sbends_and_radii.py index 8a7402e..3b8fa28 100644 --- a/examples/04_sbends_and_radii.py +++ b/examples/04_sbends_and_radii.py @@ -1,3 +1,4 @@ +from shapely.geometry import Polygon from inire.geometry.collision import CollisionEngine from inire.geometry.primitives import Port diff --git a/examples/05_orientation_stress.py b/examples/05_orientation_stress.py index ddff8b9..a84306d 100644 --- a/examples/05_orientation_stress.py +++ b/examples/05_orientation_stress.py @@ -16,26 +16,26 @@ def main() -> None: engine = CollisionEngine(clearance=2.0) danger_map = DangerMap(bounds=bounds) danger_map.precompute([]) - + evaluator = CostEvaluator(engine, danger_map, greedy_h_weight=1.1) router = AStarRouter(evaluator, node_limit=100000) pf = PathFinder(router, evaluator) - + # 2. Define Netlist with various orientation challenges netlist = { # Opposite directions: requires two 90-degree bends to flip orientation "opposite": (Port(10, 80, 0), Port(90, 80, 180)), - + # 90-degree turn: standard L-shape "turn_90": (Port(10, 60, 0), Port(40, 90, 90)), - + # Output behind input: requires a full U-turn "behind": (Port(80, 40, 0), Port(20, 40, 0)), - + # Sharp return: output is behind and oriented towards the input "return_loop": (Port(80, 20, 0), Port(40, 10, 180)), } - net_widths = dict.fromkeys(netlist, 2.0) + net_widths = {nid: 2.0 for nid in netlist} # 3. Route results = pf.route_all(netlist, net_widths) diff --git a/examples/06_bend_collision_models.png b/examples/06_bend_collision_models.png deleted file mode 100644 index 9088508..0000000 Binary files a/examples/06_bend_collision_models.png and /dev/null differ diff --git a/examples/06_bend_collision_models.py b/examples/06_bend_collision_models.py deleted file mode 100644 index 30fc4b5..0000000 --- a/examples/06_bend_collision_models.py +++ /dev/null @@ -1,70 +0,0 @@ -from shapely.geometry import Polygon - -from inire.geometry.collision import CollisionEngine -from inire.geometry.primitives import Port -from inire.router.astar import AStarRouter -from inire.router.cost import CostEvaluator -from inire.router.danger_map import DangerMap -from inire.router.pathfinder import PathFinder -from inire.utils.visualization import plot_routing_results - - -def main() -> None: - print("Running Example 06: Bend Collision Models...") - - # 1. Setup Environment - # Give room for 10um bends near the edges - bounds = (-20, -20, 170, 170) - engine = CollisionEngine(clearance=2.0) - danger_map = DangerMap(bounds=bounds) - - # Create three scenarios with identical obstacles - # We'll space them out vertically - obs_arc = Polygon([(40, 110), (60, 110), (60, 130), (40, 130)]) - obs_bbox = Polygon([(40, 60), (60, 60), (60, 80), (40, 80)]) - obs_clipped = Polygon([(40, 10), (60, 10), (60, 30), (40, 30)]) - - obstacles = [obs_arc, obs_bbox, obs_clipped] - for obs in obstacles: - engine.add_static_obstacle(obs) - danger_map.precompute(obstacles) - - # We'll run three separate routers since collision_type is a router-level config - evaluator = CostEvaluator(engine, danger_map) - - # Scenario 1: Standard 'arc' model (High fidelity) - router_arc = AStarRouter(evaluator, bend_collision_type="arc") - netlist_arc = {"arc_model": (Port(10, 120, 0), Port(90, 140, 90))} - - # Scenario 2: 'bbox' model (Conservative axis-aligned box) - router_bbox = AStarRouter(evaluator, bend_collision_type="bbox") - netlist_bbox = {"bbox_model": (Port(10, 70, 0), Port(90, 90, 90))} - - # Scenario 3: 'clipped_bbox' model (Balanced) - router_clipped = AStarRouter(evaluator, bend_collision_type="clipped_bbox", bend_clip_margin=1.0) - netlist_clipped = {"clipped_model": (Port(10, 20, 0), Port(90, 40, 90))} - - # 2. Route each scenario - print("Routing Scenario 1 (Arc)...") - res_arc = PathFinder(router_arc, evaluator).route_all(netlist_arc, {"arc_model": 2.0}) - - print("Routing Scenario 2 (BBox)...") - res_bbox = PathFinder(router_bbox, evaluator).route_all(netlist_bbox, {"bbox_model": 2.0}) - - print("Routing Scenario 3 (Clipped BBox)...") - res_clipped = PathFinder(router_clipped, evaluator).route_all(netlist_clipped, {"clipped_model": 2.0}) - - # 3. Combine results for visualization - all_results = {**res_arc, **res_bbox, **res_clipped} - all_netlists = {**netlist_arc, **netlist_bbox, **netlist_clipped} - - # 4. Visualize - # Note: plot_routing_results will show the 'collision geometry' used by the router - # since that's what's stored in res.path[i].geometry - fig, ax = plot_routing_results(all_results, obstacles, bounds, netlist=all_netlists) - fig.savefig("examples/06_bend_collision_models.png") - print("Saved plot to examples/06_bend_collision_models.png") - - -if __name__ == "__main__": - main() diff --git a/inire/geometry/collision.py b/inire/geometry/collision.py index 908a9f6..1faf6e2 100644 --- a/inire/geometry/collision.py +++ b/inire/geometry/collision.py @@ -1,141 +1,149 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING import rtree from shapely.geometry import Point, Polygon +from shapely.ops import unary_union from shapely.prepared import prep if TYPE_CHECKING: from shapely.prepared import PreparedGeometry + from inire.geometry.primitives import Port class CollisionEngine: - """Manages spatial queries for collision detection with unified dilation logic.""" + """Manages spatial queries for collision detection.""" def __init__(self, clearance: float, max_net_width: float = 2.0, safety_zone_radius: float = 0.0021) -> None: self.clearance = clearance self.max_net_width = max_net_width self.safety_zone_radius = safety_zone_radius - - # Static obstacles: store raw geometries to avoid double-dilation - self.static_index = rtree.index.Index() - self.static_geometries: dict[int, Polygon] = {} # ID -> Polygon - self.static_prepared: dict[int, PreparedGeometry] = {} # ID -> PreparedGeometry - self._static_id_counter = 0 + self.static_obstacles = rtree.index.Index() + # To store geometries for precise checks + self.obstacle_geometries: dict[int, Polygon] = {} # ID -> Polygon + self.prepared_obstacles: dict[int, PreparedGeometry] = {} # ID -> PreparedGeometry + self._id_counter = 0 # Dynamic paths for multi-net congestion - self.dynamic_index = rtree.index.Index() - # obj_id -> (net_id, raw_geometry) - self.dynamic_geometries: dict[int, tuple[str, Polygon]] = {} + self.dynamic_paths = rtree.index.Index() + # obj_id -> (net_id, geometry) + self.path_geometries: dict[int, tuple[str, Polygon]] = {} self._dynamic_id_counter = 0 - def add_static_obstacle(self, polygon: Polygon) -> None: - """Add a static obstacle (raw geometry) to the engine.""" - obj_id = self._static_id_counter - self._static_id_counter += 1 + def add_static_obstacle(self, polygon: Polygon, pre_dilate: bool = True) -> None: + """Add a static obstacle to the engine.""" + _ = pre_dilate # Keep for API compatibility + obj_id = self._id_counter + self._id_counter += 1 - self.static_geometries[obj_id] = polygon - self.static_prepared[obj_id] = prep(polygon) - self.static_index.insert(obj_id, polygon.bounds) + self.obstacle_geometries[obj_id] = polygon + self.prepared_obstacles[obj_id] = prep(polygon) + + # Index the bounding box of the original polygon + # We query with dilated moves, so original bounds are enough + self.static_obstacles.insert(obj_id, polygon.bounds) def add_path(self, net_id: str, geometry: list[Polygon]) -> None: - """Add a net's routed path (raw geometry) to the dynamic index.""" + """Add a net's routed path to the dynamic R-Tree.""" + # Dilate by clearance/2 for congestion + dilation = self.clearance / 2.0 for poly in geometry: + dilated = poly.buffer(dilation) obj_id = self._dynamic_id_counter self._dynamic_id_counter += 1 - self.dynamic_geometries[obj_id] = (net_id, poly) - self.dynamic_index.insert(obj_id, poly.bounds) + self.path_geometries[obj_id] = (net_id, dilated) + self.dynamic_paths.insert(obj_id, dilated.bounds) def remove_path(self, net_id: str) -> None: - """Remove a net's path from the dynamic index.""" - to_remove = [obj_id for obj_id, (nid, _) in self.dynamic_geometries.items() if nid == net_id] + """Remove a net's path from the dynamic R-Tree.""" + to_remove = [obj_id for obj_id, (nid, _) in self.path_geometries.items() if nid == net_id] for obj_id in to_remove: - nid, poly = self.dynamic_geometries.pop(obj_id) - self.dynamic_index.delete(obj_id, poly.bounds) + nid, dilated = self.path_geometries.pop(obj_id) + self.dynamic_paths.delete(obj_id, dilated.bounds) def lock_net(self, net_id: str) -> None: """Move a net's dynamic path to static obstacles permanently.""" - to_move = [obj_id for obj_id, (nid, _) in self.dynamic_geometries.items() if nid == net_id] + to_move = [obj_id for obj_id, (nid, _) in self.path_geometries.items() if nid == net_id] for obj_id in to_move: - nid, poly = self.dynamic_geometries.pop(obj_id) - self.dynamic_index.delete(obj_id, poly.bounds) - self.add_static_obstacle(poly) + nid, dilated = self.path_geometries.pop(obj_id) + self.dynamic_paths.delete(obj_id, dilated.bounds) - def is_collision( - self, - geometry: Polygon, - net_width: float = 2.0, - start_port: Port | None = None, - end_port: Port | None = None - ) -> bool: - """Alias for check_collision(buffer_mode='static') for backward compatibility.""" - _ = net_width - res = self.check_collision(geometry, "default", buffer_mode="static", start_port=start_port, end_port=end_port) - return bool(res) + # Add to static (already dilated for clearance) + new_static_id = self._id_counter + self._id_counter += 1 + self.obstacle_geometries[new_static_id] = dilated + self.prepared_obstacles[new_static_id] = prep(dilated) + self.static_obstacles.insert(new_static_id, dilated.bounds) def count_congestion(self, geometry: Polygon, net_id: str) -> int: - """Alias for check_collision(buffer_mode='congestion') for backward compatibility.""" - res = self.check_collision(geometry, net_id, buffer_mode="congestion") - return int(res) + """Count how many other nets collide with this geometry.""" + dilation = self.clearance / 2.0 + test_poly = geometry.buffer(dilation) + return self.count_congestion_prebuffered(test_poly, net_id) - def check_collision( - self, - geometry: Polygon, - net_id: str, - buffer_mode: Literal["static", "congestion"] = "static", + def count_congestion_prebuffered(self, dilated_geometry: Polygon, net_id: str) -> int: + """Count how many other nets collide with this pre-dilated geometry.""" + candidates = self.dynamic_paths.intersection(dilated_geometry.bounds) + count = 0 + for obj_id in candidates: + other_net_id, other_poly = self.path_geometries[obj_id] + if other_net_id != net_id and dilated_geometry.intersects(other_poly): + count += 1 + return count + + def is_collision( + self, + geometry: Polygon, + net_width: float, start_port: Port | None = None, - end_port: Port | None = None - ) -> bool | int: - """ - Check for collisions using unified dilation logic. - - If buffer_mode == "static": - Returns True if geometry collides with static obstacles (buffered by full clearance). - If buffer_mode == "congestion": - Returns count of other nets colliding with geometry (both buffered by clearance/2). - """ - if buffer_mode == "static": - # Buffered move vs raw static obstacle - # Distance must be >= clearance - test_poly = geometry.buffer(self.clearance) - candidates = self.static_index.intersection(test_poly.bounds) - - for obj_id in candidates: - if self.static_prepared[obj_id].intersects(test_poly): - # Safety zone check (using exact intersection area/bounds) - if start_port or end_port: - intersection = test_poly.intersection(self.static_geometries[obj_id]) - if intersection.is_empty: - continue - - ix_minx, ix_miny, ix_maxx, ix_maxy = intersection.bounds - - is_safe = False - for p in [start_port, end_port]: - if p and (abs(ix_minx - p.x) < self.safety_zone_radius and - abs(ix_maxx - p.x) < self.safety_zone_radius and - abs(ix_miny - p.y) < self.safety_zone_radius and - abs(ix_maxy - p.y) < self.safety_zone_radius): - is_safe = True - break - if is_safe: - continue - return True - return False + end_port: Port | None = None, + ) -> bool: + """Check if a geometry (e.g. a Move) collides with static obstacles.""" + _ = net_width # Width is already integrated into engine dilation settings + dilation = self.clearance / 2.0 + test_poly = geometry.buffer(dilation) + return self.is_collision_prebuffered(test_poly, start_port=start_port, end_port=end_port) - else: # buffer_mode == "congestion" - # Both paths buffered by clearance/2 => Total separation = clearance - dilation = self.clearance / 2.0 - test_poly = geometry.buffer(dilation) - candidates = self.dynamic_index.intersection(test_poly.bounds) - - count = 0 - for obj_id in candidates: - other_net_id, other_poly = self.dynamic_geometries[obj_id] - if other_net_id != net_id: - # Buffer the other path segment too - if test_poly.intersects(other_poly.buffer(dilation)): - count += 1 - return count + def is_collision_prebuffered( + self, + dilated_geometry: Polygon, + start_port: Port | None = None, + end_port: Port | None = None, + ) -> bool: + """Check if a pre-dilated geometry collides with static obstacles.""" + # Query R-Tree using the bounds of the dilated move + candidates = self.static_obstacles.intersection(dilated_geometry.bounds) + + for obj_id in candidates: + # Use prepared geometry for fast intersection + if self.prepared_obstacles[obj_id].intersects(dilated_geometry): + # Check safety zone (2nm radius) + if start_port or end_port: + obstacle = self.obstacle_geometries[obj_id] + intersection = dilated_geometry.intersection(obstacle) + + if intersection.is_empty: + continue + + # Precise check: is every point in the intersection close to either port? + ix_minx, ix_miny, ix_maxx, ix_maxy = intersection.bounds + + is_near_start = False + if start_port: + if (abs(ix_minx - start_port.x) < self.safety_zone_radius and abs(ix_maxx - start_port.x) < self.safety_zone_radius and + abs(ix_miny - start_port.y) < self.safety_zone_radius and abs(ix_maxy - start_port.y) < self.safety_zone_radius): + is_near_start = True + + is_near_end = False + if end_port: + if (abs(ix_minx - end_port.x) < self.safety_zone_radius and abs(ix_maxx - end_port.x) < self.safety_zone_radius and + abs(ix_miny - end_port.y) < self.safety_zone_radius and abs(ix_maxy - end_port.y) < self.safety_zone_radius): + is_near_end = True + + if is_near_start or is_near_end: + continue + + return True + return False diff --git a/inire/geometry/components.py b/inire/geometry/components.py index 82df9b5..da238be 100644 --- a/inire/geometry/components.py +++ b/inire/geometry/components.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import NamedTuple, Literal, Any +from typing import NamedTuple, Literal, Union import numpy as np from shapely.geometry import Polygon, box @@ -35,7 +35,7 @@ class Straight: ex = start_port.x + dx ey = start_port.y + dy - + if snap_to_grid: ex = snap_search_grid(ex) ey = snap_search_grid(ey) @@ -84,83 +84,39 @@ def _get_arc_polygons(cx: float, cy: float, radius: float, width: float, t_start def _apply_collision_model( - arc_poly: Polygon, + arc_poly: Polygon, collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon, radius: float, - width: float, - cx: float = 0.0, - cy: float = 0.0, clip_margin: float = 10.0 ) -> list[Polygon]: """Applies the specified collision model to an arc geometry.""" if isinstance(collision_type, Polygon): return [collision_type] - + if collision_type == "arc": return [arc_poly] - + # Get bounding box minx, miny, maxx, maxy = arc_poly.bounds bbox = box(minx, miny, maxx, maxy) - + if collision_type == "bbox": return [bbox] - + if collision_type == "clipped_bbox": - res_poly = bbox - - # Determine quadrant signs from arc centroid relative to center - # This ensures we always cut 'into' the box correctly - ac = arc_poly.centroid - sx = 1.0 if ac.x >= cx else -1.0 - sy = 1.0 if ac.y >= cy else -1.0 - - r_out_cut = radius + width / 2.0 + clip_margin - r_in_cut = radius - width / 2.0 - clip_margin - - corners = [(minx, miny), (minx, maxy), (maxx, miny), (maxx, maxy)] - for px, py in corners: - dx, dy = px - cx, py - cy - dist = np.sqrt(dx**2 + dy**2) - - if dist > r_out_cut: - # Outer corner: remove part furthest from center - # We want minimum distance to line to be r_out_cut - d_cut = r_out_cut * np.sqrt(2) - elif r_in_cut > 0 and dist < r_in_cut: - # Inner corner: remove part closest to center - # We want maximum distance to line to be r_in_cut - d_cut = r_in_cut - else: - continue - - # The cut line is sx*(x-cx) + sy*(y-cy) = d_cut - # sx*x + sy*y = sx*cx + sy*cy + d_cut - val = cx * sx + cy * sy + d_cut - - try: - p1 = (px, py) - p2 = (px, (val - sx * px) / sy) - p3 = ((val - sy * py) / sx, py) - - triangle = Polygon([p1, p2, p3]) - if triangle.is_valid and triangle.area > 1e-9: - res_poly = res_poly.difference(triangle) - except ZeroDivisionError: - continue - - return [res_poly] - + safe_zone = arc_poly.buffer(clip_margin) + return [bbox.intersection(safe_zone)] + return [arc_poly] class Bend90: @staticmethod def generate( - start_port: Port, - radius: float, - width: float, - direction: str = "CW", + start_port: Port, + radius: float, + width: float, + direction: str = "CW", sagitta: float = 0.01, collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc", clip_margin: float = 10.0 @@ -177,11 +133,9 @@ class Bend90: ex = snap_search_grid(cx + radius * np.cos(t_end)) ey = snap_search_grid(cy + radius * np.sin(t_end)) end_port = Port(ex, ey, float((start_port.orientation + turn_angle) % 360)) - + arc_polys = _get_arc_polygons(cx, cy, radius, width, t_start, t_end, sagitta) - collision_polys = _apply_collision_model( - arc_polys[0], collision_type, radius, width, cx, cy, clip_margin - ) + collision_polys = _apply_collision_model(arc_polys[0], collision_type, radius, clip_margin) return ComponentResult(geometry=collision_polys, end_port=end_port, length=radius * np.pi / 2.0) @@ -189,10 +143,10 @@ class Bend90: class SBend: @staticmethod def generate( - start_port: Port, - offset: float, - radius: float, - width: float, + start_port: Port, + offset: float, + radius: float, + width: float, sagitta: float = 0.01, collision_type: Literal["arc", "bbox", "clipped_bbox"] | Polygon = "arc", clip_margin: float = 10.0 @@ -208,7 +162,7 @@ class SBend: ex = snap_search_grid(start_port.x + dx * np.cos(rad_start) - dy * np.sin(rad_start)) ey = snap_search_grid(start_port.y + dx * np.sin(rad_start) + dy * np.cos(rad_start)) end_port = Port(ex, ey, start_port.orientation) - + direction = 1 if offset > 0 else -1 c1_angle = rad_start + direction * np.pi / 2 cx1 = start_port.x + radius * np.cos(c1_angle) @@ -226,14 +180,6 @@ class SBend: arc1 = _get_arc_polygons(cx1, cy1, radius, width, ts1, te1, sagitta)[0] arc2 = _get_arc_polygons(cx2, cy2, radius, width, ts2, te2, sagitta)[0] combined_arc = unary_union([arc1, arc2]) - - if collision_type == "clipped_bbox": - col1 = _apply_collision_model(arc1, collision_type, radius, width, cx1, cy1, clip_margin) - col2 = _apply_collision_model(arc2, collision_type, radius, width, cx2, cy2, clip_margin) - collision_polys = [unary_union(col1 + col2)] - else: - collision_polys = _apply_collision_model( - combined_arc, collision_type, radius, width, 0, 0, clip_margin - ) - + + collision_polys = _apply_collision_model(combined_arc, collision_type, radius, clip_margin) return ComponentResult(geometry=collision_polys, end_port=end_port, length=2 * radius * theta) diff --git a/inire/router/astar.py b/inire/router/astar.py index 93df12e..f77c044 100644 --- a/inire/router/astar.py +++ b/inire/router/astar.py @@ -189,7 +189,7 @@ class AStarRouter: lengths = self.config.straight_lengths if dist < 5.0: fine_steps = [0.1, 0.5] - lengths = sorted(set(lengths + fine_steps)) + lengths = sorted(list(set(lengths + fine_steps))) for length in lengths: res = Straight.generate(current.port, length, net_width) @@ -255,9 +255,7 @@ class AStarRouter: else: hard_coll = False for poly in result.geometry: - if self.cost_evaluator.collision_engine.check_collision( - poly, net_id, buffer_mode="static", start_port=parent.port, end_port=result.end_port - ): + if self.cost_evaluator.collision_engine.is_collision(poly, net_width, start_port=parent.port, end_port=result.end_port): hard_coll = True break self._collision_cache[cache_key] = hard_coll @@ -302,6 +300,8 @@ class AStarRouter: # Turn penalties scaled by radius to favor larger turns ref_radius = 10.0 if "B" in move_type and move_radius is not None: + # Scale penalty: larger radius -> smaller penalty + # e.g. radius 10 -> factor 1.0, radius 30 -> factor 0.33 penalty_factor = ref_radius / move_radius move_cost += self.config.bend_penalty * penalty_factor elif "SB" in move_type and move_radius is not None: diff --git a/inire/router/config.py b/inire/router/config.py index 0a9e115..b9af31f 100644 --- a/inire/router/config.py +++ b/inire/router/config.py @@ -1,8 +1,10 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Literal, Any +from typing import Literal, TYPE_CHECKING, Any +if TYPE_CHECKING: + from shapely.geometry import Polygon @dataclass diff --git a/inire/router/cost.py b/inire/router/cost.py index 996e135..17a18a3 100644 --- a/inire/router/cost.py +++ b/inire/router/cost.py @@ -40,7 +40,7 @@ class CostEvaluator: greedy_h_weight=greedy_h_weight, congestion_penalty=congestion_penalty, ) - + # Use config values self.unit_length_cost = self.config.unit_length_cost self.greedy_h_weight = self.config.greedy_h_weight @@ -73,24 +73,25 @@ class CostEvaluator: """Calculate the cost of a single move (Straight, Bend, SBend).""" _ = net_width # Unused total_cost = length * self.unit_length_cost - - # 1. Boundary Check (Centerline based for compatibility) - if not self.danger_map.is_within_bounds(end_port.x, end_port.y): - return 1e15 - - # 2. Collision Check + + # 1. Hard Collision check (Static obstacles) + # We buffer by the full clearance to ensure distance >= clearance + hard_dilation = self.collision_engine.clearance for poly in geometry: - # Hard Collision (Static obstacles) - if self.collision_engine.check_collision( - poly, net_id, buffer_mode="static", start_port=start_port, end_port=end_port - ): - return 1e15 + dilated_poly = poly.buffer(hard_dilation) + if self.collision_engine.is_collision_prebuffered(dilated_poly, start_port=start_port, end_port=end_port): + return 1e15 # Impossible cost for hard collisions - # Soft Collision (Negotiated Congestion) - overlaps = self.collision_engine.check_collision(poly, net_id, buffer_mode="congestion") - if isinstance(overlaps, int) and overlaps > 0: + # 2. Soft Collision check (Negotiated Congestion) + # We buffer by clearance/2 because both paths are buffered by clearance/2 + soft_dilation = self.collision_engine.clearance / 2.0 + for poly in geometry: + dilated_poly = poly.buffer(soft_dilation) + overlaps = self.collision_engine.count_congestion_prebuffered(dilated_poly, net_id) + if overlaps > 0: total_cost += overlaps * self.congestion_penalty # 3. Proximity cost from Danger Map total_cost += self.g_proximity(end_port.x, end_port.y) return total_cost + diff --git a/inire/router/danger_map.py b/inire/router/danger_map.py index 209edf0..1ebf617 100644 --- a/inire/router/danger_map.py +++ b/inire/router/danger_map.py @@ -3,14 +3,13 @@ from __future__ import annotations from typing import TYPE_CHECKING import numpy as np -import shapely if TYPE_CHECKING: from shapely.geometry import Polygon class DangerMap: - """A pre-computed grid for heuristic proximity costs, vectorized for performance.""" + """A pre-computed grid for heuristic proximity costs.""" def __init__( self, @@ -29,36 +28,47 @@ class DangerMap: self.width_cells = int(np.ceil((self.maxx - self.minx) / self.resolution)) self.height_cells = int(np.ceil((self.maxy - self.miny) / self.resolution)) + # Use uint8 for memory efficiency if normalized, or float16/float32. + # Let's use float32 for simplicity and precision in the prototype. + # For a 1000x1000 grid, this is only 4MB. + # For 20000x20000, it's 1.6GB. self.grid = np.zeros((self.width_cells, self.height_cells), dtype=np.float32) def precompute(self, obstacles: list[Polygon]) -> None: - """Pre-compute the proximity costs for the entire grid using vectorized operations.""" + """Pre-compute the proximity costs for the entire grid.""" + # For each cell, find distance to nearest obstacle. + # This is a distance transform problem. + # For the prototype, we can use a simpler approach or scipy.ndimage.distance_transform_edt. from scipy.ndimage import distance_transform_edt - # 1. Create a binary mask of obstacles + # Create a binary mask of obstacles mask = np.ones((self.width_cells, self.height_cells), dtype=bool) - - # Create coordinate grids - x_coords = np.linspace(self.minx + self.resolution/2, self.maxx - self.resolution/2, self.width_cells) - y_coords = np.linspace(self.miny + self.resolution/2, self.maxy - self.resolution/2, self.height_cells) - xv, yv = np.meshgrid(x_coords, y_coords, indexing='ij') + # Rasterize obstacles (simplified: mark cells whose center is inside an obstacle) + # This is slow for many obstacles; in a real engine, we'd use a faster rasterizer. + from shapely.geometry import Point for poly in obstacles: - # Use shapely.contains_xy for fast vectorized point-in-polygon check - in_poly = shapely.contains_xy(poly, xv, yv) - mask[in_poly] = False + # Get bounding box in grid coordinates + p_minx, p_miny, p_maxx, p_maxy = poly.bounds + x_start = max(0, int((p_minx - self.minx) / self.resolution)) + x_end = min(self.width_cells, int((p_maxx - self.minx) / self.resolution) + 1) + y_start = max(0, int((p_miny - self.miny) / self.resolution)) + y_end = min(self.height_cells, int((p_maxy - self.miny) / self.resolution) + 1) - # 2. Distance transform (mask=True for empty space) + for ix in range(x_start, x_end): + cx = self.minx + (ix + 0.5) * self.resolution + for iy in range(y_start, y_end): + cy = self.miny + (iy + 0.5) * self.resolution + if poly.contains(Point(cx, cy)): + mask[ix, iy] = False + + # Distance transform (mask=True for empty space) distances = distance_transform_edt(mask) * self.resolution - # 3. Proximity cost: k / d^2 if d < threshold, else 0 - # Cap distances at a small epsilon (e.g. 0.1um) to avoid division by zero + # Proximity cost: k / d^2 if d < threshold, else 0 + # To avoid division by zero, we cap distances at a small epsilon (e.g. 0.1um) safe_distances = np.maximum(distances, 0.1) - self.grid = np.where( - distances < self.safety_threshold, - self.k / (safe_distances**2), - 0.0 - ).astype(np.float32) + self.grid = np.where(distances < self.safety_threshold, self.k / (safe_distances**2), 0.0).astype(np.float32) def is_within_bounds(self, x: float, y: float) -> bool: """Check if a coordinate is within the design bounds.""" diff --git a/inire/router/pathfinder.py b/inire/router/pathfinder.py index 19ef9fd..a61993a 100644 --- a/inire/router/pathfinder.py +++ b/inire/router/pathfinder.py @@ -79,7 +79,7 @@ class PathFinder: logger.debug(f" Net {net_id} routed in {time.monotonic() - net_start:.4f}s") if path: - # 3. Add to index + # 3. Add to R-Tree all_geoms = [] for res in path: all_geoms.extend(res.geometry) @@ -88,11 +88,7 @@ class PathFinder: # Check if this new path has any congestion collision_count = 0 for poly in all_geoms: - overlaps = self.cost_evaluator.collision_engine.check_collision( - poly, net_id, buffer_mode="congestion" - ) - if isinstance(overlaps, int): - collision_count += overlaps + collision_count += self.cost_evaluator.collision_engine.count_congestion(poly, net_id) if collision_count > 0: any_congestion = True @@ -124,12 +120,9 @@ class PathFinder: collision_count = 0 for comp in res.path: for poly in comp.geometry: - overlaps = self.cost_evaluator.collision_engine.check_collision( - poly, net_id, buffer_mode="congestion" - ) - if isinstance(overlaps, int): - collision_count += overlaps + collision_count += self.cost_evaluator.collision_engine.count_congestion(poly, net_id) final_results[net_id] = RoutingResult(net_id, res.path, collision_count == 0, collision_count) return final_results + diff --git a/inire/tests/test_components.py b/inire/tests/test_components.py index 3ce6b18..37da884 100644 --- a/inire/tests/test_components.py +++ b/inire/tests/test_components.py @@ -1,4 +1,6 @@ +import numpy as np import pytest +from shapely.geometry import Point from inire.geometry.components import Bend90, SBend, Straight from inire.geometry.primitives import Port, rotate_port, translate_port @@ -64,7 +66,7 @@ def test_bend_collision_models() -> None: # 1. BBox model res_bbox = Bend90.generate(start, radius, width, direction="CCW", collision_type="bbox") - # Arc CCW R=10 from (0,0,0) ends at (10,10,90). + # Arc CCW R=10 from (0,0,0) ends at (10,10,90). # Waveguide width is 2.0, so bbox will be slightly larger than (0,0,10,10) minx, miny, maxx, maxy = res_bbox.geometry[0].bounds assert minx <= 0.0 + 1e-6 @@ -87,7 +89,7 @@ def test_sbend_collision_models() -> None: res_bbox = SBend.generate(start, offset, radius, width, collision_type="bbox") # Geometry should be a single bounding box polygon assert len(res_bbox.geometry) == 1 - + res_arc = SBend.generate(start, offset, radius, width, collision_type="arc") assert res_bbox.geometry[0].area > res_arc.geometry[0].area @@ -98,15 +100,15 @@ def test_sbend_continuity() -> None: offset = 4.0 radius = 20.0 width = 1.0 - + res = SBend.generate(start, offset, radius, width) - + # Target orientation should be same as start assert abs(res.end_port.orientation - 90.0) < 1e-6 - + # For a port at 90 deg, +offset is a shift in -x direction assert abs(res.end_port.x - (10.0 - offset)) < 1e-6 - + # Geometry should be connected (unary_union results in 1 polygon) assert len(res.geometry) == 1 assert res.geometry[0].is_valid @@ -117,17 +119,17 @@ def test_arc_sagitta_precision() -> None: start = Port(0, 0, 0) radius = 100.0 # Large radius to make sagitta significant width = 2.0 - + # Coarse: 1um sagitta res_coarse = Bend90.generate(start, radius, width, sagitta=1.0) # Fine: 0.01um (10nm) sagitta res_fine = Bend90.generate(start, radius, width, sagitta=0.01) - + # Number of segments should be significantly higher for fine # Exterior points = (segments + 1) * 2 pts_coarse = len(res_coarse.geometry[0].exterior.coords) pts_fine = len(res_fine.geometry[0].exterior.coords) - + assert pts_fine > pts_coarse * 2 @@ -137,20 +139,20 @@ def test_component_transform_invariance() -> None: start0 = Port(0, 0, 0) radius = 10.0 width = 2.0 - + res0 = Bend90.generate(start0, radius, width, direction="CCW") - + # Transform: Translate (10, 10) then Rotate 90 dx, dy = 10.0, 5.0 angle = 90.0 - + # 1. Transform the generated geometry p_end_transformed = rotate_port(translate_port(res0.end_port, dx, dy), angle) - + # 2. Generate at transformed start start_transformed = rotate_port(translate_port(start0, dx, dy), angle) res_transformed = Bend90.generate(start_transformed, radius, width, direction="CCW") - + assert abs(res_transformed.end_port.x - p_end_transformed.x) < 1e-6 assert abs(res_transformed.end_port.y - p_end_transformed.y) < 1e-6 assert abs(res_transformed.end_port.orientation - p_end_transformed.orientation) < 1e-6 diff --git a/inire/utils/validation.py b/inire/utils/validation.py index 662fd81..6dd8986 100644 --- a/inire/utils/validation.py +++ b/inire/utils/validation.py @@ -3,7 +3,8 @@ from __future__ import annotations import numpy as np from typing import TYPE_CHECKING, Any -from shapely.geometry import Polygon +from shapely.geometry import Point, Polygon +from shapely.ops import unary_union if TYPE_CHECKING: from inire.geometry.primitives import Port @@ -27,7 +28,7 @@ def validate_routing_result( obstacle_collision_geoms = [] self_intersection_geoms = [] connectivity_errors = [] - + # 1. Connectivity Check total_length = 0.0 for i, comp in enumerate(result.path): @@ -37,7 +38,7 @@ def validate_routing_result( if expected_end: last_port = result.path[-1].end_port dist_to_end = np.sqrt((last_port.x - expected_end.x)**2 + (last_port.y - expected_end.y)**2) - if dist_to_end > 0.005: + if dist_to_end > 0.005: connectivity_errors.append(f"Final port position mismatch: {dist_to_end*1000:.2f}nm") if abs(last_port.orientation - expected_end.orientation) > 0.1: connectivity_errors.append(f"Final port orientation mismatch: {last_port.orientation} vs {expected_end.orientation}") @@ -45,9 +46,9 @@ def validate_routing_result( # 2. Geometry Buffering dilation_half = clearance / 2.0 dilation_full = clearance - + dilated_for_self = [] - + for i, comp in enumerate(result.path): for poly in comp.geometry: # Check against obstacles @@ -57,7 +58,7 @@ def validate_routing_result( intersection = d_full.intersection(obs) if intersection.area > 1e-9: obstacle_collision_geoms.append(intersection) - + # Save for self-intersection check dilated_for_self.append(poly.buffer(dilation_half)) @@ -67,13 +68,13 @@ def validate_routing_result( if j > i + 1: # Non-adjacent if seg_i.intersects(seg_j): overlap = seg_i.intersection(seg_j) - if overlap.area > 1e-6: + if overlap.area > 1e-6: self_intersection_geoms.append((i, j, overlap)) - is_valid = (len(obstacle_collision_geoms) == 0 and - len(self_intersection_geoms) == 0 and + is_valid = (len(obstacle_collision_geoms) == 0 and + len(self_intersection_geoms) == 0 and len(connectivity_errors) == 0) - + reasons = [] if obstacle_collision_geoms: reasons.append(f"Found {len(obstacle_collision_geoms)} obstacle collisions.")