Files
periscope/backend/periscopex/pcb_power_thermal.py
T
michele 1b3529501f Ship remaining PCB plan slices: BOM chain, SPOF, EMI FACT, gated Tj.
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.
2026-09-20 12:29:27 +02:00

682 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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×(VinVout) 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