"""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