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:
@@ -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×(Vin−Vout) 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,
|
||||
|
||||
Reference in New Issue
Block a user