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.
Files changed (75) hide show
  1. enginehall-2.0.0.dist-info/METADATA +511 -0
  2. enginehall-2.0.0.dist-info/RECORD +75 -0
  3. enginehall-2.0.0.dist-info/WHEEL +4 -0
  4. enginehall-2.0.0.dist-info/entry_points.txt +4 -0
  5. enginehall-2.0.0.dist-info/licenses/LICENSE +51 -0
  6. pcba/THIRD_PARTY_NOTICES.md +72 -0
  7. pcba/__init__.py +6 -0
  8. pcba/adapters/__init__.py +10 -0
  9. pcba/adapters/altium_bridge/MANIFEST.json +5 -0
  10. pcba/adapters/altium_bridge/__init__.py +1 -0
  11. pcba/adapters/altium_bridge/bridge.pas +3342 -0
  12. pcba/adapters/altium_drive.py +1160 -0
  13. pcba/adapters/altium_house.py +591 -0
  14. pcba/adapters/altium_ops.py +858 -0
  15. pcba/adapters/altium_outjob.py +247 -0
  16. pcba/adapters/altium_uia.py +304 -0
  17. pcba/adapters/altium_vcs.py +197 -0
  18. pcba/adapters/altium_win32.py +457 -0
  19. pcba/adapters/cubemx_drive.py +248 -0
  20. pcba/baseline.py +151 -0
  21. pcba/changes.py +81 -0
  22. pcba/cli.py +891 -0
  23. pcba/components.py +323 -0
  24. pcba/config.py +180 -0
  25. pcba/data/components/generic.json +61 -0
  26. pcba/discover.py +350 -0
  27. pcba/doctor.py +131 -0
  28. pcba/house.py +123 -0
  29. pcba/mcp/__init__.py +5 -0
  30. pcba/mcp/altium_server.py +1358 -0
  31. pcba/mcp/protocol.py +224 -0
  32. pcba/mcp/server.py +332 -0
  33. pcba/model/__init__.py +38 -0
  34. pcba/model/design.py +232 -0
  35. pcba/model/finding.py +148 -0
  36. pcba/model/pinmap.py +200 -0
  37. pcba/parsers/__init__.py +0 -0
  38. pcba/parsers/altium/__init__.py +37 -0
  39. pcba/parsers/altium/_sheet.py +293 -0
  40. pcba/parsers/altium/geometry.py +103 -0
  41. pcba/parsers/altium/netlist.py +302 -0
  42. pcba/parsers/altium/pinmap.py +336 -0
  43. pcba/parsers/altium/project.py +249 -0
  44. pcba/parsers/altium/records.py +227 -0
  45. pcba/parsers/firmware/__init__.py +5 -0
  46. pcba/parsers/firmware/index.py +250 -0
  47. pcba/parsers/ioc/__init__.py +49 -0
  48. pcba/parsers/ioc/mcu_db.py +180 -0
  49. pcba/parsers/ioc/reader.py +182 -0
  50. pcba/parsers/ioc/validate.py +192 -0
  51. pcba/parsers/kicad/__init__.py +103 -0
  52. pcba/parsers/kicad/board.py +269 -0
  53. pcba/parsers/kicad/kicad_cli.py +190 -0
  54. pcba/parsers/kicad/pinmap.py +90 -0
  55. pcba/parsers/kicad/schematic.py +517 -0
  56. pcba/parsers/kicad/sexpr.py +136 -0
  57. pcba/publish.py +269 -0
  58. pcba/report/__init__.py +7 -0
  59. pcba/report/json_report.py +39 -0
  60. pcba/report/sarif.py +128 -0
  61. pcba/report/text.py +304 -0
  62. pcba/requirements.py +320 -0
  63. pcba/review.py +177 -0
  64. pcba/rules/__init__.py +312 -0
  65. pcba/rules/cross_domain.py +496 -0
  66. pcba/rules/kicad_board.py +775 -0
  67. pcba/rules/kicad_common.py +167 -0
  68. pcba/rules/kicad_components.py +603 -0
  69. pcba/rules/kicad_schematic.py +621 -0
  70. pcba/rules/locate.py +22 -0
  71. pcba/rules/schematic.py +123 -0
  72. pcba/rules/signals.py +149 -0
  73. pcba/rules/spice.py +59 -0
  74. pcba/rules/stm32.py +155 -0
  75. pcba/simulation.py +143 -0
@@ -0,0 +1,293 @@
1
+ """The sheet resolver shared by the pin-map and netlist extractors.
2
+
3
+ Both extractors must agree on the format traps from `records.py`: the designator child
4
+ record, the multi-part filter, the pin's electrical end, and point-to-segment wire contact.
5
+ They must also agree on what joins and names a net: net labels, power ports, ports and
6
+ junctions, and which name wins when a net carries several. All of it lives here once, in
7
+ `resolve`, so `enginehall altium netlist` and the pin map the rules trust cannot name the same
8
+ pin's net differently.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import dataclasses
14
+ from collections.abc import Hashable, Iterable
15
+ from pathlib import Path
16
+
17
+ from pcba.parsers.altium.geometry import Point, PointIndex, UnionFind, key
18
+ from pcba.parsers.altium.records import (
19
+ PIN_DIRECTION,
20
+ REC_COMPONENT,
21
+ REC_DESIGNATOR,
22
+ REC_JUNCTION,
23
+ REC_NET_LABEL,
24
+ REC_PIN,
25
+ REC_PORT,
26
+ REC_POWER_PORT,
27
+ REC_WIRE,
28
+ SchDocError,
29
+ coord,
30
+ text_of,
31
+ )
32
+
33
+ # Naming precedence when one net carries several names; lower wins. A rail's identity is not
34
+ # up for debate, so a power port beats a net label, and a port names a net only when nothing
35
+ # else on it does.
36
+ RANK_POWER_PORT, RANK_NET_LABEL, RANK_PORT = 0, 1, 2
37
+
38
+ # Port `Style` 4-7 (None, Top, Bottom, Top & Bottom) run vertically; 0-3 horizontally.
39
+ _VERTICAL_PORT_STYLES = frozenset(range(4, 8))
40
+
41
+ Key = tuple[float, float] # a quantized point, as `geometry.key` returns it
42
+
43
+
44
+ def components(records: list[dict[str, str]]) -> dict[int, dict[str, str]]:
45
+ """Component records keyed by their index in the record stream."""
46
+ return {i: f for i, f in enumerate(records) if f.get("RECORD") == REC_COMPONENT}
47
+
48
+
49
+ def designators(records: list[dict[str, str]]) -> dict[int, str]:
50
+ """Component index -> designator. "U1" lives in a child record; see records.py."""
51
+ out: dict[int, str] = {}
52
+ for fields in records:
53
+ if fields.get("RECORD") == REC_DESIGNATOR and "OwnerIndex" in fields:
54
+ out.setdefault(int(fields["OwnerIndex"]) + 1, text_of(fields))
55
+ return out
56
+
57
+
58
+ def _part_id(fields: dict[str, str], name: str) -> int:
59
+ """A part number as an integer; 0 when the field is absent or blank.
60
+
61
+ Compared as integers, not strings: `" 1"` is part 1. `validate_fields` has already
62
+ refused anything but a blank-padded plain integer, as the TypeScript decoder does.
63
+ """
64
+ value = fields.get(name, "").strip(" \t")
65
+ return int(value) if value else 0
66
+
67
+
68
+ def placed_pins(
69
+ records: list[dict[str, str]],
70
+ comps: dict[int, dict[str, str]],
71
+ names: dict[int, str],
72
+ ) -> list[dict]:
73
+ """Every pin actually placed on the sheet, with its electrical (hot) end.
74
+
75
+ Keys: `ci` (component index), `num` (pad designator), `name`, `hot`, `lib`, `designator`.
76
+ """
77
+ pins = []
78
+ for fields in records:
79
+ if fields.get("RECORD") != REC_PIN or "OwnerIndex" not in fields:
80
+ continue
81
+ # OwnerIndex is 0-based over the records; the component index is OwnerIndex + 1.
82
+ ci = int(fields["OwnerIndex"]) + 1
83
+ if ci not in comps:
84
+ continue
85
+ # The instance places part `CurrentPartId`; a pin of another part shares its
86
+ # coordinates but is not on this sheet. Part 0, a negative id, or none at all is
87
+ # Altium's part zero: shared by every part, so always placed. An instance without a
88
+ # positive CurrentPartId places every pin. The extension's parser agrees; both run
89
+ # fixtures/conformance/schdoc-placed-pins.json.
90
+ current = _part_id(comps[ci], "CurrentPartId")
91
+ owner = _part_id(fields, "OwnerPartId")
92
+ if current > 0 and owner > 0 and owner != current:
93
+ continue # a different part of this multi-part symbol; not placed here
94
+ x, y = coord(fields, "Location.X"), coord(fields, "Location.Y")
95
+ length = int(fields.get("PinLength", 0) or 0)
96
+ dx, dy = PIN_DIRECTION[int(fields.get("PinConglomerate", 0) or 0) & 3]
97
+ pins.append(
98
+ {
99
+ "ci": ci,
100
+ # A pad designator, kept as text: BGA parts use "A1", "B12", and an int()
101
+ # here took the whole review down on the first BGA schematic it met.
102
+ "num": fields.get("Designator", "").strip(),
103
+ "name": fields.get("Name", ""),
104
+ # The electrical end, not the body-attach end.
105
+ "hot": (x + dx * length, y + dy * length),
106
+ "lib": comps[ci].get("LibReference", ""),
107
+ "designator": names.get(ci, ""),
108
+ }
109
+ )
110
+ return pins
111
+
112
+
113
+ def wire_segments(records: list[dict[str, str]]) -> list[tuple[Point, Point]]:
114
+ """Every wire (RECORD=27) as consecutive vertex pairs."""
115
+ wires = []
116
+ for fields in records:
117
+ if fields.get("RECORD") != REC_WIRE:
118
+ continue
119
+ n = int(fields.get("LocationCount", "0") or 0)
120
+ verts = [(coord(fields, f"X{k}"), coord(fields, f"Y{k}")) for k in range(1, n + 1)]
121
+ wires += list(zip(verts, verts[1:]))
122
+ return wires
123
+
124
+
125
+ def connect(
126
+ wires: list[tuple[Point, Point]], points: set[tuple[float, float]]
127
+ ) -> tuple[UnionFind, set[tuple[float, float]]]:
128
+ """Union every wire with the (quantized) points that touch it, point-to-segment.
129
+
130
+ Returns the union-find and the set of points that lie on some wire.
131
+ """
132
+ uf = UnionFind()
133
+ index = PointIndex(points)
134
+ wire_contacts: set[tuple[float, float]] = set()
135
+ for a, b in wires:
136
+ ka = key(a)
137
+ uf.union(ka, key(b))
138
+ for q in index.near_segment(a, b):
139
+ uf.union(q, ka)
140
+ wire_contacts.add(q)
141
+ return uf, wire_contacts
142
+
143
+
144
+ @dataclasses.dataclass(frozen=True, slots=True)
145
+ class Anchors:
146
+ """Everything on a sheet, besides wires and pins, that joins or names a net."""
147
+
148
+ names: list[tuple[Point, str, int]] # (anchor, text, rank); the text may be blank
149
+ junctions: list[Point]
150
+ ports: list[tuple[Point, Point]] # one object, two hotspots
151
+
152
+
153
+ def _location(fields: dict[str, str]) -> Point:
154
+ return (coord(fields, "Location.X"), coord(fields, "Location.Y"))
155
+
156
+
157
+ def anchors(records: list[dict[str, str]]) -> Anchors:
158
+ """Net labels, power ports, ports and junctions, with each name's rank.
159
+
160
+ A port touches at both ends: `Location`, and `Width` further along +X for the horizontal
161
+ styles 0-3 or along +Y for the vertical styles 4-7. Its name is anchored at `Location`;
162
+ the two ends are joined by `resolve`. Raises ValueError on a port `Width` or `Style` that
163
+ is not an integer: `validate_fields` does not cover them.
164
+ """
165
+ names: list[tuple[Point, str, int]] = []
166
+ junctions: list[Point] = []
167
+ ports: list[tuple[Point, Point]] = []
168
+ for fields in records:
169
+ kind = fields.get("RECORD")
170
+ if kind == REC_JUNCTION:
171
+ junctions.append(_location(fields))
172
+ elif kind in (REC_NET_LABEL, REC_POWER_PORT):
173
+ rank = RANK_POWER_PORT if kind == REC_POWER_PORT else RANK_NET_LABEL
174
+ names.append((_location(fields), text_of(fields), rank))
175
+ elif kind == REC_PORT:
176
+ a = _location(fields)
177
+ width = coord(fields, "Width")
178
+ if int(fields.get("Style", 0) or 0) in _VERTICAL_PORT_STYLES:
179
+ b = (a[0], a[1] + width)
180
+ else:
181
+ b = (a[0] + width, a[1])
182
+ name = (fields.get("%UTF8%Name") or fields.get("Name", "")).strip()
183
+ names.append((a, name, RANK_PORT))
184
+ ports.append((a, b))
185
+ return Anchors(names, junctions, ports)
186
+
187
+
188
+ def precedence(name: str, rank: int) -> tuple[int, str]:
189
+ """Sort key for the names on one net: the lowest rank, then the smallest text, wins.
190
+
191
+ Text, not record order, breaks a tie, so the answer does not depend on which label was
192
+ drawn first. Two names on one net are a conflict either way; the netlist lists them.
193
+ """
194
+ return (rank, name)
195
+
196
+
197
+ @dataclasses.dataclass(frozen=True, slots=True)
198
+ class Sheet:
199
+ """One sheet resolved: its placed pins, the clusters its geometry forms, the names on them."""
200
+
201
+ components: dict[int, dict[str, str]]
202
+ designators: dict[int, str]
203
+ pins: list[dict] # `placed_pins`
204
+ uf: UnionFind
205
+ wire_contacts: set[Key] # points that lie on some wire
206
+ names: list[tuple[Key, str, int]] # (cluster root, text, rank); blank names dropped
207
+
208
+ def root(self, point: Point) -> Key:
209
+ """The cluster a point belongs to."""
210
+ return self.uf.find(key(point))
211
+
212
+ def named(self) -> dict[Key, str]:
213
+ """Cluster root -> the name that wins on its net, for every named cluster on this sheet.
214
+
215
+ Clusters carrying the same identifier type and name are one net, so each takes
216
+ the winner over that net's names: a pin wired only to port `P` is on `SIGNAL` when
217
+ another port `P` on the sheet also carries that label.
218
+ """
219
+ nets = join_by_name(self.names)
220
+ winners = {net: nets.winner(net) for net in nets.carried()}
221
+ return {root: winners[nets.uf.find(root)] for root, _, _ in self.names}
222
+
223
+
224
+ def chosen(names: Iterable[tuple[str, int]]) -> str:
225
+ """The winning name among `(text, rank)` pairs on one net; see `precedence`."""
226
+ return min(names, key=lambda item: precedence(*item))[0]
227
+
228
+
229
+ @dataclasses.dataclass(frozen=True, slots=True)
230
+ class Nets:
231
+ """Clusters joined by matching identifier types and names."""
232
+
233
+ uf: UnionFind # over the callers' cluster keys and ("name", rank, text)
234
+ rank_of: dict[Hashable, dict[str, int]] # each net's own names and best ranks
235
+
236
+ def carried(self) -> dict[Hashable, set[str]]:
237
+ """Net (its union-find root) -> every name on it."""
238
+ return {root: set(names) for root, names in self.rank_of.items()}
239
+
240
+ def winner(self, root: Hashable) -> str:
241
+ """The name that wins among names on one net."""
242
+ return chosen(self.rank_of[self.uf.find(root)].items())
243
+
244
+
245
+ def join_by_name(named: Iterable[tuple[Hashable, str, int]]) -> Nets:
246
+ """Join clusters sharing the same identifier type and text, never text alone.
247
+
248
+ The pin map joins the clusters of one sheet; the netlist joins those of every sheet,
249
+ keyed `("sheet", index, root)`. Rank also identifies the anchor type (power, label,
250
+ port). Different types meet only through a shared geometric cluster. A cluster key
251
+ must not look like `("name", rank, text)`.
252
+ """
253
+ uf = UnionFind()
254
+ identifiers: set[tuple[str, int]] = set()
255
+ for cluster, name, rank in named:
256
+ uf.union(cluster, ("name", rank, name))
257
+ identifiers.add((name, rank))
258
+ rank_of: dict[Hashable, dict[str, int]] = {}
259
+ for name, rank in identifiers:
260
+ names = rank_of.setdefault(uf.find(("name", rank, name)), {})
261
+ names[name] = min(rank, names.get(name, rank))
262
+ return Nets(uf, rank_of)
263
+
264
+
265
+ def resolve(path: Path | str, records: list[dict[str, str]]) -> Sheet:
266
+ """Resolve one sheet's connectivity: the one resolver both extractors use.
267
+
268
+ Wires join point-to-segment; a junction joins every wire passing through it, so two
269
+ crossing wires connect only where a junction sits; a port joins its two ends; pins whose
270
+ electrical ends coincide join without a wire. Every pin takes part in the geometry, even
271
+ one that cannot be a node: an end lying on two crossing wires still joins them.
272
+ """
273
+ comps = components(records)
274
+ names = designators(records)
275
+ pins = placed_pins(records, comps, names)
276
+ wires = wire_segments(records)
277
+ try:
278
+ found = anchors(records)
279
+ except ValueError as exc:
280
+ raise SchDocError(f"{path}: invalid field value ({exc})") from exc
281
+
282
+ # One flat point set: wire vertices, pin hot ends, anchors, junctions and port ends.
283
+ points = {key(v) for seg in wires for v in seg}
284
+ points |= {key(p["hot"]) for p in pins}
285
+ points |= {key(at) for at, _, _ in found.names}
286
+ points |= {key(j) for j in found.junctions}
287
+ points |= {key(v) for port in found.ports for v in port}
288
+ uf, wire_contacts = connect(wires, points)
289
+ for a, b in found.ports:
290
+ uf.union(key(a), key(b))
291
+
292
+ named = [(uf.find(key(at)), text, rank) for at, text, rank in found.names if text]
293
+ return Sheet(comps, names, pins, uf, wire_contacts, named)
@@ -0,0 +1,103 @@
1
+ """Connectivity primitives for schematic geometry.
2
+
3
+ Why geometric rather than text
4
+ ------------------------------
5
+ A design that encodes the pin in the net label ("PA5_SPI1_SCK") invites a text scan. On a
6
+ real 176-pin draft, four labels did not match the pin they attach to — so a text scan
7
+ silently produces a wrong map, and produces it *confidently*. Connectivity is resolved from
8
+ geometry; the label supplies only the name. The mislabel rule then reports the disagreement
9
+ instead of hiding it.
10
+
11
+ The subtle part is that connectivity is point-to-**segment**, not point-to-point. A net-label
12
+ anchor normally lies in the INTERIOR of a wire segment, not on a vertex, so comparing labels
13
+ against vertices needs a fudge radius — and a radius large enough to work will grab a
14
+ neighbouring row.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import collections
20
+ import math
21
+ from collections.abc import Iterable
22
+
23
+ Point = tuple[float, float]
24
+
25
+ # Coordinates are integers on a 10-unit grid, so a sub-unit tolerance is exact in practice
26
+ # while still absorbing the fractional sub-step.
27
+ ON_SEGMENT = 0.5
28
+
29
+ # A non-MCU hot end this close to an MCU hot end, without touching it, is a placement slip
30
+ # rather than a connection. Altium's grid here is 10 units.
31
+ NEAR_MISS = 15.0
32
+
33
+
34
+ class UnionFind:
35
+ """Path-compressing union-find over hashable point keys."""
36
+
37
+ __slots__ = ("parent",)
38
+
39
+ def __init__(self) -> None:
40
+ self.parent: dict = {}
41
+
42
+ def find(self, x):
43
+ self.parent.setdefault(x, x)
44
+ while self.parent[x] != x:
45
+ self.parent[x] = self.parent[self.parent[x]]
46
+ x = self.parent[x]
47
+ return x
48
+
49
+ def union(self, a, b) -> None:
50
+ ra, rb = self.find(a), self.find(b)
51
+ if ra != rb:
52
+ self.parent[ra] = rb
53
+
54
+
55
+ def point_to_segment(p: Point, a: Point, b: Point) -> float:
56
+ """Distance from point `p` to the segment `a`-`b` (not to the infinite line)."""
57
+ (px, py), (ax, ay), (bx, by) = p, a, b
58
+ dx, dy = bx - ax, by - ay
59
+ length_sq = dx * dx + dy * dy
60
+ if length_sq == 0:
61
+ return math.hypot(px - ax, py - ay)
62
+ t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / length_sq))
63
+ return math.hypot(px - (ax + t * dx), py - (ay + t * dy))
64
+
65
+
66
+ def key(p: Point) -> tuple[float, float]:
67
+ """Quantize a point so two coordinates that differ only in float noise collide."""
68
+ return (round(p[0], 3), round(p[1], 3))
69
+
70
+
71
+ class PointIndex:
72
+ """Points bucketed by row and by column, so a segment only tests its own row.
73
+
74
+ Testing every point against every wire segment is O(segments x points), and a dense
75
+ MCU sheet has a few thousand of each: measured at 9.5 s for 4,000 segments. Schematic
76
+ wires are axis-aligned almost without exception, and a point within `tolerance` of a
77
+ horizontal segment must lie in one of two unit-height rows. Bucketing by floor(y) and
78
+ floor(x) narrows the candidates to those rows; the distance test itself is unchanged,
79
+ so the result is identical. A diagonal segment falls back to the full scan.
80
+ """
81
+
82
+ __slots__ = ("points", "rows", "cols")
83
+
84
+ def __init__(self, points: Iterable[Point]) -> None:
85
+ self.points = list(points)
86
+ self.rows: dict[int, list[Point]] = collections.defaultdict(list)
87
+ self.cols: dict[int, list[Point]] = collections.defaultdict(list)
88
+ for p in self.points:
89
+ self.rows[math.floor(p[1])].append(p)
90
+ self.cols[math.floor(p[0])].append(p)
91
+
92
+ def near_segment(self, a: Point, b: Point, tolerance: float = ON_SEGMENT) -> list[Point]:
93
+ """Every indexed point closer than `tolerance` to the segment `a`-`b`."""
94
+ if a[1] == b[1]:
95
+ buckets, at = self.rows, a[1]
96
+ elif a[0] == b[0]:
97
+ buckets, at = self.cols, a[0]
98
+ else:
99
+ return [q for q in self.points if point_to_segment(q, a, b) < tolerance]
100
+ candidates: list[Point] = []
101
+ for bucket in range(math.floor(at - tolerance), math.floor(at + tolerance) + 1):
102
+ candidates += buckets.get(bucket, ())
103
+ return [q for q in candidates if point_to_segment(q, a, b) < tolerance]