enginehall 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- enginehall-2.0.0.dist-info/METADATA +511 -0
- enginehall-2.0.0.dist-info/RECORD +75 -0
- enginehall-2.0.0.dist-info/WHEEL +4 -0
- enginehall-2.0.0.dist-info/entry_points.txt +4 -0
- enginehall-2.0.0.dist-info/licenses/LICENSE +51 -0
- pcba/THIRD_PARTY_NOTICES.md +72 -0
- pcba/__init__.py +6 -0
- pcba/adapters/__init__.py +10 -0
- pcba/adapters/altium_bridge/MANIFEST.json +5 -0
- pcba/adapters/altium_bridge/__init__.py +1 -0
- pcba/adapters/altium_bridge/bridge.pas +3342 -0
- pcba/adapters/altium_drive.py +1160 -0
- pcba/adapters/altium_house.py +591 -0
- pcba/adapters/altium_ops.py +858 -0
- pcba/adapters/altium_outjob.py +247 -0
- pcba/adapters/altium_uia.py +304 -0
- pcba/adapters/altium_vcs.py +197 -0
- pcba/adapters/altium_win32.py +457 -0
- pcba/adapters/cubemx_drive.py +248 -0
- pcba/baseline.py +151 -0
- pcba/changes.py +81 -0
- pcba/cli.py +891 -0
- pcba/components.py +323 -0
- pcba/config.py +180 -0
- pcba/data/components/generic.json +61 -0
- pcba/discover.py +350 -0
- pcba/doctor.py +131 -0
- pcba/house.py +123 -0
- pcba/mcp/__init__.py +5 -0
- pcba/mcp/altium_server.py +1358 -0
- pcba/mcp/protocol.py +224 -0
- pcba/mcp/server.py +332 -0
- pcba/model/__init__.py +38 -0
- pcba/model/design.py +232 -0
- pcba/model/finding.py +148 -0
- pcba/model/pinmap.py +200 -0
- pcba/parsers/__init__.py +0 -0
- pcba/parsers/altium/__init__.py +37 -0
- pcba/parsers/altium/_sheet.py +293 -0
- pcba/parsers/altium/geometry.py +103 -0
- pcba/parsers/altium/netlist.py +302 -0
- pcba/parsers/altium/pinmap.py +336 -0
- pcba/parsers/altium/project.py +249 -0
- pcba/parsers/altium/records.py +227 -0
- pcba/parsers/firmware/__init__.py +5 -0
- pcba/parsers/firmware/index.py +250 -0
- pcba/parsers/ioc/__init__.py +49 -0
- pcba/parsers/ioc/mcu_db.py +180 -0
- pcba/parsers/ioc/reader.py +182 -0
- pcba/parsers/ioc/validate.py +192 -0
- pcba/parsers/kicad/__init__.py +103 -0
- pcba/parsers/kicad/board.py +269 -0
- pcba/parsers/kicad/kicad_cli.py +190 -0
- pcba/parsers/kicad/pinmap.py +90 -0
- pcba/parsers/kicad/schematic.py +517 -0
- pcba/parsers/kicad/sexpr.py +136 -0
- pcba/publish.py +269 -0
- pcba/report/__init__.py +7 -0
- pcba/report/json_report.py +39 -0
- pcba/report/sarif.py +128 -0
- pcba/report/text.py +304 -0
- pcba/requirements.py +320 -0
- pcba/review.py +177 -0
- pcba/rules/__init__.py +312 -0
- pcba/rules/cross_domain.py +496 -0
- pcba/rules/kicad_board.py +775 -0
- pcba/rules/kicad_common.py +167 -0
- pcba/rules/kicad_components.py +603 -0
- pcba/rules/kicad_schematic.py +621 -0
- pcba/rules/locate.py +22 -0
- pcba/rules/schematic.py +123 -0
- pcba/rules/signals.py +149 -0
- pcba/rules/spice.py +59 -0
- pcba/rules/stm32.py +155 -0
- pcba/simulation.py +143 -0
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
"""Newmatik house DFM for Altium boards (Class C helper of the Altium drive; never on the review path).
|
|
2
|
+
|
|
3
|
+
Two halves:
|
|
4
|
+
|
|
5
|
+
- **Rules Altium can check.** `HOUSE_RULES` holds the house values for Altium design-rule kinds.
|
|
6
|
+
`rul_text()` writes them as an Altium rules file (`.RUL`, Design > Rules > Import Rules), with
|
|
7
|
+
the record syntax Altium itself stores in a PcbDoc; `house_ops()` gives the typed operations
|
|
8
|
+
that apply the same values through `enginehall altium mcp`. The rules keep Altium's default names, so
|
|
9
|
+
an import replaces the defaults instead of competing with them.
|
|
10
|
+
- **What Altium's rule system cannot express**: fiducials, and the house layout checks review kept
|
|
11
|
+
finding by hand. `check_fiducials()` takes what the drive reads from a board (`pcb_components`,
|
|
12
|
+
`pcb_pads`, the outline) and requires three global fiducials on every side that carries SMD
|
|
13
|
+
parts; `check_layout()` adds silkscreen beyond the edge, track angles, vias in pads, same-net
|
|
14
|
+
via spacing, 3D models and the barcode field from `pcb_primitives` and `pcb_bodies`.
|
|
15
|
+
|
|
16
|
+
Values marked ``proposed`` are starting points until Newmatik process engineering confirms them;
|
|
17
|
+
`status()` lists them so nobody mistakes them for confirmed capability. This module never claims
|
|
18
|
+
manufacturability: it reports.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import itertools
|
|
24
|
+
import math
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
|
|
27
|
+
from pcba.house import ( # noqa: F401 (re-exported: the drive and its tests read them here)
|
|
28
|
+
FIDUCIAL_STATUS,
|
|
29
|
+
FIDUCIALS,
|
|
30
|
+
HOUSE,
|
|
31
|
+
LAYOUT,
|
|
32
|
+
LAYOUT_STATUS,
|
|
33
|
+
VERSION,
|
|
34
|
+
)
|
|
35
|
+
from pcba.house import angles as _angles
|
|
36
|
+
from pcba.house import distance_to_pad as _house_distance_to_pad
|
|
37
|
+
from pcba.house import edge_distance as _edge_distance
|
|
38
|
+
from pcba.house import inside as _inside
|
|
39
|
+
|
|
40
|
+
MM_PER_MIL = 0.0254
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class HouseRule:
|
|
45
|
+
kind: str # Altium RULEKIND
|
|
46
|
+
name: str # Altium's default rule name, so an import replaces the default
|
|
47
|
+
fields: tuple # (KEY, value) pairs after the common header; mm floats become mils
|
|
48
|
+
why: str
|
|
49
|
+
netscope: str = "AnyNet"
|
|
50
|
+
scope1: str = "All"
|
|
51
|
+
scope2: str = "All"
|
|
52
|
+
status: str = "proposed"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _mm(value: float) -> str:
|
|
56
|
+
"""Altium writes rule lengths in mils with up to four decimals."""
|
|
57
|
+
mils = round(value / MM_PER_MIL, 4)
|
|
58
|
+
text = f"{mils:.4f}".rstrip("0").rstrip(".")
|
|
59
|
+
return f"{text}mil"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
HOUSE_RULES: tuple[HouseRule, ...] = (
|
|
63
|
+
HouseRule("Clearance", "Clearance",
|
|
64
|
+
(("GAP", 0.15), ("GENERICCLEARANCE", 0.15), ("IGNOREPADTOPADCLEARANCEINFOOTPRINT", "FALSE"),
|
|
65
|
+
("OBJECTCLEARANCES", "")),
|
|
66
|
+
"Copper to copper, different nets, all layers.", netscope="DifferentNets"),
|
|
67
|
+
HouseRule("Width", "Width", (("MAXLIMIT", 5.0), ("MINLIMIT", 0.15), ("PREFEREDWIDTH", 0.2)),
|
|
68
|
+
"Track width; wider power and EMC nets get their own rules above this one."),
|
|
69
|
+
HouseRule("RoutingVias", "RoutingVias",
|
|
70
|
+
(("HOLEWIDTH", 0.3), ("WIDTH", 0.6), ("VIASTYLE", "Through Hole"), ("MINHOLEWIDTH", 0.25),
|
|
71
|
+
("MINWIDTH", 0.5), ("MAXHOLEWIDTH", 0.8), ("MAXWIDTH", 1.4)),
|
|
72
|
+
"Through vias only; 0.125 mm minimum annular ring at the minimum sizes."),
|
|
73
|
+
HouseRule("HoleSize", "HoleSize",
|
|
74
|
+
(("ABSOLUTEVALUES", "TRUE"), ("MAXLIMIT", 6.4), ("MINLIMIT", 0.2), ("MAXPERCENT", "80.000"),
|
|
75
|
+
("MINPERCENT", "20.000")),
|
|
76
|
+
"Drilled holes from 0.2 mm up to M6 clearance."),
|
|
77
|
+
HouseRule("HoleToHoleClearance", "HoleToHoleClearance", (("GAP", 0.25), ("ALLOWSTACKEDMICROVIAS", "TRUE")),
|
|
78
|
+
"Drill web between holes."),
|
|
79
|
+
HouseRule("MinimumSolderMaskSliver", "MinimumSolderMaskSliver", (("MINSOLDERMASKWIDTH", 0.1),),
|
|
80
|
+
"Narrowest solder-mask dam the mask process holds."),
|
|
81
|
+
HouseRule("SolderMaskExpansion", "SolderMaskExpansion", (("EXPANSION", 0.05), ("ISTENTINGTOP", "FALSE")),
|
|
82
|
+
"Mask opening around pads; vias are tented per board."),
|
|
83
|
+
HouseRule("PasteMaskExpansion", "PasteMaskExpansion", (("EXPANSION", 0.0),),
|
|
84
|
+
"Paste follows the pad; stencil apertures are tuned at assembly."),
|
|
85
|
+
HouseRule("SilkToSolderMaskClearance", "SilkToSolderMaskClearance",
|
|
86
|
+
(("MINSILKSCREENTOMASKGAP", 0.15), ("CLEARANCETOEXPOSEDCOPPER", "TRUE")),
|
|
87
|
+
"Silkscreen kept off pads and mask openings.", scope1="IsPad"),
|
|
88
|
+
HouseRule("SilkToSilkClearance", "SilkToSilkClearance", (("SILKTOSILKCLEARANCE", 0.15),),
|
|
89
|
+
"Legible, non-overlapping silkscreen."),
|
|
90
|
+
HouseRule("ComponentClearance", "ComponentClearance",
|
|
91
|
+
(("GAP", 0.25), ("COLLISIONCHECKMODE", "3"), ("VERTICALGAP", 0.25), ("SHOWDISTANCES", "FALSE")),
|
|
92
|
+
"Body to body for placement and rework."),
|
|
93
|
+
HouseRule("PlaneClearance", "PlaneClearance", (("CLEARANCE", 0.3),), "Antipad in power and ground planes."),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
_HEADER = ("SELECTION=FALSE", "LAYER=TOP", "LOCKED=FALSE", "POLYGONOUTLINE=FALSE", "USERROUTED=TRUE",
|
|
97
|
+
"KEEPOUT=FALSE", "UNIONINDEX=0")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _unique_id(rule: HouseRule) -> str:
|
|
101
|
+
"""Eight capitals, stable per rule, like the ids Altium writes."""
|
|
102
|
+
seed = sum((i + 1) * ord(c) for i, c in enumerate(rule.kind + rule.name))
|
|
103
|
+
letters = []
|
|
104
|
+
for _ in range(8):
|
|
105
|
+
seed = (seed * 1103515245 + 12345) % (1 << 31)
|
|
106
|
+
letters.append(chr(ord("A") + seed % 26))
|
|
107
|
+
return "".join(letters)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# Altium's Export Rules ends every record with the rule set name and a pilcrow (0xB6), writes an
|
|
111
|
+
# empty value as one space, and encodes the file as ISO-8859-1. Its importer rejects other forms,
|
|
112
|
+
# a leading "|" included, as "Invalid RUL file structure" (Altium Designer Develop 26.10).
|
|
113
|
+
RULE_SET = "Default Rule Set"
|
|
114
|
+
RECORD_END = "¶"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def rul_record(rule: HouseRule) -> str:
|
|
118
|
+
parts = list(_HEADER) + [
|
|
119
|
+
f"RULEKIND={rule.kind}", f"NETSCOPE={rule.netscope}", "LAYERKIND=SameLayer",
|
|
120
|
+
f"SCOPE1EXPRESSION={rule.scope1}", f"SCOPE2EXPRESSION={rule.scope2}", f"NAME={rule.name}",
|
|
121
|
+
"ENABLED=TRUE", "PRIORITY=1", f"COMMENT={HOUSE} v{VERSION} ({rule.status})",
|
|
122
|
+
f"UNIQUEID={_unique_id(rule)}", "DEFINEDBYLOGICALDOCUMENT=FALSE",
|
|
123
|
+
]
|
|
124
|
+
for key, value in rule.fields:
|
|
125
|
+
text = _mm(value) if isinstance(value, float) else value
|
|
126
|
+
parts.append(f"{key}={text or ' '}")
|
|
127
|
+
parts.append(f"CUSTOMRULESETNAME={RULE_SET}")
|
|
128
|
+
return "|".join(parts) + RECORD_END
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def rul_text() -> str:
|
|
132
|
+
"""The `.RUL` file: one record per line and exactly one line break at the end (Altium's
|
|
133
|
+
importer fails on a second blank line)."""
|
|
134
|
+
return "\r\n".join(rul_record(rule) for rule in HOUSE_RULES) + "\r\n"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def rul_bytes() -> bytes:
|
|
138
|
+
return rul_text().encode("latin-1")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# Altium exposes no writable property for the silk-to-mask gap to scripts; that one rule reaches a
|
|
142
|
+
# board only through the .RUL import.
|
|
143
|
+
RUL_ONLY = ("SilkToSolderMaskClearance",)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def house_ops() -> list[dict]:
|
|
147
|
+
"""Every house rule except RUL_ONLY as typed operations. Apply them before a board's own
|
|
148
|
+
rules: Altium gives the most recently added rule the highest priority."""
|
|
149
|
+
values = {r.name: dict(r.fields) for r in HOUSE_RULES}
|
|
150
|
+
via = values["RoutingVias"]
|
|
151
|
+
hole = values["HoleSize"]
|
|
152
|
+
return [
|
|
153
|
+
{"op": "pcb_rule_clearance", "name": "Clearance", "gap": values["Clearance"]["GAP"]},
|
|
154
|
+
{"op": "pcb_rule_width", "name": "Width", "scope": "All", "min": values["Width"]["MINLIMIT"],
|
|
155
|
+
"preferred": values["Width"]["PREFEREDWIDTH"], "max": values["Width"]["MAXLIMIT"]},
|
|
156
|
+
{"op": "pcb_rule_via", "name": "RoutingVias", "scope": "All", "hole_min": via["MINHOLEWIDTH"],
|
|
157
|
+
"hole": via["HOLEWIDTH"], "hole_max": via["MAXHOLEWIDTH"], "diameter_min": via["MINWIDTH"],
|
|
158
|
+
"diameter": via["WIDTH"], "diameter_max": via["MAXWIDTH"]},
|
|
159
|
+
{"op": "pcb_rule_hole_size", "name": "HoleSize", "min": hole["MINLIMIT"], "max": hole["MAXLIMIT"]},
|
|
160
|
+
{"op": "pcb_rule_mask_sliver", "name": "MinimumSolderMaskSliver",
|
|
161
|
+
"gap": values["MinimumSolderMaskSliver"]["MINSOLDERMASKWIDTH"]},
|
|
162
|
+
{"op": "pcb_rule_silk_to_silk", "name": "SilkToSilkClearance",
|
|
163
|
+
"gap": values["SilkToSilkClearance"]["SILKTOSILKCLEARANCE"]},
|
|
164
|
+
{"op": "pcb_rule_value", "name": "HoleToHoleClearance", "kind": "hole_to_hole",
|
|
165
|
+
"value": values["HoleToHoleClearance"]["GAP"]},
|
|
166
|
+
{"op": "pcb_rule_value", "name": "ComponentClearance", "kind": "component_clearance",
|
|
167
|
+
"value": values["ComponentClearance"]["GAP"], "vertical": values["ComponentClearance"]["VERTICALGAP"]},
|
|
168
|
+
{"op": "pcb_rule_value", "name": "SolderMaskExpansion", "kind": "solder_mask_expansion",
|
|
169
|
+
"value": values["SolderMaskExpansion"]["EXPANSION"]},
|
|
170
|
+
{"op": "pcb_rule_value", "name": "PasteMaskExpansion", "kind": "paste_mask_expansion",
|
|
171
|
+
"value": values["PasteMaskExpansion"]["EXPANSION"]},
|
|
172
|
+
{"op": "pcb_rule_value", "name": "PlaneClearance", "kind": "plane_clearance",
|
|
173
|
+
"value": values["PlaneClearance"]["CLEARANCE"]},
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def status() -> dict:
|
|
178
|
+
"""Which values are Newmatik requirements and which are still proposals."""
|
|
179
|
+
return {
|
|
180
|
+
"house": HOUSE,
|
|
181
|
+
"version": VERSION,
|
|
182
|
+
"rules": [{"kind": r.kind, "name": r.name, "status": r.status, "why": r.why,
|
|
183
|
+
"values": {k: (v if not isinstance(v, float) else round(v, 4)) for k, v in r.fields}}
|
|
184
|
+
for r in HOUSE_RULES],
|
|
185
|
+
"fiducials": {k: {"value": v, "status": FIDUCIAL_STATUS.get(k, "proposed")} for k, v in FIDUCIALS.items()},
|
|
186
|
+
"layout": {k: {"value": v, "status": LAYOUT_STATUS.get(k, "proposed")} for k, v in LAYOUT.items()},
|
|
187
|
+
"not_in_rul": "fiducials, silkscreen past the board edge, track angles, vias in pads, same-net via spacing, "
|
|
188
|
+
"3D models and the barcode field: Altium has no rule kind for them; altium_dfm checks them",
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# --- fiducial check ------------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass
|
|
196
|
+
class Finding:
|
|
197
|
+
rule_id: str
|
|
198
|
+
severity: str # error | warning
|
|
199
|
+
side: str
|
|
200
|
+
message: str
|
|
201
|
+
evidence: dict = field(default_factory=dict)
|
|
202
|
+
|
|
203
|
+
def to_dict(self) -> dict:
|
|
204
|
+
return {"rule_id": self.rule_id, "severity": self.severity, "side": self.side,
|
|
205
|
+
"message": self.message, "evidence": self.evidence}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
SMD_LAYERS = {"top": 1, "bottom": 32}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def is_fiducial(component: dict) -> bool:
|
|
212
|
+
return ("FIDUCIAL" in str(component.get("footprint", "")).upper()
|
|
213
|
+
or str(component.get("designator", "")).upper().startswith("FID"))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _side(component: dict) -> str:
|
|
217
|
+
return "bottom" if component.get("bottom") else "top"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def check_fiducials(components: list[dict], pads: list[dict], outline: list[list[float]] | None = None) -> dict:
|
|
221
|
+
"""Fiducial findings for a board read through the drive (lengths in mm).
|
|
222
|
+
|
|
223
|
+
A side needs fiducials when a non-fiducial part on it has an SMD pad. Returns
|
|
224
|
+
``{"sides": {...}, "findings": [...], "passed": bool}``; ``passed`` ignores warnings.
|
|
225
|
+
"""
|
|
226
|
+
policy = FIDUCIALS
|
|
227
|
+
by_des = {c["designator"]: c for c in components}
|
|
228
|
+
pads_by_des: dict[str, list[dict]] = {}
|
|
229
|
+
for pad in pads:
|
|
230
|
+
pads_by_des.setdefault(pad.get("designator", ""), []).append(pad)
|
|
231
|
+
|
|
232
|
+
findings: list[Finding] = []
|
|
233
|
+
sides = {}
|
|
234
|
+
for side in ("top", "bottom"):
|
|
235
|
+
smd_parts = sorted(
|
|
236
|
+
des for des, comp in by_des.items()
|
|
237
|
+
if _side(comp) == side and not is_fiducial(comp)
|
|
238
|
+
and any(not p.get("hole") and p.get("layer") in SMD_LAYERS.values() for p in pads_by_des.get(des, []))
|
|
239
|
+
)
|
|
240
|
+
fids = sorted((des for des, comp in by_des.items() if _side(comp) == side and is_fiducial(comp)))
|
|
241
|
+
sides[side] = {"smd_parts": len(smd_parts), "fiducials": fids}
|
|
242
|
+
if not smd_parts:
|
|
243
|
+
continue
|
|
244
|
+
need = policy["per_smd_side"]
|
|
245
|
+
if len(fids) < need:
|
|
246
|
+
findings.append(Finding(
|
|
247
|
+
"NM-FID-001", "error", side,
|
|
248
|
+
f"{side} carries {len(smd_parts)} SMD parts but {len(fids)} fiducial(s); "
|
|
249
|
+
f"the house rule is {need} per SMD side",
|
|
250
|
+
{"smd_parts": smd_parts[:10], "fiducials": fids}))
|
|
251
|
+
points = {}
|
|
252
|
+
for des in fids:
|
|
253
|
+
comp = by_des[des]
|
|
254
|
+
points[des] = (comp["x"], comp["y"])
|
|
255
|
+
fpads = pads_by_des.get(des, [])
|
|
256
|
+
if len(fpads) != 1:
|
|
257
|
+
findings.append(Finding("NM-FID-002", "error", side, f"{des} has {len(fpads)} pads; a fiducial has one",
|
|
258
|
+
{"designator": des}))
|
|
259
|
+
continue
|
|
260
|
+
pad = fpads[0]
|
|
261
|
+
size = max(pad.get("w", 0), pad.get("h", 0))
|
|
262
|
+
if (abs(pad.get("w", 0) - pad.get("h", 0)) > 1e-3
|
|
263
|
+
or abs(size - policy["copper_mm"]) > policy["copper_tolerance_mm"]):
|
|
264
|
+
findings.append(Finding("NM-FID-002", "error", side,
|
|
265
|
+
f"{des} copper is {pad.get('w')} x {pad.get('h')} mm; "
|
|
266
|
+
f"the house fiducial is a {policy['copper_mm']} mm round pad",
|
|
267
|
+
{"designator": des}))
|
|
268
|
+
# The bridge names the pad shape; a square 1.0 mm pad has the right size and is still wrong.
|
|
269
|
+
if pad.get("shape", "round") != "round":
|
|
270
|
+
findings.append(Finding("NM-FID-002", "error", side,
|
|
271
|
+
f"{des} is a {pad['shape']} pad; "
|
|
272
|
+
f"the house fiducial is a {policy['copper_mm']} mm round pad",
|
|
273
|
+
{"designator": des, "shape": pad["shape"]}))
|
|
274
|
+
if pad.get("hole"):
|
|
275
|
+
findings.append(Finding("NM-FID-002", "error", side, f"{des} is drilled; a fiducial is a bare SMD pad",
|
|
276
|
+
{"designator": des}))
|
|
277
|
+
if pad.get("net"):
|
|
278
|
+
findings.append(Finding("NM-FID-004", "error", side,
|
|
279
|
+
f"{des} is on net {pad['net']}; a fiducial carries no net",
|
|
280
|
+
{"designator": des, "net": pad["net"]}))
|
|
281
|
+
if "mask_mode" in pad:
|
|
282
|
+
opening = size + 2 * (pad.get("mask", 0.0) if pad["mask_mode"] == 2 else 0.0)
|
|
283
|
+
if pad["mask_mode"] != 2 or abs(opening - policy["mask_opening_mm"]) > policy["mask_tolerance_mm"]:
|
|
284
|
+
findings.append(Finding("NM-FID-003", "error", side,
|
|
285
|
+
f"{des} mask opening is {opening:.2f} mm; the house fiducial needs "
|
|
286
|
+
f"{policy['mask_opening_mm']} mm (manual expansion on the pad)",
|
|
287
|
+
{"designator": des, "mask_mode": pad["mask_mode"]}))
|
|
288
|
+
if pad.get("paste_mode") != 2 or pad.get("paste", 0.0) > -size / 2:
|
|
289
|
+
findings.append(Finding("NM-FID-004", "error", side,
|
|
290
|
+
f"{des} would get solder paste; a fiducial has none",
|
|
291
|
+
{"designator": des}))
|
|
292
|
+
if outline and not _inside(points[des], outline):
|
|
293
|
+
findings.append(Finding("NM-FID-006", "error", side, f"{des} lies outside the board outline",
|
|
294
|
+
{"designator": des}))
|
|
295
|
+
elif outline:
|
|
296
|
+
d = _edge_distance(points[des], outline)
|
|
297
|
+
if d < policy["edge_min_mm"]:
|
|
298
|
+
findings.append(Finding("NM-FID-006", "warning", side,
|
|
299
|
+
f"{des} is {d:.2f} mm from the board edge; "
|
|
300
|
+
f"house target {policy['edge_min_mm']} mm",
|
|
301
|
+
{"designator": des, "distance_mm": round(d, 3)}))
|
|
302
|
+
if len(points) >= 3:
|
|
303
|
+
pts = list(points.values())
|
|
304
|
+
# With more than three, the best-conditioned triple is the one a machine would use.
|
|
305
|
+
best = max(min(_angles(a, b, c)) for a, b, c in itertools.combinations(pts, 3))
|
|
306
|
+
if best < policy["min_angle_deg"]:
|
|
307
|
+
findings.append(Finding("NM-FID-005", "error", side,
|
|
308
|
+
f"the {side} fiducials are nearly in a line (smallest angle {best:.1f} deg); "
|
|
309
|
+
"a machine cannot resolve rotation from them",
|
|
310
|
+
{"fiducials": sorted(points)}))
|
|
311
|
+
if outline:
|
|
312
|
+
xs = [p[0] for p in outline]
|
|
313
|
+
ys = [p[1] for p in outline]
|
|
314
|
+
fx = [p[0] for p in pts]
|
|
315
|
+
fy = [p[1] for p in pts]
|
|
316
|
+
sx = (max(fx) - min(fx)) / max(1e-9, max(xs) - min(xs))
|
|
317
|
+
sy = (max(fy) - min(fy)) / max(1e-9, max(ys) - min(ys))
|
|
318
|
+
if min(sx, sy) < policy["min_spread"]:
|
|
319
|
+
findings.append(Finding("NM-FID-005", "warning", side,
|
|
320
|
+
f"the {side} fiducials span {sx:.0%} of the board width and "
|
|
321
|
+
f"{sy:.0%} of its depth; house target {policy['min_spread']:.0%} each",
|
|
322
|
+
{"fiducials": sorted(points)}))
|
|
323
|
+
return {
|
|
324
|
+
"policy": dict(policy),
|
|
325
|
+
"sides": sides,
|
|
326
|
+
"findings": [f.to_dict() for f in findings],
|
|
327
|
+
"passed": not any(f.severity == "error" for f in findings),
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# --- house layout checks ------------------------------------------------------------------------
|
|
332
|
+
#
|
|
333
|
+
# What review found one correction at a time on the first boards built with the drive, and what
|
|
334
|
+
# Altium's DRC does not check: silkscreen beyond the board edge (the fabricator cuts it off, so a
|
|
335
|
+
# board name lost its end), copper tracks at free angles, vias in or touching pads, two vias of
|
|
336
|
+
# one net side by side, components without a real 3D model, and a missing barcode field. All of
|
|
337
|
+
# them read the board through the drive (pcb_primitives, pcb_pads, pcb_bodies, the outline).
|
|
338
|
+
|
|
339
|
+
OVERLAYS = {33: "top", 34: "bottom"}
|
|
340
|
+
COPPER = set(range(1, 33))
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _outline_points(outline: list[list[float]] | None) -> list[tuple[float, float]]:
|
|
344
|
+
return [(float(x), float(y)) for x, y in outline or []]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _outside_by(point, outline) -> float:
|
|
348
|
+
"""How far a point lies outside the outline polygon (0 when inside or on it)."""
|
|
349
|
+
return 0.0 if _inside(point, outline) else _edge_distance(point, outline)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _silk_points(p: dict) -> list[tuple[float, float]]:
|
|
353
|
+
"""The points of a silkscreen primitive that must lie on the board. A line counts by its centre
|
|
354
|
+
line (a barrier mark may end on the edge); text, fills and regions by their extent."""
|
|
355
|
+
if p["kind"] == "track":
|
|
356
|
+
return [(p["x1"], p["y1"]), (p["x2"], p["y2"])]
|
|
357
|
+
if p["kind"] == "arc":
|
|
358
|
+
r = p["r"]
|
|
359
|
+
ends = [(p["xc"] + r * math.cos(math.radians(a)), p["yc"] + r * math.sin(math.radians(a)))
|
|
360
|
+
for a in (p.get("start", 0), p.get("end", 360))]
|
|
361
|
+
return ends + [(p["xc"] + dx * r, p["yc"] + dy * r) for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1))
|
|
362
|
+
if _arc_covers(p, dx, dy)]
|
|
363
|
+
return [(p["box_left"], p["box_bottom"]), (p["box_right"], p["box_bottom"]),
|
|
364
|
+
(p["box_right"], p["box_top"]), (p["box_left"], p["box_top"])]
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _arc_covers(p: dict, dx: int, dy: int) -> bool:
|
|
368
|
+
angle = math.degrees(math.atan2(dy, dx)) % 360
|
|
369
|
+
start, end = p.get("start", 0) % 360, p.get("end", 360) % 360 or 360
|
|
370
|
+
return start <= angle <= end if start <= end else angle >= start or angle <= end
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def check_silkscreen(primitives: list[dict], outline: list[list[float]] | None) -> list[Finding]:
|
|
374
|
+
"""NM-SILK-001: silkscreen beyond the board outline. Hidden component texts do not print."""
|
|
375
|
+
poly = _outline_points(outline)
|
|
376
|
+
if len(poly) < 3:
|
|
377
|
+
return []
|
|
378
|
+
tol = LAYOUT["silk_tolerance_mm"]
|
|
379
|
+
findings = []
|
|
380
|
+
for p in primitives:
|
|
381
|
+
side = OVERLAYS.get(p.get("layer"))
|
|
382
|
+
if side is None or p.get("kind") not in ("track", "arc", "text", "fill", "region") or p.get("hidden"):
|
|
383
|
+
continue
|
|
384
|
+
worst = max(_outside_by(pt, poly) for pt in _silk_points(p))
|
|
385
|
+
if worst > tol:
|
|
386
|
+
what = f"text '{p['text']}'" if p["kind"] == "text" else p["kind"]
|
|
387
|
+
owner = " of a component" if p.get("in_component") else ""
|
|
388
|
+
findings.append(Finding("NM-SILK-001", "error", side,
|
|
389
|
+
f"{side} silkscreen {what}{owner} reaches {worst:.2f} mm beyond the board outline; "
|
|
390
|
+
"the fabricator cuts it off",
|
|
391
|
+
{"box": [p.get("box_left"), p.get("box_bottom"),
|
|
392
|
+
p.get("box_right"), p.get("box_top")],
|
|
393
|
+
"beyond_mm": round(worst, 3)}))
|
|
394
|
+
return findings
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def check_track_angles(primitives: list[dict]) -> list[Finding]:
|
|
398
|
+
"""NM-ROUTE-001: a routed copper track not at a multiple of 45 degrees (footprint copper and
|
|
399
|
+
polygon pours excepted)."""
|
|
400
|
+
step, tol = LAYOUT["track_angle_step_deg"], LAYOUT["track_angle_tolerance_deg"]
|
|
401
|
+
findings = []
|
|
402
|
+
for p in primitives:
|
|
403
|
+
if p.get("kind") != "track" or p.get("layer") not in COPPER or p.get("in_component") or p.get("in_polygon"):
|
|
404
|
+
continue
|
|
405
|
+
dx, dy = p["x2"] - p["x1"], p["y2"] - p["y1"]
|
|
406
|
+
if math.hypot(dx, dy) < 1e-4:
|
|
407
|
+
continue
|
|
408
|
+
angle = math.degrees(math.atan2(dy, dx)) % 180
|
|
409
|
+
off = abs(angle - round(angle / step) * step)
|
|
410
|
+
if off > tol:
|
|
411
|
+
findings.append(Finding("NM-ROUTE-001", "error", "copper",
|
|
412
|
+
f"track on {p.get('layer_name', p['layer'])} "
|
|
413
|
+
f"(net {p.get('net') or 'none'}) runs at "
|
|
414
|
+
f"{angle:.1f} deg; house routing is 0, 45 or 90 deg",
|
|
415
|
+
{"from": [p["x1"], p["y1"]], "to": [p["x2"], p["y2"]]}))
|
|
416
|
+
return findings
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
# A joint and a track end closer than this are the same point (Altium's internal unit is
|
|
420
|
+
# 0.0000254 mm; read-back rounds to it).
|
|
421
|
+
SAME_POINT_MM = 1e-5
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _anchors(primitives: list[dict], pads: list[dict]) -> list[tuple[float, float, int | None, str, str, int]]:
|
|
425
|
+
"""Every point a track end may join: (x, y, layer or None for all copper, net, what, owner),
|
|
426
|
+
owner being the track's index in `primitives` (-1 for vias and pads)."""
|
|
427
|
+
points: list[tuple[float, float, int | None, str, str, int]] = []
|
|
428
|
+
for i, p in enumerate(primitives):
|
|
429
|
+
if p.get("kind") == "via":
|
|
430
|
+
points.append((p["x"], p["y"], None, p.get("net") or "", "via", -1))
|
|
431
|
+
elif p.get("kind") == "track" and p.get("layer") in COPPER and not p.get("in_polygon"):
|
|
432
|
+
for end in (1, 2):
|
|
433
|
+
points.append((p[f"x{end}"], p[f"y{end}"], p["layer"], p.get("net") or "", "track", i))
|
|
434
|
+
for pad in pads:
|
|
435
|
+
layer = pad.get("layer")
|
|
436
|
+
points.append((pad["x"], pad["y"], None if layer == 74 else layer, pad.get("net") or "", "pad", -1))
|
|
437
|
+
return points
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def near_miss_joins(primitives: list[dict], pads: list[dict], tolerance: float | None = None) -> list[dict]:
|
|
441
|
+
"""Track ends that sit next to a joint of their own net (another track end on the layer, a via,
|
|
442
|
+
a pad centre) without being on it: Altium reports them as net antennae and the copper may not
|
|
443
|
+
join. Each comes with the point to snap to, a pad or via centre before another track end."""
|
|
444
|
+
tol = LAYOUT["join_tolerance_mm"] if tolerance is None else tolerance
|
|
445
|
+
anchors = _anchors(primitives, pads)
|
|
446
|
+
grid: dict[tuple[int, int], list[int]] = {}
|
|
447
|
+
for i, (x, y, *_rest) in enumerate(anchors):
|
|
448
|
+
grid.setdefault((int(x // tol), int(y // tol)), []).append(i)
|
|
449
|
+
rank = {"pad": 0, "via": 1, "track": 2}
|
|
450
|
+
misses, seen = [], set()
|
|
451
|
+
for index, p in enumerate(primitives):
|
|
452
|
+
if p.get("kind") != "track" or p.get("layer") not in COPPER or p.get("in_component") or p.get("in_polygon"):
|
|
453
|
+
continue
|
|
454
|
+
for end in (1, 2):
|
|
455
|
+
x, y = p[f"x{end}"], p[f"y{end}"]
|
|
456
|
+
gx, gy = int(x // tol), int(y // tol)
|
|
457
|
+
best = None
|
|
458
|
+
for dx in (-1, 0, 1):
|
|
459
|
+
for dy in (-1, 0, 1):
|
|
460
|
+
for i in grid.get((gx + dx, gy + dy), []):
|
|
461
|
+
ax, ay, layer, net, what, owner = anchors[i]
|
|
462
|
+
# A track's own other end is no joint: snapping onto it would remove the track.
|
|
463
|
+
if owner == index or (layer is not None and layer != p["layer"]) or net != (p.get("net") or ""):
|
|
464
|
+
continue
|
|
465
|
+
d = math.hypot(ax - x, ay - y)
|
|
466
|
+
# On the point already (its own end included), or too far to be a near miss.
|
|
467
|
+
if d <= SAME_POINT_MM or d > tol:
|
|
468
|
+
continue
|
|
469
|
+
# Two track ends apart: only one of them moves, onto the other.
|
|
470
|
+
if what == "track" and (ax, ay) > (x, y):
|
|
471
|
+
continue
|
|
472
|
+
if best is None or (rank[what], d) < (rank[best[2]], best[3]):
|
|
473
|
+
best = (ax, ay, what, d)
|
|
474
|
+
if best is None:
|
|
475
|
+
continue
|
|
476
|
+
key = (p["layer"], round(x, 6), round(y, 6))
|
|
477
|
+
if key in seen:
|
|
478
|
+
continue
|
|
479
|
+
seen.add(key)
|
|
480
|
+
misses.append({"layer": p["layer"], "layer_name": p.get("layer_name"), "net": p.get("net") or "",
|
|
481
|
+
"at": [x, y], "snap_to": [best[0], best[1]], "target": best[2],
|
|
482
|
+
"gap_mm": round(best[3], 6)})
|
|
483
|
+
return misses
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def check_joins(primitives: list[dict], pads: list[dict]) -> list[Finding]:
|
|
487
|
+
"""NM-ROUTE-002: a track end a fraction of a micron off its joint (net antenna)."""
|
|
488
|
+
return [Finding("NM-ROUTE-002", "warning", "copper",
|
|
489
|
+
f"track end on {m['layer_name'] or m['layer']} (net {m['net'] or 'none'}) "
|
|
490
|
+
f"is {m['gap_mm'] * 1000:.2f} µm "
|
|
491
|
+
f"off a {m['target']}; snap it (altium_snap_joins)", {"at": m["at"], "snap_to": m["snap_to"]})
|
|
492
|
+
for m in near_miss_joins(primitives, pads)]
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _distance_to_pad(x: float, y: float, pad: dict) -> float:
|
|
496
|
+
"""Distance from a point to a pad's copper (see `pcba.house.distance_to_pad`)."""
|
|
497
|
+
return _house_distance_to_pad(x, y, pad)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def check_vias(primitives: list[dict], pads: list[dict]) -> list[Finding]:
|
|
501
|
+
"""NM-VIA-001: a via in or touching a pad. NM-VIA-002: two vias of one net closer than the
|
|
502
|
+
house distance (they could be one)."""
|
|
503
|
+
vias = [p for p in primitives if p.get("kind") == "via"]
|
|
504
|
+
findings = []
|
|
505
|
+
for v in vias:
|
|
506
|
+
r = v["diameter"] / 2
|
|
507
|
+
for pad in pads:
|
|
508
|
+
if _distance_to_pad(v["x"], v["y"], pad) <= r:
|
|
509
|
+
findings.append(Finding("NM-VIA-001", "error", "copper",
|
|
510
|
+
f"via of net {v.get('net') or 'none'} at ({v['x']:.2f}, {v['y']:.2f}) "
|
|
511
|
+
f"touches pad {pad.get('designator')}-{pad.get('pad')}; "
|
|
512
|
+
"house rule: no via in or touching a pad",
|
|
513
|
+
{"via": [v["x"], v["y"]], "pad": f"{pad.get('designator')}-{pad.get('pad')}"}))
|
|
514
|
+
break
|
|
515
|
+
limit = LAYOUT["same_net_via_min_mm"]
|
|
516
|
+
for a, b in itertools.combinations(vias, 2):
|
|
517
|
+
if a.get("net") and a.get("net") == b.get("net"):
|
|
518
|
+
gap = math.hypot(a["x"] - b["x"], a["y"] - b["y"])
|
|
519
|
+
if gap < limit:
|
|
520
|
+
findings.append(Finding("NM-VIA-002", "warning", "copper",
|
|
521
|
+
f"two vias of net {a['net']} are {gap:.2f} mm apart (house {limit:.1f} mm); "
|
|
522
|
+
"one via, or neighbouring pins sharing one, usually does",
|
|
523
|
+
{"vias": [[a["x"], a["y"]], [b["x"], b["y"]]]}))
|
|
524
|
+
return findings
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def check_models(bodies: list[dict], components: list[dict]) -> list[Finding]:
|
|
528
|
+
"""NM-3D-001: a component without a STEP model (none, or only a drawn extrusion)."""
|
|
529
|
+
models: dict[str, list[str]] = {}
|
|
530
|
+
for b in bodies:
|
|
531
|
+
models.setdefault(b.get("designator", ""), []).append(b.get("model", ""))
|
|
532
|
+
findings = []
|
|
533
|
+
for c in components:
|
|
534
|
+
des = c.get("designator", "")
|
|
535
|
+
if is_fiducial(c):
|
|
536
|
+
continue
|
|
537
|
+
kinds = models.get(des, [])
|
|
538
|
+
if not any(m and m != "extruded" for m in kinds):
|
|
539
|
+
what = "only a drawn extrusion" if kinds else "no 3D body"
|
|
540
|
+
findings.append(Finding("NM-3D-001", "warning", _side(c),
|
|
541
|
+
f"{des} ({c.get('footprint')}) has {what}; use the manufacturer's STEP model or a "
|
|
542
|
+
"recognised library's, or one built from the data sheet, and say which",
|
|
543
|
+
{"bodies": kinds}))
|
|
544
|
+
return findings
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def check_barcode(primitives: list[dict]) -> list[Finding]:
|
|
548
|
+
"""NM-BC-001: a square silkscreen field with "BC" for the serial label, at least 5 mm (7 mm
|
|
549
|
+
preferred), top side preferred."""
|
|
550
|
+
texts = [p for p in primitives if p.get("kind") == "text" and str(p.get("text", "")).strip().upper() == "BC"
|
|
551
|
+
and p.get("layer") in OVERLAYS and not p.get("in_component") and not p.get("hidden")]
|
|
552
|
+
best = None
|
|
553
|
+
for t in texts:
|
|
554
|
+
cx, cy = (t["box_left"] + t["box_right"]) / 2, (t["box_bottom"] + t["box_top"]) / 2
|
|
555
|
+
lines = [p for p in primitives if p.get("kind") == "track" and p.get("layer") == t["layer"]
|
|
556
|
+
and not p.get("in_component")]
|
|
557
|
+
horizontal = [p for p in lines if abs(p["y1"] - p["y2"]) < 1e-3
|
|
558
|
+
and min(p["x1"], p["x2"]) <= cx <= max(p["x1"], p["x2"])]
|
|
559
|
+
vertical = [p for p in lines if abs(p["x1"] - p["x2"]) < 1e-3
|
|
560
|
+
and min(p["y1"], p["y2"]) <= cy <= max(p["y1"], p["y2"])]
|
|
561
|
+
below = [p["y1"] for p in horizontal if p["y1"] < cy]
|
|
562
|
+
above = [p["y1"] for p in horizontal if p["y1"] > cy]
|
|
563
|
+
left = [p["x1"] for p in vertical if p["x1"] < cx]
|
|
564
|
+
right = [p["x1"] for p in vertical if p["x1"] > cx]
|
|
565
|
+
if below and above and left and right:
|
|
566
|
+
side = min(min(above) - max(below), min(right) - max(left))
|
|
567
|
+
if best is None or side > best[0]:
|
|
568
|
+
best = (side, OVERLAYS[t["layer"]])
|
|
569
|
+
if best is None:
|
|
570
|
+
return [Finding("NM-BC-001", "warning", "board",
|
|
571
|
+
"no barcode field: a square silkscreen frame with \"BC\" inside, "
|
|
572
|
+
f"{LAYOUT['barcode_preferred_mm']:.0f} x {LAYOUT['barcode_preferred_mm']:.0f} mm "
|
|
573
|
+
f"(at least {LAYOUT['barcode_min_mm']:.0f} mm), top side preferred", {})]
|
|
574
|
+
if best[0] + 1e-3 < LAYOUT["barcode_min_mm"]:
|
|
575
|
+
return [Finding("NM-BC-001", "warning", best[1],
|
|
576
|
+
f"the barcode field is {best[0]:.1f} mm; "
|
|
577
|
+
f"the house minimum is {LAYOUT['barcode_min_mm']:.0f} mm",
|
|
578
|
+
{"side_mm": round(best[0], 2)})]
|
|
579
|
+
return []
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def check_layout(primitives: list[dict], pads: list[dict], components: list[dict], bodies: list[dict],
|
|
583
|
+
outline: list[list[float]] | None) -> dict:
|
|
584
|
+
"""Every house layout check on one board read through the drive (lengths in mm)."""
|
|
585
|
+
findings = (check_silkscreen(primitives, outline) + check_track_angles(primitives) + check_joins(primitives, pads)
|
|
586
|
+
+ check_vias(primitives, pads) + check_models(bodies, components) + check_barcode(primitives))
|
|
587
|
+
return {
|
|
588
|
+
"policy": dict(LAYOUT),
|
|
589
|
+
"findings": [f.to_dict() for f in findings],
|
|
590
|
+
"passed": not any(f.severity == "error" for f in findings),
|
|
591
|
+
}
|