Skip to content

Commit e5ad241

Browse files
authored
Multi-board netlist generation (#487)
Add netlister functionality to generate multiple netlists for sub-boards. The netlister can now return multiple netlists per design. Changes netlisting where in subboard blocks, connections happen in both the enclosing and sub-board scope. This is needed to handle connections across the external facing boundary ports, external-scope blocks, and internal-scope blocks. Changes refdesing to have a single consistent numbering across all sub-boards, instead of restarting every board at 1.
1 parent 3e87b41 commit e5ad241

13 files changed

Lines changed: 387 additions & 167 deletions

edg/BoardCompiler.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from contextlib import suppress
44
from typing import Type, Optional, Tuple
55

6+
from . import edgir
67
from .core import Block, ScalaCompiler, CompiledDesign, CompiledDesignExportTransform
78
from .electronics_model.NetlistBackend import NetlistBackend # imported separately b/c mypy confuses with the modules
89
from .electronics_model.SvgPcbBackend import SvgPcbBackend
@@ -18,15 +19,16 @@ def compile_board(design: Type[Block], target_dir_name: Optional[Tuple[str, str]
1819
assert os.path.isdir(target_dir), f"target_dir {target_dir} to compile_board must be directory"
1920

2021
design_filename = os.path.join(target_dir, f"{target_name}.edg")
21-
netlist_filename = os.path.join(target_dir, f"{target_name}.net")
22+
netlist_filename_prefix = os.path.join(target_dir, f"{target_name}")
2223
bom_filename = os.path.join(target_dir, f"{target_name}.csv")
2324
svgpcb_filename = os.path.join(target_dir, f"{target_name}.svgpcb.js")
2425
compiled_json_filename = os.path.join(target_dir, f"{target_name}.compiled.json")
2526

2627
with suppress(FileNotFoundError):
2728
os.remove(design_filename)
28-
with suppress(FileNotFoundError):
29-
os.remove(netlist_filename)
29+
for filename in os.listdir(target_dir):
30+
if filename.startswith(target_name) and filename.endswith(".net"):
31+
os.remove(os.path.join(target_dir, filename))
3032
with suppress(FileNotFoundError):
3133
os.remove(bom_filename)
3234
with suppress(FileNotFoundError):
@@ -48,13 +50,21 @@ def compile_board(design: Type[Block], target_dir_name: Optional[Tuple[str, str]
4850

4951
netlist_all = NetlistBackend().run(compiled)
5052
bom_all = GenerateBom().run(compiled)
53+
assert len(bom_all) == 1
5154
svgpcb_all = SvgPcbBackend().run(compiled)
55+
assert len(svgpcb_all) == 1
5256
compiled_json = CompiledDesignExportTransform(compiled).transform()
53-
assert len(netlist_all) == 1
5457

5558
if target_dir_name is not None:
56-
with open(netlist_filename, "w", encoding="utf-8") as net_file:
57-
net_file.write(netlist_all[0][1])
59+
for path, netlist in netlist_all:
60+
path_str = edgir.local_path_to_str_list(path)
61+
if not path_str:
62+
net_filename = netlist_filename_prefix + ".net"
63+
else:
64+
net_filename = netlist_filename_prefix + "_" + "_".join(path_str) + ".net"
65+
66+
with open(net_filename, "w", encoding="utf-8") as net_file:
67+
net_file.write(netlist)
5868

5969
with open(bom_filename, "w", encoding="utf-8") as bom_file:
6070
bom_file.write(bom_all[0][1])

edg/abstract_parts/test_kicad_import_netlist.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def __init__(self) -> None:
3939

4040
class KiCadImportBlackboxTestCase(unittest.TestCase):
4141
def test_netlist(self) -> None:
42-
net = NetlistTestCase.generate_net(
42+
net = NetlistTestCase.generate_single_net(
4343
KiCadBlackboxTop,
4444
refinements=Refinements(
4545
class_refinements=[

edg/electronics_model/BoardScopedTransform.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ class BoardScopedTransform(TransformUtil.Transform):
1515
def __init__(self, design: CompiledDesign) -> None:
1616
super().__init__()
1717
self._design = design
18-
self._board_scopes: Dict[TransformUtil.Path, Optional[TransformUtil.Path]] = {
18+
self._board_parent_scopes: Dict[TransformUtil.Path, Optional[TransformUtil.Path]] = {
1919
TransformUtil.Path.empty(): TransformUtil.Path.empty()
2020
} # always initialized in parent
21+
self._board_scopes: Dict[TransformUtil.Path, Optional[TransformUtil.Path]] = {}
2122

2223
def visit_block_scoped(
2324
self, context: TransformUtil.TransformContext, scope: Optional[TransformUtil.Path], block: edgir.BlockTypes
@@ -36,7 +37,7 @@ def visit_linkarray_scoped(
3637

3738
@override
3839
def visit_block(self, context: TransformContext, block: edgir.HierarchyBlock) -> None:
39-
scope = self._board_scopes[context.path]
40+
parent_scope = self._board_parent_scopes[context.path]
4041

4142
if "fp_subboard" in block.meta.members.node:
4243
fp_external_blocks = self._design.get_value(context.path.to_tuple() + ("fp_external_blocks",))
@@ -48,15 +49,17 @@ def visit_block(self, context: TransformContext, block: edgir.HierarchyBlock) ->
4849
internal_scope = context.path
4950
else:
5051
external_blocks = None
51-
internal_scope = scope
52+
internal_scope = parent_scope
53+
54+
self._board_scopes[context.path] = internal_scope
5255

5356
for block_pair in block.blocks:
5457
if external_blocks is not None and block_pair.name not in external_blocks:
55-
self._board_scopes[context.path.append_block(block_pair.name)] = internal_scope
58+
self._board_parent_scopes[context.path.append_block(block_pair.name)] = internal_scope
5659
else:
57-
self._board_scopes[context.path.append_block(block_pair.name)] = scope
60+
self._board_parent_scopes[context.path.append_block(block_pair.name)] = parent_scope
5861

59-
self.visit_block_scoped(context, scope, block)
62+
self.visit_block_scoped(context, internal_scope, block)
6063

6164
@override
6265
def visit_link(self, context: TransformContext, link: edgir.Link) -> None:

edg/electronics_model/NetlistBackend.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ def run(self, design: CompiledDesign, args: Dict[str, str] = {}) -> List[Tuple[e
2121
else:
2222
raise ValueError(f"Invalid RefdesMode value {refdes_mode_arg}")
2323

24-
netlist = NetlistTransform(design).run()
25-
netlist_string = kicad.generate_netlist(netlist, refdes_mode)
26-
27-
return [(edgir.LocalPath(), netlist_string)]
24+
board_netlists = NetlistTransform(design).run()
25+
return [
26+
(netlist_path.to_local_path(), kicad.generate_netlist(netlist, refdes_mode))
27+
for (netlist_path, netlist) in board_netlists.items()
28+
]

edg/electronics_model/NetlistGenerator.py

Lines changed: 51 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ def empty(cls, path: TransformUtil.Path) -> "BoardScope": # returns a fresh, em
5858
return BoardScope(path, {}, {}, {}, [])
5959

6060

61-
Scopes = Dict[TransformUtil.Path, Optional[BoardScope]] # Block -> board scope (reference, aliased across entries)
6261
ClassPaths = Dict[
6362
TransformUtil.Path, List[edgir.LibraryPath]
6463
] # Path -> class names corresponding to shortened path name
@@ -82,8 +81,9 @@ def flatten_port(path: TransformUtil.Path, port: edgir.PortLike) -> Iterable[Tra
8281
def __init__(self, design: CompiledDesign):
8382
super().__init__(design)
8483

85-
self.all_scopes = [BoardScope.empty(TransformUtil.Path.empty())] # list of unique scopes
86-
self.scopes: Scopes = {TransformUtil.Path.empty(): self.all_scopes[0]}
84+
self.scopes: Dict[TransformUtil.Path, Optional[BoardScope]] = {
85+
TransformUtil.Path.empty(): BoardScope.empty(TransformUtil.Path.empty())
86+
} # board scope path to scope object
8787
self.class_paths: ClassPaths = {TransformUtil.Path.empty(): []} # seed root
8888
self.path_traverse_order: List[TransformUtil.Path] = []
8989

@@ -98,6 +98,16 @@ def process_blocklike(
9898
else:
9999
scope_obj = None
100100

101+
if isinstance(block, edgir.HierarchyBlock) and "fp_subboard" in block.meta.members.node:
102+
# only valid for sub-board blocks, where some things happen in the parent scope
103+
parent_scope = self._board_parent_scopes[path]
104+
if parent_scope is not None:
105+
parent_scope_obj: Optional[BoardScope] = self.scopes[parent_scope]
106+
else:
107+
parent_scope_obj = None
108+
else:
109+
parent_scope_obj = None
110+
101111
if isinstance(block, edgir.HierarchyBlock):
102112
class_path = self.class_paths[path]
103113
for block_pair in block.blocks:
@@ -171,66 +181,85 @@ def process_blocklike(
171181
scope_obj.edges.setdefault(src_path, []) # make sure there is a port entry so single-pin nets are named
172182
scope_obj.pins.setdefault(src_path, []).append(NetPin(path, pin_name))
173183

174-
for constraint_pair in block.constraints:
175-
if scope_obj is not None:
184+
# for blocks with mixed scope, connections happen in both scopes, since blocks may be in either
185+
# this may cause inner connections to leach out into the parent scope, or
186+
# result in extraneous connections in either scope,
187+
# but is much simpler implementation-wise
188+
all_scopes = []
189+
if scope_obj is not None:
190+
all_scopes.append(scope_obj)
191+
if parent_scope_obj is not None and parent_scope_obj is not scope_obj:
192+
all_scopes.append(parent_scope_obj)
193+
194+
if all_scopes:
195+
for constraint_pair in block.constraints:
176196
if constraint_pair.value.HasField("connected"):
177-
self.process_connected(path, block, scope_obj, constraint_pair.value.connected)
197+
self.process_connected(path, block, all_scopes, constraint_pair.value.connected)
178198
elif constraint_pair.value.HasField("connectedArray"):
179199
for expanded_connect in constraint_pair.value.connectedArray.expanded:
180-
self.process_connected(path, block, scope_obj, expanded_connect)
200+
self.process_connected(path, block, all_scopes, expanded_connect)
181201
elif constraint_pair.value.HasField("exported"):
182-
self.process_exported(path, block, scope_obj, constraint_pair.value.exported)
202+
self.process_exported(path, block, all_scopes, constraint_pair.value.exported)
183203
elif constraint_pair.value.HasField("exportedArray"):
184204
for expanded_export in constraint_pair.value.exportedArray.expanded:
185-
self.process_exported(path, block, scope_obj, expanded_export)
205+
self.process_exported(path, block, all_scopes, expanded_export)
186206
elif constraint_pair.value.HasField("exportedTunnel"):
187-
self.process_exported(path, block, scope_obj, constraint_pair.value.exportedTunnel)
207+
self.process_exported(path, block, all_scopes, constraint_pair.value.exportedTunnel)
188208

189209
def process_connected(
190-
self, path: TransformUtil.Path, current: edgir.EltTypes, scope: BoardScope, constraint: edgir.ConnectedExpr
210+
self,
211+
path: TransformUtil.Path,
212+
current: edgir.EltTypes,
213+
scopes: List[BoardScope],
214+
constraint: edgir.ConnectedExpr,
191215
) -> None:
192216
if constraint.expanded:
193217
assert len(constraint.expanded) == 1
194-
self.process_connected(path, current, scope, constraint.expanded[0])
218+
self.process_connected(path, current, scopes, constraint.expanded[0])
195219
return
196220
assert constraint.block_port.HasField("ref")
197221
assert constraint.link_port.HasField("ref")
198222
self.connect_ports(
199-
scope, path.follow(constraint.block_port.ref, current), path.follow(constraint.link_port.ref, current)
223+
scopes, path.follow(constraint.block_port.ref, current), path.follow(constraint.link_port.ref, current)
200224
)
201225

202226
def process_exported(
203-
self, path: TransformUtil.Path, current: edgir.EltTypes, scope: BoardScope, constraint: edgir.ExportedExpr
227+
self,
228+
path: TransformUtil.Path,
229+
current: edgir.EltTypes,
230+
scopes: List[BoardScope],
231+
constraint: edgir.ExportedExpr,
204232
) -> None:
205233
if constraint.expanded:
206234
assert len(constraint.expanded) == 1
207-
self.process_exported(path, current, scope, constraint.expanded[0])
235+
self.process_exported(path, current, scopes, constraint.expanded[0])
208236
return
209237
assert constraint.internal_block_port.HasField("ref")
210238
assert constraint.exterior_port.HasField("ref")
211239
self.connect_ports(
212-
scope,
240+
scopes,
213241
path.follow(constraint.internal_block_port.ref, current),
214242
path.follow(constraint.exterior_port.ref, current),
215243
)
216244

217245
def connect_ports(
218246
self,
219-
scope: BoardScope,
247+
scopes: List[BoardScope],
220248
elt1: Tuple[TransformUtil.Path, edgir.EltTypes],
221249
elt2: Tuple[TransformUtil.Path, edgir.EltTypes],
222250
) -> None:
223251
"""Recursively connect ports, including containers and leaf ports. Net-ness is ignored here."""
224252
if isinstance(elt1[1], edgir.Port) and isinstance(elt2[1], edgir.Port):
225-
scope.edges.setdefault(elt1[0], []).append(elt2[0])
226-
scope.edges.setdefault(elt2[0], []).append(elt1[0])
253+
for scope in scopes:
254+
scope.edges.setdefault(elt1[0], []).append(elt2[0])
255+
scope.edges.setdefault(elt2[0], []).append(elt1[0])
227256

228257
elt1_names = list(map(lambda pair: pair.name, elt1[1].ports))
229258
elt2_names = list(map(lambda pair: pair.name, elt2[1].ports))
230259
assert elt1_names == elt2_names, f"mismatched port sub-ports in types {elt1}, {elt2}"
231260
for key in elt2_names:
232261
self.connect_ports(
233-
scope,
262+
scopes,
234263
(elt1[0].append_port(key), edgir.resolve_portlike(edgir.pair_get(elt1[1].ports, key))),
235264
(elt2[0].append_port(key), edgir.resolve_portlike(edgir.pair_get(elt2[1].ports, key))),
236265
)
@@ -371,11 +400,10 @@ def port_ignored_paths(path: TransformUtil.Path) -> bool: # ignore link ports f
371400

372401
return Netlist(netlist_footprints, netlist_nets)
373402

374-
def run(self) -> Netlist:
403+
def run(self) -> Dict[TransformUtil.Path, Netlist]:
375404
self.transform_design(self._design.design)
376405

377-
assert len(self.all_scopes) == 1, "TODO: support multiple boards"
378-
return self.scope_to_netlist(self.all_scopes[0])
406+
return {path: self.scope_to_netlist(scope) for path, scope in self.scopes.items() if scope is not None}
379407

380408

381409
class PathShortener:

edg/electronics_model/RefdesRefinementPass.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def __init__(self, design: CompiledDesign):
3030
self.board_refdes_prefix = board_refdes_prefix
3131

3232
self.block_refdes_list: List[Tuple[TransformUtil.Path, str]] = [] # populated in traversal order
33-
self.refdes_last: Dict[Tuple[TransformUtil.Path, str], int] = {} # (scope, prefix) -> num
33+
self.refdes_last: Dict[str, int] = {} # prefix -> num
3434

3535
@override
3636
def visit_block_scoped(
@@ -40,8 +40,8 @@ def visit_block_scoped(
4040
refdes_prefix = self._design.get_value(context.path.to_tuple() + ("fp_refdes_prefix",))
4141
assert isinstance(refdes_prefix, str)
4242

43-
refdes_id = self.refdes_last.get((scope, refdes_prefix), 0) + 1
44-
self.refdes_last[(scope, refdes_prefix)] = refdes_id
43+
refdes_id = self.refdes_last.get(refdes_prefix, 0) + 1
44+
self.refdes_last[refdes_prefix] = refdes_id
4545
self.block_refdes_list.append((context.path, self.board_refdes_prefix + refdes_prefix + str(refdes_id)))
4646

4747
def run(self) -> List[Tuple[TransformUtil.Path, str]]:

edg/electronics_model/SvgPcbBackend.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ def run(self) -> List[SvgPcbGeneratedBlock]:
190190
class SvgPcbBackend(BaseBackend):
191191
@override
192192
def run(self, design: CompiledDesign, args: Dict[str, str] = {}) -> List[Tuple[edgir.LocalPath, str]]:
193-
netlist = NetlistTransform(design).run()
193+
netlist = NetlistTransform(design).run()[TransformUtil.Path.empty()] # only generate for top-level board
194194
result = self._generate(design, netlist)
195195
return [(edgir.LocalPath(), result)]
196196

@@ -213,7 +213,7 @@ def filter_blocks_by_pathname(
213213
svgpcb_block_bboxes = [BlackBoxBlock(block.path, block.bbox) for block in svgpcb_blocks]
214214

215215
# handle footprints
216-
netlist = NetlistTransform(design).run()
216+
netlist = NetlistTransform(design).run()[TransformUtil.Path.empty()] # only generate for top-level board
217217
svgpcb_block_prefixes = [block.path.to_tuple() for block in svgpcb_blocks]
218218
other_blocks = filter_blocks_by_pathname(netlist.blocks, svgpcb_block_prefixes)
219219
arranged_blocks = arrange_blocks(other_blocks, svgpcb_block_bboxes)

edg/electronics_model/test_bundle_netlist.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ def contents(self) -> None:
123123

124124
class BundleNetlistTestCase(unittest.TestCase):
125125
def test_spi_netlist(self) -> None:
126-
net = NetlistTestCase.generate_net(TestSpiCircuit)
126+
net = NetlistTestCase.generate_single_net(TestSpiCircuit)
127127

128128
self.assertIn(
129129
Net(
@@ -237,7 +237,7 @@ def test_spi_netlist(self) -> None:
237237
)
238238

239239
def test_uart_netlist(self) -> None:
240-
net = NetlistTestCase.generate_net(TestUartCircuit)
240+
net = NetlistTestCase.generate_single_net(TestUartCircuit)
241241

242242
self.assertIn(
243243
Net(
@@ -285,7 +285,7 @@ def test_uart_netlist(self) -> None:
285285
)
286286

287287
def test_can_netlist(self) -> None:
288-
net = NetlistTestCase.generate_net(TestCanCircuit)
288+
net = NetlistTestCase.generate_single_net(TestCanCircuit)
289289

290290
self.assertIn(
291291
Net(

edg/electronics_model/test_multipack_netlist.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def multipack(self) -> None:
7878

7979
class MultipackNetlistTestCase(unittest.TestCase):
8080
def test_packed_netlist(self) -> None:
81-
net = NetlistTestCase.generate_net(TestPackedDevices)
81+
net = NetlistTestCase.generate_single_net(TestPackedDevices)
8282

8383
self.assertIn(
8484
Net(
@@ -152,4 +152,4 @@ def test_invalid_netlist(self) -> None:
152152
from .NetlistGenerator import InvalidPackingException
153153

154154
with self.assertRaises(InvalidPackingException):
155-
NetlistTestCase.generate_net(TestInvalidPackedDevices)
155+
NetlistTestCase.generate_single_net(TestInvalidPackedDevices)

0 commit comments

Comments
 (0)