enginehall 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- enginehall-2.0.0.dist-info/METADATA +511 -0
- enginehall-2.0.0.dist-info/RECORD +75 -0
- enginehall-2.0.0.dist-info/WHEEL +4 -0
- enginehall-2.0.0.dist-info/entry_points.txt +4 -0
- enginehall-2.0.0.dist-info/licenses/LICENSE +51 -0
- pcba/THIRD_PARTY_NOTICES.md +72 -0
- pcba/__init__.py +6 -0
- pcba/adapters/__init__.py +10 -0
- pcba/adapters/altium_bridge/MANIFEST.json +5 -0
- pcba/adapters/altium_bridge/__init__.py +1 -0
- pcba/adapters/altium_bridge/bridge.pas +3342 -0
- pcba/adapters/altium_drive.py +1160 -0
- pcba/adapters/altium_house.py +591 -0
- pcba/adapters/altium_ops.py +858 -0
- pcba/adapters/altium_outjob.py +247 -0
- pcba/adapters/altium_uia.py +304 -0
- pcba/adapters/altium_vcs.py +197 -0
- pcba/adapters/altium_win32.py +457 -0
- pcba/adapters/cubemx_drive.py +248 -0
- pcba/baseline.py +151 -0
- pcba/changes.py +81 -0
- pcba/cli.py +891 -0
- pcba/components.py +323 -0
- pcba/config.py +180 -0
- pcba/data/components/generic.json +61 -0
- pcba/discover.py +350 -0
- pcba/doctor.py +131 -0
- pcba/house.py +123 -0
- pcba/mcp/__init__.py +5 -0
- pcba/mcp/altium_server.py +1358 -0
- pcba/mcp/protocol.py +224 -0
- pcba/mcp/server.py +332 -0
- pcba/model/__init__.py +38 -0
- pcba/model/design.py +232 -0
- pcba/model/finding.py +148 -0
- pcba/model/pinmap.py +200 -0
- pcba/parsers/__init__.py +0 -0
- pcba/parsers/altium/__init__.py +37 -0
- pcba/parsers/altium/_sheet.py +293 -0
- pcba/parsers/altium/geometry.py +103 -0
- pcba/parsers/altium/netlist.py +302 -0
- pcba/parsers/altium/pinmap.py +336 -0
- pcba/parsers/altium/project.py +249 -0
- pcba/parsers/altium/records.py +227 -0
- pcba/parsers/firmware/__init__.py +5 -0
- pcba/parsers/firmware/index.py +250 -0
- pcba/parsers/ioc/__init__.py +49 -0
- pcba/parsers/ioc/mcu_db.py +180 -0
- pcba/parsers/ioc/reader.py +182 -0
- pcba/parsers/ioc/validate.py +192 -0
- pcba/parsers/kicad/__init__.py +103 -0
- pcba/parsers/kicad/board.py +269 -0
- pcba/parsers/kicad/kicad_cli.py +190 -0
- pcba/parsers/kicad/pinmap.py +90 -0
- pcba/parsers/kicad/schematic.py +517 -0
- pcba/parsers/kicad/sexpr.py +136 -0
- pcba/publish.py +269 -0
- pcba/report/__init__.py +7 -0
- pcba/report/json_report.py +39 -0
- pcba/report/sarif.py +128 -0
- pcba/report/text.py +304 -0
- pcba/requirements.py +320 -0
- pcba/review.py +177 -0
- pcba/rules/__init__.py +312 -0
- pcba/rules/cross_domain.py +496 -0
- pcba/rules/kicad_board.py +775 -0
- pcba/rules/kicad_common.py +167 -0
- pcba/rules/kicad_components.py +603 -0
- pcba/rules/kicad_schematic.py +621 -0
- pcba/rules/locate.py +22 -0
- pcba/rules/schematic.py +123 -0
- pcba/rules/signals.py +149 -0
- pcba/rules/spice.py +59 -0
- pcba/rules/stm32.py +155 -0
- pcba/simulation.py +143 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Extract a full netlist from Altium schematic sheets, binary or ASCII.
|
|
2
|
+
|
|
3
|
+
The pin map (`pinmap.py`) resolves one MCU's pins. This resolves every placed pin of every
|
|
4
|
+
component into nets with the same sheet resolver (`_sheet.resolve`): the same format traps
|
|
5
|
+
(the designator child record, the multi-part filter, the pin's electrical end,
|
|
6
|
+
point-to-segment wire contact) and the same anchors and naming precedence. A node is
|
|
7
|
+
`(component designator, pin designator)`, e.g. `("J1", "1")`.
|
|
8
|
+
|
|
9
|
+
What connects (on one sheet, as in the pin map)
|
|
10
|
+
-----------------------------------------------
|
|
11
|
+
- Wires (RECORD=27), point-to-segment: a pin end, label anchor, junction or another wire's
|
|
12
|
+
vertex lying on a segment's interior joins it.
|
|
13
|
+
- Junctions (RECORD=29) join every wire passing through them, so two crossing wires connect
|
|
14
|
+
only where a junction sits.
|
|
15
|
+
- Pins whose electrical ends coincide connect directly, without a wire.
|
|
16
|
+
- Net labels (RECORD=25), power ports (RECORD=17) and ports (RECORD=18) name the net their
|
|
17
|
+
anchor touches. A port touches at both ends: `Location`, and `Width` further along +X for
|
|
18
|
+
the horizontal styles 0-3 or along +Y for the vertical styles 4-7.
|
|
19
|
+
|
|
20
|
+
Assumptions, stated because each one changes the answer
|
|
21
|
+
-------------------------------------------------------
|
|
22
|
+
- Flat project, global names. Net labels, power ports and ports are global: the same name
|
|
23
|
+
on two sheets is one net, as in Altium's "Global" net-identifier scope. Sheet symbols and
|
|
24
|
+
sheet entries are ignored, so a hierarchical design's sheet-local labels would be merged
|
|
25
|
+
by name. Identifier types have separate namespaces: a power port, label and port with
|
|
26
|
+
the same text join only when physically connected by sheet geometry.
|
|
27
|
+
- Naming precedence when one net carries several names: power port, then net label, then
|
|
28
|
+
port; the lexicographically smallest name within that rank (`_sheet.precedence`, which
|
|
29
|
+
the pin map uses too). Every net with more than one name is listed in
|
|
30
|
+
`Netlist.conflicts`; one of those names is wrong or a deliberate tie.
|
|
31
|
+
- An unnamed net is called `Net<Designator>_<Pin>` after its naturally lowest node, as
|
|
32
|
+
Altium does (`NetJ1_1`). Should that collide with a name on the sheets, `_1`, `_2`, ...
|
|
33
|
+
is appended.
|
|
34
|
+
- A net with a single node is not a net. It goes to `Netlist.unconnected` with the name it
|
|
35
|
+
carries, if any, and never to `Netlist.nets`.
|
|
36
|
+
- A pin needs both a component designator and a pad designator to be a node. One drawn
|
|
37
|
+
more than once (an unannotated `R?`, a duplicated designator, a part placed twice) is
|
|
38
|
+
listed in `Netlist.duplicates`; the netlist cannot tell those pads apart.
|
|
39
|
+
- Distinct multi-node nets choosing the same output name are a controlled extraction
|
|
40
|
+
error: the name-keyed export cannot represent them without inventing names or losing pins.
|
|
41
|
+
- Hidden power pins are not handled: their implicit `HiddenNetName` connection is not
|
|
42
|
+
applied, so such a pin is unconnected unless something touches its end. Part-zero pins
|
|
43
|
+
(`OwnerPartId` 0, negative or absent: shared by every part of a symbol) are placed with
|
|
44
|
+
every placed part, by the same multi-part filter the pin map uses. With several parts of
|
|
45
|
+
one symbol placed, such a pin is one pad drawn more than once and is listed in
|
|
46
|
+
`Netlist.duplicates`.
|
|
47
|
+
- Not modelled: buses and bus entries, harnesses, off-sheet connectors, net ties, and
|
|
48
|
+
series parts (the pin map's resistor inference is naming, not connectivity).
|
|
49
|
+
|
|
50
|
+
This is the engine's netlist; the extension's `netlist/builder.ts` is a heuristic for
|
|
51
|
+
display (it snaps labels within a radius) and is not what rules are tested against.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import collections
|
|
57
|
+
import dataclasses
|
|
58
|
+
from pathlib import Path
|
|
59
|
+
|
|
60
|
+
from pcba.model.pinmap import natural_key
|
|
61
|
+
from pcba.parsers.altium import _sheet
|
|
62
|
+
from pcba.parsers.altium.records import (
|
|
63
|
+
REC_IMPLEMENTATION,
|
|
64
|
+
REC_PARAMETER,
|
|
65
|
+
SchDocError,
|
|
66
|
+
load_any_records,
|
|
67
|
+
text_of,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
Node = tuple[str, str] # (component designator, pin designator)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
74
|
+
class Net:
|
|
75
|
+
name: str
|
|
76
|
+
nodes: tuple[Node, ...] # natural order
|
|
77
|
+
generated: bool # the name is the `Net<Designator>_<Pin>` fallback, not from the sheets
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
81
|
+
class UnconnectedPin:
|
|
82
|
+
designator: str
|
|
83
|
+
pin: str
|
|
84
|
+
name: str # the pin's own name, e.g. "PA5"
|
|
85
|
+
net: str | None # a label, port or power-port name the lone pin carries, if any
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
89
|
+
class NameConflict:
|
|
90
|
+
net: str # the name chosen
|
|
91
|
+
names: tuple[str, ...] # every name found on the net, sorted
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
95
|
+
class Component:
|
|
96
|
+
designator: str
|
|
97
|
+
lib_ref: str
|
|
98
|
+
comment: str | None
|
|
99
|
+
footprint: str | None # the current PCB implementation's model name
|
|
100
|
+
sheet: str # file name of the first sheet it is drawn on
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclasses.dataclass(slots=True)
|
|
104
|
+
class Netlist:
|
|
105
|
+
source: str
|
|
106
|
+
sheets: list[str] = dataclasses.field(default_factory=list)
|
|
107
|
+
nets: dict[str, Net] = dataclasses.field(default_factory=dict)
|
|
108
|
+
unconnected: list[UnconnectedPin] = dataclasses.field(default_factory=list)
|
|
109
|
+
conflicts: list[NameConflict] = dataclasses.field(default_factory=list)
|
|
110
|
+
duplicates: list[Node] = dataclasses.field(default_factory=list)
|
|
111
|
+
components: dict[str, Component] = dataclasses.field(default_factory=dict)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def extract_netlist(directory: Path | str) -> Netlist:
|
|
115
|
+
"""Netlist of every `.SchDoc` directly in `directory` (not recursive), one flat project."""
|
|
116
|
+
directory = Path(directory)
|
|
117
|
+
sheets = sorted(directory.glob("*.SchDoc"))
|
|
118
|
+
if not sheets:
|
|
119
|
+
raise SchDocError(f"no .SchDoc under {directory}")
|
|
120
|
+
return _build(sheets, source=str(directory))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def extract_sheet(path: Path | str) -> Netlist:
|
|
124
|
+
"""Netlist of a single sheet, on its own."""
|
|
125
|
+
path = Path(path)
|
|
126
|
+
return _build([path], source=str(path))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def to_dict(netlist: Netlist) -> dict:
|
|
130
|
+
"""A JSON-ready shape; every list and mapping is in natural order."""
|
|
131
|
+
return {
|
|
132
|
+
"sheets": list(netlist.sheets),
|
|
133
|
+
"nets": {
|
|
134
|
+
name: [list(node) for node in net.nodes] for name, net in netlist.nets.items()
|
|
135
|
+
},
|
|
136
|
+
"unconnected": [
|
|
137
|
+
{"designator": u.designator, "pin": u.pin, "name": u.name, "net": u.net}
|
|
138
|
+
for u in netlist.unconnected
|
|
139
|
+
],
|
|
140
|
+
"conflicts": [{"net": c.net, "names": list(c.names)} for c in netlist.conflicts],
|
|
141
|
+
"duplicates": [list(node) for node in netlist.duplicates],
|
|
142
|
+
"components": {
|
|
143
|
+
c.designator: {
|
|
144
|
+
"lib_ref": c.lib_ref,
|
|
145
|
+
"comment": c.comment,
|
|
146
|
+
"footprint": c.footprint,
|
|
147
|
+
"sheet": c.sheet,
|
|
148
|
+
}
|
|
149
|
+
for c in netlist.components.values()
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _node_key(node: Node) -> tuple:
|
|
155
|
+
return (natural_key(node[0]), natural_key(node[1]), node)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _name_key(name: str) -> tuple:
|
|
159
|
+
return (natural_key(name), name)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _build(paths: list[Path], source: str) -> Netlist:
|
|
163
|
+
# A sheet-local cluster is ("sheet", i, root). A name joins every cluster it touches, on
|
|
164
|
+
# any sheet: the pin map joins by name within one sheet, this across all of them.
|
|
165
|
+
placements: list[tuple[Node, str, tuple]] = []
|
|
166
|
+
named: list[tuple[tuple, str, int]] = []
|
|
167
|
+
components: dict[str, Component] = {}
|
|
168
|
+
for index, path in enumerate(paths):
|
|
169
|
+
pins, names, comps = _read_sheet(path)
|
|
170
|
+
for node, pin_name, root in pins:
|
|
171
|
+
placements.append((node, pin_name, ("sheet", index, root)))
|
|
172
|
+
named += [(("sheet", index, root), name, rank) for root, name, rank in names]
|
|
173
|
+
for comp in comps:
|
|
174
|
+
components.setdefault(comp.designator, comp)
|
|
175
|
+
nets = _sheet.join_by_name(named)
|
|
176
|
+
|
|
177
|
+
members: dict = collections.defaultdict(list)
|
|
178
|
+
for node, pin_name, cluster in placements:
|
|
179
|
+
members[nets.uf.find(cluster)].append((node, pin_name))
|
|
180
|
+
names_on = nets.carried()
|
|
181
|
+
|
|
182
|
+
netlist = Netlist(source=source, sheets=[p.name for p in paths])
|
|
183
|
+
counts = collections.Counter(node for node, _, _ in placements)
|
|
184
|
+
netlist.duplicates = sorted((n for n, c in counts.items() if c > 1), key=_node_key)
|
|
185
|
+
|
|
186
|
+
anonymous: list[tuple[Node, ...]] = []
|
|
187
|
+
for root, found in members.items():
|
|
188
|
+
pin_names: dict[Node, str] = {}
|
|
189
|
+
for node, pin_name in found:
|
|
190
|
+
pin_names.setdefault(node, pin_name)
|
|
191
|
+
nodes = tuple(sorted(pin_names, key=_node_key))
|
|
192
|
+
carried = names_on.get(root, set())
|
|
193
|
+
chosen = nets.winner(root) if carried else None
|
|
194
|
+
if len(carried) > 1:
|
|
195
|
+
netlist.conflicts.append(NameConflict(chosen, tuple(sorted(carried))))
|
|
196
|
+
if len(nodes) == 1:
|
|
197
|
+
designator, pin = nodes[0]
|
|
198
|
+
netlist.unconnected.append(
|
|
199
|
+
UnconnectedPin(designator, pin, pin_names[nodes[0]], chosen)
|
|
200
|
+
)
|
|
201
|
+
elif chosen is not None:
|
|
202
|
+
if chosen in netlist.nets:
|
|
203
|
+
raise SchDocError(
|
|
204
|
+
f"distinct nets choose the same output name {chosen!r}; "
|
|
205
|
+
"resolve the naming ambiguity before exporting"
|
|
206
|
+
)
|
|
207
|
+
netlist.nets[chosen] = Net(chosen, nodes, generated=False)
|
|
208
|
+
else:
|
|
209
|
+
anonymous.append(nodes)
|
|
210
|
+
|
|
211
|
+
# Named nets first, so a generated name never displaces a name from the sheets.
|
|
212
|
+
for nodes in sorted(anonymous, key=lambda ns: _node_key(ns[0])):
|
|
213
|
+
name = base = f"Net{nodes[0][0]}_{nodes[0][1]}"
|
|
214
|
+
suffix = 0
|
|
215
|
+
while name in netlist.nets:
|
|
216
|
+
suffix += 1
|
|
217
|
+
name = f"{base}_{suffix}"
|
|
218
|
+
netlist.nets[name] = Net(name, nodes, generated=True)
|
|
219
|
+
|
|
220
|
+
netlist.nets = dict(sorted(netlist.nets.items(), key=lambda item: _name_key(item[0])))
|
|
221
|
+
netlist.unconnected.sort(key=lambda u: _node_key((u.designator, u.pin)))
|
|
222
|
+
netlist.conflicts.sort(key=lambda c: _name_key(c.net))
|
|
223
|
+
netlist.components = dict(
|
|
224
|
+
sorted(components.items(), key=lambda item: _name_key(item[0]))
|
|
225
|
+
)
|
|
226
|
+
return netlist
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _read_sheet(
|
|
230
|
+
path: Path,
|
|
231
|
+
) -> tuple[list[tuple[Node, str, tuple]], list[tuple[tuple, str, int]], list[Component]]:
|
|
232
|
+
"""One sheet's (pin placements, names, components), clustered by local geometry."""
|
|
233
|
+
records = load_any_records(path)
|
|
234
|
+
sheet = _sheet.resolve(path, records)
|
|
235
|
+
placements = [
|
|
236
|
+
((p["designator"], p["num"]), p["name"], sheet.root(p["hot"]))
|
|
237
|
+
for p in sheet.pins
|
|
238
|
+
if p["designator"] and p["num"]
|
|
239
|
+
]
|
|
240
|
+
return placements, sheet.names, _components(path, records, sheet.components, sheet.designators)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _components(
|
|
244
|
+
path: Path,
|
|
245
|
+
records: list[dict[str, str]],
|
|
246
|
+
comps: dict[int, dict[str, str]],
|
|
247
|
+
designators: dict[int, str],
|
|
248
|
+
) -> list[Component]:
|
|
249
|
+
params: dict[int, dict[str, str]] = collections.defaultdict(dict)
|
|
250
|
+
footprints: dict[int, str] = {}
|
|
251
|
+
for index, fields in enumerate(records):
|
|
252
|
+
kind = fields.get("RECORD")
|
|
253
|
+
if kind == REC_PARAMETER and fields.get("OwnerIndex"):
|
|
254
|
+
owner = int(fields["OwnerIndex"]) + 1
|
|
255
|
+
if owner in comps:
|
|
256
|
+
params[owner].setdefault(fields.get("Name", "").strip().lower(), text_of(fields))
|
|
257
|
+
elif (
|
|
258
|
+
kind == REC_IMPLEMENTATION
|
|
259
|
+
and fields.get("ModelType", "").upper() == "PCBLIB"
|
|
260
|
+
and fields.get("IsCurrent", "").upper() in ("T", "TRUE")
|
|
261
|
+
):
|
|
262
|
+
owner = _owning_component(records, index, comps)
|
|
263
|
+
if owner is not None and fields.get("ModelName"):
|
|
264
|
+
footprints.setdefault(owner, fields["ModelName"].strip())
|
|
265
|
+
|
|
266
|
+
out = []
|
|
267
|
+
for ci, fields in comps.items():
|
|
268
|
+
designator = designators.get(ci)
|
|
269
|
+
if not designator:
|
|
270
|
+
continue
|
|
271
|
+
mine = params.get(ci, {})
|
|
272
|
+
comment = mine.get("comment") or (
|
|
273
|
+
fields.get("%UTF8%Comment") or fields.get("Comment", "")
|
|
274
|
+
).strip()
|
|
275
|
+
if comment.startswith("="): # "=Value": the comment shows another parameter
|
|
276
|
+
comment = mine.get(comment[1:].strip().lower(), comment)
|
|
277
|
+
out.append(
|
|
278
|
+
Component(
|
|
279
|
+
designator=designator,
|
|
280
|
+
lib_ref=fields.get("LibReference", ""),
|
|
281
|
+
comment=comment or None,
|
|
282
|
+
footprint=footprints.get(ci),
|
|
283
|
+
sheet=path.name,
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
return out
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _owning_component(
|
|
290
|
+
records: list[dict[str, str]], index: int, comps: dict[int, dict[str, str]]
|
|
291
|
+
) -> int | None:
|
|
292
|
+
"""Walk OwnerIndex up from an implementation (45 -> 44 list -> 1 component)."""
|
|
293
|
+
for _ in range(4):
|
|
294
|
+
owner = records[index].get("OwnerIndex", "")
|
|
295
|
+
if not owner:
|
|
296
|
+
return None
|
|
297
|
+
index = int(owner) + 1
|
|
298
|
+
if index in comps:
|
|
299
|
+
return index
|
|
300
|
+
if not 0 <= index < len(records):
|
|
301
|
+
return None
|
|
302
|
+
return None
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Extract an MCU pin -> net map from binary Altium schematic sheets.
|
|
2
|
+
|
|
3
|
+
The schematic is the authoritative source for pin assignment: the board gets built from it.
|
|
4
|
+
Reading it directly means the pin map, the `.ioc` and the firmware can all be checked
|
|
5
|
+
against one artifact instead of three hand-maintained ones.
|
|
6
|
+
|
|
7
|
+
The one trap that matters most
|
|
8
|
+
------------------------------
|
|
9
|
+
A multi-part symbol's component record carries the pins of EVERY part, all at the same
|
|
10
|
+
symbol-local coordinates. A 176-pin MCU is typically a 4-part symbol. Only pins whose
|
|
11
|
+
`OwnerPartId` equals that instance's `CurrentPartId` are actually placed here, plus Altium's
|
|
12
|
+
part zero (`OwnerPartId` 0, negative or absent), which every part shares. Without that
|
|
13
|
+
filter, part 1's PA* pins and part 2's PE* pins land on identical coordinates and merge into
|
|
14
|
+
one nonsense net — and nothing about the output looks wrong.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import collections
|
|
20
|
+
import math
|
|
21
|
+
import re
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from pcba.model.pinmap import Pin, Resolution, SchematicPinMap, is_power_pin, natural_key
|
|
25
|
+
from pcba.parsers.altium import _sheet
|
|
26
|
+
from pcba.parsers.altium.geometry import NEAR_MISS, key
|
|
27
|
+
from pcba.parsers.altium.records import (
|
|
28
|
+
REC_COMPONENT,
|
|
29
|
+
REC_DESIGNATOR,
|
|
30
|
+
SchDocError,
|
|
31
|
+
contains_part,
|
|
32
|
+
load_records,
|
|
33
|
+
text_of,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Resistor symbols by library reference: `Res1`/`Res2`/`Res3` and `Res Semi` from Altium's
|
|
38
|
+
# Miscellaneous Devices, `RES`, `Resistor_0603`, `R_0603`, `R`. A capacitor, diode, or ferrite
|
|
39
|
+
# is not a series element for net naming, so only these support a series inference.
|
|
40
|
+
_RESISTOR = re.compile(r"(?:RES(?:ISTOR)?\d*|R)(?:[_ -].*)?", re.I)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _resolve_sheet(path: Path, part_hint: str, component: str | None = None) -> tuple[dict[str, Pin], dict]:
|
|
44
|
+
"""Resolve one sheet. Returns (pins keyed by package pin, near-miss report).
|
|
45
|
+
|
|
46
|
+
Connectivity and naming come from `_sheet.resolve`, the resolver `enginehall altium netlist`
|
|
47
|
+
uses too: net labels, power ports and ports name a net (power port first, then label, then
|
|
48
|
+
port; the smallest text within a rank), junctions and port ends join it, and clusters on
|
|
49
|
+
this sheet that share a name are one net. Unlike the netlist, the pin map does not join
|
|
50
|
+
names across sheets: in a hierarchical design that would merge sheet-local nets.
|
|
51
|
+
"""
|
|
52
|
+
sheet = _sheet.resolve(path, load_records(path))
|
|
53
|
+
mcu_components = {
|
|
54
|
+
ci for ci, fields in sheet.components.items()
|
|
55
|
+
if part_hint in fields.get("LibReference", "")
|
|
56
|
+
# "U1" lives in a child record, not on the component. See records.py.
|
|
57
|
+
and (component is None or sheet.designators.get(ci) == component)
|
|
58
|
+
}
|
|
59
|
+
pins = sheet.pins
|
|
60
|
+
|
|
61
|
+
hot_counts = collections.Counter(key(p["hot"]) for p in pins)
|
|
62
|
+
net_of_root = sheet.named()
|
|
63
|
+
|
|
64
|
+
# Only explicitly identified resistor symbols support a series inference.
|
|
65
|
+
# Other two-pin devices do not establish conductive connectivity.
|
|
66
|
+
by_component = collections.defaultdict(list)
|
|
67
|
+
for p in pins:
|
|
68
|
+
by_component[p["ci"]].append(p)
|
|
69
|
+
adjacency = collections.defaultdict(list)
|
|
70
|
+
for _ci, ps in by_component.items():
|
|
71
|
+
if len(ps) == 2 and _RESISTOR.fullmatch(ps[0]["lib"]):
|
|
72
|
+
a, b = sheet.root(ps[0]["hot"]), sheet.root(ps[1]["hot"])
|
|
73
|
+
adjacency[a].append(b)
|
|
74
|
+
adjacency[b].append(a)
|
|
75
|
+
|
|
76
|
+
out: dict[str, Pin] = {}
|
|
77
|
+
for p in pins:
|
|
78
|
+
if p["ci"] not in mcu_components or not p["num"]:
|
|
79
|
+
continue
|
|
80
|
+
root = sheet.root(p["hot"])
|
|
81
|
+
net, how = net_of_root.get(root), Resolution.DIRECT
|
|
82
|
+
if not net:
|
|
83
|
+
reachable = [net_of_root[n] for n in adjacency.get(root, []) if net_of_root.get(n)]
|
|
84
|
+
if len(reachable) == 1:
|
|
85
|
+
net, how = reachable[0], Resolution.SERIES_R
|
|
86
|
+
elif reachable:
|
|
87
|
+
net, how = "(" + " / ".join(reachable) + ")", Resolution.AMBIGUOUS
|
|
88
|
+
else:
|
|
89
|
+
net, how = None, Resolution.NONE
|
|
90
|
+
out[p["num"]] = Pin(
|
|
91
|
+
name=p["name"],
|
|
92
|
+
package_pin=p["num"],
|
|
93
|
+
net=net,
|
|
94
|
+
resolution=how,
|
|
95
|
+
sheet=str(path),
|
|
96
|
+
component=p["designator"] or None,
|
|
97
|
+
connected=(
|
|
98
|
+
key(p["hot"]) in sheet.wire_contacts
|
|
99
|
+
or hot_counts[key(p["hot"])] > 1
|
|
100
|
+
or root in net_of_root
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Open-circuit detector: a non-MCU hot end that lands NEAR an MCU hot end without
|
|
105
|
+
# touching it is a placement slip. Two constraints keep this from crying wolf:
|
|
106
|
+
# - Axis-aligned only. A diagonal neighbour at 10*sqrt(2) is a different grid point,
|
|
107
|
+
# not a slip, so requiring dx==0 or dy==0 drops those.
|
|
108
|
+
# - The caller intersects with resolution == NONE. A pin that still resolves to a net
|
|
109
|
+
# is connected regardless of what else sits nearby.
|
|
110
|
+
mcu_hots = {key(p["hot"]): p for p in pins if p["ci"] in mcu_components}
|
|
111
|
+
other_hots = [p for p in pins if p["ci"] not in mcu_components]
|
|
112
|
+
exact = sum(1 for p in other_hots if key(p["hot"]) in mcu_hots)
|
|
113
|
+
near: dict[str, dict] = {}
|
|
114
|
+
for p in other_hots:
|
|
115
|
+
if key(p["hot"]) in mcu_hots:
|
|
116
|
+
continue
|
|
117
|
+
for (hx, hy), mp in mcu_hots.items():
|
|
118
|
+
dx, dy = p["hot"][0] - hx, p["hot"][1] - hy
|
|
119
|
+
if not mp["num"]:
|
|
120
|
+
continue
|
|
121
|
+
if (dx == 0 or dy == 0) and 0 < math.hypot(dx, dy) <= NEAR_MISS:
|
|
122
|
+
near.setdefault(
|
|
123
|
+
mp["num"],
|
|
124
|
+
{
|
|
125
|
+
"mcu_pin": mp["name"],
|
|
126
|
+
"package_pin": mp["num"],
|
|
127
|
+
"gap": round(math.hypot(dx, dy), 2),
|
|
128
|
+
"sheet": str(path),
|
|
129
|
+
},
|
|
130
|
+
)
|
|
131
|
+
return out, {"exact": exact, "near": list(near.values())}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def detect_part(directory: Path, hint: str = "STM32") -> str | None:
|
|
135
|
+
"""Find the MCU's orderable part number by scanning component records.
|
|
136
|
+
|
|
137
|
+
Beats hardcoding a part per project, and beats asking the user for something the file
|
|
138
|
+
already knows.
|
|
139
|
+
"""
|
|
140
|
+
for sheet in sorted(directory.glob("*.SchDoc")):
|
|
141
|
+
try:
|
|
142
|
+
records = load_records(sheet)
|
|
143
|
+
except SchDocError:
|
|
144
|
+
continue
|
|
145
|
+
for fields in records:
|
|
146
|
+
if fields.get("RECORD") != REC_COMPONENT:
|
|
147
|
+
continue
|
|
148
|
+
for field in ("SourceLibReference", "LibReference", "Comment", "%UTF8%Comment"):
|
|
149
|
+
value = fields.get(field, "")
|
|
150
|
+
if value.startswith(hint) and len(value) > len(hint):
|
|
151
|
+
return value
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def detect_parts(directory: Path, hint: str = "STM32") -> list[tuple[str, Path]]:
|
|
156
|
+
"""Distinct MCU symbol references and their first sheets, for identity checks."""
|
|
157
|
+
found: dict[str, Path] = {}
|
|
158
|
+
for sheet in sorted(Path(directory).glob("*.SchDoc")):
|
|
159
|
+
try:
|
|
160
|
+
records = load_records(sheet)
|
|
161
|
+
except SchDocError:
|
|
162
|
+
continue
|
|
163
|
+
for fields in records:
|
|
164
|
+
if fields.get("RECORD") != REC_COMPONENT:
|
|
165
|
+
continue
|
|
166
|
+
value = fields.get("LibReference") or fields.get("SourceLibReference", "")
|
|
167
|
+
if value.startswith(hint) and len(value) > len(hint):
|
|
168
|
+
found.setdefault(value, sheet)
|
|
169
|
+
return list(found.items())
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def extract(
|
|
173
|
+
directory: Path,
|
|
174
|
+
part: str,
|
|
175
|
+
part_hint: str | None = None,
|
|
176
|
+
component: str | None = None,
|
|
177
|
+
) -> SchematicPinMap:
|
|
178
|
+
"""Build a pin map for `part` from every `.SchDoc` in `directory`.
|
|
179
|
+
|
|
180
|
+
`part_hint` selects which components count as "the MCU" by LibReference substring; it
|
|
181
|
+
defaults to the part's family prefix, which is what a symbol library normally uses.
|
|
182
|
+
"""
|
|
183
|
+
directory = Path(directory)
|
|
184
|
+
sheets = sorted(directory.glob("*.SchDoc"))
|
|
185
|
+
if not sheets:
|
|
186
|
+
raise SchDocError(f"no .SchDoc under {directory}")
|
|
187
|
+
|
|
188
|
+
if not part_hint:
|
|
189
|
+
family = re.match(r"^([A-Za-z]+\d*)", part or "")
|
|
190
|
+
if not family:
|
|
191
|
+
raise SchDocError(f"cannot derive an MCU family from part {part!r}; pass --part")
|
|
192
|
+
part_hint = family.group(1)
|
|
193
|
+
|
|
194
|
+
# The MCU is drawn on only a couple of sheets; skip the rest rather than hardcoding names.
|
|
195
|
+
mcu_sheets = [s for s in sheets if contains_part(s, part)]
|
|
196
|
+
|
|
197
|
+
# Sheets exist but none draws the MCU: a renamed part, a moved directory, or a bad
|
|
198
|
+
# --dir. Falling through would produce an empty map and let a check pass having
|
|
199
|
+
# validated no pins at all.
|
|
200
|
+
if not mcu_sheets:
|
|
201
|
+
raise SchDocError(
|
|
202
|
+
f"none of the {len(sheets)} .SchDoc under {directory} contains {part}. "
|
|
203
|
+
"Wrong --part, wrong --schematic-dir, or the symbol was renamed."
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
instances: set[str] = set()
|
|
207
|
+
selected_sheets = []
|
|
208
|
+
for sheet in mcu_sheets:
|
|
209
|
+
records = load_records(sheet)
|
|
210
|
+
designators = {
|
|
211
|
+
int(f["OwnerIndex"]) + 1: text_of(f) for f in records
|
|
212
|
+
if f.get("RECORD") == REC_DESIGNATOR and f.get("OwnerIndex")
|
|
213
|
+
}
|
|
214
|
+
matched = False
|
|
215
|
+
for index, fields in enumerate(records):
|
|
216
|
+
if fields.get("RECORD") != REC_COMPONENT or part_hint not in fields.get("LibReference", ""):
|
|
217
|
+
continue
|
|
218
|
+
name = designators.get(index)
|
|
219
|
+
if component is not None and name != component:
|
|
220
|
+
continue
|
|
221
|
+
# An unannotated designator (`U?`) names no instance: two of them are two MCUs.
|
|
222
|
+
annotated = name and "?" not in name
|
|
223
|
+
instances.add(name if annotated else f"{name or 'unnamed'} ({sheet.name} record {index})")
|
|
224
|
+
matched = True
|
|
225
|
+
if matched:
|
|
226
|
+
selected_sheets.append(sheet)
|
|
227
|
+
if not instances:
|
|
228
|
+
raise SchDocError("selected MCU component was not found; check --schematic-component")
|
|
229
|
+
if len(instances) > 1:
|
|
230
|
+
raise SchDocError(
|
|
231
|
+
"several MCU instances found (" + ", ".join(sorted(instances))
|
|
232
|
+
+ "); annotate the schematic or select one with --schematic-component"
|
|
233
|
+
)
|
|
234
|
+
pin_map = SchematicPinMap(part=part, source=str(directory))
|
|
235
|
+
for sheet in selected_sheets:
|
|
236
|
+
pins, slip = _resolve_sheet(sheet, part_hint, component)
|
|
237
|
+
for number, pin in pins.items():
|
|
238
|
+
previous = pin_map.pins.get(number)
|
|
239
|
+
if previous and (previous.name, previous.net, previous.resolution) != (pin.name, pin.net, pin.resolution):
|
|
240
|
+
raise SchDocError(f"conflicting MCU package pin {number} across sheets")
|
|
241
|
+
pin_map.pins[number] = pin
|
|
242
|
+
pin_map.near_misses += slip["near"]
|
|
243
|
+
pin_map.exact_landings += slip["exact"]
|
|
244
|
+
return pin_map
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def to_dict(pin_map: SchematicPinMap) -> dict:
|
|
248
|
+
"""The JSON shape shared by `enginehall altium pinmap --format json` and the MCP tool.
|
|
249
|
+
|
|
250
|
+
One builder, because the two used to be separate literals that had already drifted. Pins are
|
|
251
|
+
keyed by pad, the one name that is unique: every VSS of a part shares its name, and keyed by
|
|
252
|
+
name an open supply pin vanished behind a connected one.
|
|
253
|
+
"""
|
|
254
|
+
return {
|
|
255
|
+
"part": pin_map.part,
|
|
256
|
+
"source": pin_map.source,
|
|
257
|
+
"pins": {
|
|
258
|
+
p.package_pin: {
|
|
259
|
+
"package_pin": p.package_pin,
|
|
260
|
+
"port_pin": p.port_pin,
|
|
261
|
+
"name": p.name,
|
|
262
|
+
"net": p.net,
|
|
263
|
+
"resolution": str(p.resolution),
|
|
264
|
+
"sheet": Path(p.sheet).name,
|
|
265
|
+
"component": p.component,
|
|
266
|
+
}
|
|
267
|
+
for p in sorted(pin_map.pins.values(), key=lambda p: natural_key(p.package_pin))
|
|
268
|
+
},
|
|
269
|
+
"mislabelled": mislabelled(pin_map),
|
|
270
|
+
"suspected_open": suspected_opens(pin_map),
|
|
271
|
+
"exact_landings": pin_map.exact_landings,
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def suspected_opens(pin_map: SchematicPinMap) -> list[dict]:
|
|
276
|
+
"""Near-misses that are actually open circuits.
|
|
277
|
+
|
|
278
|
+
A near-miss only means an open circuit if the pin also failed to resolve to any net.
|
|
279
|
+
Something sitting near a pin that IS connected is just an adjacent part.
|
|
280
|
+
"""
|
|
281
|
+
return [
|
|
282
|
+
n
|
|
283
|
+
for n in pin_map.near_misses
|
|
284
|
+
if pin_map.pins.get(n["package_pin"])
|
|
285
|
+
and pin_map.pins[n["package_pin"]].resolution == Resolution.NONE
|
|
286
|
+
]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# --- Net-label naming convention -------------------------------------------------------
|
|
290
|
+
#
|
|
291
|
+
# Some designs prefix every net label with the pin it belongs to ("PA5_SPI1_SCK"). Where
|
|
292
|
+
# that convention holds, a label whose prefix disagrees with the pin geometry places it on
|
|
293
|
+
# is a real defect worth reporting.
|
|
294
|
+
#
|
|
295
|
+
# It is a *convention*, not a law, so it is detected rather than assumed. Asserting it on a
|
|
296
|
+
# project that never adopted it would flag every net on the board.
|
|
297
|
+
|
|
298
|
+
_PIN_PREFIX = re.compile(r"^(P[A-KN-Q]\d{1,2}(?:_C)?)_", re.IGNORECASE)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def claimed_pin(net: str | None) -> str | None:
|
|
302
|
+
"""Return the pin a net label claims to be on, if it claims one.
|
|
303
|
+
|
|
304
|
+
Case-insensitive, like signal normalization: `pa5_spi1_sck` claims PA5 as plainly as
|
|
305
|
+
`PA5_SPI1_SCK` does.
|
|
306
|
+
"""
|
|
307
|
+
m = _PIN_PREFIX.match(net or "")
|
|
308
|
+
return m.group(1).upper() if m else None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def uses_pin_prefix_convention(pin_map: SchematicPinMap, threshold: float = 0.5) -> bool:
|
|
312
|
+
"""Does this design prefix net labels with the pin name?"""
|
|
313
|
+
nets = [
|
|
314
|
+
p.net for p in pin_map.pins.values() if p.net and not is_power_pin(p.name)
|
|
315
|
+
]
|
|
316
|
+
if len(nets) < 8: # too few to infer a convention from
|
|
317
|
+
return False
|
|
318
|
+
claimed = sum(1 for n in nets if claimed_pin(n))
|
|
319
|
+
return claimed / len(nets) >= threshold
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def mislabelled(pin_map: SchematicPinMap) -> list[tuple[str, str, str]]:
|
|
323
|
+
"""(actual pin, pin the label claims, net text) for every disagreement.
|
|
324
|
+
|
|
325
|
+
Geometry wins; the label is wrong.
|
|
326
|
+
"""
|
|
327
|
+
if not uses_pin_prefix_convention(pin_map):
|
|
328
|
+
return []
|
|
329
|
+
out = []
|
|
330
|
+
for pin in pin_map.pins.values():
|
|
331
|
+
if is_power_pin(pin.name):
|
|
332
|
+
continue
|
|
333
|
+
claim = claimed_pin(pin.net)
|
|
334
|
+
if claim and claim != pin.port_pin:
|
|
335
|
+
out.append((pin.port_pin, claim, pin.net))
|
|
336
|
+
return sorted(out)
|