Add board data-model foundation: units, layers, transform, nets, clearance
Ports the foundational board classes: Unit (mil/inch/mm/um with micrometer scaling), Layer/LayerStructure (the layer stack with name and signal-layer lookups), CoordinateTransform (DSN<->board scaling by the resolution), Net/Nets (connectivity keyed by (name, subnet) and a board- unique net number), and ClearanceMatrix (class x class spacing with a reserved null class and a default class). The per-layer axis of ClearanceMatrix is simplified to a single value per class pair (DSN default clearance rules are layer-independent for the boards we build); the router phase can add the layer dimension. Unit-tested: unit scaling/parsing, layer lookups, transform round-trip, net registration/lookup, and clearance default/append/symmetry.
This commit is contained in:
parent
0e508107ba
commit
dd40aaba5d
75
src/freeroute/board/clearance.py
Normal file
75
src/freeroute/board/clearance.py
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
"""``ClearanceMatrix`` — required spacing between clearance classes.
|
||||||
|
|
||||||
|
Ports the data-model core of ``rules/ClearanceMatrix.java``. Each clearance
|
||||||
|
class has a name; the matrix stores the required clearance between every pair of
|
||||||
|
classes. This port keeps a single value per class pair (the DSN default rules
|
||||||
|
are layer-independent for the boards we build); the per-layer axis in the Java
|
||||||
|
source is a refinement the router phase can add.
|
||||||
|
|
||||||
|
Class 0 is the reserved ``null`` class (no clearance); class 1 is ``default``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
__all__ = ["ClearanceMatrix"]
|
||||||
|
|
||||||
|
#: safety margin added on lookup, matching ClearanceMatrix.clearance_safety_margin
|
||||||
|
CLEARANCE_SAFETY_MARGIN = 16
|
||||||
|
|
||||||
|
|
||||||
|
class ClearanceMatrix:
|
||||||
|
"""Symmetric class x class clearance table."""
|
||||||
|
|
||||||
|
__slots__ = ("_names", "_values")
|
||||||
|
|
||||||
|
def __init__(self, names: list[str]) -> None:
|
||||||
|
self._names: list[str] = list(names)
|
||||||
|
n = len(self._names)
|
||||||
|
self._values: list[list[int]] = [[0] * n for _ in range(n)]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def default_instance(default_value: int = 0) -> ClearanceMatrix:
|
||||||
|
"""A matrix with the reserved ``null`` class and a ``default`` class."""
|
||||||
|
matrix = ClearanceMatrix(["null", "default"])
|
||||||
|
matrix.set_default_value(default_value)
|
||||||
|
return matrix
|
||||||
|
|
||||||
|
def get_class_count(self) -> int:
|
||||||
|
return len(self._names)
|
||||||
|
|
||||||
|
def get_no(self, name: str) -> int:
|
||||||
|
try:
|
||||||
|
return self._names.index(name)
|
||||||
|
except ValueError:
|
||||||
|
return -1
|
||||||
|
|
||||||
|
def get_name(self, class_no: int) -> str | None:
|
||||||
|
if 0 <= class_no < len(self._names):
|
||||||
|
return self._names[class_no]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def append_class(self, name: str) -> int:
|
||||||
|
"""Add a new clearance class (copying the default row/column) and return its index."""
|
||||||
|
self._names.append(name)
|
||||||
|
for row in self._values:
|
||||||
|
row.append(0)
|
||||||
|
self._values.append([0] * len(self._names))
|
||||||
|
return len(self._names) - 1
|
||||||
|
|
||||||
|
def set_default_value(self, value: int) -> None:
|
||||||
|
"""Set the clearance between every non-null class pair to ``value``."""
|
||||||
|
for i in range(1, len(self._names)):
|
||||||
|
for j in range(1, len(self._names)):
|
||||||
|
self.set_value(i, j, value)
|
||||||
|
|
||||||
|
def set_value(self, i: int, j: int, value: int) -> None:
|
||||||
|
self._values[i][j] = value
|
||||||
|
self._values[j][i] = value
|
||||||
|
|
||||||
|
def get_value(self, i: int, j: int, add_safety_margin: bool = False) -> int:
|
||||||
|
if not (0 <= i < len(self._names) and 0 <= j < len(self._names)):
|
||||||
|
return 0
|
||||||
|
value = self._values[i][j]
|
||||||
|
if add_safety_margin and value > 0:
|
||||||
|
value += CLEARANCE_SAFETY_MARGIN
|
||||||
|
return value
|
||||||
59
src/freeroute/board/layer.py
Normal file
59
src/freeroute/board/layer.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"""``Layer`` and ``LayerStructure`` — the board's layer stack.
|
||||||
|
|
||||||
|
Ports ``board/Layer.java`` and ``board/LayerStructure.java``. Layer index 0 is
|
||||||
|
the component (top) side; ``is_signal`` marks routable copper layers (as opposed
|
||||||
|
to power/ground planes).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
__all__ = ["Layer", "LayerStructure"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Layer:
|
||||||
|
"""A single board layer."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
is_signal: bool = True
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class LayerStructure:
|
||||||
|
"""The ordered layer stack, with name/index lookups."""
|
||||||
|
|
||||||
|
__slots__ = ("arr",)
|
||||||
|
|
||||||
|
def __init__(self, layers: list[Layer]) -> None:
|
||||||
|
self.arr: list[Layer] = list(layers)
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self.arr)
|
||||||
|
|
||||||
|
def get_no(self, layer_or_name) -> int:
|
||||||
|
"""Index of a layer (by :class:`Layer` or by name), or -1."""
|
||||||
|
if isinstance(layer_or_name, Layer):
|
||||||
|
for i, layer in enumerate(self.arr):
|
||||||
|
if layer is layer_or_name or layer == layer_or_name:
|
||||||
|
return i
|
||||||
|
return -1
|
||||||
|
for i, layer in enumerate(self.arr):
|
||||||
|
if layer.name == layer_or_name:
|
||||||
|
return i
|
||||||
|
return -1
|
||||||
|
|
||||||
|
def signal_layer_count(self) -> int:
|
||||||
|
return sum(1 for layer in self.arr if layer.is_signal)
|
||||||
|
|
||||||
|
def get_signal_layer(self, no: int) -> Layer:
|
||||||
|
found = 0
|
||||||
|
for layer in self.arr:
|
||||||
|
if layer.is_signal:
|
||||||
|
if found == no:
|
||||||
|
return layer
|
||||||
|
found += 1
|
||||||
|
return self.arr[-1]
|
||||||
65
src/freeroute/board/net.py
Normal file
65
src/freeroute/board/net.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
"""``Net`` and ``Nets`` — the board connectivity model.
|
||||||
|
|
||||||
|
Ports the data-model core of ``rules/Net.java`` and ``rules/Nets.java``: nets
|
||||||
|
are identified by a ``(name, subnet_number)`` pair and a board-unique
|
||||||
|
``net_number`` (assigned sequentially from 1). Items reference nets by number.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
__all__ = ["Net", "Nets"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Net:
|
||||||
|
"""A single net. ``net_number`` is the board-unique id (>= 1)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
subnet_number: int
|
||||||
|
net_number: int
|
||||||
|
contains_plane: bool = False
|
||||||
|
#: the ``(component, pin)`` terminals declared for this net in the DSN
|
||||||
|
pins: list[tuple[str, str]] = field(default_factory=list)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"Net(#{self.net_number} {self.name!r})"
|
||||||
|
|
||||||
|
|
||||||
|
class Nets:
|
||||||
|
"""Container of :class:`Net`, indexed by number and by ``(name, subnet)``."""
|
||||||
|
|
||||||
|
__slots__ = ("_by_number", "_by_key")
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._by_number: dict[int, Net] = {}
|
||||||
|
self._by_key: dict[tuple[str, int], Net] = {}
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self._by_number)
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return iter(self._by_number.values())
|
||||||
|
|
||||||
|
def max_net_no(self) -> int:
|
||||||
|
return max(self._by_number, default=0)
|
||||||
|
|
||||||
|
def add(self, name: str, subnet_number: int = 1, contains_plane: bool = False) -> Net:
|
||||||
|
"""Create and register a net with the next available number."""
|
||||||
|
number = self.max_net_no() + 1
|
||||||
|
net = Net(
|
||||||
|
name=name, subnet_number=subnet_number, net_number=number, contains_plane=contains_plane
|
||||||
|
)
|
||||||
|
self._by_number[number] = net
|
||||||
|
self._by_key[(name, subnet_number)] = net
|
||||||
|
return net
|
||||||
|
|
||||||
|
def get(self, name: str, subnet_number: int = 1) -> Net | None:
|
||||||
|
return self._by_key.get((name, subnet_number))
|
||||||
|
|
||||||
|
def get_by_number(self, net_number: int) -> Net | None:
|
||||||
|
return self._by_number.get(net_number)
|
||||||
|
|
||||||
|
def names(self) -> set[str]:
|
||||||
|
return {net.name for net in self._by_number.values()}
|
||||||
50
src/freeroute/board/transform.py
Normal file
50
src/freeroute/board/transform.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
"""``CoordinateTransform`` — DSN <-> board coordinate scaling.
|
||||||
|
|
||||||
|
Ports ``io/CoordinateTransform.java``. Board coordinates are the external
|
||||||
|
(DSN) coordinates multiplied by ``scale_factor`` (the DSN resolution), plus an
|
||||||
|
optional base offset. ``FreeRouting`` builds this with ``scale_factor =
|
||||||
|
resolution`` and zero offset (see ``Structure.create_board``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from freeroute.geometry import FloatPoint, IntPoint
|
||||||
|
|
||||||
|
__all__ = ["CoordinateTransform"]
|
||||||
|
|
||||||
|
|
||||||
|
class CoordinateTransform:
|
||||||
|
"""Scales values and points between the DSN and board coordinate systems."""
|
||||||
|
|
||||||
|
__slots__ = ("scale_factor", "base_x", "base_y")
|
||||||
|
|
||||||
|
def __init__(self, scale_factor: float, base_x: float = 0.0, base_y: float = 0.0) -> None:
|
||||||
|
self.scale_factor = float(scale_factor)
|
||||||
|
self.base_x = float(base_x)
|
||||||
|
self.base_y = float(base_y)
|
||||||
|
|
||||||
|
# scalar -----------------------------------------------------------------
|
||||||
|
|
||||||
|
def board_to_dsn(self, value: float) -> float:
|
||||||
|
return value / self.scale_factor
|
||||||
|
|
||||||
|
def dsn_to_board(self, value: float) -> float:
|
||||||
|
return value * self.scale_factor
|
||||||
|
|
||||||
|
# points -----------------------------------------------------------------
|
||||||
|
|
||||||
|
def dsn_to_board_point(self, x: float, y: float) -> IntPoint:
|
||||||
|
"""Transform an absolute DSN coordinate pair to a board :class:`IntPoint`."""
|
||||||
|
bx = round((x - self.base_x) * self.scale_factor)
|
||||||
|
by = round((y - self.base_y) * self.scale_factor)
|
||||||
|
return IntPoint(bx, by)
|
||||||
|
|
||||||
|
def dsn_to_board_rel(self, x: float, y: float) -> IntPoint:
|
||||||
|
"""Transform a relative (vector) DSN coordinate pair to board units."""
|
||||||
|
return IntPoint(round(x * self.scale_factor), round(y * self.scale_factor))
|
||||||
|
|
||||||
|
def board_to_dsn_point(self, point: IntPoint) -> FloatPoint:
|
||||||
|
return FloatPoint(
|
||||||
|
self.board_to_dsn(point.x) + self.base_x,
|
||||||
|
self.board_to_dsn(point.y) + self.base_y,
|
||||||
|
)
|
||||||
36
src/freeroute/board/unit.py
Normal file
36
src/freeroute/board/unit.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"""``Unit`` — the user measurement units (mil / inch / mm / um).
|
||||||
|
|
||||||
|
Ports ``board/Unit.java``. Each unit records its size in micrometers so values
|
||||||
|
can be scaled between units.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
__all__ = ["Unit"]
|
||||||
|
|
||||||
|
|
||||||
|
class Unit(Enum):
|
||||||
|
MIL = 25.4
|
||||||
|
INCH = 25_400.0
|
||||||
|
MM = 1000.0
|
||||||
|
UM = 1.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def micrometers(self) -> float:
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def scale(value: float, from_unit: Unit, to_unit: Unit) -> float:
|
||||||
|
return value * from_unit.micrometers / to_unit.micrometers
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_string(text: str) -> Unit | None:
|
||||||
|
try:
|
||||||
|
return Unit[text.upper()]
|
||||||
|
except KeyError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name.lower()
|
||||||
69
tests/board/test_board.py
Normal file
69
tests/board/test_board.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
"""Unit tests for the board data model (layers, units, transform, nets, clearance)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from freeroute.board import (
|
||||||
|
ClearanceMatrix,
|
||||||
|
CoordinateTransform,
|
||||||
|
Layer,
|
||||||
|
LayerStructure,
|
||||||
|
Nets,
|
||||||
|
Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_scale_and_parse():
|
||||||
|
assert Unit.from_string("um") is Unit.UM
|
||||||
|
assert Unit.from_string("MM") is Unit.MM
|
||||||
|
assert Unit.from_string("bogus") is None
|
||||||
|
assert Unit.scale(1.0, Unit.MM, Unit.UM) == 1000.0
|
||||||
|
assert Unit.scale(1.0, Unit.INCH, Unit.MIL) == 1000.0
|
||||||
|
assert str(Unit.MIL) == "mil"
|
||||||
|
|
||||||
|
|
||||||
|
def test_layer_structure_lookup():
|
||||||
|
ls = LayerStructure([Layer("F.Cu", True), Layer("In1", False), Layer("B.Cu", True)])
|
||||||
|
assert len(ls) == 3
|
||||||
|
assert ls.get_no("F.Cu") == 0
|
||||||
|
assert ls.get_no("B.Cu") == 2
|
||||||
|
assert ls.get_no("missing") == -1
|
||||||
|
assert ls.signal_layer_count() == 2
|
||||||
|
assert ls.get_signal_layer(1).name == "B.Cu"
|
||||||
|
|
||||||
|
|
||||||
|
def test_coordinate_transform_roundtrip():
|
||||||
|
t = CoordinateTransform(10)
|
||||||
|
assert t.dsn_to_board(5) == 50
|
||||||
|
assert t.board_to_dsn(50) == 5
|
||||||
|
p = t.dsn_to_board_point(100, -200)
|
||||||
|
assert (p.x, p.y) == (1000, -2000)
|
||||||
|
back = t.board_to_dsn_point(p)
|
||||||
|
assert (back.x, back.y) == (100.0, -200.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nets_add_and_lookup():
|
||||||
|
nets = Nets()
|
||||||
|
a = nets.add("GND")
|
||||||
|
b = nets.add("VCC", subnet_number=1)
|
||||||
|
assert a.net_number == 1
|
||||||
|
assert b.net_number == 2
|
||||||
|
assert nets.max_net_no() == 2
|
||||||
|
assert nets.get("GND") is a
|
||||||
|
assert nets.get_by_number(2) is b
|
||||||
|
assert nets.get("missing") is None
|
||||||
|
assert nets.names() == {"GND", "VCC"}
|
||||||
|
assert len(nets) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_clearance_matrix_default_and_classes():
|
||||||
|
cm = ClearanceMatrix.default_instance(200)
|
||||||
|
assert cm.get_class_count() == 2 # null + default
|
||||||
|
assert cm.get_name(0) == "null"
|
||||||
|
assert cm.get_name(1) == "default"
|
||||||
|
assert cm.get_value(1, 1) == 200
|
||||||
|
assert cm.get_value(0, 1) == 0 # null class has no clearance
|
||||||
|
via = cm.append_class("via")
|
||||||
|
assert cm.get_no("via") == via
|
||||||
|
cm.set_value(1, via, 300)
|
||||||
|
assert cm.get_value(via, 1) == 300 # symmetric
|
||||||
|
assert cm.get_value(1, 1, add_safety_margin=True) == 216 # +16 margin
|
||||||
Loading…
x
Reference in New Issue
Block a user