FACT, REQUIREMENT, and INFERENCE are separate fields; datasheet provenance lives in the rule DB so Recommended cannot stay ERROR; LLM output is REVIEW. Designer decisions persist across rescans. Schematic and PCB share one library and the same finding object in the report UI.
465 lines
16 KiB
Python
465 lines
16 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)
|
||
|
||
|
||
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 _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:
|
||
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."
|
||
),
|
||
why="Via current is reported from board geometry + datasheet I_load only.",
|
||
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."
|
||
),
|
||
source="pcb_power_thermal",
|
||
rule_id="PE-VIA-001",
|
||
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 or len(fp.courtyard) < 3:
|
||
continue
|
||
theta = _first(values, _THETA_KEYS)
|
||
via_n = 0
|
||
for v in layout.vias:
|
||
if _in_poly(v.x, v.y, fp.courtyard):
|
||
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:
|
||
# GND by name
|
||
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
|
||
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."
|
||
),
|
||
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."
|
||
),
|
||
source="pcb_power_thermal",
|
||
rule_id="PE-THM-001",
|
||
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 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."""
|
||
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 != NetType.SIGNAL:
|
||
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)
|
||
if xmax - xmin < 2.0 and ymax - ymin < 2.0:
|
||
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
|
||
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 "
|
||
"over a GND pour with no GND via in that bounding box."
|
||
),
|
||
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."
|
||
),
|
||
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
|