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
pcba/rules/schematic.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Single-domain schematic rules, resolved from geometry.
|
|
2
|
+
|
|
3
|
+
Both detectors here already existed as working code; what they lacked was a finding schema
|
|
4
|
+
to report through.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from pcba.model.finding import Detector, Domain, Evidence, Finding, Location, Severity
|
|
13
|
+
from pcba.model.pinmap import Project
|
|
14
|
+
from pcba.parsers.altium.pinmap import mislabelled, suspected_opens
|
|
15
|
+
from pcba.rules.locate import at_pin
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def mislabelled_net(project: Project) -> Iterator[Finding]:
|
|
19
|
+
"""A net label whose pin prefix disagrees with the pin it actually attaches to.
|
|
20
|
+
|
|
21
|
+
Only runs where the design demonstrably uses the pin-prefix convention — asserting it on
|
|
22
|
+
a project that never adopted it would flag every net on the board.
|
|
23
|
+
"""
|
|
24
|
+
if not project.schematic:
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
by_name = project.schematic.by_pin_name()
|
|
28
|
+
for actual, claim, net in mislabelled(project.schematic):
|
|
29
|
+
pin = by_name.get(actual)
|
|
30
|
+
location = (
|
|
31
|
+
at_pin(project, pin)
|
|
32
|
+
if pin
|
|
33
|
+
else Location(artifact=project.relative(project.schematic.source), pin=actual)
|
|
34
|
+
)
|
|
35
|
+
yield Finding(
|
|
36
|
+
rule_id="schematic.net.mislabelled",
|
|
37
|
+
title=f"Net {net!r} is on {actual}, but its label claims {claim}",
|
|
38
|
+
severity=Severity.ERROR,
|
|
39
|
+
domain=Domain.SCHEMATIC,
|
|
40
|
+
location=location,
|
|
41
|
+
evidence=(
|
|
42
|
+
Evidence(
|
|
43
|
+
kind="geometry",
|
|
44
|
+
value=f"wire geometry attaches net {net!r} to {actual}",
|
|
45
|
+
source=location.artifact,
|
|
46
|
+
),
|
|
47
|
+
Evidence(kind="label", value=f"the label's prefix claims {claim}"),
|
|
48
|
+
),
|
|
49
|
+
explanation=(
|
|
50
|
+
f"This design prefixes net labels with the pin they belong to. Connectivity "
|
|
51
|
+
f"resolved from wire geometry puts {net!r} on {actual}, but the label says "
|
|
52
|
+
f"{claim}. Geometry wins; the label is wrong."
|
|
53
|
+
),
|
|
54
|
+
consequence=(
|
|
55
|
+
"Anyone reading the schematic — or any tool scanning label text rather than "
|
|
56
|
+
"geometry — builds a wrong pin map from this and does not find out until the "
|
|
57
|
+
"interface fails on hardware."
|
|
58
|
+
),
|
|
59
|
+
recommendation=(
|
|
60
|
+
f"Rename the label to match {actual}, or move the wire if {claim} was intended."
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def open_circuit(project: Project) -> Iterator[Finding]:
|
|
66
|
+
"""A part that visually abuts an MCU pin without electrically touching it.
|
|
67
|
+
|
|
68
|
+
Heuristic, and labelled as such: it is a tuned distance threshold, not a proof. The two
|
|
69
|
+
constraints that keep it from crying wolf are axis-alignment (a diagonal neighbour is a
|
|
70
|
+
different grid point, not a slip) and requiring the pin to have resolved to no net at all.
|
|
71
|
+
"""
|
|
72
|
+
if not project.schematic:
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
opens = suspected_opens(project.schematic)
|
|
76
|
+
for slip in opens:
|
|
77
|
+
sheet = project.relative(slip["sheet"])
|
|
78
|
+
yield Finding(
|
|
79
|
+
rule_id="schematic.net.open_circuit",
|
|
80
|
+
title=f"{slip['mcu_pin']} looks connected but is not",
|
|
81
|
+
severity=Severity.ERROR,
|
|
82
|
+
domain=Domain.SCHEMATIC,
|
|
83
|
+
location=Location(artifact=sheet, sheet=Path(sheet).name, pin=slip["mcu_pin"]),
|
|
84
|
+
evidence=(
|
|
85
|
+
Evidence(
|
|
86
|
+
kind="geometry",
|
|
87
|
+
value=(
|
|
88
|
+
f"a pin lands {slip['gap']} units from {slip['mcu_pin']} "
|
|
89
|
+
f"(package pin {slip['package_pin']}) without touching it"
|
|
90
|
+
),
|
|
91
|
+
source=sheet,
|
|
92
|
+
),
|
|
93
|
+
Evidence(
|
|
94
|
+
kind="geometry",
|
|
95
|
+
value=(
|
|
96
|
+
f"{project.schematic.exact_landings} other pin"
|
|
97
|
+
f"{'' if project.schematic.exact_landings == 1 else 's'} on this "
|
|
98
|
+
f"design land{'s' if project.schematic.exact_landings == 1 else ''} "
|
|
99
|
+
"exactly on an MCU pin"
|
|
100
|
+
),
|
|
101
|
+
),
|
|
102
|
+
Evidence(
|
|
103
|
+
kind="net", value=f"{slip['mcu_pin']} resolves to no net at all"
|
|
104
|
+
),
|
|
105
|
+
),
|
|
106
|
+
explanation=(
|
|
107
|
+
f"A part's pin sits {slip['gap']} units away from {slip['mcu_pin']} along one "
|
|
108
|
+
f"axis and does not connect to it. {slip['mcu_pin']} resolves to no net."
|
|
109
|
+
),
|
|
110
|
+
consequence=(
|
|
111
|
+
"The parts visually abut the pin, so a rendered PDF looks correct and a "
|
|
112
|
+
"design review passes it. The net is open on the fabricated board."
|
|
113
|
+
),
|
|
114
|
+
recommendation=(
|
|
115
|
+
f"Open the sheet at {slip['mcu_pin']} and check the connection. If it is "
|
|
116
|
+
"intentional, the pin should carry an explicit no-connect marker."
|
|
117
|
+
),
|
|
118
|
+
detector=Detector.HEURISTIC,
|
|
119
|
+
confidence=0.85,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
RULES = (mislabelled_net, open_circuit)
|
pcba/rules/signals.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Normalizing signal names across artifacts.
|
|
2
|
+
|
|
3
|
+
The schematic and the `.ioc` name the same wire differently. The schematic is written by a
|
|
4
|
+
human ("UART8_TxD"); the `.ioc` carries ST's own name ("UART8_TX"). Comparing them raw
|
|
5
|
+
produces either a flood of false mismatches or a silent failure to compare at all.
|
|
6
|
+
|
|
7
|
+
The rule this module follows: **normalize conservatively, and only compare signals both
|
|
8
|
+
sides actually name.** A missed finding is a disappointment; a false one gets the tool
|
|
9
|
+
switched off.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
# Peripheral families whose pin assignment is worth cross-checking. A net called
|
|
17
|
+
# "SENSOR_ENABLE" is a GPIO and means nothing to the .ioc's Signal field, so it is skipped
|
|
18
|
+
# rather than guessed at.
|
|
19
|
+
_FAMILIES = (
|
|
20
|
+
"SPI|FDCAN|CAN|SDMMC|I2C|I2S|I3C|UART|USART|LPUART|QUADSPI|OCTOSPI|XSPIM|XSPI|ETH|USB|OTG|"
|
|
21
|
+
"ADC|DAC|FMC|TIM|LPTIM|SAI|SDIO|RCC|RTC|SWPMI|MDIOS|DCMIPP|DCMI|LTDC"
|
|
22
|
+
)
|
|
23
|
+
_PERIPHERAL = re.compile(rf"^({_FAMILIES})\d*(_|$)")
|
|
24
|
+
|
|
25
|
+
# The first token of a peripheral signal, split into family and instance ("USART3" ->
|
|
26
|
+
# USART, 3; "ETH" -> ETH, ""). Longer names first, so DCMIPP is not read as DCMI + "PP".
|
|
27
|
+
_INSTANCE = re.compile(
|
|
28
|
+
rf"^({'|'.join(sorted(_FAMILIES.split('|'), key=len, reverse=True))})(\d*)$"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Names a human and ST use for the same block. A schematic says UART1 where ST says USART1,
|
|
32
|
+
# USB_DP where ST says USB1_OTG_HS_DP, OCTOSPI where a newer part says XSPI. LPUART1 is a
|
|
33
|
+
# different peripheral from USART1, and the XSPIM port manager is not an XSPI instance, so
|
|
34
|
+
# neither is folded.
|
|
35
|
+
_FAMILY_ALIAS = {
|
|
36
|
+
"USART": "UART",
|
|
37
|
+
"OTG": "USB",
|
|
38
|
+
"QUADSPI": "XSPI", "OCTOSPI": "XSPI",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Roles a signal name identifies. Two signals of one block that name different roles are
|
|
42
|
+
# different wires (SDA on SCL, TX on RX, SCK on MOSI); an unrecognised last token (TXD0,
|
|
43
|
+
# CH1N) is left alone rather than guessed at.
|
|
44
|
+
_ROLE = re.compile(
|
|
45
|
+
r"^(SDA|SCL|TX|RX|MISO|MOSI|SCK|NSS|DP|DM|ID|CTS|RTS|DE|CK|CMD|MDC|MDIO|D\d{1,2}|IO\d{1,2})$"
|
|
46
|
+
)
|
|
47
|
+
_ROLE_SPELLING = {"DN": "DM", "TXD": "TX", "RXD": "RX", "CS": "NSS", "CLK": "CK", "SCLK": "SCK"}
|
|
48
|
+
|
|
49
|
+
# Net labels in some designs are prefixed with the pin they belong to ("PA5_SPI1_SCK").
|
|
50
|
+
# Ports run A..K, and on the STM32N6 also N..Q.
|
|
51
|
+
_PIN_PREFIX = re.compile(r"^P[A-KN-Q]\d{1,2}(_C)?_")
|
|
52
|
+
|
|
53
|
+
# Role aliases. Deliberately exact-match only, no digits.
|
|
54
|
+
#
|
|
55
|
+
# The temptation is to fold TXD -> TX globally. That breaks ETH_MII_TXD0, which is a real
|
|
56
|
+
# ST signal name where the D and the digit are load-bearing. So aliasing applies only when
|
|
57
|
+
# the role token is exactly TXD/RXD, never when a digit follows.
|
|
58
|
+
_ROLE_ALIAS = {
|
|
59
|
+
"TXD": "TX",
|
|
60
|
+
"RXD": "RX",
|
|
61
|
+
"TXO": "TX",
|
|
62
|
+
"RXI": "RX",
|
|
63
|
+
"SCLK": "SCK",
|
|
64
|
+
"MISO": "MISO",
|
|
65
|
+
"MOSI": "MOSI",
|
|
66
|
+
"CLK": "CK",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def strip_pin_prefix(net: str) -> str:
|
|
71
|
+
"""'PA5_SPI1_SCK' -> 'SPI1_SCK'. Leaves an unprefixed net untouched.
|
|
72
|
+
|
|
73
|
+
Strips whatever prefix is present, not the pin we believe we are on — a mislabeled net
|
|
74
|
+
must still yield its signal so the cross-domain rule can catch the disagreement.
|
|
75
|
+
"""
|
|
76
|
+
return _PIN_PREFIX.sub("", net or "", count=1)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_peripheral_signal(name: str) -> bool:
|
|
80
|
+
"""Is this a peripheral signal (worth comparing) or just a GPIO net name?"""
|
|
81
|
+
return bool(_PERIPHERAL.match((name or "").upper()))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def normalize(name: str | None) -> str | None:
|
|
85
|
+
"""Canonical form of a peripheral signal name, or None if it isn't one.
|
|
86
|
+
|
|
87
|
+
'PE1_UART8_TxD' -> 'UART8_TX'
|
|
88
|
+
'ETH_MII_TXD0' -> 'ETH_MII_TXD0' (digit-suffixed role: left alone)
|
|
89
|
+
'SENSOR_ENABLE' -> None (not a peripheral signal)
|
|
90
|
+
"""
|
|
91
|
+
if not name:
|
|
92
|
+
return None
|
|
93
|
+
# Uppercase BEFORE stripping: the prefix pattern is uppercase-only, so a lowercase net
|
|
94
|
+
# name would keep its prefix, fail the peripheral test, and return None. That reads as
|
|
95
|
+
# "not a peripheral signal" and silently skips the comparison rather than erroring.
|
|
96
|
+
bare = strip_pin_prefix(name.strip().upper())
|
|
97
|
+
if not is_peripheral_signal(bare):
|
|
98
|
+
return None
|
|
99
|
+
tokens = bare.split("_")
|
|
100
|
+
if len(tokens) >= 2:
|
|
101
|
+
tokens[-1] = _ROLE_ALIAS.get(tokens[-1], tokens[-1])
|
|
102
|
+
return "_".join(tokens)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def peripheral_instance(signal: str | None) -> tuple[str, str] | None:
|
|
106
|
+
"""('UART', '1') for 'USART1_TX', ('ETH', '') for 'ETH_MDC'; None for a GPIO net.
|
|
107
|
+
|
|
108
|
+
The family is folded through the aliases above, so USART and UART compare equal.
|
|
109
|
+
"""
|
|
110
|
+
name = normalize(signal)
|
|
111
|
+
if not name:
|
|
112
|
+
return None
|
|
113
|
+
match = _INSTANCE.match(name.split("_")[0])
|
|
114
|
+
if not match:
|
|
115
|
+
return None
|
|
116
|
+
family, instance = match.groups()
|
|
117
|
+
return _FAMILY_ALIAS.get(family, family), instance
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _role(name: str) -> str | None:
|
|
121
|
+
role = name.split("_")[-1]
|
|
122
|
+
role = _ROLE_SPELLING.get(role, role)
|
|
123
|
+
return role if _ROLE.match(role) else None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def same_block(net: str | None, signal: str | None) -> bool:
|
|
127
|
+
"""Do both name the same peripheral instance, as far as each names one?
|
|
128
|
+
|
|
129
|
+
An instance on only one side (I2C_SDA against I2C1_SDA) is no disagreement.
|
|
130
|
+
"""
|
|
131
|
+
left, right = peripheral_instance(net), peripheral_instance(signal)
|
|
132
|
+
if not left or not right or left[0] != right[0]:
|
|
133
|
+
return False
|
|
134
|
+
return not (left[1] and right[1] and left[1] != right[1])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def compatible(net: str | None, signal: str | None) -> bool:
|
|
138
|
+
"""Can a net with this name be the wire for that ST signal?
|
|
139
|
+
|
|
140
|
+
Lenient where people legitimately differ — a net names the family without its instance
|
|
141
|
+
(I2C_SDA for I2C1_SDA, ETH_MDC for ETH1_MDC), or the human spelling of the block (UART1
|
|
142
|
+
for USART1, USB_DN for USB1_OTG_HS_DM). Strict only where a difference is a real wiring
|
|
143
|
+
error: another instance (SPI2 on an SPI1 pin), another peripheral, or another role of the
|
|
144
|
+
same block (SDA on SCL, TX on RX, SCK on MOSI).
|
|
145
|
+
"""
|
|
146
|
+
if not same_block(net, signal):
|
|
147
|
+
return False
|
|
148
|
+
a, b = _role(normalize(net) or ""), _role(normalize(signal) or "")
|
|
149
|
+
return not (a and b and a != b)
|
pcba/rules/spice.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Declared SPICE testbenches: a failed or marginal measurement is a finding.
|
|
2
|
+
|
|
3
|
+
The testbenches run while the project is loaded (`pcba/simulation.py`), so their outcome is in
|
|
4
|
+
`design.external["spice"]` before any rule runs; this rule only reports. A testbench that did not
|
|
5
|
+
run (no ngspice, a refused deck, no value printed) is listed as not checked with its reason.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
|
|
12
|
+
from pcba.model.finding import Detector, Domain, Evidence, Finding, Location, Severity
|
|
13
|
+
from pcba.model.pinmap import Project
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _results(project: Project) -> list[dict]:
|
|
17
|
+
run = (project.design.external.get("spice") or {}) if project.design else {}
|
|
18
|
+
return run.get("results", []) if run.get("ran") else []
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def testbench(project: Project) -> Iterator[Finding]:
|
|
22
|
+
"""A declared testbench whose measured value is outside its pass range."""
|
|
23
|
+
for result in _results(project):
|
|
24
|
+
status = result.get("status")
|
|
25
|
+
if status not in ("fail", "warn"):
|
|
26
|
+
continue
|
|
27
|
+
low, high = result["pass"]
|
|
28
|
+
yield Finding(
|
|
29
|
+
rule_id="simulation.spice.testbench",
|
|
30
|
+
title=f"Testbench {result['name']!r}: {result['measure']} = {result['value']:g}, "
|
|
31
|
+
f"pass range {low:g} to {high:g}",
|
|
32
|
+
severity=Severity.ERROR if status == "fail" else Severity.WARNING,
|
|
33
|
+
domain=Domain.SCHEMATIC,
|
|
34
|
+
location=Location(artifact=project.relative(result["netlist"]), key=result["name"]),
|
|
35
|
+
evidence=(Evidence(kind="simulation", value=f"ngspice printed {result['measure']} = {result['value']:g}",
|
|
36
|
+
source=project.relative(result["netlist"])),
|
|
37
|
+
Evidence(kind="requirement", value=f"pass {low:g} to {high:g}"
|
|
38
|
+
+ (f", warn {result['warn'][0]:g} to {result['warn'][1]:g}" if result.get("warn")
|
|
39
|
+
else ""), source="pcba-requirements.toml")),
|
|
40
|
+
explanation=f"The project declares this testbench and its limits; the simulated value is "
|
|
41
|
+
f"{'outside the warn range' if status == 'fail' else 'inside the warn range only'}.",
|
|
42
|
+
consequence="The circuit the testbench models does not meet the value the design relies on.",
|
|
43
|
+
recommendation="Check the component values against the testbench (and the testbench against the "
|
|
44
|
+
"schematic); change the circuit or the declared limits deliberately.",
|
|
45
|
+
detector=Detector.EXTERNAL,
|
|
46
|
+
confidence=0.95,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _scope(project: Project) -> tuple[int, list[str]]:
|
|
51
|
+
run = (project.design.external.get("spice") or {}) if project.design else {}
|
|
52
|
+
results = run.get("results", [])
|
|
53
|
+
checked = sum(r.get("status") in ("pass", "warn", "fail") for r in results)
|
|
54
|
+
unchecked = [f"{r['name']}: {r.get('reason', 'not run')}" for r in results if r.get("status") == "not_run"]
|
|
55
|
+
return checked, unchecked
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
RULES = (testbench,)
|
|
59
|
+
SCOPE = {"simulation.spice.testbench": _scope}
|
pcba/rules/stm32.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Single-domain STM32 rules, checked against ST's own MCU database.
|
|
2
|
+
|
|
3
|
+
CubeMX ships a per-part XML listing every pin and every signal it can carry. It is the
|
|
4
|
+
authority on what a pin can do — never answer that from memory, and never from the datasheet
|
|
5
|
+
PDF either.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
|
|
12
|
+
from pcba.model.finding import Domain, Evidence, Finding, Severity
|
|
13
|
+
from pcba.model.pinmap import Project, Resolution, is_power_pin
|
|
14
|
+
from pcba.parsers.ioc import IocError, alternate_function_table, find_mcu_db, resolve_part_xml
|
|
15
|
+
from pcba.rules.locate import at_pin
|
|
16
|
+
from pcba.rules.signals import compatible, normalize, same_block, strip_pin_prefix
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def af_table(project: Project) -> dict[str, set[str]] | None:
|
|
20
|
+
"""ST's pin -> signals table for this project's part, or None if we have no usable one.
|
|
21
|
+
|
|
22
|
+
An empty table is None, not {}: a table that answers nothing cannot support the rule,
|
|
23
|
+
and reporting it as present would make `coverage()` claim this check ran.
|
|
24
|
+
"""
|
|
25
|
+
part = None
|
|
26
|
+
if project.mcu and project.mcu.part:
|
|
27
|
+
part = project.mcu.part
|
|
28
|
+
elif project.schematic:
|
|
29
|
+
part = project.schematic.part
|
|
30
|
+
if not part:
|
|
31
|
+
return None
|
|
32
|
+
try:
|
|
33
|
+
db = find_mcu_db()
|
|
34
|
+
mcu_name = project.mcu.raw.get("Mcu.Name") if project.mcu else None
|
|
35
|
+
return alternate_function_table(resolve_part_xml(db, part, mcu_name)) or None
|
|
36
|
+
except (IocError, OSError):
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def alternate_function_impossible(project: Project) -> Iterator[Finding]:
|
|
41
|
+
"""A schematic net names a peripheral the pin physically cannot carry.
|
|
42
|
+
|
|
43
|
+
The net name is only a hint, and several benign naming conventions look like mismatches —
|
|
44
|
+
FMC_D0_DA0 for ST's FMC_D0/FMC_DA0, ADC1_Ch2 for ADC1_INP2. So this reports only the case
|
|
45
|
+
it can prove: a pin that offers no signal of that peripheral instance *at all*, which is
|
|
46
|
+
unroutable silicon and always a defect.
|
|
47
|
+
|
|
48
|
+
A tuned check that cries wolf gets switched off, and then it protects nothing. Hence the
|
|
49
|
+
variant tolerance below, and hence every finding here is an ERROR — because by the time
|
|
50
|
+
one is emitted, it is certain.
|
|
51
|
+
"""
|
|
52
|
+
if not project.schematic or project.part_conflict:
|
|
53
|
+
return
|
|
54
|
+
table = af_table(project)
|
|
55
|
+
if not table:
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
for pin in sorted(
|
|
59
|
+
project.schematic.pins.values(), key=lambda p: (p.port_pin, p.package_pin)
|
|
60
|
+
):
|
|
61
|
+
if is_power_pin(pin.name) or not pin.net:
|
|
62
|
+
continue
|
|
63
|
+
if pin.resolution in (Resolution.NONE, Resolution.AMBIGUOUS):
|
|
64
|
+
continue
|
|
65
|
+
signals = table.get(pin.port_pin)
|
|
66
|
+
if not signals:
|
|
67
|
+
continue
|
|
68
|
+
# The .ioc puts a matching signal on this very pin, and ST's table confirms the pad
|
|
69
|
+
# carries it: the net merely spells it differently (I2C_SDA for I2C1_SDA, UART1_TX
|
|
70
|
+
# for USART1_TX). A hand-edited .ioc naming a signal the pad lacks proves nothing.
|
|
71
|
+
assigned = project.mcu.assignments.get(pin.port_pin) if project.mcu else None
|
|
72
|
+
if assigned and assigned.signal in signals and compatible(pin.net, assigned.signal):
|
|
73
|
+
continue
|
|
74
|
+
# ST's signal names are upper case; net names often are not. Upper-case before
|
|
75
|
+
# stripping, as `normalize` does, or `usart1_tx` on a USART1_TX pin reads as
|
|
76
|
+
# unroutable silicon. The original net stays in the evidence.
|
|
77
|
+
want = strip_pin_prefix(pin.net.strip().upper())
|
|
78
|
+
if not normalize(want):
|
|
79
|
+
continue # a plain GPIO net; the AF table has nothing to say about it
|
|
80
|
+
|
|
81
|
+
# Accept ST's MII/RMII prefixes and its D0/DA0 split naming.
|
|
82
|
+
variants = {
|
|
83
|
+
want,
|
|
84
|
+
want.replace("ETH_", "ETH_MII_", 1),
|
|
85
|
+
want.split("_DA")[0],
|
|
86
|
+
want.rsplit("_", 1)[0],
|
|
87
|
+
}
|
|
88
|
+
if any(v in signals for v in variants):
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
# The only defensible signal is: this pin offers NOTHING for that peripheral
|
|
92
|
+
# instance. Anything weaker is a false positive.
|
|
93
|
+
#
|
|
94
|
+
# Measured on a real 176-pin design: reporting "the pin offers ADC1 signals but not
|
|
95
|
+
# under this exact name" produced 3 findings, all wrong — the nets were simply named
|
|
96
|
+
# descriptively (PF11_ADC1_Ch2_ICP_Check against ST's ADC1_INP2). The pin is
|
|
97
|
+
# correct; only the human-readable suffix differs. Flagging those trains engineers
|
|
98
|
+
# to ignore the rule, which costs us the one finding that is real.
|
|
99
|
+
#
|
|
100
|
+
# Requiring the whole instance to be absent leaves exactly the unroutable case: a
|
|
101
|
+
# signal assigned to silicon that cannot produce it. That is never a naming quibble.
|
|
102
|
+
#
|
|
103
|
+
# "Instance" is read as loosely as people write it: a net that names only the family
|
|
104
|
+
# (I2C_SDA, ETH_MDC, USB_DP) matches any instance of it, and UART/USART, OTG/USB name
|
|
105
|
+
# the same block. Only a pin offering no signal of that block at all is reported.
|
|
106
|
+
instance = want.split("_")[0]
|
|
107
|
+
if any(same_block(want, s) for s in signals):
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
offered = sorted(s for s in signals if s.startswith(instance[:4]))
|
|
111
|
+
|
|
112
|
+
yield Finding(
|
|
113
|
+
rule_id="stm32.pin.alternate_function_impossible",
|
|
114
|
+
title=f"{pin.port_pin} cannot carry {want}",
|
|
115
|
+
severity=Severity.ERROR,
|
|
116
|
+
domain=Domain.IOC,
|
|
117
|
+
location=at_pin(project, pin),
|
|
118
|
+
evidence=(
|
|
119
|
+
Evidence(
|
|
120
|
+
kind="schematic_net",
|
|
121
|
+
value=f"{pin.port_pin} carries net {pin.net!r}",
|
|
122
|
+
source=project.relative(pin.sheet),
|
|
123
|
+
),
|
|
124
|
+
Evidence(
|
|
125
|
+
kind="mcu_database",
|
|
126
|
+
value=f"{pin.port_pin} offers no {instance} signal at all",
|
|
127
|
+
source="STM32CubeMX MCU database",
|
|
128
|
+
),
|
|
129
|
+
Evidence(
|
|
130
|
+
kind="mcu_database",
|
|
131
|
+
value=(
|
|
132
|
+
f"nearest signals on {pin.port_pin}: {', '.join(offered)}"
|
|
133
|
+
if offered
|
|
134
|
+
else f"{pin.port_pin} offers nothing resembling {instance}"
|
|
135
|
+
),
|
|
136
|
+
source="STM32CubeMX MCU database",
|
|
137
|
+
),
|
|
138
|
+
),
|
|
139
|
+
explanation=(
|
|
140
|
+
f"The net on {pin.port_pin} names {want}. Checked against ST's own MCU "
|
|
141
|
+
f"database for {project.schematic.part}, {pin.port_pin} has no {instance} "
|
|
142
|
+
f"alternate function of any kind."
|
|
143
|
+
),
|
|
144
|
+
consequence=(
|
|
145
|
+
"The signal cannot be routed to this pin in silicon. No firmware "
|
|
146
|
+
"configuration can make this work; the board needs an ECO before layout."
|
|
147
|
+
),
|
|
148
|
+
recommendation=(
|
|
149
|
+
f"Move {want} to a pin that offers {instance}, or correct the net name if the "
|
|
150
|
+
"net is not actually that peripheral."
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
RULES = (alternate_function_impossible,)
|
pcba/simulation.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""SPICE testbenches a project declares, run with ngspice in batch mode (stage 1).
|
|
2
|
+
|
|
3
|
+
A project lists testbenches in `pcba-requirements.toml` (`[[simulations]]`): an ngspice deck,
|
|
4
|
+
the name of one measured value it prints, a pass range and an optional wider warn range. The
|
|
5
|
+
review runs each deck with `ngspice -b -n` and reads the value from a `name = value` line
|
|
6
|
+
(what `.measure` and `print`/`echo` produce). Each testbench ends as pass, warn, fail or not run,
|
|
7
|
+
and "not run" always says why: no ngspice, a refused deck, a timeout, no value printed.
|
|
8
|
+
|
|
9
|
+
A deck is a program. A review must not run what a project hides in it, so before ngspice starts:
|
|
10
|
+
|
|
11
|
+
- the deck and every file it includes (`.include`, `.lib`, `source`) must lie inside the project;
|
|
12
|
+
- inside `.control` blocks, commands that reach outside the simulator are refused (`shell`,
|
|
13
|
+
`codemodel`, `load`, `osdi`, `pre_osdi`, `cd`, `write`, `wrdata`, `plot`, ...): a measurement
|
|
14
|
+
needs `run`, `meas`, `let`, `print` and `echo`, nothing that starts programs or writes files;
|
|
15
|
+
- ngspice starts with `-n` (no `spinit` / `.spiceinit`), in an empty temporary directory, with a
|
|
16
|
+
timeout, and nothing it writes is kept.
|
|
17
|
+
|
|
18
|
+
No model is ever downloaded: a deck that needs a vendor model ships it in the project.
|
|
19
|
+
`PCBA_NGSPICE` names the executable, or switches simulation off with `off`.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
import tempfile
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from pcba.requirements import Testbench
|
|
32
|
+
|
|
33
|
+
ENVIRONMENT = "PCBA_NGSPICE"
|
|
34
|
+
_OFF = {"off", "0", "none", "no", "false", ""}
|
|
35
|
+
_REFUSED = re.compile(r"^\s*(shell|codemodel|load|osdi|pre_osdi|cd|setcs|edit|wrdata|write|wrs2p|hardcopy|"
|
|
36
|
+
r"plot|gnuplot|sendcmd|rehash|rspice|aspice|jobs)\b", re.I | re.M)
|
|
37
|
+
_CONTROL = re.compile(r"^\s*\.control\b(.*?)(?:^\s*\.endc\b|\Z)", re.I | re.M | re.S)
|
|
38
|
+
_INCLUDE = re.compile(r"^\s*(?:\.include|\.inc|\.lib|source)\s+(?:\"([^\"]+)\"|'([^']+)'|(\S+))", re.I | re.M)
|
|
39
|
+
_RESULT = r"^\s*{name}\s*=\s*([-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?)"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def find() -> tuple[Path | None, str]:
|
|
43
|
+
configured = os.environ.get(ENVIRONMENT)
|
|
44
|
+
if configured is not None:
|
|
45
|
+
if configured.strip().lower() in _OFF:
|
|
46
|
+
return None, f"switched off by {ENVIRONMENT}"
|
|
47
|
+
path = Path(configured).expanduser()
|
|
48
|
+
if path.is_file():
|
|
49
|
+
return path, ENVIRONMENT
|
|
50
|
+
return None, f"{ENVIRONMENT} names {configured}, which does not exist"
|
|
51
|
+
for candidate in (shutil.which("ngspice"), "/opt/homebrew/bin/ngspice", "/usr/local/bin/ngspice",
|
|
52
|
+
"/usr/bin/ngspice"):
|
|
53
|
+
if candidate and Path(candidate).is_file():
|
|
54
|
+
return Path(candidate), "PATH" if candidate == shutil.which("ngspice") else "default install location"
|
|
55
|
+
return None, "ngspice not found (install ngspice, or set PCBA_NGSPICE to its path)"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _inside(root: Path, candidate: Path) -> bool:
|
|
59
|
+
try:
|
|
60
|
+
candidate.resolve().relative_to(root.resolve())
|
|
61
|
+
return True
|
|
62
|
+
except ValueError:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def vet(deck: Path, project_root: Path, seen: set[Path] | None = None) -> str | None:
|
|
67
|
+
"""Why a deck must not run, or None. Follows includes, all of which must stay in the project."""
|
|
68
|
+
seen = seen if seen is not None else set()
|
|
69
|
+
if deck.resolve() in seen:
|
|
70
|
+
return None
|
|
71
|
+
seen.add(deck.resolve())
|
|
72
|
+
if not _inside(project_root, deck):
|
|
73
|
+
return f"{deck.name} lies outside the project"
|
|
74
|
+
try:
|
|
75
|
+
text = deck.read_text(encoding="utf-8", errors="replace")
|
|
76
|
+
except OSError as exc:
|
|
77
|
+
return f"cannot read {deck.name}: {exc}"
|
|
78
|
+
refused = next((m for block in _CONTROL.findall(text) for m in [_REFUSED.search(block)] if m), None)
|
|
79
|
+
if refused:
|
|
80
|
+
return f"{deck.name} uses the control command {refused.group(1)!r}, which a review does not run"
|
|
81
|
+
for match in _INCLUDE.finditer(text):
|
|
82
|
+
target = next(g for g in match.groups() if g)
|
|
83
|
+
included = Path(target) if Path(target).is_absolute() else deck.parent / target
|
|
84
|
+
if not _inside(project_root, included):
|
|
85
|
+
return f"{deck.name} includes {target}, outside the project"
|
|
86
|
+
if not included.is_file():
|
|
87
|
+
return f"{deck.name} includes {target}, which does not exist"
|
|
88
|
+
reason = vet(included, project_root, seen)
|
|
89
|
+
if reason:
|
|
90
|
+
return reason
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def run(testbench: Testbench, base: Path, project_root: Path, ngspice: Path) -> dict:
|
|
95
|
+
"""One testbench: {"name", "netlist", "status": pass|warn|fail|not_run, "value"?, "reason"?}."""
|
|
96
|
+
deck = (base / testbench.netlist).resolve()
|
|
97
|
+
result = {"name": testbench.name, "netlist": str(deck), "measure": testbench.measure,
|
|
98
|
+
"pass": list(testbench.passing), "warn": list(testbench.warning) if testbench.warning else None}
|
|
99
|
+
if not deck.is_file():
|
|
100
|
+
return {**result, "status": "not_run", "reason": f"{testbench.netlist} not found"}
|
|
101
|
+
reason = vet(deck, project_root)
|
|
102
|
+
if reason:
|
|
103
|
+
return {**result, "status": "not_run", "reason": reason}
|
|
104
|
+
with tempfile.TemporaryDirectory(prefix="pcba-spice-") as tmp:
|
|
105
|
+
try:
|
|
106
|
+
done = subprocess.run([str(ngspice), "-b", "-n", str(deck)], cwd=tmp, capture_output=True, text=True,
|
|
107
|
+
errors="replace", timeout=testbench.timeout_s, check=False,
|
|
108
|
+
env={**os.environ, "SPICE_ASCIIRAWFILE": "1", "HOME": tmp})
|
|
109
|
+
except subprocess.TimeoutExpired:
|
|
110
|
+
return {**result, "status": "not_run", "reason": f"ngspice timed out after {testbench.timeout_s:g} s"}
|
|
111
|
+
except OSError as exc:
|
|
112
|
+
return {**result, "status": "not_run", "reason": f"ngspice could not start: {exc}"}
|
|
113
|
+
output = done.stdout + "\n" + done.stderr
|
|
114
|
+
match = None
|
|
115
|
+
for match in re.finditer(_RESULT.format(name=re.escape(testbench.measure)), output, re.I | re.M):
|
|
116
|
+
pass # the last printed value wins
|
|
117
|
+
if match is None:
|
|
118
|
+
tail = [line for line in output.strip().splitlines() if "error" in line.lower()][-1:]
|
|
119
|
+
return {**result, "status": "not_run",
|
|
120
|
+
"reason": f"ngspice printed no value for {testbench.measure} (exit {done.returncode})"
|
|
121
|
+
+ (f": {tail[0].strip()}" if tail else "")}
|
|
122
|
+
value = float(match.group(1))
|
|
123
|
+
low, high = testbench.passing
|
|
124
|
+
if low <= value <= high:
|
|
125
|
+
status = "pass"
|
|
126
|
+
elif testbench.warning and testbench.warning[0] <= value <= testbench.warning[1]:
|
|
127
|
+
status = "warn"
|
|
128
|
+
else:
|
|
129
|
+
status = "fail"
|
|
130
|
+
return {**result, "status": status, "value": value}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def run_all(testbenches: list[Testbench], base: Path, project_root: Path) -> dict:
|
|
134
|
+
"""Every declared testbench. {"ran": bool, "reason"?, "tool"?, "results": [...]}."""
|
|
135
|
+
if not testbenches:
|
|
136
|
+
return {"ran": False, "reason": "no [[simulations]] in pcba-requirements.toml", "results": []}
|
|
137
|
+
ngspice, how = find()
|
|
138
|
+
if ngspice is None:
|
|
139
|
+
return {"ran": False, "reason": how,
|
|
140
|
+
"results": [{"name": t.name, "netlist": t.netlist, "status": "not_run", "reason": how}
|
|
141
|
+
for t in testbenches]}
|
|
142
|
+
results = [run(t, base, project_root, ngspice) for t in testbenches]
|
|
143
|
+
return {"ran": True, "tool": "ngspice", "path": str(ngspice), "found_by": how, "results": results}
|