Files
periscope/backend/periscopex/pcb_power_thermal.py
T
michele dc65e96a22 Apply the finding engine to schematic and PCB reviews.
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.
2026-09-20 08:38:59 +02:00

465 lines
16 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)
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×(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."
),
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