Tighten PCB findings: actions, stitch gating, real copper.

Fill action on every finding and show it in the report. Stitch/return only
on large HS/power nets. Parse stackup thickness, arcs, and vias from the
board. IC courtyard pad copper is not PE-PLC-004 RULE. Recommended RC/cap
stay REVIEW.
This commit is contained in:
2026-09-20 08:58:54 +02:00
parent 1e379f6042
commit f63c1c3411
9 changed files with 326 additions and 59 deletions
+23 -12
View File
@@ -18,7 +18,10 @@ Provenance = Literal["MANDATORY", "RECOMMENDED", "TYPICAL", "EXAMPLE"]
FindingClass = Literal["RULE", "RISK", "REVIEW", "INFO"]
EvidenceStatus = Literal["SUFFICIENT", "INSUFFICIENT"]
_LLM_SOURCES = frozenset({None, "review", "pcb_review"})
_DEFAULT_ACTION = (
"Review this finding against the datasheet and the board, then change "
"the design if it applies."
)
_SOFT_PROVENANCE = frozenset({"RECOMMENDED", "TYPICAL", "EXAMPLE"})
@@ -71,17 +74,20 @@ def _seed() -> None:
("PE-MUX-001", "Pin function must match the mux / datasheet pin table."),
("PE-NC-001", "NC pins must not be connected."),
("PE-DEC-001", "Supply pin requires a decoupling capacitor on that net."),
("PE-DEC-002", "Decoupling capacitor value vs datasheet."),
("PE-I2C-001", "I2C bus requires pull-ups."),
("PE-I2C-002", "I2C pull-up value vs datasheet."),
("PE-RST-001", "Reset pin requires the specified pull."),
("PE-RST-002", "Reset pull value vs datasheet."),
("PE-BOM-001", "BOM MPN must match the netlist part."),
("PE-BOM-002", "BOM line missing from the netlist."),
("PE-DNP-001", "DNP / fitted state vs netlist."),
("PE-DRT-001", "Operating voltage must not exceed the capacitor rating."),
):
_add(rid, "MANDATORY", "RULE", domain="schema", requirement=req)
for rid, req in (
("PE-DEC-002", "Decoupling capacitor value vs datasheet (recommended)."),
("PE-I2C-002", "I2C pull-up value vs datasheet (recommended)."),
("PE-RST-002", "Reset pull value vs datasheet (recommended)."),
):
_add(rid, "RECOMMENDED", "REVIEW", domain="schema", requirement=req)
for rid in ("PE-TH-001", "PE-TH-003"):
_add(rid, "TYPICAL", "RISK", domain="schema",
requirement="Thermal estimate only with I_load and θJA from specs.")
@@ -93,12 +99,12 @@ def _seed() -> None:
requirement="Trace width × copper thickness vs I_load (IPC-2221 when ΔT known).")
for rid, prov, cls, req in (
("PE-SEQ-001", "RECOMMENDED", "RISK", "Power sequencing from datasheet notes."),
("PE-FLT-001", "RECOMMENDED", "RISK", "Filter topology vs datasheet."),
("PE-FLT-002", "RECOMMENDED", "RISK", "Filter component vs datasheet."),
("PE-FLT-003", "RECOMMENDED", "RISK", "Filter cutoff vs datasheet."),
("PE-FLT-001", "RECOMMENDED", "REVIEW", "Filter topology vs datasheet."),
("PE-FLT-002", "RECOMMENDED", "REVIEW", "Filter component vs datasheet."),
("PE-FLT-003", "RECOMMENDED", "REVIEW", "Filter cutoff vs datasheet."),
("PE-XTAL-001", "MANDATORY", "RULE", "Crystal load capacitance vs Cl."),
("PE-XTAL-002", "RECOMMENDED", "RISK", "Crystal layout / load-cap note."),
("PE-XTAL-003", "RECOMMENDED", "RISK", "Crystal drive / Cl note."),
("PE-XTAL-002", "RECOMMENDED", "REVIEW", "Crystal layout / load-cap note."),
("PE-XTAL-003", "RECOMMENDED", "REVIEW", "Crystal drive / Cl note."),
("PE-LF-001", "TYPICAL", "INFO", "Lifecycle / NRND."),
("PE-LF-002", "TYPICAL", "INFO", "Lifecycle / last-time-buy."),
("PE-LF-003", "TYPICAL", "INFO", "Lifecycle / obsolete."),
@@ -118,10 +124,11 @@ def _seed() -> None:
("PE-PLC-001", "Decoupling proximity max_distance_mm from layout_rules."),
("PE-PLC-002", "Thermal via min_via_count from layout_rules."),
("PE-PLC-003", "same_layer decoupling from layout_rules."),
("PE-PLC-004", "Keepout from layout_rules."),
("PE-SI-001", "Differential skew vs length_match mm."),
):
_add(rid, "MANDATORY", "RULE", domain="pcb", requirement=req)
_add("PE-PLC-004", "RECOMMENDED", "REVIEW", domain="pcb",
requirement="Keepout from layout_rules — not a DRC for pad copper.")
_add("PE-VIA-001", "TYPICAL", "INFO", domain="pcb",
requirement="Via current share from I_load and via count (no invented table).")
_add("PE-THM-001", "RECOMMENDED", "RISK", domain="pcb",
@@ -160,8 +167,8 @@ def complete_finding(f: Finding) -> Finding:
if not (f.requirement or "").strip():
f.requirement = (rec.requirement if rec and rec.requirement else None) or f.why
if not (f.action or "").strip():
f.action = f.recommendation or ""
if not (f.recommendation or "").strip() and f.action:
f.action = (f.recommendation or "").strip() or _DEFAULT_ACTION
if not (f.recommendation or "").strip():
f.recommendation = f.action
llm = (f.source in {None, "review", "pcb_review"}) and not f.rule_id
@@ -219,6 +226,10 @@ def complete_finding(f: Finding) -> Finding:
f.why = f.requirement
if not (f.finding or "").strip():
f.finding = f.facts
if not (f.action or "").strip():
f.action = _DEFAULT_ACTION
if not (f.recommendation or "").strip():
f.recommendation = f.action
return f
+35 -3
View File
@@ -5,6 +5,7 @@ No SI/DRC. Schema validation stays complete without this file.
from __future__ import annotations
import re
from pathlib import Path
from backend.periscopex.models import (
@@ -29,6 +30,24 @@ from backend.periscopex.parsers_kicad import (
)
def _copper_thickness_token(raw: object) -> float | None:
"""Parse KiCad stackup thickness; units mm/um/mil. Never default 1 oz."""
if isinstance(raw, (int, float)) and not isinstance(raw, bool):
v = float(raw)
return v if v > 0 else None
s = str(raw or "").strip().lower().replace(" ", "")
m = re.match(r"^([0-9]*\.?[0-9]+)(mm|um|µm|mil)?", s)
if not m:
return None
n = float(m.group(1))
unit = m.group(2) or "mm"
if unit in {"um", "µm"}:
n = n / 1000.0
elif unit == "mil":
n = n * 0.0254
return n if n > 0 else None
def _xy(node: object, name: str) -> tuple[float, float]:
k = _kid(node, name)
if not k or len(k) < 3:
@@ -128,7 +147,11 @@ def _parse_stackup(tree: object) -> LayoutStackup | None:
name = str(layer[1]) if len(layer) > 1 and not isinstance(layer[1], list) else ""
kind = _layer_type(layer)
thick = _kid(layer, "thickness")
height = _fnum(thick[1]) if thick and len(thick) > 1 else None
height = None
if thick and len(thick) > 1:
height = _copper_thickness_token(thick[1])
if height is None and len(thick) > 2:
height = _copper_thickness_token(f"{thick[1]}{thick[2]}")
if kind == "copper" or name.endswith(".Cu"):
if name:
copper.append(name)
@@ -147,9 +170,9 @@ def _parse_stackup(tree: object) -> LayoutStackup | None:
er=er,
height_mm=height,
))
if len(copper) < 2 or len(dielectrics) != len(copper) - 1:
return None
t = thicknesses[0] if thicknesses else None
if not copper and t is None:
return None
return LayoutStackup(
copper_layers=copper,
dielectrics=dielectrics,
@@ -291,6 +314,15 @@ def parse_kicad_pcb(path: str | Path) -> LayoutGraph:
net=_net_name(node, nets),
))
continue
if tag == "arc":
segments.append(LayoutSegment(
start=_xy(node, "start"),
end=_xy(node, "end"),
width=_fnum(_val(node, "width") or 0),
layer=_val(node, "layer"),
net=_net_name(node, nets),
))
continue
if tag == "via":
drill_el = _kid(node, "drill")
drill = _fnum(drill_el[1]) if drill_el and len(drill_el) > 1 else None
+89 -27
View File
@@ -37,6 +37,12 @@ _IPC_K_EXT = 0.048
_IPC_K_INT = 0.024
_TJMAX_KEYS = ("tj_max", "tj_max_c", "tjmax", "max_junction_temp_c", "t_jmax")
_SENSE_RE = re.compile(r"(kelvin|sense|isns|i_sns|cs\+|cs-|iout_sns)", re.I)
_HS_NET_RE = re.compile(
r"(USB|DP|DM|D\+|D-|HS|HDMI|MIPI|DDR|CLK|XTAL|HFX|LFX|SWP|DIFF)",
re.I,
)
_STITCH_MIN_SPAN_MM = 12.0
_STITCH_MIN_AREA_MM2 = 40.0
def _mm_to_mil(mm: float) -> float:
@@ -75,6 +81,17 @@ def _via_stats(layout: LayoutGraph, sch_net: str) -> tuple[int, float | None]:
return n, (min(drills) if drills else None)
def _footprint_region(fp) -> list[tuple[float, float]]:
if len(fp.courtyard) >= 3:
return fp.courtyard
xs = [fp.x] + [p.x for p in fp.pads]
ys = [fp.y] + [p.y for p in fp.pads]
pad = 1.5
xmin, xmax = min(xs) - pad, max(xs) + pad
ymin, ymax = min(ys) - pad, max(ys) + pad
return [(xmin, ymin), (xmax, ymin), (xmax, ymax), (xmin, ymax)]
def _external_layers(layers: list[str]) -> bool:
if not layers:
return True
@@ -223,28 +240,36 @@ def check_pcb_via_current(
if key in seen:
continue
seen.add(key)
n, drill = _via_stats(layout, net)
if n <= 0:
n, _drill = _via_stats(layout, net)
if n > 0:
# Geometry is present; no via-ampacity table in the library — do not
# invent a rating or claim the vias were not parsed.
continue
per = i_load / n
out.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="layout_power",
finding=(
f"{net} shares I_load={i_load:.3g} A across {n} via(s) "
f"(≈{per:.3g} A each"
+ (f", min drill {drill:g} mm" if drill else "")
+ "). No via current rating in the library extraction."
f"{net} carries I_load={i_load:.3g} A with no vias on that net "
"in the parsed .kicad_pcb."
),
why="Via current is reported from board geometry + datasheet I_load only.",
why="Via count is taken from board vias whose net matches this supply.",
status="INFO",
recommendation=(
"If the datasheet or stackup vendor quotes via current, add more/larger vias "
f"so each via stays under that rating at {i_load:.3g} A."
f"Add vias on {net} if the current leaves this layer, then re-run PCB review."
),
action=(
f"Add vias on {net} if the current leaves this layer, then re-run PCB review."
),
source="pcb_power_thermal",
rule_id="PE-VIA-001",
evidence_status="SUFFICIENT",
facts=(
f"I_load={i_load:.3g} A on {net}; 0 vias with a matching net "
f"in the PCB file (board has {len(layout.vias)} via(s) total)."
),
requirement="No datasheet via current; report missing vias only.",
inference="Ampacity of vias is not judged without a rating.",
net=net,
pins=[],
))
@@ -276,12 +301,15 @@ def check_pcb_thermal_copper(
continue
p = i_load * (vin - vout)
fp = layout.footprints.get(ref)
if not fp or len(fp.courtyard) < 3:
if not fp:
continue
region = _footprint_region(fp)
if len(region) < 3:
continue
theta = _first(values, _THETA_KEYS)
via_n = 0
for v in layout.vias:
if _in_poly(v.x, v.y, fp.courtyard):
if _in_poly(v.x, v.y, region):
via_n += 1
pour = False
for z in layout.zones:
@@ -296,7 +324,6 @@ def check_pcb_thermal_copper(
):
pass
else:
# GND by name
leaf = normalize_kicad_hierarchy_net(z.net).upper()
if not (leaf == "GND" or leaf.endswith("/GND") or "GND" in leaf):
continue
@@ -306,26 +333,39 @@ def check_pcb_thermal_copper(
break
if via_n or pour:
continue
rec = (
"Add a copper pour and/or thermal vias under the package as the datasheet "
"layout page specifies, then re-run PCB review."
)
out.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="layout_thermal",
finding=(
f"{ref} dissipates ≈{p:.3g} W (I_load={i_load:.3g} A) but the courtyard "
"has no thermal vias and no overlapping copper pour."
f"{ref} dissipates ≈{p:.3g} W (I_load={i_load:.3g} A) but the package "
f"region has no thermal vias ({via_n}) and no overlapping copper pour "
f"(board vias parsed: {len(layout.vias)})."
),
facts=(
f"P≈{p:.3g} W; {via_n} vias in package region; pour={pour}; "
f"{len(layout.vias)} vias on the board."
),
requirement=(
"Datasheet layout notes for copper/vias under the package "
"(recommended, not a shall)."
),
inference="No pour/vias under the package on the parsed board.",
why=(
"P = I_load×(VinVout) from the shared extraction/specs. "
+ (f"θJA={theta:.3g} °C/W. " if theta else "θJA missing. ")
+ "No millimetres invented."
),
status="WARNING",
recommendation=(
"Add a copper pour and/or thermal vias under the package as the datasheet "
"layout page specifies, then re-run PCB review."
),
recommendation=rec,
action=rec,
source="pcb_power_thermal",
rule_id="PE-THM-001",
evidence_status="SUFFICIENT" if layout.vias or layout.zones else "INSUFFICIENT",
net=vout_n,
pins=[],
))
@@ -337,11 +377,24 @@ def _is_gnd_name(name: str) -> bool:
return leaf in {"GND", "AGND", "DGND", "PGND", "VSS"} or leaf.endswith("/GND")
def _is_stitch_candidate(net_name: str, net, dx: float, dy: float) -> bool:
"""High-speed / power nets, or a large bbox — never tiny GPIO stubs."""
span = max(dx, dy)
area = dx * dy
if span < _STITCH_MIN_SPAN_MM or area < _STITCH_MIN_AREA_MM2:
return False
if net.net_type == NetType.POWER:
return True
if _HS_NET_RE.search(net_name or ""):
return True
return False
def check_pcb_gnd_stitch(
graph: DesignGraph,
layout: LayoutGraph | None,
) -> list[Finding]:
"""INFO when a signal span has a GND pour but no GND via in its bbox."""
"""INFO when a long HS/power span has a GND pour but no GND via in its bbox."""
if layout is None:
return []
gnd_zones = [
@@ -357,7 +410,7 @@ def check_pcb_gnd_stitch(
out: list[Finding] = []
seen: set[str] = set()
for net_name, net in sorted(graph.nets.items()):
if net.net_type != NetType.SIGNAL:
if net.net_type not in (NetType.SIGNAL, NetType.POWER):
continue
key = normalize_kicad_hierarchy_net(net_name)
if key in seen:
@@ -375,30 +428,39 @@ def check_pcb_gnd_stitch(
ys.extend([s.start[1], s.end[1]])
xmin, xmax = min(xs), max(xs)
ymin, ymax = min(ys), max(ys)
if xmax - xmin < 2.0 and ymax - ymin < 2.0:
dx, dy = xmax - xmin, ymax - ymin
if not _is_stitch_candidate(net_name, net, dx, dy):
continue
if any(xmin <= v.x <= xmax and ymin <= v.y <= ymax for v in gnd_vias):
continue
seen.add(key)
refs = [p.component_ref for p in net.pins[:1]]
ref = refs[0] if refs else net_name
rec = (
f"Add GND stitch vias along '{net_name}' so the return current "
"has a nearby via to the ground plane."
)
out.append(Finding(
designator=ref,
mpn=(graph.components[ref].mpn if ref in graph.components else "") or "",
aspect="layout_return",
finding=(
f"Signal '{net_name}' spans {xmax - xmin:.1f}×{ymax - ymin:.1f} mm "
f"Net '{net_name}' spans {dx:.1f}×{dy:.1f} mm "
"over a GND pour with no GND via in that bounding box."
),
facts=(
f"BBox {dx:.1f}×{dy:.1f} mm on '{net_name}'; "
f"{len(gnd_vias)} GND via(s) on the board, none in bbox."
),
requirement="Return current needs a nearby GND via on long HS/power routes.",
inference="No GND via in the span — stitch if the return path should stay local.",
why=(
"Return path / stitch vias are inferred from board zones and vias only "
"(no IEC clearance invented)."
),
status="INFO",
recommendation=(
f"Add GND stitch vias along '{net_name}' so the return current "
"has a nearby via to the ground plane."
),
recommendation=rec,
action=rec,
source="pcb_power_thermal",
rule_id="PE-STCH-001",
net=net_name,
+25 -4
View File
@@ -289,9 +289,15 @@ def _keepout_finding(ref, comp, cons, rule, graph: DesignGraph, layout: LayoutGr
own = _net_for_pin(graph, ref, pin_no) if pin_no else None
if not own:
return []
pad_nets = [
p.net for p in fp.pads
if p.net
]
foreign: list[str] = []
for s in layout.segments:
if not s.net or s.net == own:
if not s.net or kicad_nets_match(s.net, own):
continue
if any(kicad_nets_match(s.net, pn) for pn in pad_nets):
continue
if _in_poly(s.start[0], s.start[1], fp.courtyard) or _in_poly(
s.end[0], s.end[1], fp.courtyard
@@ -300,17 +306,32 @@ def _keepout_finding(ref, comp, cons, rule, graph: DesignGraph, layout: LayoutGr
if not foreign:
return []
net = sorted(set(foreign))[0]
is_crystal = comp.component_type == ComponentType.CRYSTAL
rec = (
"Keep unrelated nets out of the keepout if the datasheet shows a "
"keep-out zone; traces to this part's own pads are normal copper."
)
return [Finding(
designator=ref,
mpn=comp.mpn or cons.mpn,
aspect="placement",
finding=f"Track on {net} enters courtyard of {ref} (keepout on {own}).",
facts=f"Segment on '{net}' has an endpoint inside {ref} courtyard.",
requirement="layout_rules kind=keepout (recommended courtyard isolation).",
inference=(
"Crystal keepout may apply."
if is_crystal
else "Not a pad net of this footprint — review, not a mandatory DRC."
),
why="layout_rules kind=keepout.",
status="WARNING",
recommendation="Keep other nets out of the courtyard.",
status="WARNING" if is_crystal else "INFO",
recommendation=rec,
action=rec,
source="placement_check",
rule_id="PE-PLC-004",
finding_class="REVIEW",
provenance="RECOMMENDED",
net=net,
pins=[pin_no],
pins=[pin_no] if pin_no else [],
source_page=rule.get("source_page"),
)]