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/report/text.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Human-readable output.
|
|
2
|
+
|
|
3
|
+
The layout puts evidence directly under the claim, because the first thing an engineer does
|
|
4
|
+
with a finding is disbelieve it. Making the proof immediate is the difference between a tool
|
|
5
|
+
that gets adopted and one that gets muted.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from pcba.model.finding import Detector, Finding, Severity
|
|
14
|
+
|
|
15
|
+
_COLOR = {
|
|
16
|
+
Severity.ERROR: "\033[31m",
|
|
17
|
+
Severity.WARNING: "\033[33m",
|
|
18
|
+
Severity.INFO: "\033[36m",
|
|
19
|
+
}
|
|
20
|
+
_DIM = "\033[2m"
|
|
21
|
+
_BOLD = "\033[1m"
|
|
22
|
+
_RESET = "\033[0m"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _supports_color(stream=sys.stdout) -> bool:
|
|
26
|
+
if os.environ.get("NO_COLOR"):
|
|
27
|
+
return False
|
|
28
|
+
return bool(getattr(stream, "isatty", lambda: False)())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _markdown_table_cell(value: str) -> str:
|
|
32
|
+
"""Keep arbitrary evidence on one Markdown table row."""
|
|
33
|
+
escaped = value.replace("\\", "\\\\").replace("|", "\\|")
|
|
34
|
+
return escaped.replace("\r\n", "<br>").replace("\r", "<br>").replace("\n", "<br>")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def to_text(
|
|
38
|
+
findings: list[Finding],
|
|
39
|
+
coverage: dict | None = None,
|
|
40
|
+
baseline: dict | None = None,
|
|
41
|
+
changes: dict | None = None,
|
|
42
|
+
) -> str:
|
|
43
|
+
color = _supports_color()
|
|
44
|
+
|
|
45
|
+
def paint(text: str, code: str) -> str:
|
|
46
|
+
return f"{code}{text}{_RESET}" if color else text
|
|
47
|
+
|
|
48
|
+
lines: list[str] = []
|
|
49
|
+
for f in findings:
|
|
50
|
+
head = paint(str(f.severity).ljust(7), _COLOR[f.severity])
|
|
51
|
+
lines.append(f"{head} {paint(f.rule_id, _BOLD)} {f.title}")
|
|
52
|
+
lines.append(f" {paint(f.location.describe(), _DIM)}")
|
|
53
|
+
for evidence in f.evidence:
|
|
54
|
+
source = f" [{evidence.source}]" if evidence.source else ""
|
|
55
|
+
lines.append(f" {paint('·', _DIM)} {evidence.value}{paint(source, _DIM)}")
|
|
56
|
+
lines.append(f" {f.explanation}")
|
|
57
|
+
lines.append(f" {paint('→ ' + f.recommendation, _DIM)}")
|
|
58
|
+
# Never let a heuristic pass for a proof.
|
|
59
|
+
if f.detector is not Detector.DETERMINISTIC:
|
|
60
|
+
lines.append(
|
|
61
|
+
f" {paint(f'({f.detector}, confidence {f.confidence:.0%})', _DIM)}"
|
|
62
|
+
)
|
|
63
|
+
lines.append("")
|
|
64
|
+
|
|
65
|
+
counts = {s: sum(1 for f in findings if f.severity is s) for s in Severity}
|
|
66
|
+
summary = ", ".join(
|
|
67
|
+
f"{counts[s]} {s}{'s' if counts[s] != 1 else ''}" for s in Severity if counts[s]
|
|
68
|
+
)
|
|
69
|
+
lines.append(summary or "no findings")
|
|
70
|
+
stats = (coverage or {}).get("statistics", {})
|
|
71
|
+
if stats.get("ioc_signals") is not None:
|
|
72
|
+
lines.append(
|
|
73
|
+
f"compared {stats['compared_signals']} of {stats['ioc_signals']} configured "
|
|
74
|
+
"peripheral signal placements"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if baseline:
|
|
78
|
+
lines.append(f"{baseline['suppressed']} findings suppressed by {baseline['path']}")
|
|
79
|
+
if baseline["stale"]:
|
|
80
|
+
lines.append(f"{len(baseline['stale'])} stale baseline entries")
|
|
81
|
+
if changes:
|
|
82
|
+
lines.append(
|
|
83
|
+
f"changed-only against {changes['base']}: {len(changes['files'])} files, "
|
|
84
|
+
f"{changes['omitted']} unchanged findings omitted"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if coverage and coverage.get("skipped"):
|
|
88
|
+
# "0 findings" from checks that never ran is the most dangerous output this tool
|
|
89
|
+
# could produce. Always say what did not run.
|
|
90
|
+
lines.append("")
|
|
91
|
+
lines.append(paint("Not checked:", _BOLD))
|
|
92
|
+
for skipped in coverage["skipped"]:
|
|
93
|
+
lines.append(f" {paint('·', _DIM)} {skipped}")
|
|
94
|
+
ambiguous = (coverage or {}).get("statistics", {}).get("ambiguous_signals")
|
|
95
|
+
if ambiguous:
|
|
96
|
+
lines.append("")
|
|
97
|
+
lines.append(paint("Not compared (signal on several pins of one side):", _BOLD))
|
|
98
|
+
lines.append(f" {paint('·', _DIM)} {', '.join(ambiguous)}")
|
|
99
|
+
uncompared = stats.get("uncompared_signals", [])
|
|
100
|
+
if uncompared:
|
|
101
|
+
lines.append("")
|
|
102
|
+
lines.append(paint("Configured signals not compared:", _BOLD))
|
|
103
|
+
for item in uncompared:
|
|
104
|
+
context = f"; schematic net {item['schematic_net']}" if item.get("schematic_net") else ""
|
|
105
|
+
lines.append(f" {paint('·', _DIM)} {item['signal']} at {item['ioc_pin']} "
|
|
106
|
+
f"({item['ioc_key']}): "
|
|
107
|
+
f"{item['reason'].replace('_', ' ')}{context}")
|
|
108
|
+
if (coverage or {}).get("unmet"):
|
|
109
|
+
lines.append("")
|
|
110
|
+
lines.append(paint("Required checks without usable coverage:", _BOLD))
|
|
111
|
+
for rule in coverage["unmet"]:
|
|
112
|
+
lines.append(f" {paint('·', _DIM)} {rule}")
|
|
113
|
+
|
|
114
|
+
tools = (coverage or {}).get("tools")
|
|
115
|
+
if tools:
|
|
116
|
+
lines.append("")
|
|
117
|
+
lines.append(paint("External tools:", _BOLD))
|
|
118
|
+
for name, run in tools.items():
|
|
119
|
+
lines.append(f" {paint('·', _DIM)} {_tool_line(name, run)}")
|
|
120
|
+
simulations = (coverage or {}).get("simulations")
|
|
121
|
+
if simulations:
|
|
122
|
+
lines.append("")
|
|
123
|
+
lines.append(paint("Simulations:", _BOLD))
|
|
124
|
+
for sim in simulations:
|
|
125
|
+
lines.append(f" {paint('·', _DIM)} {_simulation_line(sim)}")
|
|
126
|
+
statuses = (coverage or {}).get("rules")
|
|
127
|
+
if statuses:
|
|
128
|
+
lines.append("")
|
|
129
|
+
lines.append(paint(f"Rules ({_status_summary(statuses)}):", _BOLD))
|
|
130
|
+
width = max(len(entry["rule_id"]) for entry in statuses)
|
|
131
|
+
for entry in statuses:
|
|
132
|
+
if entry["status"] == "not_applicable":
|
|
133
|
+
continue
|
|
134
|
+
label = entry["status"].replace("_", " ")
|
|
135
|
+
lines.append(f" {label:<12} {entry['rule_id']:<{width}} {_status_detail(entry)}".rstrip())
|
|
136
|
+
for item in _unchecked(entry):
|
|
137
|
+
lines.append(f" {'':<12} {'':<{width}} {paint('not judged: ' + item, _DIM)}")
|
|
138
|
+
|
|
139
|
+
return "\n".join(lines)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
_STATUS_ORDER = ("failed", "info", "passed", "not_checked", "not_applicable")
|
|
143
|
+
UNCHECKED_SHOWN = 5
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _status_summary(statuses: list[dict]) -> str:
|
|
147
|
+
counts = {s: sum(1 for e in statuses if e["status"] == s) for s in _STATUS_ORDER}
|
|
148
|
+
parts = [f"{counts[s]} {s.replace('_', ' ')}" for s in _STATUS_ORDER if counts[s]]
|
|
149
|
+
return ", ".join(parts)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _status_detail(entry: dict) -> str:
|
|
153
|
+
if entry.get("findings"):
|
|
154
|
+
return f"{entry['findings']} finding{'s' if entry['findings'] != 1 else ''}"
|
|
155
|
+
return entry.get("reason", "")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _unchecked(entry: dict) -> list[str]:
|
|
159
|
+
items = entry.get("unchecked", [])
|
|
160
|
+
shown = items[:UNCHECKED_SHOWN]
|
|
161
|
+
if len(items) > UNCHECKED_SHOWN:
|
|
162
|
+
shown.append(f"and {len(items) - UNCHECKED_SHOWN} more")
|
|
163
|
+
return shown
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _tool_line(name: str, run: dict) -> str:
|
|
167
|
+
label = {"erc": "ERC", "drc": "DRC", "netlist": "netlist export", "spice": "SPICE testbenches"}.get(name, name)
|
|
168
|
+
if run.get("ran"):
|
|
169
|
+
version = f" {run['version']}" if run.get("version") else ""
|
|
170
|
+
return f"{label}: ran with {run.get('tool', 'tool')}{version}"
|
|
171
|
+
return f"{label}: not run ({run.get('reason', 'no reason given')})"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _simulation_line(sim: dict) -> str:
|
|
175
|
+
low, high = sim.get("pass", [None, None])
|
|
176
|
+
if sim.get("status") == "not_run":
|
|
177
|
+
return f"not run {sim['name']}: {sim.get('reason', '')}"
|
|
178
|
+
return (f"{sim['status']:<8} {sim['name']}: {sim.get('measure')} = {sim.get('value'):g} "
|
|
179
|
+
f"(pass {low:g} to {high:g})")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def to_markdown(
|
|
183
|
+
findings: list[Finding],
|
|
184
|
+
coverage: dict | None = None,
|
|
185
|
+
baseline: dict | None = None,
|
|
186
|
+
changes: dict | None = None,
|
|
187
|
+
) -> str:
|
|
188
|
+
lines = ["# EngineHall review", ""]
|
|
189
|
+
counts = {s: sum(1 for f in findings if f.severity is s) for s in Severity}
|
|
190
|
+
lines.append(
|
|
191
|
+
f"**{counts[Severity.ERROR]} errors, {counts[Severity.WARNING]} warnings, "
|
|
192
|
+
f"{counts[Severity.INFO]} info.**"
|
|
193
|
+
)
|
|
194
|
+
lines.append("")
|
|
195
|
+
if (coverage or {}).get("unmet"):
|
|
196
|
+
# Why an exit-2 run failed belongs at the top of a PR comment, not after the findings.
|
|
197
|
+
lines.append("## Required checks without usable coverage")
|
|
198
|
+
lines.append("")
|
|
199
|
+
for rule in coverage["unmet"]:
|
|
200
|
+
lines.append(f"- `{rule}`")
|
|
201
|
+
lines.append("")
|
|
202
|
+
stats = (coverage or {}).get("statistics", {})
|
|
203
|
+
if stats.get("ioc_signals") is not None:
|
|
204
|
+
lines.append(
|
|
205
|
+
f"Compared **{stats['compared_signals']} of {stats['ioc_signals']}** "
|
|
206
|
+
"configured peripheral signal placements."
|
|
207
|
+
)
|
|
208
|
+
lines.append("")
|
|
209
|
+
|
|
210
|
+
if baseline:
|
|
211
|
+
lines.append(
|
|
212
|
+
f"Baseline `{baseline['path']}` suppressed {baseline['suppressed']} findings; "
|
|
213
|
+
f"{len(baseline['stale'])} entries are stale."
|
|
214
|
+
)
|
|
215
|
+
lines.append("")
|
|
216
|
+
if changes:
|
|
217
|
+
lines.append(
|
|
218
|
+
f"Changed-only against `{changes['base']}`: {len(changes['files'])} files; "
|
|
219
|
+
f"{changes['omitted']} unchanged findings omitted."
|
|
220
|
+
)
|
|
221
|
+
lines.append("")
|
|
222
|
+
|
|
223
|
+
if not findings:
|
|
224
|
+
lines.append("No findings.")
|
|
225
|
+
for f in findings:
|
|
226
|
+
lines.append(f"## {f.severity.upper()} — {f.title}")
|
|
227
|
+
lines.append("")
|
|
228
|
+
lines.append(f"`{f.rule_id}` · {f.location.describe()}")
|
|
229
|
+
lines.append("")
|
|
230
|
+
if f.evidence:
|
|
231
|
+
lines.append("| Evidence | Value | Source |")
|
|
232
|
+
lines.append("| -------- | ----- | ------ |")
|
|
233
|
+
for e in f.evidence:
|
|
234
|
+
kind = _markdown_table_cell(e.kind)
|
|
235
|
+
value = _markdown_table_cell(e.value)
|
|
236
|
+
source = _markdown_table_cell(e.source or "—")
|
|
237
|
+
lines.append(f"| {kind} | {value} | {source} |")
|
|
238
|
+
lines.append("")
|
|
239
|
+
lines.append(f.explanation)
|
|
240
|
+
lines.append("")
|
|
241
|
+
lines.append(f"**Consequence.** {f.consequence}")
|
|
242
|
+
lines.append("")
|
|
243
|
+
lines.append(f"**Recommendation.** {f.recommendation}")
|
|
244
|
+
lines.append("")
|
|
245
|
+
if f.detector is not Detector.DETERMINISTIC:
|
|
246
|
+
lines.append(f"_Detected {f.detector}; confidence {f.confidence:.0%}._")
|
|
247
|
+
lines.append("")
|
|
248
|
+
|
|
249
|
+
if coverage and coverage.get("skipped"):
|
|
250
|
+
lines.append("## Not checked")
|
|
251
|
+
lines.append("")
|
|
252
|
+
for skipped in coverage["skipped"]:
|
|
253
|
+
lines.append(f"- {skipped}")
|
|
254
|
+
lines.append("")
|
|
255
|
+
ambiguous = (coverage or {}).get("statistics", {}).get("ambiguous_signals")
|
|
256
|
+
if ambiguous:
|
|
257
|
+
lines.append("## Not compared")
|
|
258
|
+
lines.append("")
|
|
259
|
+
lines.append("Each of these signals sits on several pins of one side: "
|
|
260
|
+
+ ", ".join(f"`{s}`" for s in ambiguous))
|
|
261
|
+
lines.append("")
|
|
262
|
+
uncompared = stats.get("uncompared_signals", [])
|
|
263
|
+
if uncompared:
|
|
264
|
+
lines.append("## Configured signals not compared")
|
|
265
|
+
lines.append("")
|
|
266
|
+
for item in uncompared:
|
|
267
|
+
context = f"; schematic net `{item['schematic_net']}`" if item.get("schematic_net") else ""
|
|
268
|
+
lines.append(f"- `{item['signal']}` at `{item['ioc_pin']}` "
|
|
269
|
+
f"(`{item['ioc_key']}`): "
|
|
270
|
+
f"{item['reason'].replace('_', ' ')}{context}")
|
|
271
|
+
lines.append("")
|
|
272
|
+
|
|
273
|
+
tools = (coverage or {}).get("tools")
|
|
274
|
+
if tools:
|
|
275
|
+
lines.append("## External tools")
|
|
276
|
+
lines.append("")
|
|
277
|
+
lines.extend(f"- {_markdown_table_cell(_tool_line(name, run))}" for name, run in tools.items())
|
|
278
|
+
lines.append("")
|
|
279
|
+
simulations = (coverage or {}).get("simulations")
|
|
280
|
+
if simulations:
|
|
281
|
+
lines.append("## Simulations")
|
|
282
|
+
lines.append("")
|
|
283
|
+
lines.extend(f"- {_markdown_table_cell(_simulation_line(sim))}" for sim in simulations)
|
|
284
|
+
lines.append("")
|
|
285
|
+
statuses = (coverage or {}).get("rules")
|
|
286
|
+
if statuses:
|
|
287
|
+
lines.append("## Rules")
|
|
288
|
+
lines.append("")
|
|
289
|
+
lines.append(f"{_status_summary(statuses)}.")
|
|
290
|
+
lines.append("")
|
|
291
|
+
lines.append("| Rule | Status | Detail |")
|
|
292
|
+
lines.append("| ---- | ------ | ------ |")
|
|
293
|
+
for entry in statuses:
|
|
294
|
+
if entry["status"] == "not_applicable":
|
|
295
|
+
continue
|
|
296
|
+
detail = _status_detail(entry)
|
|
297
|
+
unchecked = _unchecked(entry)
|
|
298
|
+
if unchecked:
|
|
299
|
+
detail = (detail + "; " if detail else "") + "not judged: " + "; ".join(unchecked)
|
|
300
|
+
lines.append(f"| `{entry['rule_id']}` | {entry['status'].replace('_', ' ')} | "
|
|
301
|
+
f"{_markdown_table_cell(detail) or '—'} |")
|
|
302
|
+
lines.append("")
|
|
303
|
+
|
|
304
|
+
return "\n".join(lines)
|
pcba/requirements.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""`pcba-requirements.toml`: what a project promises that its files cannot say by themselves.
|
|
2
|
+
|
|
3
|
+
A schematic names a rail `+3V3`, but only the designer knows it is 3.3 V +-5 %, that the board is
|
|
4
|
+
assembled on one side, which signal each connector pin must carry, and which SPICE testbenches
|
|
5
|
+
prove a filter. The KiCad rules read those promises from this optional file next to the project
|
|
6
|
+
(the review root first, then the KiCad project folder). Without it, rail voltages come from
|
|
7
|
+
power-symbol names (`+3V3`, `+24V`) and are labelled as inferred, and the checks that need a
|
|
8
|
+
promise are listed as not checked.
|
|
9
|
+
|
|
10
|
+
version = 1
|
|
11
|
+
|
|
12
|
+
[rails."+3V3"] # exact net name (or the label without its sheet path)
|
|
13
|
+
voltage = 3.3
|
|
14
|
+
tolerance = 0.05 # or min = 3.135 / max = 3.465
|
|
15
|
+
|
|
16
|
+
[derating] # maximum applied/rated ratio; defaults shown
|
|
17
|
+
ceramic = 0.5
|
|
18
|
+
electrolytic = 0.8
|
|
19
|
+
tantalum = 0.5
|
|
20
|
+
film = 0.7
|
|
21
|
+
resistor_power = 0.5
|
|
22
|
+
|
|
23
|
+
[board]
|
|
24
|
+
assembly = "single-sided" # or "double-sided"
|
|
25
|
+
smd_edge_clearance_mm = 5.0
|
|
26
|
+
test_points = "advisory" # "required", "advisory" or "off"
|
|
27
|
+
test_point_exempt = ["GND"]
|
|
28
|
+
|
|
29
|
+
[[connectors]]
|
|
30
|
+
ref = "J1"
|
|
31
|
+
interface = "debug"
|
|
32
|
+
pins = { "1" = "+3V3", "2" = "SWDIO", "3" = "SWCLK", "4" = "GND", "5" = "NC" }
|
|
33
|
+
|
|
34
|
+
[[simulations]]
|
|
35
|
+
name = "sense divider"
|
|
36
|
+
netlist = "sim/divider.cir" # ngspice deck; prints `name = value` lines
|
|
37
|
+
measure = "vout"
|
|
38
|
+
pass = [3.25, 3.35]
|
|
39
|
+
warn = [3.20, 3.40]
|
|
40
|
+
timeout_s = 30
|
|
41
|
+
|
|
42
|
+
[components]
|
|
43
|
+
databases = ["parts/components.json"] # extra component data, same schema as the shipped file
|
|
44
|
+
assign = { U1 = "generic-mcu-3v3" } # reference -> component id
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import dataclasses
|
|
50
|
+
import math
|
|
51
|
+
import re
|
|
52
|
+
import tomllib
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
from typing import Any
|
|
55
|
+
|
|
56
|
+
FILE_NAME = "pcba-requirements.toml"
|
|
57
|
+
|
|
58
|
+
DERATING_DEFAULTS = {
|
|
59
|
+
"ceramic": 0.5, # MLCC (X7R, X5R, C0G ...): applied voltage at most half the rating
|
|
60
|
+
"electrolytic": 0.8,
|
|
61
|
+
"tantalum": 0.5,
|
|
62
|
+
"film": 0.7,
|
|
63
|
+
"polymer": 0.8,
|
|
64
|
+
"resistor_power": 0.5,
|
|
65
|
+
}
|
|
66
|
+
TEST_POINT_MODES = ("required", "advisory", "off")
|
|
67
|
+
ASSEMBLY_MODES = ("single-sided", "double-sided")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class RequirementsError(ValueError):
|
|
71
|
+
"""The requirements file is malformed."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
75
|
+
class Rail:
|
|
76
|
+
name: str
|
|
77
|
+
voltage: float
|
|
78
|
+
low: float
|
|
79
|
+
high: float
|
|
80
|
+
source: str # "requirements" or "net name"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
84
|
+
class Connector:
|
|
85
|
+
ref: str
|
|
86
|
+
interface: str
|
|
87
|
+
pins: dict[str, str]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
91
|
+
class Testbench:
|
|
92
|
+
name: str
|
|
93
|
+
netlist: str
|
|
94
|
+
measure: str
|
|
95
|
+
passing: tuple[float, float]
|
|
96
|
+
warning: tuple[float, float] | None
|
|
97
|
+
timeout_s: float
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclasses.dataclass(slots=True)
|
|
101
|
+
class Requirements:
|
|
102
|
+
path: Path | None = None
|
|
103
|
+
rails: dict[str, Rail] = dataclasses.field(default_factory=dict)
|
|
104
|
+
derating: dict[str, float] = dataclasses.field(default_factory=lambda: dict(DERATING_DEFAULTS))
|
|
105
|
+
assembly: str | None = None
|
|
106
|
+
smd_edge_clearance_mm: float | None = None
|
|
107
|
+
test_points: str = "advisory"
|
|
108
|
+
test_point_exempt: tuple[str, ...] = ()
|
|
109
|
+
connectors: list[Connector] = dataclasses.field(default_factory=list)
|
|
110
|
+
simulations: list[Testbench] = dataclasses.field(default_factory=list)
|
|
111
|
+
databases: tuple[Path, ...] = ()
|
|
112
|
+
assign: dict[str, str] = dataclasses.field(default_factory=dict)
|
|
113
|
+
|
|
114
|
+
def rail(self, net_name: str | None, label: str | None = None) -> Rail | None:
|
|
115
|
+
"""The voltage of a net: the requirements file first, then the net's name."""
|
|
116
|
+
for key in (net_name, label):
|
|
117
|
+
if key and key in self.rails:
|
|
118
|
+
return self.rails[key]
|
|
119
|
+
for key in (label, net_name):
|
|
120
|
+
if not key:
|
|
121
|
+
continue
|
|
122
|
+
bare = key.rsplit("/", 1)[-1]
|
|
123
|
+
if is_ground(bare):
|
|
124
|
+
return Rail(bare, 0.0, 0.0, 0.0, "net name")
|
|
125
|
+
voltage = voltage_from_name(bare)
|
|
126
|
+
if voltage is not None:
|
|
127
|
+
return Rail(bare, voltage, voltage, voltage, "net name")
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
_GROUND = re.compile(r"^(?:[ADPSC]?GND[A-Z0-9]*|GND[_-].*|.*[_-]GND|VSS[A-Z0-9]*|0V|EARTH|PE|CHASSIS)$", re.I)
|
|
132
|
+
_VOLTAGE = re.compile(r"^[+-]?(\d+)(?:[.p](\d+))?V(\d+)?$", re.I)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def is_ground(name: str | None) -> bool:
|
|
136
|
+
return bool(name and _GROUND.match(name.rsplit("/", 1)[-1]))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def voltage_from_name(name: str | None) -> float | None:
|
|
140
|
+
"""`+3V3` -> 3.3, `+24V_F` -> 24, `-12V` -> -12, `+1V8` -> 1.8, `3.3V` -> 3.3; else None.
|
|
141
|
+
|
|
142
|
+
Only a token that is a voltage by itself counts, as the first token or after the rail name
|
|
143
|
+
(`VCC_3V3`); `V15_SENSE` or `VBUS_DIV` are not rails and stay unknown.
|
|
144
|
+
"""
|
|
145
|
+
if not name:
|
|
146
|
+
return None
|
|
147
|
+
bare = name.rsplit("/", 1)[-1]
|
|
148
|
+
tokens = re.split(r"[_\s]", bare)
|
|
149
|
+
for index, token in enumerate(tokens[:2]):
|
|
150
|
+
match = _VOLTAGE.match(token)
|
|
151
|
+
if not match or (index == 1 and not re.match(r"^[A-Z]+\d*$", tokens[0], re.I)):
|
|
152
|
+
continue
|
|
153
|
+
whole, decimals, after = match.group(1), match.group(2), match.group(3)
|
|
154
|
+
if decimals and after:
|
|
155
|
+
continue
|
|
156
|
+
value = float(f"{whole}.{decimals or after or 0}")
|
|
157
|
+
return -value if token.startswith("-") else value
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _number(table: dict, key: str, where: str, *, positive: bool = False) -> float | None:
|
|
162
|
+
value = table.get(key)
|
|
163
|
+
if value is None:
|
|
164
|
+
return None
|
|
165
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
|
166
|
+
raise RequirementsError(f"{where}.{key} must be a number")
|
|
167
|
+
if positive and value <= 0:
|
|
168
|
+
raise RequirementsError(f"{where}.{key} must be positive")
|
|
169
|
+
return float(value)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _unknown(table: dict, allowed: set[str], where: str) -> None:
|
|
173
|
+
extra = sorted(set(table) - allowed)
|
|
174
|
+
if extra:
|
|
175
|
+
raise RequirementsError(f"unknown fields in {where}: {', '.join(extra)}")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _range(value: Any, where: str) -> tuple[float, float]:
|
|
179
|
+
if (not isinstance(value, list) or len(value) != 2
|
|
180
|
+
or any(isinstance(v, bool) or not isinstance(v, (int, float)) for v in value) or value[0] > value[1]):
|
|
181
|
+
raise RequirementsError(f"{where} must be [low, high] with low <= high")
|
|
182
|
+
return float(value[0]), float(value[1])
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def find(*directories: Path | str) -> Path | None:
|
|
186
|
+
for directory in directories:
|
|
187
|
+
if directory is None:
|
|
188
|
+
continue
|
|
189
|
+
candidate = Path(directory) / FILE_NAME
|
|
190
|
+
if candidate.is_file():
|
|
191
|
+
return candidate
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def load(path: Path | str | None) -> Requirements:
|
|
196
|
+
"""Parse a requirements file; `None` gives the defaults."""
|
|
197
|
+
if path is None:
|
|
198
|
+
return Requirements()
|
|
199
|
+
path = Path(path)
|
|
200
|
+
try:
|
|
201
|
+
payload = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
202
|
+
except OSError as exc:
|
|
203
|
+
raise RequirementsError(f"cannot read {path}: {exc}") from exc
|
|
204
|
+
except (UnicodeDecodeError, tomllib.TOMLDecodeError) as exc:
|
|
205
|
+
raise RequirementsError(f"invalid {path.name}: {exc}") from exc
|
|
206
|
+
_unknown(payload, {"version", "rails", "derating", "board", "connectors", "simulations", "components"},
|
|
207
|
+
FILE_NAME)
|
|
208
|
+
if payload.get("version") != 1:
|
|
209
|
+
raise RequirementsError(f"{FILE_NAME} version must be 1")
|
|
210
|
+
out = Requirements(path=path.resolve())
|
|
211
|
+
|
|
212
|
+
rails = payload.get("rails", {})
|
|
213
|
+
if not isinstance(rails, dict):
|
|
214
|
+
raise RequirementsError("rails must be a table of [rails.NAME] tables")
|
|
215
|
+
for name, table in rails.items():
|
|
216
|
+
where = f"rails.{name}"
|
|
217
|
+
if not isinstance(table, dict):
|
|
218
|
+
raise RequirementsError(f"{where} must be a table")
|
|
219
|
+
_unknown(table, {"voltage", "tolerance", "min", "max"}, where)
|
|
220
|
+
voltage = _number(table, "voltage", where)
|
|
221
|
+
if voltage is None:
|
|
222
|
+
raise RequirementsError(f"{where}.voltage is required")
|
|
223
|
+
tolerance = _number(table, "tolerance", where) or 0.0
|
|
224
|
+
if not 0 <= tolerance < 1:
|
|
225
|
+
raise RequirementsError(f"{where}.tolerance is a fraction between 0 and 1")
|
|
226
|
+
low = _number(table, "min", where)
|
|
227
|
+
high = _number(table, "max", where)
|
|
228
|
+
spread = abs(voltage) * tolerance
|
|
229
|
+
low = voltage - spread if low is None else low
|
|
230
|
+
high = voltage + spread if high is None else high
|
|
231
|
+
if not low <= voltage <= high:
|
|
232
|
+
raise RequirementsError(f"{where}: min <= voltage <= max does not hold")
|
|
233
|
+
out.rails[name] = Rail(name, voltage, low, high, "requirements")
|
|
234
|
+
|
|
235
|
+
derating = payload.get("derating", {})
|
|
236
|
+
if not isinstance(derating, dict):
|
|
237
|
+
raise RequirementsError("derating must be a table")
|
|
238
|
+
_unknown(derating, set(DERATING_DEFAULTS), "derating")
|
|
239
|
+
for key in derating:
|
|
240
|
+
value = _number(derating, key, "derating", positive=True)
|
|
241
|
+
if value is None or value > 1:
|
|
242
|
+
raise RequirementsError(f"derating.{key} is a ratio between 0 and 1")
|
|
243
|
+
out.derating[key] = value
|
|
244
|
+
|
|
245
|
+
board = payload.get("board", {})
|
|
246
|
+
if not isinstance(board, dict):
|
|
247
|
+
raise RequirementsError("board must be a table")
|
|
248
|
+
_unknown(board, {"assembly", "smd_edge_clearance_mm", "test_points", "test_point_exempt"}, "board")
|
|
249
|
+
assembly = board.get("assembly")
|
|
250
|
+
if assembly is not None and assembly not in ASSEMBLY_MODES:
|
|
251
|
+
raise RequirementsError(f"board.assembly must be one of {', '.join(ASSEMBLY_MODES)}")
|
|
252
|
+
out.assembly = assembly
|
|
253
|
+
out.smd_edge_clearance_mm = _number(board, "smd_edge_clearance_mm", "board", positive=True)
|
|
254
|
+
mode = board.get("test_points", "advisory")
|
|
255
|
+
if mode not in TEST_POINT_MODES:
|
|
256
|
+
raise RequirementsError(f"board.test_points must be one of {', '.join(TEST_POINT_MODES)}")
|
|
257
|
+
out.test_points = mode
|
|
258
|
+
exempt = board.get("test_point_exempt", [])
|
|
259
|
+
if not isinstance(exempt, list) or any(not isinstance(v, str) for v in exempt):
|
|
260
|
+
raise RequirementsError("board.test_point_exempt must be a list of net names")
|
|
261
|
+
out.test_point_exempt = tuple(exempt)
|
|
262
|
+
|
|
263
|
+
connectors = payload.get("connectors", [])
|
|
264
|
+
if not isinstance(connectors, list):
|
|
265
|
+
raise RequirementsError("connectors must be an array of [[connectors]] tables")
|
|
266
|
+
for index, table in enumerate(connectors):
|
|
267
|
+
where = f"connectors[{index}]"
|
|
268
|
+
if not isinstance(table, dict):
|
|
269
|
+
raise RequirementsError(f"{where} must be a table")
|
|
270
|
+
_unknown(table, {"ref", "interface", "pins"}, where)
|
|
271
|
+
ref, pins = table.get("ref"), table.get("pins")
|
|
272
|
+
if not isinstance(ref, str) or not ref:
|
|
273
|
+
raise RequirementsError(f"{where}.ref is required")
|
|
274
|
+
if not isinstance(pins, dict) or not pins or any(not isinstance(v, str) for v in pins.values()):
|
|
275
|
+
raise RequirementsError(f"{where}.pins must map pin numbers to signal names")
|
|
276
|
+
out.connectors.append(Connector(ref, str(table.get("interface", "")), {str(k): v for k, v in pins.items()}))
|
|
277
|
+
|
|
278
|
+
simulations = payload.get("simulations", [])
|
|
279
|
+
if not isinstance(simulations, list):
|
|
280
|
+
raise RequirementsError("simulations must be an array of [[simulations]] tables")
|
|
281
|
+
for index, table in enumerate(simulations):
|
|
282
|
+
where = f"simulations[{index}]"
|
|
283
|
+
if not isinstance(table, dict):
|
|
284
|
+
raise RequirementsError(f"{where} must be a table")
|
|
285
|
+
_unknown(table, {"name", "netlist", "measure", "pass", "warn", "timeout_s"}, where)
|
|
286
|
+
for key in ("name", "netlist", "measure"):
|
|
287
|
+
if not isinstance(table.get(key), str) or not table[key]:
|
|
288
|
+
raise RequirementsError(f"{where}.{key} is required")
|
|
289
|
+
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", table["measure"]):
|
|
290
|
+
raise RequirementsError(f"{where}.measure must be a plain identifier")
|
|
291
|
+
passing = _range(table.get("pass"), f"{where}.pass")
|
|
292
|
+
warning = _range(table["warn"], f"{where}.warn") if "warn" in table else None
|
|
293
|
+
if warning and not (warning[0] <= passing[0] and passing[1] <= warning[1]):
|
|
294
|
+
raise RequirementsError(f"{where}.warn must contain the pass range")
|
|
295
|
+
timeout = _number(table, "timeout_s", where, positive=True) or 30.0
|
|
296
|
+
out.simulations.append(Testbench(table["name"], table["netlist"], table["measure"], passing, warning,
|
|
297
|
+
min(timeout, 600.0)))
|
|
298
|
+
|
|
299
|
+
components = payload.get("components", {})
|
|
300
|
+
if not isinstance(components, dict):
|
|
301
|
+
raise RequirementsError("components must be a table")
|
|
302
|
+
_unknown(components, {"databases", "assign"}, "components")
|
|
303
|
+
databases = components.get("databases", [])
|
|
304
|
+
if not isinstance(databases, list) or any(not isinstance(v, str) for v in databases):
|
|
305
|
+
raise RequirementsError("components.databases must be a list of paths")
|
|
306
|
+
root = path.parent.resolve()
|
|
307
|
+
resolved = []
|
|
308
|
+
for value in databases:
|
|
309
|
+
candidate = (root / value).resolve()
|
|
310
|
+
try:
|
|
311
|
+
candidate.relative_to(root)
|
|
312
|
+
except ValueError as exc:
|
|
313
|
+
raise RequirementsError(f"components.databases must stay inside the project: {value}") from exc
|
|
314
|
+
resolved.append(candidate)
|
|
315
|
+
out.databases = tuple(resolved)
|
|
316
|
+
assign = components.get("assign", {})
|
|
317
|
+
if not isinstance(assign, dict) or any(not isinstance(v, str) for v in assign.values()):
|
|
318
|
+
raise RequirementsError("components.assign must map references to component ids")
|
|
319
|
+
out.assign = dict(assign)
|
|
320
|
+
return out
|