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,621 @@
1
+ """KiCad schematic rules: KiCad's own ERC, the netlist cross-check, and electrical checks ERC
2
+ does not make (or makes only when its settings are right).
3
+
4
+ Each rule reads the connectivity the engine built from wire geometry. Where kicad-cli is
5
+ installed, `kicad.netlist.parity` compares that connectivity with KiCad's own netlist, so a
6
+ reader mistake shows up as a finding rather than as a quietly wrong check.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections import defaultdict
13
+ from collections.abc import Iterator
14
+
15
+ from pcba.model.design import Net
16
+ from pcba.model.finding import Detector, Domain, Evidence, Finding, Location, Severity
17
+ from pcba.model.pinmap import Project
18
+ from pcba.requirements import voltage_from_name
19
+ from pcba.rules.kicad_common import (
20
+ at_component,
21
+ at_label,
22
+ at_net,
23
+ at_pin,
24
+ classify,
25
+ is_ground_net,
26
+ is_supply_rail,
27
+ net_display,
28
+ other_nets,
29
+ parts,
30
+ pin_label,
31
+ pin_numbers,
32
+ requirements,
33
+ schematic,
34
+ sheet_of,
35
+ )
36
+
37
+ EXTERNAL_CONFIDENCE = 0.95
38
+ # Library-table and footprint-filter messages describe the machine that ran kicad-cli more than
39
+ # the design; they are kept, as information.
40
+ _CONFIGURATION = {"lib_symbol_issues", "lib_symbol_mismatch", "footprint_link_issues", "footprint_filter",
41
+ "lib_footprint_issues", "lib_footprint_mismatch"}
42
+ _ERC_CONSEQUENCE = {
43
+ "power_pin_not_driven": "KiCad cannot see where this rail is supplied; either a source is missing or the "
44
+ "rail enters through a part KiCad does not treat as a source.",
45
+ "pin_not_connected": "An unconnected pin is either a missing wire or an intentionally open pin without a "
46
+ "no-connect flag; the two cannot be told apart in review.",
47
+ "pin_not_driven": "An input with no driver floats on the board.",
48
+ "label_dangling": "A label that touches nothing names no net; the connection it suggests does not exist.",
49
+ "pin_to_pin": "Two pin types that must not meet are connected (for example two outputs).",
50
+ }
51
+ DC_PATH_KINDS = ("resistor", "inductor", "ferrite", "fuse", "jumper", "net_tie")
52
+ DC_PATH_MAX_OHM = 1.0
53
+ DC_PATH_DEPTH = 3
54
+ _I2C = re.compile(r"(?:^|[_/])(?:I2C\d*_?)?(SDA|SCL)\d*$", re.I)
55
+
56
+
57
+ # --- KiCad ERC --------------------------------------------------------------------------------
58
+
59
+
60
+ def _item_location(text: str) -> tuple[str | None, str | None]:
61
+ """(reference, pin) named in a kicad-cli item description, e.g. "Symbol U3 Pin 3 [A2, Input,
62
+ Line]", "Pad 6 [/TX] of U1 on F.Cu", "PTH pad 4 [GND] of J2", "Footprint C2", "Reference field
63
+ of R6"."""
64
+ match = re.search(r"Symbol (\S+) Pin (\S+)", text)
65
+ if match:
66
+ return match.group(1), match.group(2)
67
+ match = re.search(r"[Pp]ad (\S+) (?:\[[^\]]*\] )?of (\S+)", text)
68
+ if match:
69
+ return match.group(2), match.group(1)
70
+ match = re.search(r"(?:Symbol|Footprint) (\S+)", text) or re.search(r"\bof (\S+)", text)
71
+ return (match.group(1), None) if match else (None, None)
72
+
73
+
74
+ def _severity(kicad: str, kind: str) -> Severity | None:
75
+ if kind in _CONFIGURATION:
76
+ return Severity.INFO
77
+ return {"error": Severity.ERROR, "warning": Severity.WARNING}.get(kicad)
78
+
79
+
80
+ def erc_violation(project: Project) -> Iterator[Finding]:
81
+ """Every violation KiCad's ERC reports, at the sheet and symbol it names."""
82
+ sch = schematic(project)
83
+ run = (project.design.external.get("erc") or {}) if project.design else {}
84
+ if sch is None or not run.get("ran"):
85
+ return
86
+ report = run["report"]
87
+ for sheet in report.get("sheets", []):
88
+ file = sch.sheet_paths.get(sheet.get("path", "/"), sch.root_file)
89
+ for violation in sheet.get("violations", []):
90
+ kind = violation.get("type", "erc")
91
+ severity = _severity(violation.get("severity", ""), kind)
92
+ if severity is None: # exclusions the designer accepted in KiCad
93
+ continue
94
+ items = violation.get("items", [])
95
+ first = items[0] if items else {}
96
+ pos = first.get("pos", {})
97
+ ref, pin = _item_location(first.get("description", ""))
98
+ location = Location(artifact=project.relative(file), sheet=sheet_of(sheet.get("path", "/")),
99
+ component=ref, pin=pin,
100
+ key=f"{kind}@{pos.get('x', 0):.2f},{pos.get('y', 0):.2f}")
101
+ evidence = tuple(
102
+ Evidence(kind="kicad_erc", value=f"{item.get('description', '')} at "
103
+ f"({item.get('pos', {}).get('x', 0):.2f}, {item.get('pos', {}).get('y', 0):.2f}) mm",
104
+ source=f"kicad-cli {run.get('version') or ''} sch erc".replace(" ", " "))
105
+ for item in items) or (Evidence(kind="kicad_erc", value=violation.get("description", kind)),)
106
+ configuration = kind in _CONFIGURATION
107
+ yield Finding(
108
+ rule_id="kicad.erc.violation",
109
+ title=f"ERC {kind}: {violation.get('description', '')}",
110
+ severity=severity,
111
+ domain=Domain.SCHEMATIC,
112
+ location=location,
113
+ evidence=evidence,
114
+ explanation=(f"KiCad's electrical rules check reports {kind!r}"
115
+ + (" — this concerns the library setup of the machine that ran kicad-cli as much "
116
+ "as the design." if configuration else ".")),
117
+ consequence=_ERC_CONSEQUENCE.get(kind, "An ERC violation left in a released schematic is either a "
118
+ "defect or a decision nobody recorded."),
119
+ recommendation=("Fix it in the schematic, or exclude it in KiCad's ERC dialog with a comment so the "
120
+ "decision is visible."),
121
+ detector=Detector.EXTERNAL,
122
+ confidence=EXTERNAL_CONFIDENCE,
123
+ )
124
+
125
+
126
+ # --- netlist cross-check ----------------------------------------------------------------------
127
+
128
+
129
+ def netlist_parity(project: Project) -> Iterator[Finding]:
130
+ """The engine's connectivity against KiCad's own exported netlist."""
131
+ sch = schematic(project)
132
+ run = (project.design.external.get("netlist") or {}) if project.design else {}
133
+ if sch is None or not run.get("ran"):
134
+ return
135
+ theirs: dict[tuple[str, str], str] = {}
136
+ for name, nodes in run["report"].items():
137
+ for ref, pin in nodes:
138
+ if not ref.startswith("#"):
139
+ theirs[(ref, pin)] = name
140
+ ours: dict[tuple[str, str], Net] = {}
141
+ for net in sch.nets:
142
+ for pin in net.parts:
143
+ ours[(pin.ref, pin.number)] = net
144
+ source = f"kicad-cli {run.get('version') or ''} sch export netlist".replace(" ", " ")
145
+ reported: set[str] = set()
146
+ for net in sch.nets:
147
+ names = sorted({theirs[(p.ref, p.number)] for p in net.parts if (p.ref, p.number) in theirs})
148
+ if len(names) > 1 and net.name not in reported:
149
+ reported.add(net.name)
150
+ yield _parity(project, net, f"the engine joins {len(names)} KiCad nets into {net_display(net)!r}",
151
+ [f"KiCad keeps apart: {', '.join(names)}"], source)
152
+ by_their_net: dict[str, set[str]] = defaultdict(set)
153
+ for node, name in theirs.items():
154
+ if node in ours:
155
+ by_their_net[name].add(ours[node].name)
156
+ for name, our_names in sorted(by_their_net.items()):
157
+ if len(our_names) > 1:
158
+ net = next(n for n in sch.nets if n.name == sorted(our_names)[0])
159
+ if net.name in reported:
160
+ continue
161
+ reported.add(net.name)
162
+ yield _parity(project, net, f"the engine splits KiCad's net {name!r} into {len(our_names)} nets",
163
+ [f"engine nets: {', '.join(sorted(our_names))}"], source)
164
+ missing = sorted(set(theirs) - set(ours))
165
+ by_ref: dict[str, list[str]] = defaultdict(list)
166
+ for ref, pin in missing:
167
+ by_ref[ref].append(pin)
168
+ for ref, pins in sorted(by_ref.items()):
169
+ component = sch.components.get(ref)
170
+ location = at_component(project, component, key="missing_pins") if component else Location(
171
+ artifact=project.relative(sch.root_file), component=ref, key="missing_pins")
172
+ yield Finding(
173
+ rule_id="kicad.netlist.parity",
174
+ title=f"KiCad's netlist has pins of {ref} the engine did not read",
175
+ severity=Severity.WARNING,
176
+ domain=Domain.SCHEMATIC,
177
+ location=location,
178
+ evidence=(Evidence(kind="kicad_netlist", value=f"{ref} pins {', '.join(sorted(pins))} in KiCad's netlist",
179
+ source=source),),
180
+ explanation="The engine has no connectivity for these pins, so every rule below skips them.",
181
+ consequence="Checks on this part would silently cover less than the design contains.",
182
+ recommendation="Check that the symbol is embedded in the sheet's lib_symbols and annotated; report the "
183
+ "case to EngineHall if it is.",
184
+ detector=Detector.EXTERNAL,
185
+ confidence=EXTERNAL_CONFIDENCE,
186
+ )
187
+
188
+
189
+ def _parity(project: Project, net: Net, what: str, extra: list[str], source: str) -> Finding:
190
+ return Finding(
191
+ rule_id="kicad.netlist.parity",
192
+ title=f"Connectivity disagrees with KiCad: {what}",
193
+ severity=Severity.WARNING,
194
+ domain=Domain.SCHEMATIC,
195
+ location=at_net(project, net),
196
+ evidence=(Evidence(kind="engine_net", value=f"{net.name}: " + ", ".join(pin_label(p) for p in net.parts[:12]),
197
+ source=project.relative(net.parts[0].sheet_file) if net.parts else None),
198
+ *(Evidence(kind="kicad_netlist", value=text, source=source) for text in extra)),
199
+ explanation=("EngineHall builds nets from wire geometry; KiCad's netlist export disagrees for this net. "
200
+ "Either the drawing relies on a connection rule the engine does not model (buses, for one), "
201
+ "or a wire does something the drawing hides."),
202
+ consequence="Rules that read this net may report or miss problems that are not there.",
203
+ recommendation="Open the net in KiCad and check which pins it really joins; trust KiCad's netlist for the "
204
+ "board.",
205
+ detector=Detector.EXTERNAL,
206
+ confidence=EXTERNAL_CONFIDENCE,
207
+ )
208
+
209
+
210
+ # --- rails ------------------------------------------------------------------------------------
211
+
212
+
213
+ def rail_without_source(project: Project) -> Iterator[Finding]:
214
+ """A net with power inputs but no power output, regulator output or PWR_FLAG."""
215
+ sch = schematic(project)
216
+ if sch is None:
217
+ return
218
+ for net in sch.nets:
219
+ inputs = [p for p in net.parts if p.type == "power_in"]
220
+ if not inputs or any(p.type == "power_out" for p in net.pins):
221
+ continue
222
+ yield Finding(
223
+ rule_id="kicad.power.rail_without_source",
224
+ title=f"Rail {net_display(net)!r} has power inputs but no source",
225
+ severity=Severity.ERROR,
226
+ domain=Domain.SCHEMATIC,
227
+ location=at_pin(project, inputs[0], key=net_display(net)),
228
+ evidence=(Evidence(kind="schematic_net", value=f"power inputs on {net_display(net)!r}: "
229
+ + ", ".join(pin_label(p) for p in inputs[:10]),
230
+ source=project.relative(inputs[0].sheet_file)),
231
+ Evidence(kind="schematic_net", value="no power output pin and no PWR_FLAG on the net")),
232
+ explanation=("Every pin on this net that is typed as a power input waits for a power output: a regulator "
233
+ "output, a power-output connector pin, or a PWR_FLAG where the rail enters the board."),
234
+ consequence=("Either the rail really has no source (a missing regulator or connection), or it enters "
235
+ "through a passive pin and KiCad's ERC keeps reporting it, which hides the next real one."),
236
+ recommendation=("Find where the rail is supplied. If it enters through a connector, fuse or diode, place a "
237
+ "PWR_FLAG there; if a regulator feeds it, type its output pin as power output."),
238
+ )
239
+
240
+
241
+ def _dc_neighbours(project: Project, net: Net) -> list[tuple[Net, str]]:
242
+ """Nets one conductive part away: resistors of at most 1 ohm, inductors, ferrites, fuses, jumpers."""
243
+ from pcba.components import parse_value
244
+
245
+ sch = schematic(project)
246
+ out = []
247
+ for pin in net.parts:
248
+ component = sch.components.get(pin.ref)
249
+ if component is None or component.dnp or len(pin_numbers(component)) != 2:
250
+ continue
251
+ kind = classify(component)
252
+ if kind not in DC_PATH_KINDS:
253
+ continue
254
+ if kind == "resistor":
255
+ ohms = parse_value(component.value)
256
+ if ohms is None or ohms > DC_PATH_MAX_OHM:
257
+ continue
258
+ for far in other_nets(sch, component, pin.number):
259
+ out.append((far, component.ref))
260
+ return out
261
+
262
+
263
+ def _capacitor_neighbours(project: Project, net: Net) -> list[tuple[Net, str]]:
264
+ sch = schematic(project)
265
+ out = []
266
+ for pin in net.parts:
267
+ component = sch.components.get(pin.ref)
268
+ if component is not None and classify(component) == "capacitor":
269
+ out.extend((far, component.ref) for far in other_nets(sch, component, pin.number))
270
+ return out
271
+
272
+
273
+ def _blocking_resistors(project: Project, net: Net) -> list[tuple[str, str]]:
274
+ """Resistors on the net that lead to a rail but are too large to count as a supply path."""
275
+ from pcba.components import parse_value
276
+
277
+ sch = schematic(project)
278
+ out = []
279
+ for pin in net.parts:
280
+ component = sch.components.get(pin.ref)
281
+ if component is None or classify(component) != "resistor":
282
+ continue
283
+ ohms = parse_value(component.value)
284
+ if (ohms is None or ohms > DC_PATH_MAX_OHM) and any(
285
+ _is_rail(project, far) for far in other_nets(sch, component, pin.number)):
286
+ out.append((component.ref, component.value))
287
+ return out
288
+
289
+
290
+ def _is_rail(project: Project, net: Net) -> bool:
291
+ return is_ground_net(net) or is_supply_rail(project, net)
292
+
293
+
294
+ def _dc_rail(project: Project, net: Net) -> tuple[Net, list[str]] | None:
295
+ """The first named rail reachable through conductive parts, with the parts on the way."""
296
+ seen = {net.name}
297
+ frontier: list[tuple[Net, list[str]]] = [(net, [])]
298
+ for _depth in range(DC_PATH_DEPTH + 1):
299
+ following = []
300
+ for current, path in frontier:
301
+ if _is_rail(project, current):
302
+ return current, path
303
+ for far, via in _dc_neighbours(project, current):
304
+ if far.name not in seen:
305
+ seen.add(far.name)
306
+ following.append((far, path + [via]))
307
+ frontier = following
308
+ return None
309
+
310
+
311
+ def pin_without_dc_path(project: Project) -> Iterator[Finding]:
312
+ """An IC power pin that reaches no named rail through a DC path."""
313
+ sch = schematic(project)
314
+ if sch is None:
315
+ return
316
+ for component in parts(project, "ic"):
317
+ if component.dnp:
318
+ continue
319
+ for pin in component.pins:
320
+ if pin.type != "power_in":
321
+ continue
322
+ net = sch.net_of(component.ref, pin.number)
323
+ if net is None or _dc_rail(project, net) is not None:
324
+ continue
325
+ via_cap = [(far, ref) for far, ref in _capacitor_neighbours(project, net) if is_supply_rail(project, far)]
326
+ if via_cap:
327
+ far, ref = via_cap[0]
328
+ detail = f"its net {net_display(net)!r} reaches {net_display(far)!r} only through capacitor {ref}"
329
+ elif len(net.parts) == 1 and not net.labels:
330
+ detail = "the pin is not connected at all"
331
+ else:
332
+ detail = f"its net {net_display(net)!r} reaches no power symbol, declared rail or power output"
333
+ blocked = _blocking_resistors(project, net)
334
+ yield Finding(
335
+ rule_id="kicad.power.pin_without_dc_path",
336
+ title=f"{component.ref} power pin {pin.number} ({pin.name or 'unnamed'}) reaches no rail",
337
+ severity=Severity.ERROR,
338
+ domain=Domain.SCHEMATIC,
339
+ location=at_pin(project, pin),
340
+ evidence=(Evidence(kind="schematic_net", value=detail, source=project.relative(pin.sheet_file)),
341
+ *(Evidence(kind="schematic_net", value=f"{ref} ({value}) is not counted as a supply path "
342
+ f"(above {DC_PATH_MAX_OHM:g} ohm)") for ref, value in blocked),
343
+ Evidence(kind="rule", value=f"DC path: wires, resistors up to {DC_PATH_MAX_OHM:g} ohm, "
344
+ f"inductors, ferrites, fuses, jumpers; at most {DC_PATH_DEPTH} parts")),
345
+ explanation=(f"{component.ref} pin {pin.number} is a power input. Following its net through "
346
+ "conductive parts reaches no supply or ground rail."),
347
+ consequence="The pin is unpowered on the board (a capacitor passes no DC); the part does not work "
348
+ "or back-powers through its I/O.",
349
+ recommendation="Connect the pin to its rail (a power symbol or the regulator output); a decoupling "
350
+ "capacitor goes beside that connection, not instead of it.",
351
+ )
352
+
353
+
354
+ def decoupling_missing(project: Project) -> Iterator[Finding]:
355
+ """An IC supply net without a capacitor to ground."""
356
+ sch = schematic(project)
357
+ if sch is None:
358
+ return
359
+ decoupled: set[str] = set()
360
+ for capacitor in parts(project, "capacitor"):
361
+ if capacitor.dnp or len(pin_numbers(capacitor)) != 2:
362
+ continue
363
+ nets = [sch.net_of(capacitor.ref, p.number) for p in capacitor.pins]
364
+ if any(is_ground_net(n) for n in nets):
365
+ decoupled.update(n.name for n in nets if n is not None and not is_ground_net(n))
366
+ for component in parts(project, "ic"):
367
+ if component.dnp:
368
+ continue
369
+ seen: set[str] = set()
370
+ for pin in sorted(component.pins, key=lambda p: p.number):
371
+ net = sch.net_of(component.ref, pin.number)
372
+ if pin.type != "power_in" or net is None or is_ground_net(net) or net.name in seen:
373
+ continue
374
+ seen.add(net.name)
375
+ if net.name in decoupled or (len(net.parts) == 1 and not net.labels):
376
+ continue
377
+ yield Finding(
378
+ rule_id="kicad.power.decoupling_missing",
379
+ title=f"{component.ref} supply {net_display(net)!r} has no decoupling capacitor",
380
+ severity=Severity.WARNING,
381
+ domain=Domain.SCHEMATIC,
382
+ location=at_pin(project, pin, key=net_display(net)),
383
+ evidence=(Evidence(kind="schematic_net", value=f"{component.ref}.{pin.number} ({pin.name}) on "
384
+ f"{net_display(net)!r}", source=project.relative(pin.sheet_file)),
385
+ Evidence(kind="schematic_net",
386
+ value=f"no capacitor connects {net_display(net)!r} to a ground net anywhere in the "
387
+ "schematic")),
388
+ explanation=("Every IC supply pin needs a local capacitor to ground. This schematic has none on the "
389
+ "net at all, so no placement on the board can provide it."),
390
+ consequence="Supply noise and transients reach the IC directly: resets, corrupted conversions, "
391
+ "radiated emissions.",
392
+ recommendation=f"Add a capacitor (typically 100 nF per supply pin, plus bulk) from "
393
+ f"{net_display(net)!r} to ground beside {component.ref}.",
394
+ )
395
+
396
+
397
+ # --- parts and pins ---------------------------------------------------------------------------
398
+
399
+
400
+ def shorted_part(project: Project) -> Iterator[Finding]:
401
+ """A two-pin part whose pins are both on one net."""
402
+ sch = schematic(project)
403
+ if sch is None:
404
+ return
405
+ for component in parts(project):
406
+ kind = classify(component)
407
+ numbers = sorted(pin_numbers(component))
408
+ if component.dnp or len(numbers) != 2 or kind in ("net_tie", "jumper", "test_point", "connector", "mechanical"):
409
+ continue
410
+ nets = [sch.net_of(component.ref, n) for n in numbers]
411
+ if nets[0] is None or nets[0] is not nets[1]:
412
+ continue
413
+ net = nets[0]
414
+ yield Finding(
415
+ rule_id="kicad.part.shorted",
416
+ title=f"{component.ref} ({component.value}) has both pins on {net_display(net)!r}",
417
+ severity=Severity.WARNING,
418
+ domain=Domain.SCHEMATIC,
419
+ location=at_component(project, component),
420
+ evidence=(Evidence(kind="schematic_net",
421
+ value=f"{component.ref}.{numbers[0]} and {component.ref}.{numbers[1]} are both on "
422
+ f"{net_display(net)!r}", source=project.relative(component.sheet_file)),),
423
+ explanation=("Both ends of a two-pin part connect to the same net, so the part does nothing. The usual "
424
+ "cause is a wire drawn straight through the symbol, which ERC does not report."),
425
+ consequence="The circuit the part was meant to form (a filter, divider, series element) does not exist.",
426
+ recommendation="Redraw the wiring so each pin reaches its own net; for an intentional short use a net "
427
+ "tie or mark the part DNP.",
428
+ )
429
+
430
+
431
+ def input_floating(project: Project) -> Iterator[Finding]:
432
+ """An input pin connected to nothing and not marked no-connect."""
433
+ sch = schematic(project)
434
+ if sch is None:
435
+ return
436
+ for component in parts(project):
437
+ if component.dnp:
438
+ continue
439
+ for pin in component.pins:
440
+ if pin.type != "input" or pin.no_connect:
441
+ continue
442
+ net = sch.net_of(component.ref, pin.number)
443
+ if net is None or len(net.parts) > 1 or net.power:
444
+ continue
445
+ yield Finding(
446
+ rule_id="kicad.pin.input_floating",
447
+ title=f"Input {component.ref}.{pin.number} ({pin.name or 'unnamed'}) is not connected",
448
+ severity=Severity.ERROR,
449
+ domain=Domain.SCHEMATIC,
450
+ location=at_pin(project, pin),
451
+ evidence=(Evidence(kind="schematic_net", value=f"{pin_label(pin)} is the only pin on its net "
452
+ f"{net_display(net)!r}", source=project.relative(pin.sheet_file)),
453
+ Evidence(kind="symbol", value=f"pin type {pin.type}; no no-connect flag")),
454
+ explanation="A CMOS input left open floats between the logic levels.",
455
+ consequence="Random toggling, extra supply current, and behaviour that changes with humidity or a "
456
+ "finger near the board.",
457
+ recommendation="Tie the input to its inactive level (directly or through a resistor), or mark it "
458
+ "with a no-connect flag if the datasheet allows it open.",
459
+ )
460
+
461
+
462
+ def pull_up_missing(project: Project) -> Iterator[Finding]:
463
+ """An I2C or open-drain net with no resistor to a supply rail."""
464
+ sch = schematic(project)
465
+ if sch is None:
466
+ return
467
+ for net in sch.nets:
468
+ open_drain = [p for p in net.parts if p.type in ("open_collector", "open_emitter")]
469
+ i2c = bool(_I2C.search(net.label or ""))
470
+ if not (open_drain or i2c) or len(net.parts) == 0:
471
+ continue
472
+ has_pull_up = False
473
+ for pin in net.parts:
474
+ component = sch.components.get(pin.ref)
475
+ if component is None or component.dnp or classify(component) != "resistor":
476
+ continue
477
+ if any(is_supply_rail(project, far) for far in other_nets(sch, component, pin.number)):
478
+ has_pull_up = True
479
+ break
480
+ if has_pull_up:
481
+ continue
482
+ why = (f"open-drain pin {pin_label(open_drain[0])}" if open_drain
483
+ else f"the net name {net_display(net)!r} marks an I2C line")
484
+ yield Finding(
485
+ rule_id="kicad.net.pull_up_missing",
486
+ title=f"{net_display(net)!r} has no pull-up resistor",
487
+ severity=Severity.WARNING,
488
+ domain=Domain.SCHEMATIC,
489
+ location=at_net(project, net),
490
+ evidence=(Evidence(kind="schematic_net", value=f"{why}", source=project.relative(sch.root_file)),
491
+ Evidence(kind="schematic_net", value="no resistor connects the net to a supply rail")),
492
+ explanation="Open-drain outputs and I2C lines only pull low; the high level comes from a pull-up.",
493
+ consequence="The line never goes high (or rises far too slowly); the bus does not work.",
494
+ recommendation=f"Add a pull-up from {net_display(net)!r} to the logic supply (for I2C typically "
495
+ "2.2 to 10 kohm, sized for the bus capacitance and speed).",
496
+ )
497
+
498
+
499
+ # --- labels and names -------------------------------------------------------------------------
500
+
501
+
502
+ def global_label_single_sheet(project: Project) -> Iterator[Finding]:
503
+ """A global label whose text appears on one sheet only."""
504
+ sch = schematic(project)
505
+ if sch is None:
506
+ return
507
+ sheets: dict[str, set[str]] = defaultdict(set)
508
+ first: dict[str, object] = {}
509
+ for label in sch.labels:
510
+ if label.kind == "global":
511
+ sheets[label.text].add(label.sheet_path)
512
+ first.setdefault(label.text, label)
513
+ if len(sch.sheet_paths) < 2:
514
+ return
515
+ for text, used in sorted(sheets.items()):
516
+ if len(used) != 1:
517
+ continue
518
+ label = first[text]
519
+ yield Finding(
520
+ rule_id="kicad.label.global_single_sheet",
521
+ title=f"Global label {text!r} is used on one sheet only",
522
+ severity=Severity.INFO,
523
+ domain=Domain.SCHEMATIC,
524
+ location=at_label(project, label, key=f"global:{text}"),
525
+ evidence=(Evidence(kind="label", value=f"every global label {text!r} is on sheet {next(iter(used))}",
526
+ source=project.relative(label.sheet_file)),),
527
+ explanation="A global label joins nets across the whole project; used on one sheet it does the job of "
528
+ "a local label.",
529
+ consequence="Anyone looking for where the signal leaves the sheet finds nothing, and a later global "
530
+ "label with the same text on another sheet joins it silently.",
531
+ recommendation="Use a local label, or connect the signal on the sheet where it is used.",
532
+ )
533
+
534
+
535
+ def label_single_connection(project: Project) -> Iterator[Finding]:
536
+ """A label on a net that reaches at most one pin."""
537
+ sch = schematic(project)
538
+ if sch is None:
539
+ return
540
+ for net in sch.nets:
541
+ named = [label for label in net.labels if label.kind in ("local", "global", "hierarchical")]
542
+ if not named or len(net.parts) > 1 or net.no_connect or net.power:
543
+ continue
544
+ label = named[0]
545
+ reached = f"only {pin_label(net.parts[0])}" if net.parts else "no pin at all"
546
+ yield Finding(
547
+ rule_id="kicad.label.single_connection",
548
+ title=f"Label {label.text!r} connects {reached}",
549
+ severity=Severity.WARNING,
550
+ domain=Domain.SCHEMATIC,
551
+ location=at_label(project, label, key=net.name),
552
+ evidence=(Evidence(kind="label", value=f"net {net.name!r} has {len(net.parts)} part pin(s)",
553
+ source=project.relative(label.sheet_file)),
554
+ *(Evidence(kind="label", value=f"{lab.kind} label {lab.text!r} on sheet {lab.sheet_path}")
555
+ for lab in named[:4])),
556
+ explanation="A label exists to join a net to other places. This one reaches at most one pin, so the "
557
+ "other end is missing or spelled differently.",
558
+ consequence="An intended connection is open on the board.",
559
+ recommendation="Check the spelling and scope (local, global, hierarchical) of the label at both ends.",
560
+ )
561
+
562
+
563
+ def net_name_hygiene(project: Project) -> Iterator[Finding]:
564
+ """Rail names reviewers and tools misread: VBUS without USB, supplies without a voltage."""
565
+ sch = schematic(project)
566
+ if sch is None:
567
+ return
568
+ has_usb = any("USB" in " ".join((c.lib_id, c.value, c.footprint)).upper() for c in parts(project))
569
+ declared = requirements(project).rails
570
+ for net in sch.nets:
571
+ name = net.label
572
+ if not name:
573
+ continue
574
+ bare = name.rsplit("/", 1)[-1]
575
+ problem = None
576
+ if "VBUS" in bare.upper() and not has_usb:
577
+ problem = (f"{bare!r} is named VBUS but the design has no USB connector",
578
+ "Reviewers and tools read VBUS as the 5 V USB supply.",
579
+ "Name the rail after its voltage and role (for example DCLINK or +24V); keep VBUS for USB.")
580
+ elif (net.power and not is_ground_net(net) and voltage_from_name(bare) is None and bare.upper() != "VBUS"
581
+ and name not in declared and net.name not in declared):
582
+ problem = (f"rail {bare!r} carries no voltage in its name",
583
+ "Its voltage is nowhere in the design files, so supply-range and derating checks cannot "
584
+ "use it.",
585
+ f"Rename it with its voltage (+3V3, +24V) or declare it in pcba-requirements.toml "
586
+ f"([rails.\"{bare}\"] voltage = ...).")
587
+ if problem is None:
588
+ continue
589
+ yield Finding(
590
+ rule_id="kicad.net.name_hygiene",
591
+ title=problem[0][0].upper() + problem[0][1:],
592
+ severity=Severity.INFO,
593
+ domain=Domain.SCHEMATIC,
594
+ location=at_net(project, net, key=net.name),
595
+ evidence=(Evidence(kind="schematic_net", value=f"net {net.name!r}",
596
+ source=project.relative(net.parts[0].sheet_file) if net.parts else None),),
597
+ explanation=problem[1],
598
+ consequence="A name that misleads costs review time and lets a wrong voltage assumption through.",
599
+ recommendation=problem[2],
600
+ )
601
+
602
+
603
+ RULES = (erc_violation, netlist_parity, rail_without_source, pin_without_dc_path, decoupling_missing, shorted_part,
604
+ input_floating, pull_up_missing, global_label_single_sheet, label_single_connection, net_name_hygiene)
605
+
606
+
607
+ def _ics(project: Project) -> tuple[int, list[str]]:
608
+ return len([c for c in parts(project, "ic") if not c.dnp]), []
609
+
610
+
611
+ def _two_pin(project: Project) -> tuple[int, list[str]]:
612
+ return len([c for c in parts(project) if len(pin_numbers(c)) == 2]), []
613
+
614
+
615
+ SCOPE = {
616
+ "kicad.power.pin_without_dc_path": _ics,
617
+ "kicad.power.decoupling_missing": _ics,
618
+ "kicad.part.shorted": _two_pin,
619
+ }
620
+
621
+ __all__ = ["RULES", "SCOPE"]
pcba/rules/locate.py ADDED
@@ -0,0 +1,22 @@
1
+ """Where a finding points.
2
+
3
+ Every artifact in a Location or an Evidence source is a project-relative POSIX path. The
4
+ rules never build one by hand; they come through here so the contract holds in one place.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from pcba.model.finding import Location
12
+ from pcba.model.pinmap import Pin, Project
13
+
14
+
15
+ def at_pin(project: Project, pin: Pin) -> Location:
16
+ """The schematic pin a finding is about."""
17
+ return Location(
18
+ artifact=project.relative(pin.sheet),
19
+ sheet=Path(pin.sheet).name,
20
+ component=pin.component,
21
+ pin=pin.port_pin,
22
+ )