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