Package/pinout/ratings mismatches, SPOF as REVIEW, EMI only with a datasheet quote, and Tj from copper+vias+θJA when every parameter exists. Re-extract SI layout_rules from 1.12.0; ImpedenceFinder license stays UNKNOWN.
682 lines
24 KiB
Python
682 lines
24 KiB
Python
"""Power-trace, via, thermal-copper, and Kelvin checks on a LayoutGraph.
|
||
|
||
Skip when current, width, copper thickness, or datasheet numbers are missing.
|
||
IPC-2221 is cited only when ΔT can be taken from datasheet Tjmax − 25 °C.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
|
||
from backend.periscopex.models import (
|
||
ComponentType,
|
||
DesignGraph,
|
||
Finding,
|
||
LayoutGraph,
|
||
NetType,
|
||
)
|
||
from backend.periscopex.pcb_net_match import kicad_nets_match, normalize_kicad_hierarchy_net
|
||
from backend.periscopex.placement_check import _in_poly
|
||
from backend.periscopex.thermal_check import (
|
||
_LOAD_KEYS,
|
||
_TA_C,
|
||
_THETA_KEYS,
|
||
_VIN_PIN,
|
||
_VOUT_PIN,
|
||
_first,
|
||
_net_voltage,
|
||
_pin_net_by_role,
|
||
_specs_values,
|
||
)
|
||
from backend.periscopex.validate import _match_constraints
|
||
|
||
# IPC-2221 §6.2 (empirical): I = k · ΔT^0.44 · A^0.725, A in mil², I in A.
|
||
_IPC_B = 0.44
|
||
_IPC_C = 0.725
|
||
_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:
|
||
return mm / 0.0254
|
||
|
||
|
||
def _ipc2221_amps(area_mil2: float, dt_c: float, *, external: bool) -> float:
|
||
k = _IPC_K_EXT if external else _IPC_K_INT
|
||
return k * (dt_c ** _IPC_B) * (area_mil2 ** _IPC_C)
|
||
|
||
|
||
def _min_width_layers(layout: LayoutGraph, sch_net: str) -> tuple[float | None, list[str]]:
|
||
widths: list[float] = []
|
||
layers: set[str] = set()
|
||
for s in layout.segments:
|
||
if not s.net or s.width <= 0:
|
||
continue
|
||
if kicad_nets_match(s.net, sch_net):
|
||
widths.append(s.width)
|
||
if s.layer:
|
||
layers.add(s.layer)
|
||
if not widths:
|
||
return None, sorted(layers)
|
||
return min(widths), sorted(layers)
|
||
|
||
|
||
def _via_stats(layout: LayoutGraph, sch_net: str) -> tuple[int, float | None]:
|
||
n = 0
|
||
drills: list[float] = []
|
||
for v in layout.vias:
|
||
if not v.net or not kicad_nets_match(v.net, sch_net):
|
||
continue
|
||
n += 1
|
||
if v.drill and v.drill > 0:
|
||
drills.append(v.drill)
|
||
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
|
||
return all(
|
||
ly.upper().startswith("F.") or ly.upper().startswith("B.")
|
||
for ly in layers
|
||
)
|
||
|
||
|
||
def _load_on_output(
|
||
graph: DesignGraph, cmap: dict, ref: str,
|
||
) -> tuple[float, str, str] | None:
|
||
"""I_load (not Iout_max) on an IC output net, plus that net name."""
|
||
comp = graph.components.get(ref)
|
||
if not comp or comp.component_type != ComponentType.IC:
|
||
return None
|
||
cons = _match_constraints(comp.mpn or comp.value, cmap)
|
||
values = _specs_values(comp)
|
||
i_load = _first(values, _LOAD_KEYS)
|
||
if i_load is None:
|
||
return None
|
||
vout = _pin_net_by_role(graph, comp, cons, _VOUT_PIN)
|
||
if not vout:
|
||
return None
|
||
return i_load, vout, ref
|
||
|
||
|
||
def check_pcb_power_traces(
|
||
graph: DesignGraph,
|
||
constraints_map: dict,
|
||
layout: LayoutGraph | None,
|
||
) -> list[Finding]:
|
||
"""Trace width × copper thickness vs datasheet I_load (IPC-2221 when ΔT known)."""
|
||
if layout is None:
|
||
return []
|
||
t_mm = layout.stackup.copper_thickness_mm if layout.stackup else None
|
||
out: list[Finding] = []
|
||
seen_nets: set[str] = set()
|
||
for ref, comp in sorted(graph.components.items()):
|
||
got = _load_on_output(graph, constraints_map, ref)
|
||
if not got:
|
||
continue
|
||
i_load, net, _ = got
|
||
key = normalize_kicad_hierarchy_net(net)
|
||
if key in seen_nets:
|
||
continue
|
||
seen_nets.add(key)
|
||
w_mm, layers = _min_width_layers(layout, net)
|
||
if w_mm is None:
|
||
continue
|
||
if t_mm is None or t_mm <= 0:
|
||
out.append(Finding(
|
||
designator=ref,
|
||
mpn=comp.mpn or "",
|
||
aspect="layout_power",
|
||
finding=(
|
||
f"{net} carries I_load={i_load:.3g} A with min trace "
|
||
f"{w_mm:g} mm; copper thickness is not in the PCB stackup."
|
||
),
|
||
why="IPC-2221 ampacity needs both width and copper thickness from the board.",
|
||
status="INFO",
|
||
recommendation=(
|
||
"Fill the KiCad stackup copper thickness (or export it) and re-run PCB review."
|
||
),
|
||
source="pcb_power_thermal",
|
||
rule_id="PE-PWR-001",
|
||
evidence_status="INSUFFICIENT",
|
||
calculation="",
|
||
assumptions=["IPC-2221 not applied without copper thickness."],
|
||
net=net,
|
||
pins=[],
|
||
))
|
||
continue
|
||
area_mil2 = _mm_to_mil(w_mm) * _mm_to_mil(t_mm)
|
||
values = _specs_values(comp)
|
||
tjmax = _first(values, _TJMAX_KEYS)
|
||
if tjmax is None or tjmax <= _TA_C:
|
||
out.append(Finding(
|
||
designator=ref,
|
||
mpn=comp.mpn or "",
|
||
aspect="layout_power",
|
||
finding=(
|
||
f"{net} I_load={i_load:.3g} A on {w_mm:g} mm × {t_mm * 1000:g} µm copper "
|
||
f"(A≈{area_mil2:.3g} mil²). Tjmax missing — IPC-2221 not applied."
|
||
),
|
||
why="ΔT for IPC-2221 is Tjmax − 25 °C from the datasheet, never a default 10 °C.",
|
||
status="INFO",
|
||
recommendation="Add Tjmax to the shared library extraction, then re-run PCB review.",
|
||
source="pcb_power_thermal",
|
||
rule_id="PE-PWR-001",
|
||
evidence_status="INSUFFICIENT",
|
||
assumptions=["ΔT is Tjmax − 25 °C from the datasheet, never a default 10 °C."],
|
||
net=net,
|
||
pins=[],
|
||
))
|
||
continue
|
||
dt = tjmax - _TA_C
|
||
ext = _external_layers(layers)
|
||
i_allow = _ipc2221_amps(area_mil2, dt, external=ext)
|
||
if i_load <= i_allow:
|
||
continue
|
||
out.append(Finding(
|
||
designator=ref,
|
||
mpn=comp.mpn or "",
|
||
aspect="layout_power",
|
||
finding=(
|
||
f"{net} I_load={i_load:.3g} A exceeds IPC-2221 capacity "
|
||
f"{i_allow:.3g} A ({'external' if ext else 'internal'} k="
|
||
f"{_IPC_K_EXT if ext else _IPC_K_INT}, ΔT={dt:.0f} °C, "
|
||
f"w={w_mm:g} mm, t={t_mm * 1000:g} µm)."
|
||
),
|
||
why="IPC-2221 §6.2 I = k·ΔT^0.44·A^0.725. I_load is the datasheet/typical load, not Iout_max.",
|
||
status="ERROR",
|
||
recommendation=(
|
||
f"Widen {net} (and/or add copper/vias/planes) so ampacity ≥ {i_load:.3g} A, "
|
||
"or reduce load current."
|
||
),
|
||
source="pcb_power_thermal",
|
||
rule_id="PE-PWR-001",
|
||
calculation=(
|
||
f"I = k·ΔT^0.44·A^0.725 → {i_allow:.3g} A; I_load={i_load:.3g} A"
|
||
),
|
||
evidence_status="SUFFICIENT",
|
||
net=net,
|
||
pins=[],
|
||
))
|
||
return out
|
||
|
||
|
||
def check_pcb_via_current(
|
||
graph: DesignGraph,
|
||
constraints_map: dict,
|
||
layout: LayoutGraph | None,
|
||
) -> list[Finding]:
|
||
"""Share I_load across vias; no invented via-ampacity table."""
|
||
if layout is None:
|
||
return []
|
||
out: list[Finding] = []
|
||
seen: set[str] = set()
|
||
for ref, comp in sorted(graph.components.items()):
|
||
got = _load_on_output(graph, constraints_map, ref)
|
||
if not got:
|
||
continue
|
||
i_load, net, _ = got
|
||
key = normalize_kicad_hierarchy_net(net)
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
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
|
||
out.append(Finding(
|
||
designator=ref,
|
||
mpn=comp.mpn or "",
|
||
aspect="layout_power",
|
||
finding=(
|
||
f"{net} carries I_load={i_load:.3g} A with no vias on that net "
|
||
"in the parsed .kicad_pcb."
|
||
),
|
||
why="Via count is taken from board vias whose net matches this supply.",
|
||
status="INFO",
|
||
recommendation=(
|
||
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=[],
|
||
))
|
||
return out
|
||
|
||
|
||
def check_pcb_thermal_copper(
|
||
graph: DesignGraph,
|
||
constraints_map: dict,
|
||
layout: LayoutGraph | None,
|
||
) -> list[Finding]:
|
||
"""Dissipating IC with P from I_load×drop and no pour/vias under courtyard."""
|
||
if layout is None:
|
||
return []
|
||
out: list[Finding] = []
|
||
for ref, comp in sorted(graph.components.items()):
|
||
if comp.component_type != ComponentType.IC:
|
||
continue
|
||
cons = _match_constraints(comp.mpn or comp.value, constraints_map)
|
||
values = _specs_values(comp)
|
||
i_load = _first(values, _LOAD_KEYS)
|
||
if i_load is None:
|
||
continue
|
||
vin_n = _pin_net_by_role(graph, comp, cons, _VIN_PIN)
|
||
vout_n = _pin_net_by_role(graph, comp, cons, _VOUT_PIN)
|
||
vin = _net_voltage(graph, vin_n) if vin_n else None
|
||
vout = _net_voltage(graph, vout_n) if vout_n else None
|
||
if vin is None or vout is None or vin <= vout:
|
||
continue
|
||
p = i_load * (vin - vout)
|
||
fp = layout.footprints.get(ref)
|
||
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, region):
|
||
via_n += 1
|
||
pour = False
|
||
for z in layout.zones:
|
||
if not z.net:
|
||
continue
|
||
if vout_n and kicad_nets_match(z.net, vout_n):
|
||
pass
|
||
elif vin_n and kicad_nets_match(z.net, vin_n):
|
||
pass
|
||
elif graph.nets.get(normalize_kicad_hierarchy_net(z.net)) and (
|
||
graph.nets.get(z.net) and graph.nets[z.net].net_type == NetType.GROUND
|
||
):
|
||
pass
|
||
else:
|
||
leaf = normalize_kicad_hierarchy_net(z.net).upper()
|
||
if not (leaf == "GND" or leaf.endswith("/GND") or "GND" in leaf):
|
||
continue
|
||
for outline in z.outlines:
|
||
if len(outline) >= 3 and _in_poly(fp.x, fp.y, outline):
|
||
pour = True
|
||
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 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=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=[],
|
||
))
|
||
return out
|
||
|
||
|
||
def _shoelace_mm2(pts: list[tuple[float, float]]) -> float:
|
||
if len(pts) < 3:
|
||
return 0.0
|
||
s = 0.0
|
||
n = len(pts)
|
||
for i in range(n):
|
||
x1, y1 = pts[i]
|
||
x2, y2 = pts[(i + 1) % n]
|
||
s += x1 * y2 - x2 * y1
|
||
return abs(s) / 2.0
|
||
|
||
|
||
def check_pcb_junction_temp(
|
||
graph: DesignGraph,
|
||
constraints_map: dict,
|
||
layout: LayoutGraph | None,
|
||
) -> list[Finding]:
|
||
"""Tj = Ta + P·θJA when P, θJA, copper area, and via count all exist.
|
||
|
||
Copper area and via count are FACT only — θJA is not scaled with an
|
||
invented spreading/FEM model. Missing any parameter → INSUFFICIENT.
|
||
"""
|
||
if layout is None:
|
||
return []
|
||
out: list[Finding] = []
|
||
for ref, comp in sorted(graph.components.items()):
|
||
if comp.component_type != ComponentType.IC:
|
||
continue
|
||
cons = _match_constraints(comp.mpn or comp.value, constraints_map)
|
||
values = _specs_values(comp)
|
||
i_load = _first(values, _LOAD_KEYS)
|
||
if i_load is None:
|
||
continue
|
||
vin_n = _pin_net_by_role(graph, comp, cons, _VIN_PIN)
|
||
vout_n = _pin_net_by_role(graph, comp, cons, _VOUT_PIN)
|
||
vin = _net_voltage(graph, vin_n) if vin_n else None
|
||
vout = _net_voltage(graph, vout_n) if vout_n else None
|
||
if vin is None or vout is None or vin <= vout:
|
||
continue
|
||
p = i_load * (vin - vout)
|
||
theta = _first(values, _THETA_KEYS)
|
||
tjmax = _first(values, _TJMAX_KEYS)
|
||
fp = layout.footprints.get(ref)
|
||
region = _footprint_region(fp) if fp else []
|
||
via_n = 0
|
||
copper_mm2 = 0.0
|
||
if len(region) >= 3:
|
||
for v in layout.vias:
|
||
if _in_poly(v.x, v.y, region):
|
||
via_n += 1
|
||
cy = _shoelace_mm2(region)
|
||
for z in layout.zones:
|
||
if not z.net:
|
||
continue
|
||
leaf = normalize_kicad_hierarchy_net(z.net).upper()
|
||
ok_net = False
|
||
if vout_n and kicad_nets_match(z.net, vout_n):
|
||
ok_net = True
|
||
elif vin_n and kicad_nets_match(z.net, vin_n):
|
||
ok_net = True
|
||
elif leaf in {"GND", "AGND", "DGND", "PGND", "VSS"} or "GND" in leaf:
|
||
ok_net = True
|
||
if not ok_net:
|
||
continue
|
||
for outline in z.outlines:
|
||
if len(outline) >= 3 and _in_poly(fp.x, fp.y, outline):
|
||
copper_mm2 += min(cy, _shoelace_mm2(outline)) if cy else _shoelace_mm2(outline)
|
||
break
|
||
missing: list[str] = []
|
||
if theta is None:
|
||
missing.append("θJA")
|
||
if len(region) < 3:
|
||
missing.append("courtyard")
|
||
rec_ins = (
|
||
"Add datasheet θJA, a KiCad courtyard, and copper/via geometry; "
|
||
"Tj is not estimated from invented 1 oz / 10 °C defaults."
|
||
)
|
||
if missing:
|
||
out.append(Finding(
|
||
designator=ref,
|
||
mpn=comp.mpn or "",
|
||
aspect="layout_thermal",
|
||
finding=(
|
||
f"{ref} P≈{p:.3g} W but Tj is not estimated "
|
||
f"(missing {', '.join(missing)})."
|
||
),
|
||
facts=(
|
||
f"P≈{p:.3g} W; θJA={theta}; copper_mm2={copper_mm2:.3g}; "
|
||
f"vias_in_courtyard={via_n}; missing={missing}."
|
||
),
|
||
requirement="Tj = Ta + P·θJA only with measured copper area, via count, and θJA.",
|
||
inference="Insufficient evidence — not a FEM solve.",
|
||
why="No invented spreading model or default copper weight.",
|
||
status="INFO",
|
||
recommendation=rec_ins,
|
||
action=rec_ins,
|
||
source="pcb_power_thermal",
|
||
rule_id="PE-THM-002",
|
||
evidence_status="INSUFFICIENT",
|
||
net=vout_n,
|
||
pins=[],
|
||
))
|
||
continue
|
||
tj = _TA_C + p * theta
|
||
calc = (
|
||
f"Tj = {_TA_C:g} + {p:.4g}×{theta:g} = {tj:.1f} °C "
|
||
f"(copper_mm2={copper_mm2:.3g}, vias={via_n}; θJA not derated)."
|
||
)
|
||
over = tjmax is not None and tj > tjmax
|
||
rec = (
|
||
f"Lower P or θJA so Tj stays below {tjmax:g} °C."
|
||
if over else
|
||
"Tj estimate is within the known θJA model; FEM not used."
|
||
)
|
||
out.append(Finding(
|
||
designator=ref,
|
||
mpn=comp.mpn or "",
|
||
aspect="layout_thermal",
|
||
finding=(
|
||
f"{ref} Tj≈{tj:.0f} °C (Ta={_TA_C:.0f} °C, P≈{p:.3g} W, "
|
||
f"θJA={theta:g} °C/W, copper≈{copper_mm2:.3g} mm², vias={via_n})"
|
||
+ (f"; Tjmax={tjmax:g} °C." if tjmax is not None else ".")
|
||
),
|
||
facts=(
|
||
f"P={p:.4g} W; θJA={theta:g}; copper_mm2={copper_mm2:.4g}; "
|
||
f"vias={via_n}; Ta={_TA_C:g}; Tj={tj:.2f}"
|
||
+ (f"; Tjmax={tjmax:g}" if tjmax is not None else "")
|
||
+ "."
|
||
),
|
||
requirement=(
|
||
"Datasheet θJA applies as published; copper/vias reported as FACT."
|
||
),
|
||
inference=(
|
||
"Tj exceeds Tjmax under this θJA model."
|
||
if over else
|
||
"Closed-form θJA estimate — not a thermal FEM."
|
||
),
|
||
why="All of P, θJA, courtyard copper area, and via count were measured.",
|
||
status="WARNING" if over else "INFO",
|
||
recommendation=rec,
|
||
action=rec,
|
||
source="pcb_power_thermal",
|
||
rule_id="PE-THM-002",
|
||
evidence_status="SUFFICIENT",
|
||
calculation=calc,
|
||
assumptions=[
|
||
f"Ta={_TA_C:g} °C (explicit default, not a 10 °C rise).",
|
||
"θJA is used as published; no via/copper spreading formula.",
|
||
],
|
||
net=vout_n,
|
||
pins=[],
|
||
))
|
||
return out
|
||
|
||
|
||
def _is_gnd_name(name: str) -> bool:
|
||
leaf = normalize_kicad_hierarchy_net(name).upper()
|
||
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 long HS/power span has a GND pour but no GND via in its bbox."""
|
||
if layout is None:
|
||
return []
|
||
gnd_zones = [
|
||
z for z in layout.zones
|
||
if z.net and _is_gnd_name(z.net) and z.outlines
|
||
]
|
||
if not gnd_zones:
|
||
return []
|
||
gnd_vias = [
|
||
v for v in layout.vias
|
||
if v.net and _is_gnd_name(v.net)
|
||
]
|
||
out: list[Finding] = []
|
||
seen: set[str] = set()
|
||
for net_name, net in sorted(graph.nets.items()):
|
||
if net.net_type not in (NetType.SIGNAL, NetType.POWER):
|
||
continue
|
||
key = normalize_kicad_hierarchy_net(net_name)
|
||
if key in seen:
|
||
continue
|
||
segs = [
|
||
s for s in layout.segments
|
||
if s.net and kicad_nets_match(s.net, net_name)
|
||
]
|
||
if len(segs) < 1:
|
||
continue
|
||
xs: list[float] = []
|
||
ys: list[float] = []
|
||
for s in segs:
|
||
xs.extend([s.start[0], s.end[0]])
|
||
ys.extend([s.start[1], s.end[1]])
|
||
xmin, xmax = min(xs), max(xs)
|
||
ymin, ymax = min(ys), max(ys)
|
||
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"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=rec,
|
||
action=rec,
|
||
source="pcb_power_thermal",
|
||
rule_id="PE-STCH-001",
|
||
net=net_name,
|
||
pins=[],
|
||
))
|
||
return out
|
||
|
||
|
||
def check_pcb_kelvin(
|
||
graph: DesignGraph,
|
||
constraints_map: dict,
|
||
) -> list[Finding]:
|
||
"""Sense/Kelvin pin on a net that also carries the load (not a 4-wire tap)."""
|
||
out: list[Finding] = []
|
||
for ref, comp in sorted(graph.components.items()):
|
||
if comp.component_type != ComponentType.IC:
|
||
continue
|
||
cons = _match_constraints(comp.mpn or comp.value, constraints_map)
|
||
if not cons:
|
||
continue
|
||
for pin in cons.pintable:
|
||
blob = " ".join(
|
||
x for x in (str(pin.number), pin.name, pin.description or "")
|
||
if x
|
||
)
|
||
if not _SENSE_RE.search(blob):
|
||
continue
|
||
net = graph.pin_net(ref, str(pin.number))
|
||
if not net:
|
||
continue
|
||
others = [
|
||
r for r in graph.components_on_net(net)
|
||
if r != ref
|
||
]
|
||
ics = [
|
||
r for r in others
|
||
if graph.components.get(r)
|
||
and graph.components[r].component_type == ComponentType.IC
|
||
]
|
||
if len(others) <= 1 and not ics:
|
||
continue
|
||
if len(others) < 2:
|
||
continue
|
||
out.append(Finding(
|
||
designator=ref,
|
||
mpn=comp.mpn or "",
|
||
aspect="layout_kelvin",
|
||
finding=(
|
||
f"{ref} pin {pin.number} ({pin.name}) looks like a current-sense/Kelvin "
|
||
f"pin on '{net}' with {len(others)} other parts on the same net."
|
||
),
|
||
why="A Kelvin/sense pin should tap the shunt, not share the high-current path.",
|
||
status="WARNING",
|
||
recommendation=(
|
||
f"Route {ref}.{pin.number} as a dedicated Kelvin pair to the sense resistor; "
|
||
"do not share that net with the load current."
|
||
),
|
||
source="pcb_power_thermal",
|
||
rule_id="PE-KEL-001",
|
||
net=net,
|
||
pins=[str(pin.number)],
|
||
))
|
||
return out
|