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,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}