Initial buildout
This commit is contained in:
parent
34615f3aac
commit
f600b52f32
25 changed files with 1856 additions and 23 deletions
|
|
@ -1,6 +1,8 @@
|
|||
"""
|
||||
inire Wave-router
|
||||
inire Wave-router
|
||||
"""
|
||||
from .geometry.primitives import Port as Port # noqa: PLC0414
|
||||
from .geometry.components import Straight as Straight, Bend90 as Bend90, SBend as SBend # noqa: PLC0414
|
||||
|
||||
__author__ = 'Jan Petykiewicz'
|
||||
__version__ = '0.1'
|
||||
|
|
|
|||
140
inire/geometry/collision.py
Normal file
140
inire/geometry/collision.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
from __future__ import annotations
|
||||
|
||||
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."""
|
||||
|
||||
def __init__(self, clearance: float, max_net_width: float = 2.0) -> None:
|
||||
self.clearance = clearance
|
||||
self.max_net_width = max_net_width
|
||||
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_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, 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.obstacle_geometries[obj_id] = polygon
|
||||
self.prepared_obstacles[obj_id] = prep(polygon)
|
||||
|
||||
# Index the bounding box of the polygon (dilated for broad prune)
|
||||
# Spec: "All user-provided obstacles are pre-dilated by (W_max + C)/2"
|
||||
dilation = (self.max_net_width + self.clearance) / 2.0
|
||||
dilated_bounds = (
|
||||
polygon.bounds[0] - dilation,
|
||||
polygon.bounds[1] - dilation,
|
||||
polygon.bounds[2] + dilation,
|
||||
polygon.bounds[3] + dilation,
|
||||
)
|
||||
self.static_obstacles.insert(obj_id, dilated_bounds)
|
||||
|
||||
def add_path(self, net_id: str, geometry: list[Polygon]) -> None:
|
||||
"""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.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 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, 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.path_geometries.items() if nid == net_id]
|
||||
for obj_id in to_move:
|
||||
nid, dilated = self.path_geometries.pop(obj_id)
|
||||
self.dynamic_paths.delete(obj_id, dilated.bounds)
|
||||
|
||||
# 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:
|
||||
"""Count how many other nets collide with this geometry."""
|
||||
dilation = self.clearance / 2.0
|
||||
test_poly = geometry.buffer(dilation)
|
||||
candidates = self.dynamic_paths.intersection(test_poly.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 test_poly.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:
|
||||
"""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)
|
||||
|
||||
# Broad prune with R-Tree
|
||||
candidates = self.static_obstacles.intersection(test_poly.bounds)
|
||||
|
||||
for obj_id in candidates:
|
||||
# Use prepared geometry for fast intersection
|
||||
if self.prepared_obstacles[obj_id].intersects(test_poly):
|
||||
# Check safety zone (2nm = 0.002 um)
|
||||
if start_port or end_port:
|
||||
obstacle = self.obstacle_geometries[obj_id]
|
||||
intersection = test_poly.intersection(obstacle)
|
||||
|
||||
if intersection.is_empty:
|
||||
continue
|
||||
|
||||
# Create safety zone polygons
|
||||
safety_zones = []
|
||||
if start_port:
|
||||
safety_zones.append(Point(start_port.x, start_port.y).buffer(0.002))
|
||||
if end_port:
|
||||
safety_zones.append(Point(end_port.x, end_port.y).buffer(0.002))
|
||||
|
||||
if safety_zones:
|
||||
safe_poly = unary_union(safety_zones)
|
||||
# Remove safe zones from intersection
|
||||
remaining_collision = intersection.difference(safe_poly)
|
||||
if remaining_collision.is_empty or remaining_collision.area < 1e-9:
|
||||
continue
|
||||
|
||||
return True
|
||||
return False
|
||||
|
||||
170
inire/geometry/components.py
Normal file
170
inire/geometry/components.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import NamedTuple
|
||||
|
||||
import numpy as np
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
from .primitives import Port
|
||||
|
||||
# Search Grid Snap (1.0 µm)
|
||||
SEARCH_GRID_SNAP_UM = 1.0
|
||||
|
||||
|
||||
def snap_search_grid(value: float) -> float:
|
||||
"""Snap a coordinate to the nearest 1µm."""
|
||||
return round(value / SEARCH_GRID_SNAP_UM) * SEARCH_GRID_SNAP_UM
|
||||
|
||||
|
||||
class ComponentResult(NamedTuple):
|
||||
"""The result of a component generation: geometry and the final port."""
|
||||
|
||||
geometry: list[Polygon]
|
||||
end_port: Port
|
||||
|
||||
|
||||
class Straight:
|
||||
@staticmethod
|
||||
def generate(start_port: Port, length: float, width: float) -> ComponentResult:
|
||||
"""Generate a straight waveguide segment."""
|
||||
# Calculate end port position
|
||||
rad = np.radians(start_port.orientation)
|
||||
dx = length * np.cos(rad)
|
||||
dy = length * np.sin(rad)
|
||||
|
||||
end_port = Port(start_port.x + dx, start_port.y + dy, start_port.orientation)
|
||||
|
||||
# Create polygon (centered on port)
|
||||
half_w = width / 2.0
|
||||
# Points relative to start port (0,0)
|
||||
points = [(0, half_w), (length, half_w), (length, -half_w), (0, -half_w)]
|
||||
|
||||
# Transform points
|
||||
cos_val = np.cos(rad)
|
||||
sin_val = np.sin(rad)
|
||||
poly_points = []
|
||||
for px, py in points:
|
||||
tx = start_port.x + px * cos_val - py * sin_val
|
||||
ty = start_port.y + px * sin_val + py * cos_val
|
||||
poly_points.append((tx, ty))
|
||||
|
||||
return ComponentResult(geometry=[Polygon(poly_points)], end_port=end_port)
|
||||
|
||||
|
||||
def _get_num_segments(radius: float, angle_deg: float, sagitta: float = 0.01) -> int:
|
||||
"""Calculate number of segments for an arc to maintain a maximum sagitta."""
|
||||
if radius <= 0:
|
||||
return 1
|
||||
# angle_deg is absolute angle turned
|
||||
# s = R(1 - cos(theta/2)) => cos(theta/2) = 1 - s/R
|
||||
# theta = 2 * acos(1 - s/R)
|
||||
# n = total_angle / theta
|
||||
ratio = max(0.0, min(1.0, 1.0 - sagitta / radius))
|
||||
theta_max = 2.0 * np.arccos(ratio)
|
||||
if theta_max == 0:
|
||||
return 16
|
||||
num = int(np.ceil(np.radians(abs(angle_deg)) / theta_max))
|
||||
return max(4, num)
|
||||
|
||||
|
||||
class Bend90:
|
||||
@staticmethod
|
||||
def generate(start_port: Port, radius: float, width: float, direction: str = "CW", sagitta: float = 0.01) -> ComponentResult:
|
||||
"""Generate a 90-degree bend."""
|
||||
# direction: 'CW' (-90) or 'CCW' (+90)
|
||||
turn_angle = -90 if direction == "CW" else 90
|
||||
|
||||
# Calculate center of the arc
|
||||
rad_start = np.radians(start_port.orientation)
|
||||
center_angle = rad_start + (np.pi / 2 if direction == "CCW" else -np.pi / 2)
|
||||
cx = start_port.x + radius * np.cos(center_angle)
|
||||
cy = start_port.y + radius * np.sin(center_angle)
|
||||
|
||||
# Center to start is radius at center_angle + pi
|
||||
theta_start = center_angle + np.pi
|
||||
theta_end = theta_start + (np.pi / 2 if direction == "CCW" else -np.pi / 2)
|
||||
|
||||
ex = cx + radius * np.cos(theta_end)
|
||||
ey = cy + radius * np.sin(theta_end)
|
||||
|
||||
# End port orientation
|
||||
end_orientation = (start_port.orientation + turn_angle) % 360
|
||||
|
||||
snapped_ex = snap_search_grid(ex)
|
||||
snapped_ey = snap_search_grid(ey)
|
||||
|
||||
end_port = Port(snapped_ex, snapped_ey, float(end_orientation))
|
||||
|
||||
# Generate arc geometry
|
||||
num_segments = _get_num_segments(radius, 90, sagitta)
|
||||
angles = np.linspace(theta_start, theta_end, num_segments + 1)
|
||||
|
||||
inner_radius = radius - width / 2.0
|
||||
outer_radius = radius + width / 2.0
|
||||
|
||||
inner_points = [(cx + inner_radius * np.cos(a), cy + inner_radius * np.sin(a)) for a in angles]
|
||||
outer_points = [(cx + outer_radius * np.cos(a), cy + outer_radius * np.sin(a)) for a in reversed(angles)]
|
||||
|
||||
return ComponentResult(geometry=[Polygon(inner_points + outer_points)], end_port=end_port)
|
||||
|
||||
|
||||
class SBend:
|
||||
@staticmethod
|
||||
def generate(start_port: Port, offset: float, radius: float, width: float, sagitta: float = 0.01) -> ComponentResult:
|
||||
"""Generate a parametric S-bend (two tangent arcs). Only for offset < 2*radius."""
|
||||
if abs(offset) >= 2 * radius:
|
||||
raise ValueError(f"SBend offset {offset} must be less than 2*radius {2 * radius}")
|
||||
|
||||
# Analytical length: L = 2 * sqrt(O * (2*R - O/4)) is for a specific S-bend type.
|
||||
# Standard S-bend with two equal arcs:
|
||||
# Offset O = 2 * R * (1 - cos(theta))
|
||||
# theta = acos(1 - O / (2*R))
|
||||
theta = np.arccos(1 - abs(offset) / (2 * radius))
|
||||
|
||||
# Length of one arc = R * theta
|
||||
# Total length of S-bend = 2 * R * theta (arc length)
|
||||
# Horizontal distance dx = 2 * R * sin(theta)
|
||||
|
||||
dx = 2 * radius * np.sin(theta)
|
||||
dy = offset
|
||||
|
||||
# End port
|
||||
rad_start = np.radians(start_port.orientation)
|
||||
ex = start_port.x + dx * np.cos(rad_start) - dy * np.sin(rad_start)
|
||||
ey = start_port.y + dx * np.sin(rad_start) + dy * np.cos(rad_start)
|
||||
|
||||
end_port = Port(ex, ey, start_port.orientation)
|
||||
|
||||
# Geometry: two arcs
|
||||
# First arc center
|
||||
direction = 1 if offset > 0 else -1
|
||||
center_angle1 = rad_start + direction * np.pi / 2
|
||||
cx1 = start_port.x + radius * np.cos(center_angle1)
|
||||
cy1 = start_port.y + radius * np.sin(center_angle1)
|
||||
|
||||
# Second arc center
|
||||
center_angle2 = rad_start - direction * np.pi / 2
|
||||
cx2 = ex + radius * np.cos(center_angle2)
|
||||
cy2 = ey + radius * np.sin(center_angle2)
|
||||
|
||||
# Generate points for both arcs
|
||||
num_segments = _get_num_segments(radius, float(np.degrees(theta)), sagitta)
|
||||
# Arc 1: theta_start1 to theta_end1
|
||||
theta_start1 = center_angle1 + np.pi
|
||||
theta_end1 = theta_start1 - direction * theta
|
||||
|
||||
# Arc 2: theta_start2 to theta_end2
|
||||
theta_start2 = center_angle2
|
||||
theta_end2 = theta_start2 + direction * theta
|
||||
|
||||
def get_arc_points(cx: float, cy: float, r_inner: float, r_outer: float, t_start: float, t_end: float) -> list[tuple[float, float]]:
|
||||
angles = np.linspace(t_start, t_end, num_segments + 1)
|
||||
inner = [(cx + r_inner * np.cos(a), cy + r_inner * np.sin(a)) for a in angles]
|
||||
outer = [(cx + r_outer * np.cos(a), cy + r_outer * np.sin(a)) for a in reversed(angles)]
|
||||
return inner + outer
|
||||
|
||||
poly1 = Polygon(get_arc_points(cx1, cy1, radius - width / 2, radius + width / 2, theta_start1, theta_end1))
|
||||
poly2 = Polygon(get_arc_points(cx2, cy2, radius - width / 2, radius + width / 2, theta_end2, theta_start2))
|
||||
|
||||
return ComponentResult(geometry=[poly1, poly2], end_port=end_port)
|
||||
|
||||
50
inire/geometry/primitives.py
Normal file
50
inire/geometry/primitives.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import numpy as np
|
||||
|
||||
# 1nm snap (0.001 µm)
|
||||
GRID_SNAP_UM = 0.001
|
||||
|
||||
def snap_nm(value: float) -> float:
|
||||
"""Snap a coordinate to the nearest 1nm (0.001 um)."""
|
||||
return round(value / GRID_SNAP_UM) * GRID_SNAP_UM
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Port:
|
||||
"""A port defined by (x, y, orientation) in micrometers."""
|
||||
x: float
|
||||
y: float
|
||||
orientation: float # Degrees: 0, 90, 180, 270
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Snap x, y to 1nm
|
||||
# We need to use object.__setattr__ because the dataclass is frozen.
|
||||
snapped_x = snap_nm(self.x)
|
||||
snapped_y = snap_nm(self.y)
|
||||
|
||||
# Ensure orientation is one of {0, 90, 180, 270}
|
||||
norm_orientation = int(round(self.orientation)) % 360
|
||||
if norm_orientation not in {0, 90, 180, 270}:
|
||||
norm_orientation = (round(norm_orientation / 90) * 90) % 360
|
||||
|
||||
object.__setattr__(self, "x", snapped_x)
|
||||
object.__setattr__(self, "y", snapped_y)
|
||||
object.__setattr__(self, "orientation", float(norm_orientation))
|
||||
|
||||
|
||||
def translate_port(port: Port, dx: float, dy: float) -> Port:
|
||||
"""Translate a port by (dx, dy)."""
|
||||
return Port(port.x + dx, port.y + dy, port.orientation)
|
||||
|
||||
|
||||
def rotate_port(port: Port, angle: float, origin: tuple[float, float] = (0, 0)) -> Port:
|
||||
"""Rotate a port by a multiple of 90 degrees around an origin."""
|
||||
ox, oy = origin
|
||||
px, py = port.x, port.y
|
||||
|
||||
rad = np.radians(angle)
|
||||
qx = ox + np.cos(rad) * (px - ox) - np.sin(rad) * (py - oy)
|
||||
qy = oy + np.sin(rad) * (px - ox) + np.cos(rad) * (py - oy)
|
||||
|
||||
return Port(qx, qy, port.orientation + angle)
|
||||
|
||||
209
inire/router/astar.py
Normal file
209
inire/router/astar.py
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import heapq
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
|
||||
from inire.geometry.components import Bend90, SBend, Straight
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from inire.geometry.components import ComponentResult
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.router.cost import CostEvaluator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AStarNode:
|
||||
_count = 0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port: Port,
|
||||
g_cost: float,
|
||||
h_cost: float,
|
||||
parent: AStarNode | None = None,
|
||||
component_result: ComponentResult | None = None,
|
||||
) -> None:
|
||||
self.port = port
|
||||
self.g_cost = g_cost
|
||||
self.h_cost = h_cost
|
||||
self.f_cost = g_cost + h_cost
|
||||
self.parent = parent
|
||||
self.component_result = component_result
|
||||
self.count = AStarNode._count
|
||||
AStarNode._count += 1
|
||||
|
||||
def __lt__(self, other: AStarNode) -> bool:
|
||||
# Tie-breaking: lower f first, then lower h, then order
|
||||
if abs(self.f_cost - other.f_cost) > 1e-9:
|
||||
return self.f_cost < other.f_cost
|
||||
if abs(self.h_cost - other.h_cost) > 1e-9:
|
||||
return self.h_cost < other.h_cost
|
||||
return self.count < other.count
|
||||
|
||||
|
||||
class AStarRouter:
|
||||
def __init__(self, cost_evaluator: CostEvaluator) -> None:
|
||||
self.cost_evaluator = cost_evaluator
|
||||
self.node_limit = 100000
|
||||
self.total_nodes_expanded = 0
|
||||
self._collision_cache: dict[tuple[float, float, float, str, float, str], bool] = {}
|
||||
|
||||
def route(
|
||||
self, start: Port, target: Port, net_width: float, net_id: str = "default"
|
||||
) -> list[ComponentResult] | None:
|
||||
"""Route a single net using A*."""
|
||||
self._collision_cache.clear()
|
||||
open_set: list[AStarNode] = []
|
||||
# Key: (x, y, orientation)
|
||||
closed_set: set[tuple[float, float, float]] = set()
|
||||
|
||||
start_node = AStarNode(start, 0.0, self.cost_evaluator.h_manhattan(start, target))
|
||||
heapq.heappush(open_set, start_node)
|
||||
|
||||
nodes_expanded = 0
|
||||
|
||||
while open_set:
|
||||
if nodes_expanded >= self.node_limit:
|
||||
logger.warning(f" AStar failed: node limit {self.node_limit} reached.")
|
||||
return None
|
||||
|
||||
current = heapq.heappop(open_set)
|
||||
|
||||
state = (current.port.x, current.port.y, current.port.orientation)
|
||||
if state in closed_set:
|
||||
continue
|
||||
closed_set.add(state)
|
||||
nodes_expanded += 1
|
||||
self.total_nodes_expanded += 1
|
||||
|
||||
# Check if we reached the target (Snap-to-Target)
|
||||
if (
|
||||
abs(current.port.x - target.x) < 1e-6
|
||||
and abs(current.port.y - target.y) < 1e-6
|
||||
and current.port.orientation == target.orientation
|
||||
):
|
||||
return self._reconstruct_path(current)
|
||||
|
||||
# Look-ahead snapping
|
||||
if self._try_snap_to_target(current, target, net_width, net_id, open_set):
|
||||
pass
|
||||
|
||||
# Expand neighbors
|
||||
self._expand_moves(current, target, net_width, net_id, open_set)
|
||||
|
||||
return None
|
||||
|
||||
def _expand_moves(
|
||||
self,
|
||||
current: AStarNode,
|
||||
target: Port,
|
||||
net_width: float,
|
||||
net_id: str,
|
||||
open_set: list[AStarNode],
|
||||
) -> None:
|
||||
# 1. Straights
|
||||
for length in [0.5, 1.0, 5.0, 25.0]:
|
||||
res = Straight.generate(current.port, length, net_width)
|
||||
self._add_node(current, res, target, net_width, net_id, open_set, f"S{length}")
|
||||
|
||||
# 2. Bends
|
||||
for radius in [5.0, 10.0, 20.0]:
|
||||
for direction in ["CW", "CCW"]:
|
||||
res = Bend90.generate(current.port, radius, net_width, direction)
|
||||
self._add_node(current, res, target, net_width, net_id, open_set, f"B{radius}{direction}")
|
||||
|
||||
# 3. Parametric SBends
|
||||
dx = target.x - current.port.x
|
||||
dy = target.y - current.port.y
|
||||
rad = np.radians(current.port.orientation)
|
||||
local_dy = -dx * np.sin(rad) + dy * np.cos(rad)
|
||||
|
||||
if 0 < abs(local_dy) < 40.0: # Match max 2*R
|
||||
try:
|
||||
# Use a standard radius for expansion
|
||||
res = SBend.generate(current.port, local_dy, 20.0, net_width)
|
||||
self._add_node(current, res, target, net_width, net_id, open_set, f"SB{local_dy}")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def _add_node(
|
||||
self,
|
||||
parent: AStarNode,
|
||||
result: ComponentResult,
|
||||
target: Port,
|
||||
net_width: float,
|
||||
net_id: str,
|
||||
open_set: list[AStarNode],
|
||||
move_type: str,
|
||||
) -> None:
|
||||
cache_key = (
|
||||
parent.port.x,
|
||||
parent.port.y,
|
||||
parent.port.orientation,
|
||||
move_type,
|
||||
net_width,
|
||||
net_id,
|
||||
)
|
||||
if cache_key in self._collision_cache:
|
||||
if self._collision_cache[cache_key]:
|
||||
return
|
||||
else:
|
||||
hard_coll = False
|
||||
for poly in result.geometry:
|
||||
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
|
||||
if hard_coll:
|
||||
return
|
||||
|
||||
move_cost = self.cost_evaluator.evaluate_move(result.geometry, result.end_port, net_width, net_id, start_port=parent.port)
|
||||
|
||||
g_cost = parent.g_cost + move_cost + self._step_cost(result)
|
||||
h_cost = self.cost_evaluator.h_manhattan(result.end_port, target)
|
||||
|
||||
new_node = AStarNode(result.end_port, g_cost, h_cost, parent, result)
|
||||
heapq.heappush(open_set, new_node)
|
||||
|
||||
def _step_cost(self, result: ComponentResult) -> float:
|
||||
_ = result # Unused in base implementation
|
||||
return 0.0
|
||||
|
||||
def _try_snap_to_target(
|
||||
self,
|
||||
current: AStarNode,
|
||||
target: Port,
|
||||
net_width: float,
|
||||
net_id: str,
|
||||
open_set: list[AStarNode],
|
||||
) -> bool:
|
||||
dist = np.sqrt((current.port.x - target.x) ** 2 + (current.port.y - target.y) ** 2)
|
||||
if dist > 10.0:
|
||||
return False
|
||||
|
||||
if current.port.orientation == target.orientation:
|
||||
rad = np.radians(current.port.orientation)
|
||||
dx = target.x - current.port.x
|
||||
dy = target.y - current.port.y
|
||||
|
||||
proj = dx * np.cos(rad) + dy * np.sin(rad)
|
||||
perp = -dx * np.sin(rad) + dy * np.cos(rad)
|
||||
|
||||
if proj > 0 and abs(perp) < 1e-6:
|
||||
res = Straight.generate(current.port, proj, net_width)
|
||||
self._add_node(current, res, target, net_width, net_id, open_set, "SnapTarget")
|
||||
return True
|
||||
return False
|
||||
|
||||
def _reconstruct_path(self, end_node: AStarNode) -> list[ComponentResult]:
|
||||
path = []
|
||||
curr = end_node
|
||||
while curr.component_result:
|
||||
path.append(curr.component_result)
|
||||
curr = curr.parent
|
||||
return path[::-1]
|
||||
|
||||
62
inire/router/cost.py
Normal file
62
inire/router/cost.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
from inire.geometry.collision import CollisionEngine
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.router.danger_map import DangerMap
|
||||
|
||||
|
||||
class CostEvaluator:
|
||||
"""Calculates total cost f(n) = g(n) + h(n)."""
|
||||
|
||||
def __init__(self, collision_engine: CollisionEngine, danger_map: DangerMap) -> None:
|
||||
self.collision_engine = collision_engine
|
||||
self.danger_map = danger_map
|
||||
# Cost weights
|
||||
self.unit_length_cost = 1.0
|
||||
self.bend_cost_multiplier = 10.0
|
||||
self.greedy_h_weight = 1.1
|
||||
self.congestion_penalty = 100.0 # Multiplier for overlaps
|
||||
|
||||
def g_proximity(self, x: float, y: float) -> float:
|
||||
"""Get proximity cost from the Danger Map."""
|
||||
return self.danger_map.get_cost(x, y)
|
||||
|
||||
def h_manhattan(self, current: Port, target: Port) -> float:
|
||||
"""Heuristic: weighted Manhattan distance + orientation penalty."""
|
||||
dist = abs(current.x - target.x) + abs(current.y - target.y)
|
||||
|
||||
# Orientation penalty if not aligned with target entry
|
||||
penalty = 0.0
|
||||
if current.orientation != target.orientation:
|
||||
penalty += 50.0 # Arbitrary high cost for mismatch
|
||||
|
||||
return self.greedy_h_weight * (dist + penalty)
|
||||
|
||||
def evaluate_move(
|
||||
self,
|
||||
geometry: list[Polygon],
|
||||
end_port: Port,
|
||||
net_width: float,
|
||||
net_id: str,
|
||||
start_port: Port | None = None,
|
||||
) -> float:
|
||||
"""Calculate the cost of a single move (Straight, Bend, SBend)."""
|
||||
total_cost = 0.0
|
||||
# Strict collision check
|
||||
for poly in geometry:
|
||||
if self.collision_engine.is_collision(poly, net_width, start_port=start_port, end_port=end_port):
|
||||
return 1e9 # Massive cost for hard collisions
|
||||
|
||||
# Negotiated Congestion Cost
|
||||
overlaps = self.collision_engine.count_congestion(poly, net_id)
|
||||
total_cost += overlaps * self.congestion_penalty
|
||||
|
||||
# Proximity cost from Danger Map
|
||||
total_cost += self.g_proximity(end_port.x, end_port.y)
|
||||
return total_cost
|
||||
|
||||
80
inire/router/danger_map.py
Normal file
80
inire/router/danger_map.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
|
||||
class DangerMap:
|
||||
"""A pre-computed grid for heuristic proximity costs."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bounds: tuple[float, float, float, float],
|
||||
resolution: float = 1.0,
|
||||
safety_threshold: float = 10.0,
|
||||
k: float = 1.0,
|
||||
) -> None:
|
||||
# bounds: (minx, miny, maxx, maxy)
|
||||
self.minx, self.miny, self.maxx, self.maxy = bounds
|
||||
self.resolution = resolution
|
||||
self.safety_threshold = safety_threshold
|
||||
self.k = k
|
||||
|
||||
# Grid dimensions
|
||||
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."""
|
||||
# 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
|
||||
|
||||
# Create a binary mask of obstacles
|
||||
mask = np.ones((self.width_cells, self.height_cells), dtype=bool)
|
||||
# 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:
|
||||
# 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)
|
||||
|
||||
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
|
||||
|
||||
# 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)
|
||||
|
||||
def get_cost(self, x: float, y: float) -> float:
|
||||
"""Get the proximity cost at a specific coordinate."""
|
||||
ix = int((x - self.minx) / self.resolution)
|
||||
iy = int((y - self.miny) / self.resolution)
|
||||
|
||||
if 0 <= ix < self.width_cells and 0 <= iy < self.height_cells:
|
||||
return float(self.grid[ix, iy])
|
||||
return 1e6 # Outside bounds is expensive
|
||||
113
inire/router/pathfinder.py
Normal file
113
inire/router/pathfinder.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from inire.geometry.components import ComponentResult
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.router.astar import AStarRouter
|
||||
from inire.router.cost import CostEvaluator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoutingResult:
|
||||
net_id: str
|
||||
path: list[ComponentResult]
|
||||
is_valid: bool
|
||||
collisions: int
|
||||
|
||||
|
||||
class PathFinder:
|
||||
"""Multi-net router using Negotiated Congestion."""
|
||||
|
||||
def __init__(self, router: AStarRouter, cost_evaluator: CostEvaluator) -> None:
|
||||
self.router = router
|
||||
self.cost_evaluator = cost_evaluator
|
||||
self.max_iterations = 20
|
||||
self.base_congestion_penalty = 100.0
|
||||
|
||||
def route_all(self, netlist: dict[str, tuple[Port, Port]], net_widths: dict[str, float]) -> dict[str, RoutingResult]:
|
||||
"""Route all nets in the netlist using Negotiated Congestion."""
|
||||
results: dict[str, RoutingResult] = {}
|
||||
self.cost_evaluator.congestion_penalty = self.base_congestion_penalty
|
||||
|
||||
start_time = time.monotonic()
|
||||
num_nets = len(netlist)
|
||||
session_timeout = max(30.0, 0.5 * num_nets * self.max_iterations)
|
||||
|
||||
for iteration in range(self.max_iterations):
|
||||
any_congestion = False
|
||||
logger.info(f"PathFinder Iteration {iteration}...")
|
||||
|
||||
# Sequence through nets
|
||||
for net_id, (start, target) in netlist.items():
|
||||
# Timeout check
|
||||
elapsed = time.monotonic() - start_time
|
||||
if elapsed > session_timeout:
|
||||
logger.warning(f"PathFinder TIMEOUT after {elapsed:.2f}s")
|
||||
# Return whatever we have so far
|
||||
return self._finalize_results(results, netlist)
|
||||
|
||||
width = net_widths.get(net_id, 2.0)
|
||||
|
||||
# 1. Rip-up existing path
|
||||
self.cost_evaluator.collision_engine.remove_path(net_id)
|
||||
|
||||
# 2. Reroute with current congestion info
|
||||
net_start = time.monotonic()
|
||||
path = self.router.route(start, target, width, net_id=net_id)
|
||||
logger.debug(f" Net {net_id} routed in {time.monotonic() - net_start:.4f}s")
|
||||
|
||||
if path:
|
||||
# 3. Add to R-Tree
|
||||
all_geoms = []
|
||||
for res in path:
|
||||
all_geoms.extend(res.geometry)
|
||||
self.cost_evaluator.collision_engine.add_path(net_id, all_geoms)
|
||||
|
||||
# Check if this new path has any congestion
|
||||
collision_count = 0
|
||||
for poly in all_geoms:
|
||||
collision_count += self.cost_evaluator.collision_engine.count_congestion(poly, net_id)
|
||||
|
||||
if collision_count > 0:
|
||||
any_congestion = True
|
||||
|
||||
results[net_id] = RoutingResult(net_id, path, collision_count == 0, collision_count)
|
||||
else:
|
||||
results[net_id] = RoutingResult(net_id, [], False, 0)
|
||||
any_congestion = True
|
||||
|
||||
if not any_congestion:
|
||||
break
|
||||
|
||||
# 4. Inflate congestion penalty
|
||||
self.cost_evaluator.congestion_penalty *= 1.5
|
||||
|
||||
return self._finalize_results(results, netlist)
|
||||
|
||||
def _finalize_results(self, results: dict[str, RoutingResult], netlist: dict[str, tuple[Port, Port]]) -> dict[str, RoutingResult]:
|
||||
"""Final check: re-verify all nets against the final static paths."""
|
||||
logger.debug(f"Finalizing results for nets: {list(results.keys())}")
|
||||
final_results = {}
|
||||
# Ensure all nets in the netlist are present in final_results
|
||||
for net_id in netlist:
|
||||
res = results.get(net_id)
|
||||
if not res or not res.path:
|
||||
final_results[net_id] = RoutingResult(net_id, [], False, 0)
|
||||
continue
|
||||
|
||||
collision_count = 0
|
||||
for comp in res.path:
|
||||
for poly in comp.geometry:
|
||||
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
|
||||
|
||||
56
inire/tests/benchmark_scaling.py
Normal file
56
inire/tests/benchmark_scaling.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import time
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.geometry.collision import CollisionEngine
|
||||
from inire.router.danger_map import DangerMap
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.astar import AStarRouter
|
||||
from inire.router.pathfinder import PathFinder
|
||||
|
||||
def benchmark_scaling() -> None:
|
||||
print("Starting Scalability Benchmark...")
|
||||
|
||||
# 1. Memory Verification (20x20mm)
|
||||
# Resolution 1um -> 20000 x 20000 grid
|
||||
bounds = (0, 0, 20000, 20000)
|
||||
print(f"Initializing DangerMap for {bounds} area...")
|
||||
dm = DangerMap(bounds=bounds, resolution=1.0)
|
||||
# nbytes for float32: 20000 * 20000 * 4 bytes = 1.6 GB
|
||||
mem_gb = dm.grid.nbytes / (1024**3)
|
||||
print(f"DangerMap memory usage: {mem_gb:.2f} GB")
|
||||
assert mem_gb < 2.0
|
||||
|
||||
# 2. Node Expansion Rate (50 nets)
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
# Use a smaller area for routing benchmark to keep it fast
|
||||
routing_bounds = (0, 0, 1000, 1000)
|
||||
danger_map = DangerMap(bounds=routing_bounds)
|
||||
danger_map.precompute([])
|
||||
evaluator = CostEvaluator(engine, danger_map)
|
||||
router = AStarRouter(evaluator)
|
||||
pf = PathFinder(router, evaluator)
|
||||
|
||||
num_nets = 50
|
||||
netlist = {}
|
||||
for i in range(num_nets):
|
||||
# Parallel nets spaced by 10um
|
||||
netlist[f"net{i}"] = (Port(0, i * 10, 0), Port(100, i * 10, 0))
|
||||
|
||||
print(f"Routing {num_nets} nets...")
|
||||
start_time = time.monotonic()
|
||||
results = pf.route_all(netlist, dict.fromkeys(netlist, 2.0))
|
||||
end_time = time.monotonic()
|
||||
|
||||
total_time = end_time - start_time
|
||||
print(f"Total routing time: {total_time:.2f} s")
|
||||
print(f"Time per net: {total_time/num_nets:.4f} s")
|
||||
|
||||
if total_time > 0:
|
||||
nodes_per_sec = router.total_nodes_expanded / total_time
|
||||
print(f"Node expansion rate: {nodes_per_sec:.2f} nodes/s")
|
||||
|
||||
# Success rate
|
||||
successes = sum(1 for r in results.values() if r.is_valid)
|
||||
print(f"Success rate: {successes/num_nets * 100:.1f}%")
|
||||
|
||||
if __name__ == "__main__":
|
||||
benchmark_scaling()
|
||||
71
inire/tests/test_astar.py
Normal file
71
inire/tests/test_astar.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import pytest
|
||||
import numpy as np
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.geometry.collision import CollisionEngine
|
||||
from inire.router.danger_map import DangerMap
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.astar import AStarRouter
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
@pytest.fixture
|
||||
def basic_evaluator():
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
danger_map = DangerMap(bounds=(0, 0, 100, 100))
|
||||
danger_map.precompute([])
|
||||
return CostEvaluator(engine, danger_map)
|
||||
|
||||
def test_astar_straight(basic_evaluator) -> None:
|
||||
router = AStarRouter(basic_evaluator)
|
||||
start = Port(0, 0, 0)
|
||||
target = Port(50, 0, 0)
|
||||
path = router.route(start, target, net_width=2.0)
|
||||
|
||||
assert path is not None
|
||||
assert len(path) > 0
|
||||
# Final port should be target
|
||||
assert abs(path[-1].end_port.x - 50.0) < 1e-6
|
||||
assert path[-1].end_port.y == 0.0
|
||||
|
||||
def test_astar_bend(basic_evaluator) -> None:
|
||||
router = AStarRouter(basic_evaluator)
|
||||
start = Port(0, 0, 0)
|
||||
target = Port(20, 20, 90)
|
||||
path = router.route(start, target, net_width=2.0)
|
||||
|
||||
assert path is not None
|
||||
assert any("Bend90" in str(res) or hasattr(res, 'geometry') for res in path) # Loose check
|
||||
assert abs(path[-1].end_port.x - 20.0) < 1e-6
|
||||
assert abs(path[-1].end_port.y - 20.0) < 1e-6
|
||||
assert path[-1].end_port.orientation == 90.0
|
||||
|
||||
def test_astar_obstacle(basic_evaluator) -> None:
|
||||
# Add an obstacle in the middle of a straight path
|
||||
obstacle = Polygon([(20, -5), (30, -5), (30, 5), (20, 5)])
|
||||
basic_evaluator.collision_engine.add_static_obstacle(obstacle)
|
||||
basic_evaluator.danger_map.precompute([obstacle])
|
||||
|
||||
router = AStarRouter(basic_evaluator)
|
||||
start = Port(0, 0, 0)
|
||||
target = Port(50, 0, 0)
|
||||
path = router.route(start, target, net_width=2.0)
|
||||
|
||||
assert path is not None
|
||||
# Path should have diverted (check that it's not a single straight)
|
||||
# The path should go around the 5um half-width obstacle.
|
||||
# Total wire length should be > 50.
|
||||
sum(np.sqrt((p.end_port.x - p.geometry[0].bounds[0])**2 + (p.end_port.y - p.geometry[0].bounds[1])**2) for p in path)
|
||||
# That's a rough length estimate.
|
||||
# Better: check that no part of the path collides.
|
||||
for res in path:
|
||||
for poly in res.geometry:
|
||||
assert not poly.intersects(obstacle)
|
||||
|
||||
def test_astar_snap_to_target_lookahead(basic_evaluator) -> None:
|
||||
router = AStarRouter(basic_evaluator)
|
||||
# Target is NOT on 1um grid
|
||||
start = Port(0, 0, 0)
|
||||
target = Port(10.005, 0, 0)
|
||||
path = router.route(start, target, net_width=2.0)
|
||||
|
||||
assert path is not None
|
||||
assert abs(path[-1].end_port.x - 10.005) < 1e-6
|
||||
59
inire/tests/test_collision.py
Normal file
59
inire/tests/test_collision.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
from shapely.geometry import Polygon
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.geometry.collision import CollisionEngine
|
||||
|
||||
def test_collision_detection() -> None:
|
||||
# Clearance = 2um
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
|
||||
# Static obstacle at (10, 10) with size 5x5
|
||||
obstacle = Polygon([(10,10), (15,10), (15,15), (10,15)])
|
||||
engine.add_static_obstacle(obstacle)
|
||||
|
||||
# Net width = 2um
|
||||
# Dilation = (W+C)/2 = (2+2)/2 = 2.0um
|
||||
|
||||
# 1. Direct hit
|
||||
test_poly = Polygon([(12,12), (13,12), (13,13), (12,13)])
|
||||
assert engine.is_collision(test_poly, net_width=2.0) is True
|
||||
|
||||
# 2. Far away
|
||||
test_poly_far = Polygon([(0,0), (5,0), (5,5), (0,5)])
|
||||
assert engine.is_collision(test_poly_far, net_width=2.0) is False
|
||||
|
||||
# 3. Near hit (within clearance)
|
||||
# Obstacle is at (10,10).
|
||||
# test_poly is at (8,10) to (9,15).
|
||||
# Centerline at 8.5. Distance to 10 is 1.5.
|
||||
# Required distance (Wi+C)/2 = 2.0. Collision!
|
||||
test_poly_near = Polygon([(8,10), (9,10), (9,15), (8,15)])
|
||||
assert engine.is_collision(test_poly_near, net_width=2.0) is True
|
||||
|
||||
def test_safety_zone() -> None:
|
||||
# Use zero clearance for this test to verify the 2nm port safety zone
|
||||
# against the physical obstacle boundary.
|
||||
engine = CollisionEngine(clearance=0.0)
|
||||
obstacle = Polygon([(10,10), (15,10), (15,15), (10,15)])
|
||||
engine.add_static_obstacle(obstacle)
|
||||
|
||||
# Port exactly on the boundary (x=10)
|
||||
start_port = Port(10.0, 12.0, 0.0)
|
||||
|
||||
# A very narrow waveguide (1nm width) that overlaps by 1nm.
|
||||
# Overlap is from x=10 to x=10.001, y=11.9995 to 12.0005.
|
||||
# This fits entirely within a 2nm radius of (10.0, 12.0).
|
||||
test_poly = Polygon([(9.999, 11.9995), (10.001, 11.9995), (10.001, 12.0005), (9.999, 12.0005)])
|
||||
|
||||
assert engine.is_collision(test_poly, net_width=0.001, start_port=start_port) is False
|
||||
|
||||
def test_configurable_max_net_width() -> None:
|
||||
# Large max_net_width (10.0) -> large pre-dilation (6.0)
|
||||
engine = CollisionEngine(clearance=2.0, max_net_width=10.0)
|
||||
obstacle = Polygon([(20, 20), (25, 20), (25, 25), (20, 25)])
|
||||
engine.add_static_obstacle(obstacle)
|
||||
|
||||
test_poly = Polygon([(15, 20), (16, 20), (16, 25), (15, 25)])
|
||||
# physical check: dilated test_poly by C/2 = 1.0.
|
||||
# Dilated test_poly bounds: (14, 19, 17, 26).
|
||||
# obstacle: (20, 20, 25, 25). No physical collision.
|
||||
assert engine.is_collision(test_poly, net_width=2.0) is False
|
||||
75
inire/tests/test_components.py
Normal file
75
inire/tests/test_components.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import pytest
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.geometry.components import Straight, Bend90, SBend
|
||||
|
||||
def test_straight_generation() -> None:
|
||||
start = Port(0, 0, 0)
|
||||
length = 10.0
|
||||
width = 2.0
|
||||
result = Straight.generate(start, length, width)
|
||||
|
||||
# End port check
|
||||
assert result.end_port.x == 10.0
|
||||
assert result.end_port.y == 0.0
|
||||
assert result.end_port.orientation == 0.0
|
||||
|
||||
# Geometry check
|
||||
poly = result.geometry[0]
|
||||
assert poly.area == length * width
|
||||
# Check bounds
|
||||
minx, miny, maxx, maxy = poly.bounds
|
||||
assert minx == 0.0
|
||||
assert maxx == 10.0
|
||||
assert miny == -1.0
|
||||
assert maxy == 1.0
|
||||
|
||||
def test_bend90_generation() -> None:
|
||||
start = Port(0, 0, 0)
|
||||
radius = 10.0
|
||||
width = 2.0
|
||||
# CW bend (0 -> 270)
|
||||
result_cw = Bend90.generate(start, radius, width, direction='CW')
|
||||
|
||||
# End port (center is at (0, -10))
|
||||
# End port is at (10, -10) relative to center if it was 90-degree turn?
|
||||
# No, from center (0, -10), start is (0, 0) which is 90 deg.
|
||||
# Turn -90 deg -> end is at 0 deg from center -> (10, -10)
|
||||
assert result_cw.end_port.x == 10.0
|
||||
assert result_cw.end_port.y == -10.0
|
||||
assert result_cw.end_port.orientation == 270.0
|
||||
|
||||
# CCW bend (0 -> 90)
|
||||
result_ccw = Bend90.generate(start, radius, width, direction='CCW')
|
||||
assert result_ccw.end_port.x == 10.0
|
||||
assert result_ccw.end_port.y == 10.0
|
||||
assert result_ccw.end_port.orientation == 90.0
|
||||
|
||||
def test_sbend_generation() -> None:
|
||||
start = Port(0, 0, 0)
|
||||
offset = 5.0
|
||||
radius = 10.0
|
||||
width = 2.0
|
||||
result = SBend.generate(start, offset, radius, width)
|
||||
|
||||
# End port check
|
||||
assert result.end_port.y == 5.0
|
||||
assert result.end_port.orientation == 0.0
|
||||
|
||||
# Geometry check (two arcs)
|
||||
assert len(result.geometry) == 2
|
||||
|
||||
# Verify failure for large offset
|
||||
with pytest.raises(ValueError):
|
||||
SBend.generate(start, 25.0, 10.0, 2.0)
|
||||
|
||||
def test_bend_snapping() -> None:
|
||||
# Radius that results in non-integer coords
|
||||
radius = 10.1234
|
||||
start = Port(0, 0, 0)
|
||||
result = Bend90.generate(start, radius, 2.0, direction='CCW')
|
||||
# End port should be snapped to 1µm (SEARCH_GRID_SNAP_UM)
|
||||
# ex = 10.1234, ey = 10.1234
|
||||
# snapped: ex = 10.0, ey = 10.0 if we round to nearest 1.0?
|
||||
# SEARCH_GRID_SNAP_UM = 1.0
|
||||
assert result.end_port.x == 10.0
|
||||
assert result.end_port.y == 10.0
|
||||
70
inire/tests/test_congestion.py
Normal file
70
inire/tests/test_congestion.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import pytest
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.geometry.collision import CollisionEngine
|
||||
from inire.router.danger_map import DangerMap
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.astar import AStarRouter
|
||||
from inire.router.pathfinder import PathFinder
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
@pytest.fixture
|
||||
def basic_evaluator():
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
# Wider bounds to allow going around (y from -40 to 40)
|
||||
danger_map = DangerMap(bounds=(0, -40, 100, 40))
|
||||
danger_map.precompute([])
|
||||
return CostEvaluator(engine, danger_map)
|
||||
|
||||
def test_astar_sbend(basic_evaluator) -> None:
|
||||
router = AStarRouter(basic_evaluator)
|
||||
# Start at (0,0), target at (50, 3) -> 3um lateral offset
|
||||
start = Port(0, 0, 0)
|
||||
target = Port(50, 3, 0)
|
||||
path = router.route(start, target, net_width=2.0)
|
||||
|
||||
assert path is not None
|
||||
# Check if any component in the path is an SBend
|
||||
found_sbend = False
|
||||
for res in path:
|
||||
# SBend should align us with the target y=3
|
||||
if abs(res.end_port.y - 3.0) < 1e-6 and res.end_port.orientation == 0:
|
||||
found_sbend = True
|
||||
assert found_sbend
|
||||
|
||||
def test_pathfinder_negotiated_congestion_resolution(basic_evaluator) -> None:
|
||||
router = AStarRouter(basic_evaluator)
|
||||
pf = PathFinder(router, basic_evaluator)
|
||||
pf.max_iterations = 10
|
||||
|
||||
netlist = {
|
||||
"net1": (Port(0, 0, 0), Port(50, 0, 0)),
|
||||
"net2": (Port(0, 10, 0), Port(50, 10, 0))
|
||||
}
|
||||
net_widths = {"net1": 2.0, "net2": 2.0}
|
||||
|
||||
# Tiny obstacles to block net1 and net2 direct paths?
|
||||
# No, let's block the space BETWEEN them so they must choose
|
||||
# to either stay far apart or squeeze together.
|
||||
# Actually, let's block their direct paths and force them
|
||||
# into a narrow corridor that only fits ONE.
|
||||
|
||||
# Obstacles creating a wide wall with a narrow 2um gap at y=5.
|
||||
# Gap y: 4 to 6. Center y=5.
|
||||
# Net 1 (y=0) and Net 2 (y=10) both want to go to y=5 to pass.
|
||||
# But only ONE fits at y=5.
|
||||
|
||||
obs_top = Polygon([(20, 6), (30, 6), (30, 30), (20, 30)])
|
||||
obs_bottom = Polygon([(20, 4), (30, 4), (30, -30), (20, -30)])
|
||||
basic_evaluator.collision_engine.add_static_obstacle(obs_top)
|
||||
basic_evaluator.collision_engine.add_static_obstacle(obs_bottom)
|
||||
basic_evaluator.danger_map.precompute([obs_top, obs_bottom])
|
||||
|
||||
# Increase base penalty to force detour immediately
|
||||
pf.base_congestion_penalty = 1000.0
|
||||
|
||||
results = pf.route_all(netlist, net_widths)
|
||||
|
||||
assert results["net1"].is_valid
|
||||
assert results["net2"].is_valid
|
||||
assert results["net1"].collisions == 0
|
||||
assert results["net2"].collisions == 0
|
||||
36
inire/tests/test_cost.py
Normal file
36
inire/tests/test_cost.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
from shapely.geometry import Polygon
|
||||
from inire.geometry.collision import CollisionEngine
|
||||
from inire.router.danger_map import DangerMap
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.geometry.primitives import Port
|
||||
|
||||
def test_cost_calculation() -> None:
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
# 50x50 um area, 1um resolution
|
||||
danger_map = DangerMap(bounds=(0, 0, 50, 50), resolution=1.0, safety_threshold=10.0, k=1.0)
|
||||
|
||||
# Add a central obstacle
|
||||
# Grid cells are indexed from self.minx.
|
||||
obstacle = Polygon([(20,20), (30,20), (30,30), (20,30)])
|
||||
danger_map.precompute([obstacle])
|
||||
|
||||
evaluator = CostEvaluator(engine, danger_map)
|
||||
|
||||
# 1. Cost far from obstacle
|
||||
cost_far = evaluator.g_proximity(5.0, 5.0)
|
||||
assert cost_far == 0.0
|
||||
|
||||
# 2. Cost near obstacle (d=1.0)
|
||||
# Cell center (20.5, 20.5) is inside. Cell (19.5, 20.5) center to boundary (20, 20.5) is 0.5.
|
||||
# Scipy EDT gives distance to mask=False.
|
||||
cost_near = evaluator.g_proximity(19.0, 25.0)
|
||||
assert cost_near > 0.0
|
||||
|
||||
# 3. Collision cost
|
||||
engine.add_static_obstacle(obstacle)
|
||||
test_poly = Polygon([(22, 22), (23, 22), (23, 23), (22, 23)])
|
||||
# end_port at (22.5, 22.5)
|
||||
move_cost = evaluator.evaluate_move(
|
||||
[test_poly], Port(22.5, 22.5, 0), net_width=2.0, net_id="net1"
|
||||
)
|
||||
assert move_cost == 1e9
|
||||
63
inire/tests/test_fuzz.py
Normal file
63
inire/tests/test_fuzz.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import pytest
|
||||
from hypothesis import given, settings, strategies as st
|
||||
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 RoutingResult
|
||||
from inire.utils.validation import validate_routing_result
|
||||
|
||||
|
||||
@st.composite
|
||||
def random_obstacle(draw):
|
||||
x = draw(st.floats(min_value=0, max_value=20))
|
||||
y = draw(st.floats(min_value=0, max_value=20))
|
||||
w = draw(st.floats(min_value=1, max_value=5))
|
||||
h = draw(st.floats(min_value=1, max_value=5))
|
||||
return Polygon([(x, y), (x + w, y), (x + w, y + h), (x, y + h)])
|
||||
|
||||
|
||||
@st.composite
|
||||
def random_port(draw):
|
||||
x = draw(st.floats(min_value=0, max_value=20))
|
||||
y = draw(st.floats(min_value=0, max_value=20))
|
||||
orientation = draw(st.sampled_from([0, 90, 180, 270]))
|
||||
return Port(x, y, orientation)
|
||||
|
||||
|
||||
@settings(max_examples=3, deadline=None)
|
||||
@given(obstacles=st.lists(random_obstacle(), min_size=0, max_size=3), start=random_port(), target=random_port())
|
||||
def test_fuzz_astar_no_crash(obstacles, start, target) -> None:
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
for obs in obstacles:
|
||||
engine.add_static_obstacle(obs)
|
||||
|
||||
danger_map = DangerMap(bounds=(0, 0, 30, 30))
|
||||
danger_map.precompute(obstacles)
|
||||
|
||||
evaluator = CostEvaluator(engine, danger_map)
|
||||
router = AStarRouter(evaluator)
|
||||
router.node_limit = 5000 # Lower limit for fuzzing stability
|
||||
|
||||
# Check if start/target are inside obstacles (safety zone check)
|
||||
# The router should handle this gracefully (either route or return None)
|
||||
try:
|
||||
path = router.route(start, target, net_width=2.0)
|
||||
|
||||
# Analytic Correctness: if path is returned, verify it's collision-free
|
||||
if path:
|
||||
result = RoutingResult(net_id="default", path=path, is_valid=True, collisions=0)
|
||||
validation = validate_routing_result(
|
||||
result,
|
||||
obstacles,
|
||||
clearance=2.0,
|
||||
start_port_coord=(start.x, start.y),
|
||||
end_port_coord=(target.x, target.y),
|
||||
)
|
||||
assert validation["is_valid"], f"Validation failed: {validation.get('reason')}"
|
||||
except Exception as e:
|
||||
# Unexpected exceptions are failures
|
||||
pytest.fail(f"Router crashed with {type(e).__name__}: {e}")
|
||||
51
inire/tests/test_pathfinder.py
Normal file
51
inire/tests/test_pathfinder.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import pytest
|
||||
from inire.geometry.primitives import Port
|
||||
from inire.geometry.collision import CollisionEngine
|
||||
from inire.router.danger_map import DangerMap
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.astar import AStarRouter
|
||||
from inire.router.pathfinder import PathFinder
|
||||
|
||||
@pytest.fixture
|
||||
def basic_evaluator():
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
danger_map = DangerMap(bounds=(0, 0, 100, 100))
|
||||
danger_map.precompute([])
|
||||
return CostEvaluator(engine, danger_map)
|
||||
|
||||
def test_pathfinder_parallel(basic_evaluator) -> None:
|
||||
router = AStarRouter(basic_evaluator)
|
||||
pf = PathFinder(router, basic_evaluator)
|
||||
|
||||
netlist = {
|
||||
"net1": (Port(0, 0, 0), Port(50, 0, 0)),
|
||||
"net2": (Port(0, 10, 0), Port(50, 10, 0))
|
||||
}
|
||||
net_widths = {"net1": 2.0, "net2": 2.0}
|
||||
|
||||
results = pf.route_all(netlist, net_widths)
|
||||
|
||||
assert results["net1"].is_valid
|
||||
assert results["net2"].is_valid
|
||||
assert results["net1"].collisions == 0
|
||||
assert results["net2"].collisions == 0
|
||||
|
||||
def test_pathfinder_congestion(basic_evaluator) -> None:
|
||||
router = AStarRouter(basic_evaluator)
|
||||
pf = PathFinder(router, basic_evaluator)
|
||||
|
||||
# Net1 blocks Net2
|
||||
netlist = {
|
||||
"net1": (Port(0, 0, 0), Port(50, 0, 0)),
|
||||
"net2": (Port(25, -10, 90), Port(25, 10, 90))
|
||||
}
|
||||
net_widths = {"net1": 2.0, "net2": 2.0}
|
||||
|
||||
results = pf.route_all(netlist, net_widths)
|
||||
|
||||
# Verify both nets are valid and collision-free
|
||||
assert results["net1"].is_valid
|
||||
assert results["net2"].is_valid
|
||||
assert results["net1"].collisions == 0
|
||||
assert results["net2"].collisions == 0
|
||||
|
||||
43
inire/tests/test_primitives.py
Normal file
43
inire/tests/test_primitives.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
from hypothesis import given, strategies as st
|
||||
from inire.geometry.primitives import Port, translate_port, rotate_port
|
||||
|
||||
@st.composite
|
||||
def port_strategy(draw):
|
||||
x = draw(st.floats(min_value=-1e6, max_value=1e6))
|
||||
y = draw(st.floats(min_value=-1e6, max_value=1e6))
|
||||
orientation = draw(st.sampled_from([0, 90, 180, 270]))
|
||||
return Port(x, y, orientation)
|
||||
|
||||
def test_port_snapping() -> None:
|
||||
p = Port(0.123456, 0.654321, 90)
|
||||
assert p.x == 0.123
|
||||
assert p.y == 0.654
|
||||
assert p.orientation == 90.0
|
||||
|
||||
@given(p=port_strategy())
|
||||
def test_port_transform_invariants(p) -> None:
|
||||
# Rotating 90 degrees 4 times should return to same orientation
|
||||
p_rot = p
|
||||
for _ in range(4):
|
||||
p_rot = rotate_port(p_rot, 90)
|
||||
|
||||
assert p_rot.orientation == p.orientation
|
||||
# Coordinates should be close (floating point error) but snapped to 1nm
|
||||
assert abs(p_rot.x - p.x) < 1e-9
|
||||
assert abs(p_rot.y - p.y) < 1e-9
|
||||
|
||||
@given(p=port_strategy(), dx=st.floats(min_value=-1000, max_value=1000), dy=st.floats(min_value=-1000, max_value=1000))
|
||||
def test_translate_snapping(p, dx, dy) -> None:
|
||||
p_trans = translate_port(p, dx, dy)
|
||||
# Check that snapped result is indeed multiple of GRID_SNAP_UM (0.001 um = 1nm)
|
||||
# Multiplication is more stable for this check
|
||||
assert abs(p_trans.x * 1000 - round(p_trans.x * 1000)) < 1e-6
|
||||
assert abs(p_trans.y * 1000 - round(p_trans.y * 1000)) < 1e-6
|
||||
|
||||
def test_orientation_normalization() -> None:
|
||||
p = Port(0, 0, 360)
|
||||
assert p.orientation == 0.0
|
||||
p2 = Port(0, 0, -90)
|
||||
assert p2.orientation == 270.0
|
||||
p3 = Port(0, 0, 95) # Should snap to 90
|
||||
assert p3.orientation == 90.0
|
||||
60
inire/tests/test_refinements.py
Normal file
60
inire/tests/test_refinements.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
from inire.geometry.primitives import Port
|
||||
from inire.geometry.collision import CollisionEngine
|
||||
from inire.router.danger_map import DangerMap
|
||||
from inire.router.cost import CostEvaluator
|
||||
from inire.router.astar import AStarRouter
|
||||
from inire.router.pathfinder import PathFinder
|
||||
from inire.geometry.components import Bend90
|
||||
|
||||
def test_arc_resolution_sagitta() -> None:
|
||||
start = Port(0, 0, 0)
|
||||
# R=10, 90 deg bend.
|
||||
# High tolerance (0.5um) -> few segments
|
||||
res_coarse = Bend90.generate(start, radius=10.0, width=2.0, sagitta=0.5)
|
||||
# Low tolerance (0.001um = 1nm) -> many segments
|
||||
res_fine = Bend90.generate(start, radius=10.0, width=2.0, sagitta=0.001)
|
||||
|
||||
# Check number of points in the polygon exterior
|
||||
# (num_segments + 1) * 2 points usually
|
||||
pts_coarse = len(res_coarse.geometry[0].exterior.coords)
|
||||
pts_fine = len(res_fine.geometry[0].exterior.coords)
|
||||
|
||||
assert pts_fine > pts_coarse
|
||||
|
||||
def test_locked_paths() -> None:
|
||||
engine = CollisionEngine(clearance=2.0)
|
||||
danger_map = DangerMap(bounds=(0, -50, 100, 50))
|
||||
danger_map.precompute([])
|
||||
evaluator = CostEvaluator(engine, danger_map)
|
||||
router = AStarRouter(evaluator)
|
||||
pf = PathFinder(router, evaluator)
|
||||
|
||||
# 1. Route Net A
|
||||
netlist_a = {"netA": (Port(0, 0, 0), Port(50, 0, 0))}
|
||||
results_a = pf.route_all(netlist_a, {"netA": 2.0})
|
||||
assert results_a["netA"].is_valid
|
||||
|
||||
# 2. Lock Net A
|
||||
engine.lock_net("netA")
|
||||
|
||||
# 3. Route Net B through the same space. It should detour or fail.
|
||||
# We'll place Net B's start/target such that it MUST cross Net A's physical path.
|
||||
netlist_b = {"netB": (Port(0, -5, 0), Port(50, 5, 0))}
|
||||
|
||||
# Route Net B
|
||||
results_b = pf.route_all(netlist_b, {"netB": 2.0})
|
||||
|
||||
# Net B should be is_valid (it detoured) or at least not have collisions
|
||||
# with Net A in the dynamic set (because netA is now static).
|
||||
# Since netA is static, netB will see it as a HARD collision if it tries to cross.
|
||||
# Our A* will find a detour around the static obstacle.
|
||||
assert results_b["netB"].is_valid
|
||||
|
||||
# Verify geometry doesn't intersect locked netA (physical check)
|
||||
poly_a = [p.geometry[0] for p in results_a["netA"].path]
|
||||
poly_b = [p.geometry[0] for p in results_b["netB"].path]
|
||||
|
||||
for pa in poly_a:
|
||||
for pb in poly_b:
|
||||
# Check physical clearance
|
||||
assert not pa.buffer(1.0).intersects(pb.buffer(1.0))
|
||||
57
inire/utils/validation.py
Normal file
57
inire/utils/validation.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shapely.geometry import Point
|
||||
from shapely.ops import unary_union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
from inire.router.pathfinder import RoutingResult
|
||||
|
||||
|
||||
def validate_routing_result(
|
||||
result: RoutingResult,
|
||||
static_obstacles: list[Polygon],
|
||||
clearance: float,
|
||||
start_port_coord: tuple[float, float] | None = None,
|
||||
end_port_coord: tuple[float, float] | None = None,
|
||||
) -> dict[str, any]:
|
||||
"""
|
||||
Perform a high-precision validation of a routed path.
|
||||
Returns a dictionary with validation results.
|
||||
"""
|
||||
if not result.path:
|
||||
return {"is_valid": False, "reason": "No path found"}
|
||||
|
||||
collision_geoms = []
|
||||
# High-precision safety zones
|
||||
safe_zones = []
|
||||
if start_port_coord:
|
||||
safe_zones.append(Point(start_port_coord).buffer(0.002))
|
||||
if end_port_coord:
|
||||
safe_zones.append(Point(end_port_coord).buffer(0.002))
|
||||
safe_poly = unary_union(safe_zones) if safe_zones else None
|
||||
|
||||
# Buffer by C/2
|
||||
dilation = clearance / 2.0
|
||||
|
||||
for comp in result.path:
|
||||
for poly in comp.geometry:
|
||||
dilated = poly.buffer(dilation)
|
||||
for obs in static_obstacles:
|
||||
if dilated.intersects(obs):
|
||||
intersection = dilated.intersection(obs)
|
||||
if safe_poly:
|
||||
# Remove safe zones from intersection
|
||||
intersection = intersection.difference(safe_poly)
|
||||
|
||||
if not intersection.is_empty and intersection.area > 1e-9:
|
||||
collision_geoms.append(intersection)
|
||||
|
||||
return {
|
||||
"is_valid": len(collision_geoms) == 0,
|
||||
"collisions": collision_geoms,
|
||||
"collision_count": len(collision_geoms),
|
||||
}
|
||||
45
inire/utils/visualization.py
Normal file
45
inire/utils/visualization.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from matplotlib.axes import Axes
|
||||
from matplotlib.figure import Figure
|
||||
from shapely.geometry import Polygon
|
||||
|
||||
from inire.router.pathfinder import RoutingResult
|
||||
|
||||
|
||||
def plot_routing_results(
|
||||
results: dict[str, RoutingResult],
|
||||
static_obstacles: list[Polygon],
|
||||
bounds: tuple[float, float, float, float],
|
||||
) -> tuple[Figure, Axes]:
|
||||
"""Plot obstacles and routed paths using matplotlib."""
|
||||
fig, ax = plt.subplots(figsize=(10, 10))
|
||||
|
||||
# Plot static obstacles (gray)
|
||||
for poly in static_obstacles:
|
||||
x, y = poly.exterior.xy
|
||||
ax.fill(x, y, alpha=0.5, fc="gray", ec="black")
|
||||
|
||||
# Plot paths
|
||||
colors = plt.get_cmap("tab10")
|
||||
for i, (net_id, res) in enumerate(results.items()):
|
||||
color = colors(i)
|
||||
if not res.is_valid:
|
||||
color = "red" # Highlight failing nets
|
||||
|
||||
for comp in res.path:
|
||||
for poly in comp.geometry:
|
||||
x, y = poly.exterior.xy
|
||||
ax.fill(x, y, alpha=0.7, fc=color, ec="black", label=net_id if i == 0 else "")
|
||||
|
||||
ax.set_xlim(bounds[0], bounds[2])
|
||||
ax.set_ylim(bounds[1], bounds[3])
|
||||
ax.set_aspect("equal")
|
||||
ax.set_title("Inire Routing Results")
|
||||
plt.grid(True)
|
||||
return fig, ax
|
||||
Loading…
Add table
Add a link
Reference in a new issue