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