"""BOM ↔ PCB footprint ↔ datasheet package / pinout / ratings. Skip when a side is missing. No invented JEDEC land patterns or IEC clearances. Thermal/EP pads may differ by one from pin_count. """ from __future__ import annotations import re from backend.periscopex.models import ( AbsMaxRating, CapacitorSpecs, Component, ComponentConstraints, ComponentType, DesignGraph, Finding, InductorSpecs, LayoutGraph, PackageInfo, ) from backend.periscopex.thermal_check import ( _LOAD_KEYS, _first, _net_voltage, _specs_values, ) from backend.periscopex.validate import _match_constraints _FAMILIES = ( "LQFP", "TQFP", "VQFP", "WQFN", "HVQFN", "VQFN", "QFN", "DFN", "WSON", "USON", "SOIC", "SSOP", "TSSOP", "MSOP", "SOP", "SOT-223", "SOT223", "SOT-23", "SOT23", "SOT-89", "SOT89", "WLCSP", "BGA", "LGA", "CSP", "SC-70", "SC70", "TO-252", "TO252", "TO-263", "QFP", ) _EP_PAD = re.compile(r"^(?:EP|PAD|TAB|TH|THERMAL|DIEPAD)$", re.I) _V_PARAM = re.compile( r"(?:^|[\s_/])(VCC|VDD|VIN|VSUPPLY|V_IN|SUPPLY|VDS|VCEO|VOLTAGE)", re.I, ) _NOT_V = re.compile(r"(IOUT|CURRENT|POWER|PD|TJ|TSTG|TEMP)", re.I) _T_PARAM = re.compile(r"(T[JA]|TJMAX|T_J|TSTG|TSTORAGE|OPERATING.?TEMP)", re.I) _I_PARAM = re.compile(r"(IOUT|I_OUT|IDC|ICONT|CURRENT)", re.I) def _family(blob: str) -> str | None: u = (blob or "").upper().replace(" ", "") if not u: return None for fam in _FAMILIES: if fam.replace("-", "") in u.replace("-", ""): return fam return None def _bom_fp(graph: DesignGraph, ref: str) -> str: row = (graph.bom_fields or {}).get(ref) or {} return str(row.get("footprint") or row.get("Footprint") or "") def _sch_fp(graph: DesignGraph, ref: str, comp: Component) -> str: row = (graph.schematic_fields or {}).get(ref) or {} return str(row.get("footprint") or comp.footprint or "") def _signal_pads(numbers: list[str]) -> set[str]: out: set[str] = set() for n in numbers: s = str(n).strip() if not s or _EP_PAD.match(s): continue out.add(s) return out def _package_row(cons: ComponentConstraints | None, specs) -> PackageInfo | None: if cons and cons.package_info: return cons.package_info pkg = getattr(specs, "package_info", None) if isinstance(pkg, PackageInfo): return pkg return None def _cap_vrated(comp: Component) -> float | None: specs = comp.specs if isinstance(specs, CapacitorSpecs) and specs.voltage_rating_v: try: return float(str(specs.voltage_rating_v).replace("V", "").strip()) except (TypeError, ValueError): return None return None def _ind_irated(comp: Component) -> float | None: specs = comp.specs if isinstance(specs, InductorSpecs) and specs.current_rating_a: try: return float(str(specs.current_rating_a).replace("A", "").strip()) except (TypeError, ValueError): return None return None def _rating_unit_v(r: AbsMaxRating) -> bool: return (r.unit or "").lower() in {"v", "volt", "volts"} def _rating_unit_c(r: AbsMaxRating) -> bool: u = (r.unit or "").lower().replace("°", "") return u in {"c", "degc", "celsius"} def _rating_unit_a(r: AbsMaxRating) -> bool: return (r.unit or "").lower() in {"a", "amp", "amps"} def check_bom_pcb_datasheet( graph: DesignGraph, constraints_map: dict, layout: LayoutGraph | None, ) -> list[Finding]: """Three-way mismatches when BOM, PCB, and library all have numbers.""" cmap = constraints_map or {} out: list[Finding] = [] for ref, comp in sorted(graph.components.items()): if ref.startswith("#"): continue cons = _match_constraints(comp.mpn or comp.value, cmap) fp_layout = layout.footprints.get(ref) if layout else None pcb_name = (fp_layout.footprint if fp_layout else "") or "" sch_name = _sch_fp(graph, ref, comp) bom_name = _bom_fp(graph, ref) pkg = _package_row(cons, comp.specs) out.extend(_package_findings( ref, comp, pkg, sch_name, bom_name, pcb_name, )) out.extend(_pinout_findings(ref, comp, cons, fp_layout, pkg)) out.extend(_voltage_findings(graph, ref, comp, cons)) out.extend(_temp_findings(ref, comp, cons)) out.extend(_current_findings(graph, ref, comp, cons)) return out def _mismatch( *, ref: str, mpn: str, rule_id: str, finding: str, facts: str, requirement: str, rec: str, status: str = "ERROR", inference: str = "", net: str | None = None, ) -> Finding: return Finding( designator=ref, mpn=mpn, aspect="bom_pcb", finding=finding, facts=facts, requirement=requirement, inference=inference or "Measured three-way mismatch — not a guessed land pattern.", why=requirement, status=status, recommendation=rec, action=rec, source="bom_pcb_check", rule_id=rule_id, evidence_status="SUFFICIENT", net=net, pins=[ref], ) def _package_findings( ref: str, comp: Component, pkg: PackageInfo | None, sch_name: str, bom_name: str, pcb_name: str, ) -> list[Finding]: out: list[Finding] = [] mpn = comp.mpn or "" names = { "schematic": sch_name, "BOM": bom_name, "PCB": pcb_name, } present = {k: v for k, v in names.items() if (v or "").strip()} fams = {k: _family(v) for k, v in present.items()} known = {k: f for k, f in fams.items() if f} if len(set(known.values())) > 1: rec = ( f"Align {ref} footprint/package across BOM, schematic, and PCB " f"to the datasheet package {pkg.package if pkg else '(unknown)'}." ) out.append(_mismatch( ref=ref, mpn=mpn, rule_id="PE-BOM-010", finding=( f"{ref} package family disagrees: " + ", ".join(f"{k}={v}" for k, v in sorted(known.items())) + (f"; datasheet={pkg.package}" if pkg else ".") ), facts=( f"sch={sch_name!r} bom={bom_name!r} pcb={pcb_name!r} " f"datasheet={getattr(pkg, 'package', None)!r}." ), requirement="BOM, PCB footprint, and datasheet package family must match.", rec=rec, )) ds_fam = _family(pkg.package) if pkg else None if ds_fam and known and ds_fam not in known.values() and any(known.values()): # Datasheet family vs every CAD family. if all(f != ds_fam for f in known.values()): rec = f"Change {ref}'s footprint to the datasheet package {pkg.package}." out.append(_mismatch( ref=ref, mpn=mpn, rule_id="PE-BOM-010", finding=( f"{ref} datasheet package {pkg.package} ({ds_fam}) does not " f"match CAD families {sorted(set(known.values()))}." ), facts=f"datasheet={pkg.package!r}; CAD={present!r}.", requirement="PCB/BOM footprint family must match datasheet package_info.", rec=rec, )) return out def _pinout_findings( ref: str, comp: Component, cons: ComponentConstraints | None, fp_layout, pkg: PackageInfo | None, ) -> list[Finding]: out: list[Finding] = [] mpn = comp.mpn or "" sch_pins = _signal_pads(list(comp.pins.keys())) ds_pins = _signal_pads( [str(p.number) for p in (cons.pintable if cons else [])] ) pcb_pins = _signal_pads( [p.number for p in (fp_layout.pads if fp_layout else [])] ) ds_count = pkg.pin_count if pkg else (len(ds_pins) or None) pcb_count = len(pcb_pins) if pcb_pins else None if ds_count and pcb_count and abs(ds_count - pcb_count) > 1: rec = ( f"Replace {ref}'s PCB footprint so pad count matches datasheet " f"pin_count={ds_count}." ) out.append(_mismatch( ref=ref, mpn=mpn, rule_id="PE-BOM-011", finding=( f"{ref} datasheet pin_count={ds_count} vs PCB signal pads={pcb_count}." ), facts=f"pin_count={ds_count}; PCB pads={sorted(pcb_pins)}; sch={sorted(sch_pins)}.", requirement="Datasheet pin_count must match the PCB footprint (EP ±1 allowed).", rec=rec, )) if ds_pins and pcb_pins: missing_pcb = sorted(ds_pins - pcb_pins) extra_pcb = sorted(pcb_pins - ds_pins) if missing_pcb or extra_pcb: rec = ( f"Fix {ref} pinout: pads vs pintable. Missing on PCB: " f"{missing_pcb or '—'}; extra on PCB: {extra_pcb or '—'}." ) out.append(_mismatch( ref=ref, mpn=mpn, rule_id="PE-BOM-011", finding=( f"{ref} pintable vs PCB pads differ " f"(missing {missing_pcb}, extra {extra_pcb})." ), facts=f"pintable={sorted(ds_pins)}; PCB={sorted(pcb_pins)}; sch={sorted(sch_pins)}.", requirement="Each datasheet pin number must exist as a PCB pad.", rec=rec, )) elif ds_pins and sch_pins and ds_pins != sch_pins: missing = sorted(ds_pins - sch_pins) extra = sorted(sch_pins - ds_pins) if missing or extra: rec = f"Align {ref} schematic pins with the datasheet pintable." out.append(_mismatch( ref=ref, mpn=mpn, rule_id="PE-BOM-011", finding=( f"{ref} pintable vs schematic pins differ " f"(missing {missing}, extra {extra})." ), facts=f"pintable={sorted(ds_pins)}; sch={sorted(sch_pins)}.", requirement="Schematic pin numbers must match the datasheet pintable.", rec=rec, status="ERROR", )) return out def _voltage_findings( graph: DesignGraph, ref: str, comp: Component, cons: ComponentConstraints | None, ) -> list[Finding]: out: list[Finding] = [] mpn = comp.mpn or "" vrated = _cap_vrated(comp) if vrated is not None: return out # PE-DRT-* owns capacitor Vop/Vrated. if not cons: return out for r in cons.absolute_maximum_ratings: if r.max is None or not _rating_unit_v(r) or _NOT_V.search(r.parameter or ""): continue if not _V_PARAM.search(r.parameter or ""): continue for pin in cons.pintable: blob = f"{pin.number} {pin.name} {pin.description or ''}" if not _V_PARAM.search(blob): continue net = graph.pin_net(ref, str(pin.number)) if not net: continue vop = _net_voltage(graph, net) if vop is None: continue if vop > float(r.max): rec = ( f"Lower '{net}' below {r.max:g} V or change {ref} " f"({r.parameter} abs-max)." ) out.append(_mismatch( ref=ref, mpn=mpn, rule_id="PE-BOM-012", finding=( f"{ref} '{net}' is {vop:g} V; datasheet {r.parameter} " f"abs-max is {r.max:g} {r.unit}." ), facts=f"Vop={vop:g} V; abs-max {r.parameter}={r.max:g} {r.unit} p.{r.source_page}.", requirement="Operating voltage must stay within absolute maximum ratings.", rec=rec, net=net, )) return out def _temp_findings( ref: str, comp: Component, cons: ComponentConstraints | None, ) -> list[Finding]: """Operating temp vs abs-max only when both numbers exist (no Ta=25 invented).""" if not cons: return [] values = _specs_values(comp) ta = _first(values, ("ta_max_c", "operating_temp_max_c", "tamb_max_c", "ta_max")) if ta is None: return [] out: list[Finding] = [] mpn = comp.mpn or "" for r in cons.absolute_maximum_ratings: if r.max is None or not _rating_unit_c(r): continue if not _T_PARAM.search(r.parameter or ""): continue if ta > float(r.max): rec = f"Keep {ref} ambient/operating temperature ≤ {r.max:g} °C." out.append(_mismatch( ref=ref, mpn=mpn, rule_id="PE-BOM-013", finding=( f"{ref} operating temp {ta:g} °C exceeds {r.parameter} " f"abs-max {r.max:g} {r.unit}." ), facts=f"Ta_max={ta:g} °C from specs; abs-max {r.parameter}={r.max:g}.", requirement="Specified operating temperature must not exceed abs-max T.", rec=rec, status="ERROR", )) return out def _current_findings( graph: DesignGraph, ref: str, comp: Component, cons: ComponentConstraints | None, ) -> list[Finding]: out: list[Finding] = [] mpn = comp.mpn or "" i_load = _first(_specs_values(comp), _LOAD_KEYS) irated = _ind_irated(comp) if irated is not None and i_load is not None and i_load > irated: rec = f"Replace {ref} with an inductor rated above {i_load:g} A." out.append(_mismatch( ref=ref, mpn=mpn, rule_id="PE-BOM-014", finding=f"{ref} I_load={i_load:g} A exceeds current rating {irated:g} A.", facts=f"I_load={i_load:g} A; Irated={irated:g} A.", requirement="Load current must not exceed the inductor current rating.", rec=rec, )) if not cons or i_load is None: return out for r in cons.absolute_maximum_ratings: if r.max is None or not _rating_unit_a(r): continue if not _I_PARAM.search(r.parameter or ""): continue if i_load > float(r.max): rec = f"Cut the load on {ref} below {r.max:g} A ({r.parameter})." out.append(_mismatch( ref=ref, mpn=mpn, rule_id="PE-BOM-014", finding=( f"{ref} I_load={i_load:g} A exceeds abs-max {r.parameter} " f"{r.max:g} A." ), facts=f"I_load={i_load:g} A; abs-max {r.parameter}={r.max:g} A p.{r.source_page}.", requirement="I_load must stay within absolute maximum current.", rec=rec, )) return out