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,603 @@
1
+ """Rules that judge parts against component data and the project's requirements file.
2
+
3
+ What a part tolerates (supply range, input thresholds, required connections) comes from the
4
+ component database (`pcba/components.py`); what a rail carries comes from
5
+ `pcba-requirements.toml` or, failing that, from the rail's name. A part without data, a
6
+ capacitor without a voltage rating, a rail of unknown voltage: each is listed under the rule as
7
+ not checked, never counted as passing.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Iterator
13
+
14
+ from pcba.components import (
15
+ Level,
16
+ Part,
17
+ capacitor_class,
18
+ parse_value,
19
+ pin_matches,
20
+ rated_power,
21
+ rated_voltage,
22
+ )
23
+ from pcba.model.design import Component, Net, SchPin
24
+ from pcba.model.finding import Domain, Evidence, Finding, Location, Severity
25
+ from pcba.model.pinmap import Project
26
+ from pcba.rules.kicad_common import (
27
+ at_component,
28
+ at_pin,
29
+ classify,
30
+ detector_for,
31
+ is_ground_net,
32
+ is_supply_rail,
33
+ net_display,
34
+ other_nets,
35
+ parts,
36
+ pin_label,
37
+ pin_numbers,
38
+ rail,
39
+ rail_evidence,
40
+ requirements,
41
+ schematic,
42
+ )
43
+
44
+ VALUE_TOLERANCE = 0.2
45
+
46
+
47
+ def _library(project: Project):
48
+ return project.design.components if project.design else None
49
+
50
+
51
+ def _matched(project: Project) -> list[tuple[Component, Part, str]]:
52
+ library = _library(project)
53
+ if library is None:
54
+ return []
55
+ out = []
56
+ for component in parts(project):
57
+ hit = library.match(component)
58
+ if hit:
59
+ out.append((component, hit[0], hit[1]))
60
+ return out
61
+
62
+
63
+ def _part_evidence(part: Part, how: str) -> Evidence:
64
+ return Evidence(kind="component_data", value=f"{part.id} ({how}): {part.description}", source=part.source)
65
+
66
+
67
+ # --- supply range -------------------------------------------------------------------------------
68
+
69
+
70
+ def supply_range(project: Project) -> Iterator[Finding]:
71
+ """Each supply pin's rail inside the part's operating range, and never past its absolute maximum."""
72
+ sch = schematic(project)
73
+ if sch is None:
74
+ return
75
+ for component, part, how in _matched(project):
76
+ for pin in sorted(component.pins, key=lambda p: p.number):
77
+ net = sch.net_of(component.ref, pin.number)
78
+ known = rail(project, net)
79
+ if known is None:
80
+ continue
81
+ for supply in part.supplies:
82
+ if not pin_matches(supply.pins, pin.name):
83
+ continue
84
+ low, high = supply.operating
85
+ absolute = supply.absolute
86
+ if absolute and (known.high > absolute[1] or known.low < absolute[0]):
87
+ severity, what = Severity.ERROR, f"beyond the absolute maximum {absolute[0]:g} to {absolute[1]:g} V"
88
+ elif known.high > high or known.low < low:
89
+ severity, what = Severity.WARNING, f"outside the operating range {low:g} to {high:g} V"
90
+ else:
91
+ continue
92
+ detector, confidence = detector_for(known)
93
+ yield Finding(
94
+ rule_id="kicad.component.supply_range",
95
+ title=f"{component.ref} {pin.name} on {known.voltage:g} V is {what}",
96
+ severity=severity,
97
+ domain=Domain.SCHEMATIC,
98
+ location=at_pin(project, pin),
99
+ evidence=(rail_evidence(project, net, known), _part_evidence(part, how),
100
+ Evidence(kind="component_data", value=f"{pin.name}: operating {low:g} to {high:g} V"
101
+ + (f", absolute {absolute[0]:g} to {absolute[1]:g} V" if absolute else ""),
102
+ source=part.source)),
103
+ explanation=(f"{component.ref} ({component.value}) pin {pin.number} {pin.name} is supplied from "
104
+ f"{net_display(net)!r} at {known.low:g} to {known.high:g} V."),
105
+ consequence=("Past the absolute maximum the part is damaged; outside the operating range it is "
106
+ "not specified to work."),
107
+ recommendation="Supply the pin from a rail inside its range, or choose a part rated for this rail.",
108
+ detector=detector,
109
+ confidence=confidence,
110
+ )
111
+ for ground in part.grounds:
112
+ if pin_matches(ground, pin.name) and known.voltage != 0 and not is_ground_net(net):
113
+ detector, confidence = detector_for(known)
114
+ yield Finding(
115
+ rule_id="kicad.component.supply_range",
116
+ title=f"{component.ref} ground pin {pin.name} is on {net_display(net)!r}",
117
+ severity=Severity.ERROR,
118
+ domain=Domain.SCHEMATIC,
119
+ location=at_pin(project, pin),
120
+ evidence=(rail_evidence(project, net, known), _part_evidence(part, how)),
121
+ explanation=(f"{pin.name} is a ground pin of {part.id}, but its net carries "
122
+ f"{known.voltage:g} V."),
123
+ consequence="The part sees a reversed or shorted supply.",
124
+ recommendation="Connect the ground pin to ground.",
125
+ detector=detector,
126
+ confidence=confidence,
127
+ )
128
+ break
129
+
130
+
131
+ def _supply_scope(project: Project) -> tuple[int, list[str]]:
132
+ sch = schematic(project)
133
+ library = _library(project)
134
+ if sch is None or library is None:
135
+ return 0, []
136
+ checked, unchecked = 0, []
137
+ for component in parts(project, "ic"):
138
+ hit = library.match(component)
139
+ if hit is None:
140
+ unchecked.append(f"{component.ref} ({component.value}): no component data")
141
+ continue
142
+ for pin in component.pins:
143
+ if any(pin_matches(s.pins, pin.name) for s in hit[0].supplies):
144
+ if rail(project, sch.net_of(component.ref, pin.number)) is None:
145
+ unchecked.append(f"{component.ref}.{pin.number} ({pin.name}): rail voltage unknown")
146
+ else:
147
+ checked += 1
148
+ return checked, unchecked
149
+
150
+
151
+ # --- capacitor derating ---------------------------------------------------------------------------
152
+
153
+
154
+ def _across(project: Project, component: Component):
155
+ sch = schematic(project)
156
+ nets = [sch.net_of(component.ref, n) for n in sorted(pin_numbers(component))]
157
+ rails = [rail(project, n) for n in nets]
158
+ return nets, rails
159
+
160
+
161
+ def capacitor_derating(project: Project) -> Iterator[Finding]:
162
+ """The voltage across each capacitor within its derated rating."""
163
+ if schematic(project) is None:
164
+ return
165
+ limits = requirements(project).derating
166
+ for component in parts(project, "capacitor"):
167
+ if component.dnp or len(pin_numbers(component)) != 2:
168
+ continue
169
+ rating = rated_voltage(component)
170
+ nets, rails = _across(project, component)
171
+ if rating is None or None in rails:
172
+ continue
173
+ applied = max(abs(rails[0].high - rails[1].low), abs(rails[1].high - rails[0].low))
174
+ cls = capacitor_class(component)
175
+ limit = limits.get(cls, limits["ceramic"])
176
+ ratio = applied / rating[0] if rating[0] else float("inf")
177
+ if ratio <= limit:
178
+ continue
179
+ severity = Severity.ERROR if applied > rating[0] else Severity.WARNING
180
+ detector, confidence = detector_for(*rails)
181
+ yield Finding(
182
+ rule_id="kicad.component.capacitor_derating",
183
+ title=(f"{component.ref} sees {applied:g} V on a {rating[0]:g} V rating "
184
+ f"({ratio:.0%}; {cls} limit {limit:.0%})"),
185
+ severity=severity,
186
+ domain=Domain.SCHEMATIC,
187
+ location=at_component(project, component),
188
+ evidence=(Evidence(kind="rating", value=f"{rating[0]:g} V from {rating[1]}",
189
+ source=project.relative(component.sheet_file)),
190
+ rail_evidence(project, nets[0], rails[0]), rail_evidence(project, nets[1], rails[1]),
191
+ Evidence(kind="derating", value=f"{cls}: applied voltage at most {limit:.0%} of the rating",
192
+ source=project.relative(requirements(project).path) if requirements(project).path
193
+ else "EngineHall default")),
194
+ explanation=(f"{component.ref} ({component.value}) sits between {net_display(nets[0])!r} and "
195
+ f"{net_display(nets[1])!r}."),
196
+ consequence=("A ceramic capacitor loses most of its capacitance near its rated voltage; an electrolytic "
197
+ "ages fast; above the rating the part fails short."),
198
+ recommendation=f"Use a part rated for at least {applied / limit:.3g} V, or document why this derating is "
199
+ "acceptable.",
200
+ detector=detector,
201
+ confidence=confidence,
202
+ )
203
+
204
+
205
+ def _capacitor_scope(project: Project) -> tuple[int, list[str]]:
206
+ if schematic(project) is None:
207
+ return 0, []
208
+ checked, unchecked = 0, []
209
+ for component in parts(project, "capacitor"):
210
+ if component.dnp or len(pin_numbers(component)) != 2:
211
+ continue
212
+ nets, rails = _across(project, component)
213
+ if rated_voltage(component) is None:
214
+ unchecked.append(f"{component.ref} ({component.value}): no voltage rating in the value or a Voltage field")
215
+ elif None in rails:
216
+ unknown = [net_display(n) for n, r in zip(nets, rails) if r is None and n is not None]
217
+ unchecked.append(f"{component.ref}: voltage of {', '.join(unknown) or 'its nets'} unknown")
218
+ else:
219
+ checked += 1
220
+ return checked, unchecked
221
+
222
+
223
+ # --- resistor power -----------------------------------------------------------------------------
224
+
225
+
226
+ def resistor_power(project: Project) -> Iterator[Finding]:
227
+ """A resistor between two rails of known voltage within its derated power rating."""
228
+ if schematic(project) is None:
229
+ return
230
+ limit = requirements(project).derating["resistor_power"]
231
+ for component in parts(project, "resistor"):
232
+ if component.dnp or len(pin_numbers(component)) != 2:
233
+ continue
234
+ nets, rails = _across(project, component)
235
+ ohms = parse_value(component.value)
236
+ rating = rated_power(component)
237
+ if None in rails or not ohms or rating is None:
238
+ continue
239
+ volts = max(abs(rails[0].high - rails[1].low), abs(rails[1].high - rails[0].low))
240
+ watts = volts * volts / ohms
241
+ if watts <= rating[0] * limit:
242
+ continue
243
+ detector, confidence = detector_for(*rails)
244
+ yield Finding(
245
+ rule_id="kicad.component.resistor_power",
246
+ title=f"{component.ref} dissipates {watts:.3g} W on a {rating[0]:g} W rating",
247
+ severity=Severity.ERROR if watts > rating[0] else Severity.WARNING,
248
+ domain=Domain.SCHEMATIC,
249
+ location=at_component(project, component),
250
+ evidence=(Evidence(kind="rating", value=f"{rating[0]:g} W from {rating[1]}",
251
+ source=project.relative(component.sheet_file)),
252
+ rail_evidence(project, nets[0], rails[0]), rail_evidence(project, nets[1], rails[1]),
253
+ Evidence(kind="derating", value=f"at most {limit:.0%} of the rated power")),
254
+ explanation=f"{volts:g} V across {component.value} is {watts:.3g} W.",
255
+ consequence="An overloaded resistor runs hot, drifts and eventually opens.",
256
+ recommendation=f"Use a resistor rated for at least {watts / limit:.3g} W, or a larger value.",
257
+ detector=detector,
258
+ confidence=confidence,
259
+ )
260
+
261
+
262
+ def _resistor_scope(project: Project) -> tuple[int, list[str]]:
263
+ if schematic(project) is None:
264
+ return 0, []
265
+ checked, unchecked = 0, []
266
+ for component in parts(project, "resistor"):
267
+ if component.dnp or len(pin_numbers(component)) != 2:
268
+ continue
269
+ _nets, rails = _across(project, component)
270
+ if None in rails:
271
+ continue
272
+ if rated_power(component) is None or not parse_value(component.value):
273
+ unchecked.append(f"{component.ref} ({component.value}): no power rating or value")
274
+ else:
275
+ checked += 1
276
+ return checked, unchecked
277
+
278
+
279
+ # --- logic levels -------------------------------------------------------------------------------
280
+
281
+
282
+ def _levels(part: Part, pin: SchPin) -> tuple[Level | None, Level | None]:
283
+ driver = next((lv for lv in part.outputs if pin_matches(lv.pins, pin.name)), None)
284
+ receiver = next((lv for lv in part.inputs if pin_matches(lv.pins, pin.name)), None)
285
+ if pin.type == "input":
286
+ driver = None
287
+ elif pin.type in ("output", "power_out"):
288
+ receiver = None
289
+ return driver, receiver
290
+
291
+
292
+ def _supply_voltage(project: Project, component: Component, part: Part) -> float | None:
293
+ sch = schematic(project)
294
+ values = []
295
+ for pin in component.pins:
296
+ if any(pin_matches(s.pins, pin.name) for s in part.supplies):
297
+ known = rail(project, sch.net_of(component.ref, pin.number))
298
+ if known is not None:
299
+ values.append(known.high)
300
+ return max(values) if values else None
301
+
302
+
303
+ def _pull_up_voltage(project: Project, net: Net) -> float | None:
304
+ sch = schematic(project)
305
+ for pin in net.parts:
306
+ component = sch.components.get(pin.ref)
307
+ if component is None or classify(component) != "resistor":
308
+ continue
309
+ for far in other_nets(sch, component, pin.number):
310
+ if is_supply_rail(project, far):
311
+ known = rail(project, far)
312
+ if known is not None:
313
+ return known.high
314
+ return None
315
+
316
+
317
+ def _net_roles(project: Project, net: Net):
318
+ library = _library(project)
319
+ sch = schematic(project)
320
+ drivers, receivers = [], []
321
+ for pin in net.parts:
322
+ component = sch.components.get(pin.ref)
323
+ hit = library.match(component) if component is not None and library else None
324
+ if hit is None:
325
+ continue
326
+ driver, receiver = _levels(hit[0], pin)
327
+ if driver:
328
+ drivers.append((pin, component, hit[0], driver))
329
+ if receiver:
330
+ receivers.append((pin, component, hit[0], receiver))
331
+ return drivers, receivers
332
+
333
+
334
+ def logic_level(project: Project) -> Iterator[Finding]:
335
+ """Driver output levels against receiver input thresholds and limits on one net."""
336
+ sch = schematic(project)
337
+ if sch is None or _library(project) is None:
338
+ return
339
+ for net in sch.nets:
340
+ if is_ground_net(net) or is_supply_rail(project, net):
341
+ continue
342
+ drivers, receivers = _net_roles(project, net)
343
+ reported: set[str] = set()
344
+ for d_pin, d_comp, d_part, d_level in drivers:
345
+ if d_level.kind == "open_drain":
346
+ high_level = _pull_up_voltage(project, net)
347
+ high_min = high_level
348
+ else:
349
+ high_level = _supply_voltage(project, d_comp, d_part) or d_level.high
350
+ high_min = d_level.high
351
+ for r_pin, r_comp, r_part, r_level in receivers:
352
+ if r_comp.ref == d_comp.ref or r_pin.ref + r_pin.number in reported:
353
+ continue
354
+ problems = []
355
+ if high_level is not None and r_level.limit is not None and high_level > r_level.limit:
356
+ problems.append((Severity.ERROR,
357
+ f"drives {high_level:g} V into an input rated {r_level.limit:g} V"))
358
+ if high_min is not None and r_level.high is not None and high_min < r_level.high:
359
+ problems.append((Severity.ERROR, f"high level {high_min:g} V is below the input's VIH "
360
+ f"{r_level.high:g} V"))
361
+ if d_level.low is not None and r_level.low is not None and d_level.low > r_level.low:
362
+ problems.append((Severity.ERROR, f"low level {d_level.low:g} V is above the input's VIL "
363
+ f"{r_level.low:g} V"))
364
+ if not problems:
365
+ continue
366
+ reported.add(r_pin.ref + r_pin.number)
367
+ yield Finding(
368
+ rule_id="kicad.component.logic_level",
369
+ title=f"{pin_label(d_pin)} {problems[0][1]} at {pin_label(r_pin)}",
370
+ severity=problems[0][0],
371
+ domain=Domain.SCHEMATIC,
372
+ location=at_pin(project, r_pin, key=net.name),
373
+ evidence=(Evidence(kind="schematic_net", value=f"{pin_label(d_pin)} drives {net_display(net)!r} "
374
+ f"to {pin_label(r_pin)}", source=project.relative(r_pin.sheet_file)),
375
+ Evidence(kind="component_data", value=f"driver {d_part.id}: VOL {d_level.low}, "
376
+ f"VOH {d_level.high}, {d_level.kind}", source=d_part.source),
377
+ Evidence(kind="component_data", value=f"receiver {r_part.id}: VIL {r_level.low}, "
378
+ f"VIH {r_level.high}, max {r_level.limit}", source=r_part.source),
379
+ *(Evidence(kind="logic_level", value=text) for _sev, text in problems)),
380
+ explanation="The driver's guaranteed output levels do not meet the receiver's input "
381
+ "requirements at their nominal supplies.",
382
+ consequence="The signal is misread, or the receiver's input is driven past its rating.",
383
+ recommendation="Add a level shifter, change one side's supply, or choose parts with compatible "
384
+ "levels.",
385
+ )
386
+ pull_up = _pull_up_voltage(project, net)
387
+ for r_pin, _r_comp, r_part, r_level in receivers:
388
+ if (pull_up is None or r_level.limit is None or pull_up <= r_level.limit
389
+ or r_pin.ref + r_pin.number in reported):
390
+ continue
391
+ reported.add(r_pin.ref + r_pin.number)
392
+ yield Finding(
393
+ rule_id="kicad.component.logic_level",
394
+ title=(f"{net_display(net)!r} is pulled up to {pull_up:g} V into {pin_label(r_pin)}, "
395
+ f"rated {r_level.limit:g} V"),
396
+ severity=Severity.ERROR,
397
+ domain=Domain.SCHEMATIC,
398
+ location=at_pin(project, r_pin, key=net.name),
399
+ evidence=(Evidence(kind="schematic_net",
400
+ value=f"a resistor pulls {net_display(net)!r} to {pull_up:g} V",
401
+ source=project.relative(r_pin.sheet_file)),
402
+ Evidence(kind="component_data", value=f"receiver {r_part.id}: input max {r_level.limit:g} V",
403
+ source=r_part.source)),
404
+ explanation="The pull-up sets the high level of the whole net, above what this input tolerates.",
405
+ consequence="The input is driven past its rating whenever the line is high.",
406
+ recommendation="Pull the line up to the receiver's supply, or add a level shifter.",
407
+ )
408
+
409
+
410
+ def _logic_scope(project: Project) -> tuple[int, list[str]]:
411
+ sch = schematic(project)
412
+ if sch is None or _library(project) is None:
413
+ return 0, []
414
+ checked, unchecked = 0, []
415
+ for net in sch.nets:
416
+ if is_ground_net(net) or is_supply_rail(project, net) or len(net.parts) < 2:
417
+ continue
418
+ drivers, receivers = _net_roles(project, net)
419
+ pairs = [(d, r) for d in drivers for r in receivers if d[1].ref != r[1].ref]
420
+ if pairs:
421
+ checked += 1
422
+ elif drivers or receivers:
423
+ known = {d[1].ref for d in drivers} | {r[1].ref for r in receivers}
424
+ others = sorted({p.ref for p in net.parts if classify(sch.components[p.ref]) == "ic"} - known)
425
+ if others:
426
+ unchecked.append(f"{net_display(net)}: no level data for {', '.join(others)}")
427
+ return checked, unchecked
428
+
429
+
430
+ # --- required connections -----------------------------------------------------------------------
431
+
432
+
433
+ def _capacitors_to_ground(project: Project, net: Net) -> list[Component]:
434
+ sch = schematic(project)
435
+ out = []
436
+ for pin in net.parts:
437
+ component = sch.components.get(pin.ref)
438
+ if component is None or component.dnp or classify(component) != "capacitor":
439
+ continue
440
+ if any(is_ground_net(far) for far in other_nets(sch, component, pin.number)):
441
+ out.append(component)
442
+ return out
443
+
444
+
445
+ def _resistor_to(project: Project, net: Net, ground: bool) -> bool:
446
+ sch = schematic(project)
447
+ for pin in net.parts:
448
+ component = sch.components.get(pin.ref)
449
+ if component is None or component.dnp or classify(component) != "resistor":
450
+ continue
451
+ for far in other_nets(sch, component, pin.number):
452
+ if (is_ground_net(far) if ground else is_supply_rail(project, far)):
453
+ return True
454
+ return False
455
+
456
+
457
+ def required_connection(project: Project) -> Iterator[Finding]:
458
+ """What the component data says a pin needs: a capacitor, a pull-up, not floating ..."""
459
+ sch = schematic(project)
460
+ if sch is None:
461
+ return
462
+ for component, part, how in _matched(project):
463
+ if component.dnp:
464
+ continue
465
+ for need in part.required:
466
+ for pin in sorted(component.pins, key=lambda p: p.number):
467
+ if not pin_matches(need.pin, pin.name):
468
+ continue
469
+ net = sch.net_of(component.ref, pin.number)
470
+ if net is None:
471
+ continue
472
+ problem = None
473
+ severity = Severity.ERROR
474
+ if need.requires == "capacitor_to_ground":
475
+ caps = _capacitors_to_ground(project, net)
476
+ if not caps:
477
+ problem = f"needs a capacitor to ground{f' ({need.value})' if need.value else ''}; it has none"
478
+ elif need.value and parse_value(need.value):
479
+ want = parse_value(need.value)
480
+ values = [(c, parse_value(c.value)) for c in caps]
481
+ if not any(v and abs(v - want) <= want * VALUE_TOLERANCE for _c, v in values):
482
+ severity = Severity.WARNING
483
+ problem = (f"needs {need.value} to ground; found "
484
+ + ", ".join(f"{c.ref} {c.value}" for c, _v in values))
485
+ elif need.requires == "not_floating":
486
+ if len(net.parts) < 2 and rail(project, net) is None and not net.power:
487
+ problem = "must not float; it connects to nothing"
488
+ elif need.requires == "connected":
489
+ if len(net.parts) < 2 and not net.labels:
490
+ problem = "must be connected; it is not"
491
+ elif need.requires == "pull_up":
492
+ if not _resistor_to(project, net, ground=False) and not is_supply_rail(project, net):
493
+ problem = "needs a pull-up resistor to a supply; it has none"
494
+ elif need.requires == "pull_down":
495
+ if not _resistor_to(project, net, ground=True) and not is_ground_net(net):
496
+ problem = "needs a pull-down resistor to ground; it has none"
497
+ if problem is None:
498
+ continue
499
+ yield Finding(
500
+ rule_id="kicad.component.required_connection",
501
+ title=f"{component.ref} {pin.name} {problem}",
502
+ severity=severity,
503
+ domain=Domain.SCHEMATIC,
504
+ location=at_pin(project, pin, key=need.requires),
505
+ evidence=(Evidence(kind="schematic_net", value=f"{pin_label(pin)} on {net_display(net)!r} with "
506
+ f"{len(net.parts)} part pin(s)", source=project.relative(pin.sheet_file)),
507
+ _part_evidence(part, how),
508
+ Evidence(kind="component_data", value=f"{need.pin}: {need.requires}"
509
+ + (f" {need.value}" if need.value else ""), source=part.source)),
510
+ explanation=f"The component data for {part.id} requires this connection on {pin.name}.",
511
+ consequence="The part does not start, resets at random, or its output oscillates.",
512
+ recommendation=f"Add the {need.requires.replace('_', ' ')} on {pin.name} as the component data "
513
+ "(and the datasheet behind it) specify.",
514
+ )
515
+
516
+
517
+ def _required_scope(project: Project) -> tuple[int, list[str]]:
518
+ library = _library(project)
519
+ if schematic(project) is None or library is None:
520
+ return 0, []
521
+ checked = sum(len(part.required) > 0 for _c, part, _h in _matched(project))
522
+ unchecked = [f"{c.ref} ({c.value}): no component data" for c in parts(project, "ic") if library.match(c) is None]
523
+ return checked, unchecked
524
+
525
+
526
+ # --- connectors ---------------------------------------------------------------------------------
527
+
528
+
529
+ def _same_signal(expected: str, net: Net | None) -> bool:
530
+ want = expected.strip().upper().lstrip("/")
531
+ if net is None:
532
+ return False
533
+ names = {n.upper() for n in (net.label, net.name.rsplit("/", 1)[-1], net.name) if n}
534
+ return want in names
535
+
536
+
537
+ def connector_pin_assignment(project: Project) -> Iterator[Finding]:
538
+ """Each connector pin carries the signal the interface spec in the requirements file names."""
539
+ sch = schematic(project)
540
+ req = requirements(project)
541
+ if sch is None or not req.connectors:
542
+ return
543
+ source = project.relative(req.path) if req.path else "pcba-requirements.toml"
544
+ for spec in req.connectors:
545
+ component = sch.components.get(spec.ref)
546
+ if component is None:
547
+ yield Finding(
548
+ rule_id="kicad.connector.pin_assignment",
549
+ title=f"Connector {spec.ref} from the requirements file is not in the schematic",
550
+ severity=Severity.ERROR,
551
+ domain=Domain.SCHEMATIC,
552
+ location=Location(artifact=source, component=spec.ref, key=spec.interface or "connector"),
553
+ evidence=(Evidence(kind="requirement", value=f"[[connectors]] ref = {spec.ref!r}", source=source),),
554
+ explanation="The interface spec names a reference the schematic does not have.",
555
+ consequence="The interface cannot be checked; it may have been renamed or removed.",
556
+ recommendation="Correct the reference in pcba-requirements.toml or restore the connector.",
557
+ )
558
+ continue
559
+ for number, expected in sorted(spec.pins.items()):
560
+ pin = component.pin(number)
561
+ if pin is None:
562
+ actual, ok = "no such pin", False
563
+ else:
564
+ net = sch.net_of(component.ref, number)
565
+ if expected.strip().upper() in ("NC", "N/C", "-"):
566
+ ok = pin.no_connect or (net is not None and len(net.parts) <= 1 and not net.labels)
567
+ actual = "not connected" if ok else f"net {net_display(net)!r}" if net else "unknown"
568
+ else:
569
+ ok = _same_signal(expected, net)
570
+ actual = f"net {net_display(net)!r}" if net is not None else "no net"
571
+ if ok:
572
+ continue
573
+ location = at_pin(project, pin, key=spec.interface or None) if pin else at_component(
574
+ project, component, pin=number, key=spec.interface or None)
575
+ yield Finding(
576
+ rule_id="kicad.connector.pin_assignment",
577
+ title=f"{spec.ref} pin {number} should carry {expected}; it has {actual}",
578
+ severity=Severity.ERROR,
579
+ domain=Domain.SCHEMATIC,
580
+ location=location,
581
+ evidence=(Evidence(kind="requirement", value=f"{spec.interface or spec.ref}: pin {number} = {expected}",
582
+ source=source),
583
+ Evidence(kind="schematic_net", value=f"{spec.ref}.{number}: {actual}",
584
+ source=project.relative(component.sheet_file))),
585
+ explanation=f"The interface spec for {spec.ref} assigns {expected} to pin {number}.",
586
+ consequence="A cable or mating board wired to the spec meets the wrong signal: at best it does not "
587
+ "work, at worst a supply lands on a signal pin.",
588
+ recommendation="Fix the schematic, or the spec if the interface changed on purpose.",
589
+ )
590
+
591
+
592
+ RULES = (supply_range, capacitor_derating, resistor_power, logic_level, required_connection,
593
+ connector_pin_assignment)
594
+
595
+ SCOPE = {
596
+ "kicad.component.supply_range": _supply_scope,
597
+ "kicad.component.capacitor_derating": _capacitor_scope,
598
+ "kicad.component.resistor_power": _resistor_scope,
599
+ "kicad.component.logic_level": _logic_scope,
600
+ "kicad.component.required_connection": _required_scope,
601
+ }
602
+
603
+ __all__ = ["RULES", "SCOPE"]