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,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"]
|