Add item hierarchy, BasicBoard, and DSN->board construction
Ports the data model of the board Item hierarchy (Item base, Pin, ObstacleArea/ConductionArea, Via, Trace) and BasicBoard (layers, nets, clearance, bounding box, items with query helpers), then build_board: the load-bearing integration that constructs a BasicBoard from a parsed DsnBoard. build_board maps layers -> LayerStructure, resolution -> transform, default clearance rule -> ClearanceMatrix, nets -> Nets plus a (component,pin)->net map, padstacks x placement -> Pin items, and keepouts -> ObstacleArea items. Rectangle pads become exact IntBoxes, convex polygon pads exact Simplexes, circle pads their bounding box (documented approximation); every pad is centred on its pin location so it contains that location by construction. Trace/Via are router-produced and lightweight here (an imported unrouted board has none). Validated on a real KiCad export (kicad_routable.dsn): layer/net/pin counts match the parsed DSN, every pin's pad shape contains its origin, every pin reports a valid net, and pin locations lie in the board bounding box. An oracle-gated test cross-checks that every net the reference FreeRouting JAR routes exists on the constructed board.
This commit is contained in:
parent
dd40aaba5d
commit
4220fe0cd6
39
src/freeroute/board/__init__.py
Normal file
39
src/freeroute/board/__init__.py
Normal file
@ -0,0 +1,39 @@
|
||||
"""Board data model for freeroute.
|
||||
|
||||
The data model a parsed DSN produces and the (future) router operates on:
|
||||
layers, units, coordinate transform, nets, clearance matrix, the item hierarchy,
|
||||
and the :class:`BasicBoard` container. :func:`build_board` constructs a board
|
||||
from a :class:`~freeroute.dsn.model.DsnBoard`.
|
||||
|
||||
The routing operations (``RoutingBoard``) and the spatial ``ShapeSearchTree``
|
||||
belong to the router phase.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .board import BasicBoard
|
||||
from .build import build_board
|
||||
from .clearance import ClearanceMatrix
|
||||
from .items import ConductionArea, Item, ObstacleArea, Pin, Trace, Via
|
||||
from .layer import Layer, LayerStructure
|
||||
from .net import Net, Nets
|
||||
from .transform import CoordinateTransform
|
||||
from .unit import Unit
|
||||
|
||||
__all__ = [
|
||||
"Unit",
|
||||
"Layer",
|
||||
"LayerStructure",
|
||||
"CoordinateTransform",
|
||||
"Net",
|
||||
"Nets",
|
||||
"ClearanceMatrix",
|
||||
"Item",
|
||||
"Pin",
|
||||
"ObstacleArea",
|
||||
"ConductionArea",
|
||||
"Via",
|
||||
"Trace",
|
||||
"BasicBoard",
|
||||
"build_board",
|
||||
]
|
||||
70
src/freeroute/board/board.py
Normal file
70
src/freeroute/board/board.py
Normal file
@ -0,0 +1,70 @@
|
||||
"""``BasicBoard`` — the board container.
|
||||
|
||||
Ports the data-model core of ``board/BasicBoard.java``: the layer stack, the
|
||||
net list, the clearance matrix, the bounding box, and the list of items with
|
||||
insertion and query helpers. The routing operations live in ``RoutingBoard``
|
||||
(the router phase); this class is what DSN import produces and what the
|
||||
board-model invariants check.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from freeroute.geometry import IntBox
|
||||
|
||||
from .clearance import ClearanceMatrix
|
||||
from .items import Item, ObstacleArea, Pin, Trace, Via
|
||||
from .layer import LayerStructure
|
||||
from .net import Nets
|
||||
|
||||
__all__ = ["BasicBoard"]
|
||||
|
||||
|
||||
class BasicBoard:
|
||||
"""A board: layers, nets, clearances, a bounding box, and items."""
|
||||
|
||||
__slots__ = ("layer_structure", "nets", "clearance_matrix", "bounding_box", "_items")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
layer_structure: LayerStructure,
|
||||
nets: Nets,
|
||||
clearance_matrix: ClearanceMatrix,
|
||||
bounding_box: IntBox,
|
||||
) -> None:
|
||||
self.layer_structure = layer_structure
|
||||
self.nets = nets
|
||||
self.clearance_matrix = clearance_matrix
|
||||
self.bounding_box = bounding_box
|
||||
self._items: list[Item] = []
|
||||
|
||||
# --- layers -------------------------------------------------------------
|
||||
|
||||
def get_layer_count(self) -> int:
|
||||
return len(self.layer_structure)
|
||||
|
||||
# --- items --------------------------------------------------------------
|
||||
|
||||
def insert(self, item: Item) -> Item:
|
||||
self._items.append(item)
|
||||
return item
|
||||
|
||||
def get_items(self) -> list[Item]:
|
||||
return list(self._items)
|
||||
|
||||
def get_pins(self) -> list[Pin]:
|
||||
return [it for it in self._items if isinstance(it, Pin)]
|
||||
|
||||
def get_obstacle_areas(self) -> list[ObstacleArea]:
|
||||
return [it for it in self._items if isinstance(it, ObstacleArea)]
|
||||
|
||||
def get_traces(self) -> list[Trace]:
|
||||
return [it for it in self._items if isinstance(it, Trace)]
|
||||
|
||||
def get_vias(self) -> list[Via]:
|
||||
return [it for it in self._items if isinstance(it, Via)]
|
||||
|
||||
def get_connectable_items(self, net_no: int) -> list[Item]:
|
||||
return [it for it in self._items if it.contains_net(net_no)]
|
||||
|
||||
def item_count(self) -> int:
|
||||
return len(self._items)
|
||||
271
src/freeroute/board/build.py
Normal file
271
src/freeroute/board/build.py
Normal file
@ -0,0 +1,271 @@
|
||||
"""Build a :class:`BasicBoard` from a parsed DSN :class:`DsnBoard`.
|
||||
|
||||
This is the load-bearing integration between :mod:`freeroute.dsn` and the board
|
||||
model. It mirrors what FreeRouting's ``Structure.create_board`` / ``Library`` /
|
||||
``Component`` / ``Network`` readers do collectively, but on the already-parsed
|
||||
``DsnBoard``:
|
||||
|
||||
* layers -> :class:`LayerStructure`
|
||||
* resolution -> :class:`CoordinateTransform` (``scale_factor = resolution``)
|
||||
* default clearance rule -> :class:`ClearanceMatrix`
|
||||
* nets -> :class:`Nets` (+ a ``(component, pin) -> net`` map)
|
||||
* padstacks x placement -> :class:`Pin` items
|
||||
* keepouts -> :class:`ObstacleArea` items
|
||||
|
||||
Pad geometry: a rectangle pad becomes an exact :class:`IntBox`; a circle pad its
|
||||
bounding box (documented approximation pending an exact circle tile); a convex
|
||||
polygon pad an exact :class:`Simplex`. Every pad is placed centred on its pin
|
||||
location, so it contains that location by construction. Component rotation is
|
||||
applied to the pin position; the pad tile itself is not rotated (irrelevant to
|
||||
the containment invariant, and pads are centre-symmetric in practice).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from freeroute.dsn.model import DsnBoard
|
||||
from freeroute.dsn.shapes import Circle, Path, Polygon, Rectangle, Shape
|
||||
from freeroute.geometry import FloatPoint, IntBox, IntPoint, IntVector, Simplex
|
||||
|
||||
from .board import BasicBoard
|
||||
from .clearance import ClearanceMatrix
|
||||
from .items import ObstacleArea, Pin
|
||||
from .layer import Layer, LayerStructure
|
||||
from .net import Nets
|
||||
from .transform import CoordinateTransform
|
||||
|
||||
__all__ = ["build_board"]
|
||||
|
||||
|
||||
def build_board(dsn: DsnBoard) -> BasicBoard:
|
||||
"""Construct a :class:`BasicBoard` from a parsed :class:`DsnBoard`."""
|
||||
layer_structure = LayerStructure([Layer(layer.name, layer.is_signal) for layer in dsn.layers])
|
||||
scale = max(dsn.resolution.value, 1)
|
||||
transform = CoordinateTransform(scale)
|
||||
|
||||
clearance = _build_clearance(dsn, transform)
|
||||
nets, terminal_to_net = _build_nets(dsn)
|
||||
bounding_box = _build_bounding_box(dsn, transform)
|
||||
|
||||
board = BasicBoard(layer_structure, nets, clearance, bounding_box)
|
||||
|
||||
_insert_pins(dsn, board, transform, terminal_to_net)
|
||||
_insert_keepouts(dsn, board, transform)
|
||||
return board
|
||||
|
||||
|
||||
# --- clearance / nets / bounds ----------------------------------------------
|
||||
|
||||
|
||||
def _build_clearance(dsn: DsnBoard, transform: CoordinateTransform) -> ClearanceMatrix:
|
||||
default_value = 0
|
||||
for rule in dsn.structure_rules.clearance_rules:
|
||||
if not rule.class_pairs: # the layer-wide default clearance
|
||||
default_value = round(transform.dsn_to_board(rule.value))
|
||||
break
|
||||
return ClearanceMatrix.default_instance(default_value)
|
||||
|
||||
|
||||
def _build_nets(dsn: DsnBoard) -> tuple[Nets, dict[tuple[str, str], int]]:
|
||||
nets = Nets()
|
||||
terminal_to_net: dict[tuple[str, str], int] = {}
|
||||
for dsn_net in dsn.nets:
|
||||
net = nets.add(dsn_net.name, dsn_net.subnet, contains_plane=False)
|
||||
net.pins = [(p.component, p.pin) for p in dsn_net.pins]
|
||||
for pin in dsn_net.pins:
|
||||
terminal_to_net[(pin.component, pin.pin)] = net.net_number
|
||||
return nets, terminal_to_net
|
||||
|
||||
|
||||
def _build_bounding_box(dsn: DsnBoard, transform: CoordinateTransform) -> IntBox:
|
||||
coords: list[float] = []
|
||||
shapes: list[Shape] = []
|
||||
if dsn.boundary is not None:
|
||||
shapes.append(dsn.boundary)
|
||||
shapes.extend(dsn.outlines)
|
||||
for shape in shapes:
|
||||
coords.extend(_shape_coords(shape))
|
||||
if not coords:
|
||||
return IntBox.empty()
|
||||
xs = coords[0::2]
|
||||
ys = coords[1::2]
|
||||
llx = round(transform.dsn_to_board(min(xs)))
|
||||
lly = round(transform.dsn_to_board(min(ys)))
|
||||
urx = round(transform.dsn_to_board(max(xs)))
|
||||
ury = round(transform.dsn_to_board(max(ys)))
|
||||
return IntBox(llx, lly, urx, ury)
|
||||
|
||||
|
||||
def _shape_coords(shape: Shape) -> list[float]:
|
||||
if isinstance(shape, Rectangle):
|
||||
return list(shape.coords)
|
||||
if isinstance(shape, Circle):
|
||||
r = shape.diameter / 2
|
||||
return [shape.center_x - r, shape.center_y - r, shape.center_x + r, shape.center_y + r]
|
||||
if isinstance(shape, (Polygon, Path)):
|
||||
return list(shape.coords)
|
||||
return []
|
||||
|
||||
|
||||
# --- pins --------------------------------------------------------------------
|
||||
|
||||
|
||||
def _insert_pins(
|
||||
dsn: DsnBoard,
|
||||
board: BasicBoard,
|
||||
transform: CoordinateTransform,
|
||||
terminal_to_net: dict[tuple[str, str], int],
|
||||
) -> None:
|
||||
images = {img.name: img for img in dsn.images}
|
||||
layer_no = {layer.name: i for i, layer in enumerate(dsn.layers)}
|
||||
signal_layers = [i for i, layer in enumerate(dsn.layers) if layer.is_signal]
|
||||
|
||||
for placement in dsn.placements:
|
||||
image = images.get(placement.lib_name)
|
||||
if image is None:
|
||||
continue
|
||||
for place in placement.places:
|
||||
if place.x is None or place.y is None:
|
||||
continue # component declared but not placed
|
||||
for dsn_pin in image.pins:
|
||||
location = _pin_location(place, dsn_pin, transform)
|
||||
padstack = dsn.padstack(dsn_pin.padstack_name)
|
||||
shape, layers = _pad_shape_and_layers(
|
||||
padstack, location, transform, layer_no, signal_layers
|
||||
)
|
||||
net_no = terminal_to_net.get((place.name, dsn_pin.name))
|
||||
pin = Pin(
|
||||
net_nos=[net_no] if net_no is not None else [],
|
||||
component_no=id(place) & 0xFFFFFF,
|
||||
fixed=True,
|
||||
name=dsn_pin.name,
|
||||
padstack_name=dsn_pin.padstack_name,
|
||||
location=location,
|
||||
layers=layers,
|
||||
shape=shape,
|
||||
)
|
||||
board.insert(pin)
|
||||
|
||||
|
||||
def _pin_location(place, dsn_pin, transform: CoordinateTransform) -> IntPoint:
|
||||
"""Absolute board location of a pin: component origin + rotated pin offset."""
|
||||
rel_x = -dsn_pin.x if not place.is_front else dsn_pin.x # back side mirrors in x
|
||||
rel = FloatPoint(rel_x, dsn_pin.y)
|
||||
rotated = rel.rotate(math.radians(place.rotation), FloatPoint(0.0, 0.0))
|
||||
return transform.dsn_to_board_point(place.x + rotated.x, place.y + rotated.y)
|
||||
|
||||
|
||||
def _pad_shape_and_layers(
|
||||
padstack,
|
||||
location: IntPoint,
|
||||
transform: CoordinateTransform,
|
||||
layer_no: dict[str, int],
|
||||
signal_layers: list[int],
|
||||
) -> tuple[object, list[int]]:
|
||||
"""A pad tile centred on ``location`` plus the layer indices it occupies."""
|
||||
if padstack is None or not padstack.shapes:
|
||||
# degenerate: a zero-size box at the location (still contains it)
|
||||
return IntBox(location.x, location.y, location.x, location.y), list(signal_layers)
|
||||
|
||||
layers: set[int] = set()
|
||||
for shape in padstack.shapes:
|
||||
if shape.layer in ("signal", "pcb"):
|
||||
layers.update(signal_layers)
|
||||
elif shape.layer in layer_no:
|
||||
layers.add(layer_no[shape.layer])
|
||||
if not layers:
|
||||
layers = set(signal_layers)
|
||||
|
||||
tile = _pad_tile(padstack.shapes[0], transform)
|
||||
tile = tile.translate_by(IntVector(location.x, location.y))
|
||||
return tile, sorted(layers)
|
||||
|
||||
|
||||
def _pad_tile(shape: Shape, transform: CoordinateTransform):
|
||||
"""A board-relative pad tile from a DSN padstack shape (centred near origin)."""
|
||||
if isinstance(shape, Rectangle):
|
||||
c = [round(transform.dsn_to_board(v)) for v in shape.coords[:4]]
|
||||
return IntBox(min(c[0], c[2]), min(c[1], c[3]), max(c[0], c[2]), max(c[1], c[3]))
|
||||
if isinstance(shape, Circle):
|
||||
r = round(transform.dsn_to_board(shape.diameter / 2))
|
||||
cx = round(transform.dsn_to_board(shape.center_x))
|
||||
cy = round(transform.dsn_to_board(shape.center_y))
|
||||
return IntBox(cx - r, cy - r, cx + r, cy + r) # bounding-box approximation
|
||||
if isinstance(shape, (Polygon, Path)):
|
||||
coords = shape.coords
|
||||
pts = [
|
||||
IntPoint(
|
||||
round(transform.dsn_to_board(coords[i])),
|
||||
round(transform.dsn_to_board(coords[i + 1])),
|
||||
)
|
||||
for i in range(0, len(coords) - 1, 2)
|
||||
]
|
||||
if len(pts) >= 3:
|
||||
simplex = Simplex.from_corners(pts)
|
||||
if not simplex.is_empty():
|
||||
return simplex
|
||||
xs = [p.x for p in pts] or [0]
|
||||
ys = [p.y for p in pts] or [0]
|
||||
return IntBox(min(xs), min(ys), max(xs), max(ys))
|
||||
return IntBox(0, 0, 0, 0)
|
||||
|
||||
|
||||
# --- keepouts ----------------------------------------------------------------
|
||||
|
||||
|
||||
def _insert_keepouts(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTransform) -> None:
|
||||
layer_no = {layer.name: i for i, layer in enumerate(dsn.layers)}
|
||||
signal_layers = [i for i, layer in enumerate(dsn.layers) if layer.is_signal]
|
||||
|
||||
for keepout in dsn.keepouts:
|
||||
area = keepout.area
|
||||
if area is None or area.border is None:
|
||||
continue
|
||||
tile = _area_border_tile(area.border, transform)
|
||||
if tile is None:
|
||||
continue
|
||||
border_layer = area.border.layer
|
||||
if border_layer in ("signal", "pcb"):
|
||||
layers = signal_layers
|
||||
elif border_layer in layer_no:
|
||||
layers = [layer_no[border_layer]]
|
||||
else:
|
||||
layers = signal_layers
|
||||
for layer_index in layers:
|
||||
board.insert(
|
||||
ObstacleArea(
|
||||
name=keepout.area.name,
|
||||
layer=layer_index,
|
||||
tiles=[tile],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _area_border_tile(shape: Shape, transform: CoordinateTransform):
|
||||
"""Convert an absolute-coordinate keepout border shape to one board tile."""
|
||||
if isinstance(shape, Rectangle):
|
||||
c = [round(transform.dsn_to_board(v)) for v in shape.coords[:4]]
|
||||
return IntBox(min(c[0], c[2]), min(c[1], c[3]), max(c[0], c[2]), max(c[1], c[3]))
|
||||
if isinstance(shape, Circle):
|
||||
r = round(transform.dsn_to_board(shape.diameter / 2))
|
||||
cx = round(transform.dsn_to_board(shape.center_x))
|
||||
cy = round(transform.dsn_to_board(shape.center_y))
|
||||
return IntBox(cx - r, cy - r, cx + r, cy + r)
|
||||
if isinstance(shape, (Polygon, Path)):
|
||||
coords = shape.coords
|
||||
pts = [
|
||||
IntPoint(
|
||||
round(transform.dsn_to_board(coords[i])),
|
||||
round(transform.dsn_to_board(coords[i + 1])),
|
||||
)
|
||||
for i in range(0, len(coords) - 1, 2)
|
||||
]
|
||||
if len(pts) >= 3:
|
||||
simplex = Simplex.from_corners(pts)
|
||||
if not simplex.is_empty():
|
||||
return simplex
|
||||
xs = [p.x for p in pts] or [0]
|
||||
ys = [p.y for p in pts] or [0]
|
||||
return IntBox(min(xs), min(ys), max(xs), max(ys))
|
||||
return None
|
||||
112
src/freeroute/board/items.py
Normal file
112
src/freeroute/board/items.py
Normal file
@ -0,0 +1,112 @@
|
||||
"""Board items — ``Item`` base and the concrete item kinds.
|
||||
|
||||
Ports the *data model* of ``board/Item.java`` and its subclasses (``Pin``,
|
||||
``ObstacleArea``/``ConductionArea``, ``Via``, ``PolylineTrace``). The routing
|
||||
and search-tree machinery in the Java classes is out of scope here — these hold
|
||||
the geometry and connectivity a :class:`~freeroute.board.board.BasicBoard`
|
||||
needs and that the DSN-import invariants check.
|
||||
|
||||
Coordinates are board units (DSN units scaled by the resolution). Shapes are the
|
||||
exact convex tiles / boxes from :mod:`freeroute.geometry`.
|
||||
|
||||
``Trace``/``Via`` are only produced by routing (an imported unrouted board has
|
||||
none); they are lightweight here and the router phase fleshes out
|
||||
``Trace`` with a proper :class:`~freeroute.geometry.Polyline` shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from freeroute.geometry import IntBox, IntPoint
|
||||
|
||||
__all__ = ["Item", "Pin", "ObstacleArea", "ConductionArea", "Via", "Trace"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Item:
|
||||
"""Base class for everything placed on the board.
|
||||
|
||||
``net_nos`` are the board net numbers this item belongs to (empty for
|
||||
unconnected obstacles); ``component_no`` is 0 for board-level items.
|
||||
"""
|
||||
|
||||
net_nos: list[int] = field(default_factory=list)
|
||||
clearance_class: int = 0
|
||||
component_no: int = 0
|
||||
fixed: bool = False
|
||||
|
||||
def contains_net(self, net_no: int) -> bool:
|
||||
return net_no in self.net_nos
|
||||
|
||||
def is_obstacle(self) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Pin(Item):
|
||||
"""A component pin: a pad shape on one or more layers at a fixed location."""
|
||||
|
||||
name: str = ""
|
||||
padstack_name: str = ""
|
||||
location: IntPoint = field(default_factory=lambda: IntPoint(0, 0))
|
||||
#: layer indices the pad occupies (all signal layers when spanning)
|
||||
layers: list[int] = field(default_factory=list)
|
||||
#: the pad tile shape in absolute board coordinates
|
||||
shape: object = None
|
||||
|
||||
def first_layer(self) -> int:
|
||||
return self.layers[0] if self.layers else 0
|
||||
|
||||
def shape_contains_origin(self) -> bool:
|
||||
"""Invariant helper: the pad shape contains the pin's own location."""
|
||||
return self.shape is not None and self.shape.contains(self.location)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ObstacleArea(Item):
|
||||
"""A keepout / obstacle region on one layer, as a list of convex tiles."""
|
||||
|
||||
name: str | None = None
|
||||
layer: int = 0
|
||||
tiles: list = field(default_factory=list)
|
||||
|
||||
def is_obstacle(self) -> bool:
|
||||
return True
|
||||
|
||||
def bounding_box(self) -> IntBox:
|
||||
if not self.tiles:
|
||||
return IntBox.empty()
|
||||
box = self.tiles[0].bounding_box()
|
||||
for tile in self.tiles[1:]:
|
||||
box = box.union(tile.bounding_box())
|
||||
return box
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConductionArea(ObstacleArea):
|
||||
"""A power-plane conduction area (an obstacle carrying a net)."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Via(Item):
|
||||
"""A routed via at a location using a named padstack (router-produced)."""
|
||||
|
||||
padstack_name: str = ""
|
||||
location: IntPoint = field(default_factory=lambda: IntPoint(0, 0))
|
||||
|
||||
|
||||
@dataclass
|
||||
class Trace(Item):
|
||||
"""A routed trace (router-produced).
|
||||
|
||||
Held as its corner points plus a half-width; the router phase attaches the
|
||||
full :class:`~freeroute.geometry.Polyline` swept shape.
|
||||
"""
|
||||
|
||||
layer: int = 0
|
||||
half_width: int = 0
|
||||
corners: list[IntPoint] = field(default_factory=list)
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
return len(self.corners) < 2
|
||||
131
tests/board/test_build.py
Normal file
131
tests/board/test_build.py
Normal file
@ -0,0 +1,131 @@
|
||||
"""DSN -> board construction invariants, validated on a real KiCad board.
|
||||
|
||||
There is no unit-test oracle for the board model, so these assert structural
|
||||
invariants against the parsed DSN (the source of truth) and — when a JVM + JAR
|
||||
are available — cross-check the board's net set against the reference router's
|
||||
routed net set.
|
||||
|
||||
Fixture: ``tests/dsn/fixtures/kicad_routable.dsn`` (a KiCad pcbnew export).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from freeroute.board import BasicBoard, ObstacleArea, Pin, build_board
|
||||
from freeroute.dsn import parse_dsn
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures"
|
||||
ROUTABLE = FIXTURES / "kicad_routable.dsn"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def dsn():
|
||||
return parse_dsn(ROUTABLE.read_text())
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def board(dsn) -> BasicBoard:
|
||||
return build_board(dsn)
|
||||
|
||||
|
||||
# --- structural counts match the DSN ----------------------------------------
|
||||
|
||||
|
||||
def test_layer_count_matches_dsn(dsn, board):
|
||||
assert board.get_layer_count() == len(dsn.layers)
|
||||
assert [layer.name for layer in board.layer_structure.arr] == [
|
||||
layer.name for layer in dsn.layers
|
||||
]
|
||||
|
||||
|
||||
def test_net_count_matches_dsn(dsn, board):
|
||||
assert len(board.nets) == len(dsn.nets)
|
||||
assert board.nets.names() == {n.name for n in dsn.nets}
|
||||
|
||||
|
||||
def test_pin_count_matches_placement(dsn, board):
|
||||
images = {img.name: img for img in dsn.images}
|
||||
expected = sum(
|
||||
len(images[pl.lib_name].pins)
|
||||
for pl in dsn.placements
|
||||
for place in pl.places
|
||||
if place.x is not None and pl.lib_name in images
|
||||
)
|
||||
assert len(board.get_pins()) == expected
|
||||
assert expected > 0
|
||||
|
||||
|
||||
def test_obstacle_count_matches_keepouts(dsn, board):
|
||||
# each keepout expands to one obstacle per applicable layer; with no keepouts
|
||||
# in this fixture the count is zero
|
||||
assert len(board.get_obstacle_areas()) >= len(dsn.keepouts)
|
||||
assert len(dsn.keepouts) == 0
|
||||
assert board.get_obstacle_areas() == []
|
||||
|
||||
|
||||
# --- per-item invariants ----------------------------------------------------
|
||||
|
||||
|
||||
def test_every_pin_shape_contains_its_origin(board):
|
||||
pins = board.get_pins()
|
||||
assert pins
|
||||
for pin in pins:
|
||||
assert pin.shape_contains_origin(), f"pad shape missed origin for pin {pin.name}"
|
||||
|
||||
|
||||
def test_every_pin_reports_a_net(dsn, board):
|
||||
# this board's netlist connects every pad, so every pin has a net number
|
||||
for pin in board.get_pins():
|
||||
assert pin.net_nos, f"pin {pin.name} has no net"
|
||||
for net_no in pin.net_nos:
|
||||
assert board.nets.get_by_number(net_no) is not None
|
||||
|
||||
|
||||
def test_items_report_correct_net_membership(board):
|
||||
for pin in board.get_pins():
|
||||
for net_no in pin.net_nos:
|
||||
assert pin in board.get_connectable_items(net_no)
|
||||
|
||||
|
||||
def test_pin_layers_are_valid(board):
|
||||
n_layers = board.get_layer_count()
|
||||
for pin in board.get_pins():
|
||||
assert pin.layers
|
||||
assert all(0 <= layer < n_layers for layer in pin.layers)
|
||||
|
||||
|
||||
def test_bounding_box_is_non_degenerate(board):
|
||||
assert not board.bounding_box.is_empty()
|
||||
assert board.bounding_box.dimension() == 2
|
||||
# every pin location lies within the board bounding box
|
||||
for pin in board.get_pins():
|
||||
assert board.bounding_box.contains(pin.location)
|
||||
|
||||
|
||||
def test_item_types(board):
|
||||
for item in board.get_items():
|
||||
assert isinstance(item, (Pin, ObstacleArea))
|
||||
|
||||
|
||||
# --- oracle cross-check ------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.oracle
|
||||
def test_board_net_set_covers_oracle_routed_nets(board):
|
||||
from oracle import HAS_ORACLE, route_dsn, routed_net_set
|
||||
|
||||
if not HAS_ORACLE:
|
||||
pytest.skip("FreeRouting oracle unavailable")
|
||||
# Routing the full board is slow; a couple of passes is enough to route most
|
||||
# nets, and a generous timeout absorbs JVM start-up variance.
|
||||
ses = route_dsn(ROUTABLE, max_passes=2, timeout=420)
|
||||
routed = routed_net_set(ses)
|
||||
assert routed, "oracle produced no routed nets"
|
||||
# every net the reference router routed exists on our constructed board
|
||||
assert routed <= board.nets.names()
|
||||
Loading…
x
Reference in New Issue
Block a user