diff --git a/backend/periscopex/derating.py b/backend/periscopex/derating.py index 789d9f9..e460a6d 100644 --- a/backend/periscopex/derating.py +++ b/backend/periscopex/derating.py @@ -95,6 +95,18 @@ def _dielectric_category(component_subtype: str | None, dielectric: str | None) return "ceramic" +def _stress(op: float | None, rated: float | None) -> str: + """PASS / MARGIN / RISK from Vop vs Vrated. No invented dielectric %.""" + if op is None or rated is None or rated <= 0: + return "UNKNOWN" + ratio = op / rated + if ratio > 1.0: + return "RISK" + if ratio > 0.8: + return "MARGIN" + return "PASS" + + def build_derating_table(graph: DesignGraph) -> list[dict]: """Build a capacitor voltage derating table from the design graph. @@ -180,6 +192,7 @@ def build_derating_table(graph: DesignGraph) -> list[dict]: "c_eff_f": c_eff, "c_eff_formatted": c_eff_fmt, "dc_bias_model": "stima" if factor is not None else None, + "stress": _stress(op_voltage, rated_v), }) rows.sort(key=lambda r: natural_sort_key(r["designator"])) diff --git a/backend/periscopex/esd_return_check.py b/backend/periscopex/esd_return_check.py new file mode 100644 index 0000000..11472b5 --- /dev/null +++ b/backend/periscopex/esd_return_check.py @@ -0,0 +1,164 @@ +"""ESD on connector nets and HS return path — REVIEW unless a MANDATORY FACT exists.""" + +from __future__ import annotations + +from backend.periscopex.models import ( + ComponentConstraints, + ComponentType, + DesignGraph, + Finding, + LayoutGraph, + NetType, +) +from backend.periscopex.pcb_net_match import kicad_nets_match, normalize_kicad_hierarchy_net +from backend.periscopex.pcb_power_thermal import _HS_NET_RE, _is_gnd_name +from backend.periscopex.validate import _match_constraints + +_USB_RE = __import__("re").compile( + r"(USB|DP|DM|D\+|D-|VBUS|CC1|CC2|HDMI|ESD)", + __import__("re").I, +) + + +def _is_esd_part(comp) -> bool: + sub = (comp.component_subtype or "").lower() + if "esd" in sub or "protection" in sub: + return True + if comp.component_type == ComponentType.DISCRETE and "esd" in (comp.value or "").lower(): + return True + return False + + +def check_esd( + graph: DesignGraph, + constraints_map: dict[str, ComponentConstraints] | None = None, +) -> list[Finding]: + """Connector / USB net without an ESD part → REVIEW. On-die clamp is not a RULE.""" + cmap = constraints_map or {} + out: list[Finding] = [] + seen: set[str] = set() + connector_nets: set[str] = set() + for comp in graph.components.values(): + if comp.component_type != ComponentType.CONNECTOR: + continue + connector_nets.update(n for n in comp.pins.values() if n) + for ref, comp in sorted(graph.components.items()): + if comp.component_type != ComponentType.IC: + continue + cons = _match_constraints(comp.mpn or comp.value, cmap) + clamp = list((cons.internal_features.esd_clamp_pins if cons and cons.internal_features else []) or []) + for pin_num, net in sorted(comp.pins.items(), key=lambda x: str(x[0])): + if not net or net in seen: + continue + interesting = net in connector_nets or bool(_USB_RE.search(net)) + if clamp: + tokens = [net, str(pin_num)] + if cons: + pin = cons.pin_by_number(pin_num) + if pin and pin.name: + tokens.append(pin.name) + if any(c.upper() in t.upper() for c in clamp for t in tokens): + interesting = True + if not interesting: + continue + seen.add(net) + if any(_is_esd_part(graph.components[r]) for r in graph.components_on_net(net) if r in graph.components): + continue + rec = ( + f"Add a datasheet-specified ESD device on {net}, or record a " + "designer decision if the connector is unused." + ) + out.append(Finding( + designator=ref, + mpn=comp.mpn or "", + aspect="esd", + finding=f"{net} reaches {ref} with no ESD/protection part on the net.", + facts=f"Net {net}; ESD parts on net: 0; connector={net in connector_nets}.", + requirement=( + "External ESD is recommended unless the extraction lists a " + "mandatory clamp requirement with a measured FACT." + ), + inference="REVIEW — on-die esd_clamp_pins are not a board RULE.", + why="No invented IEC 61000 level.", + status="WARNING", + recommendation=rec, + action=rec, + source="esd_return_check", + rule_id="PE-ESD-001", + finding_class="REVIEW", + provenance="RECOMMENDED", + evidence_status="SUFFICIENT", + net=net, + pins=[f"{ref}.{pin_num}"], + )) + return out + + +def check_return_path( + graph: DesignGraph, + layout: LayoutGraph | None, +) -> list[Finding]: + """HS net over a GND pour without a GND via in the bbox → REVIEW, not ERROR.""" + 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 + if not _HS_NET_RE.search(net_name or ""): + 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 not segs: + continue + xs = [c for s in segs for c in (s.start[0], s.end[0])] + ys = [c for s in segs for c in (s.start[1], s.end[1])] + xmin, xmax, ymin, ymax = min(xs), max(xs), min(ys), max(ys) + if (xmax - xmin) < 8.0 and (ymax - ymin) < 8.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 + rec = f"Add a GND via in the return path of {net_name}, then re-run PCB review." + out.append(Finding( + designator=ref, + mpn="", + aspect="return_path", + finding=( + f"{net_name} spans {xmax - xmin:.1f}×{ymax - ymin:.1f} mm over a " + "GND pour with no GND via in that bbox." + ), + facts=( + f"bbox=({xmin:.1f},{ymin:.1f})-({xmax:.1f},{ymax:.1f}); " + f"GND vias in bbox=0; board GND vias={len(gnd_vias)}." + ), + requirement="High-speed return via next to the pair (typical, not IEC).", + inference="REVIEW — no creepage/IEC number in context.", + why="Return path without a mandatory datasheet millimetre is not RULE ERROR.", + status="WARNING", + recommendation=rec, + action=rec, + source="esd_return_check", + rule_id="PE-RET-001", + finding_class="REVIEW", + provenance="TYPICAL", + evidence_status="SUFFICIENT", + net=net_name, + pins=[], + )) + return out diff --git a/backend/periscopex/finding_engine.py b/backend/periscopex/finding_engine.py index 2ea9155..df52bdc 100644 --- a/backend/periscopex/finding_engine.py +++ b/backend/periscopex/finding_engine.py @@ -137,6 +137,24 @@ def _seed() -> None: requirement="Kelvin/sense pin should not share the load net.") _add("PE-STCH-001", "TYPICAL", "INFO", domain="pcb", requirement="GND stitch via in the bbox of a signal over a GND pour.") + _add("PE-HIER-001", "TYPICAL", "INFO", domain="pcb", + requirement="Every component pin should land on a named net.") + _add("PE-DRT-002", "RECOMMENDED", "RISK", domain="pcb", + requirement="Vop/Vrated utilization band (not an invented dielectric %).") + _add("PE-DRT-003", "TYPICAL", "INFO", domain="pcb", + requirement="Capacitors with Vop and Vrated at or below 80% utilization.") + _add("PE-TIM-001", "MANDATORY", "RULE", domain="pcb", + requirement="Reset RC delay vs datasheet t_reset when both numbers exist.") + _add("PE-TIM-002", "MANDATORY", "RULE", domain="pcb", + requirement="Strap divider voltage vs Vih/Vil when both numbers exist.") + _add("PE-PI-001", "RECOMMENDED", "RISK", domain="pcb", + requirement="IC supply net should have a local decoupling capacitor.") + _add("PE-PI-002", "RECOMMENDED", "REVIEW", domain="pcb", + requirement="Local vs bulk on a supply when capacitance values exist.") + _add("PE-ESD-001", "RECOMMENDED", "REVIEW", domain="pcb", + requirement="Connector/USB net ESD — REVIEW unless a mandatory FACT exists.") + _add("PE-RET-001", "TYPICAL", "REVIEW", domain="pcb", + requirement="High-speed net over a GND pour should have a nearby GND via.") _seed() @@ -215,6 +233,11 @@ def complete_finding(f: Finding) -> Finding: if f.finding_class == "RULE": f.finding_class = "RISK" + # ERROR only for a measured MANDATORY RULE. REVIEW/RISK/INFO stay ≤ WARNING. + if f.status == "ERROR": + if f.finding_class != "RULE" or prov != "MANDATORY": + f.status = "WARNING" + if f.finding_class == "RULE" and f.confidence is None: f.confidence = 0.9 if f.evidence_status == "SUFFICIENT" else 0.3 elif f.finding_class == "REVIEW" and f.confidence is None: diff --git a/backend/periscopex/hierarchy.py b/backend/periscopex/hierarchy.py new file mode 100644 index 0000000..c36d6b5 --- /dev/null +++ b/backend/periscopex/hierarchy.py @@ -0,0 +1,160 @@ +"""Component → pin → net → block hierarchy (usable inventory, not a platform).""" + +from __future__ import annotations + +from pydantic import BaseModel + +from backend.periscopex.functional_groups import FunctionalGroupsReport +from backend.periscopex.models import DesignGraph, Finding + + +class HierarchyPin(BaseModel): + number: str + net: str = "" + + +class HierarchyComponent(BaseModel): + ref: str + component_type: str + pins: list[HierarchyPin] = [] + nets: list[str] = [] + block_id: str | None = None + + +class HierarchyNet(BaseModel): + name: str + net_type: str + components: list[str] = [] + block_id: str | None = None + + +class HierarchyBlock(BaseModel): + block_id: str + kind: str # domain | group | rail + ic_refs: list[str] = [] + nets: list[str] = [] + + +class HierarchyReport(BaseModel): + components: list[HierarchyComponent] = [] + nets: list[HierarchyNet] = [] + blocks: list[HierarchyBlock] = [] + + +def build_hierarchy( + graph: DesignGraph, + plan: FunctionalGroupsReport | None = None, +) -> HierarchyReport: + """Walk component.pins → nets, then group into domains / rail blocks.""" + ref_block: dict[str, str] = {} + blocks: list[HierarchyBlock] = [] + if plan is not None: + for d in plan.domains: + blocks.append(HierarchyBlock( + block_id=d.domain_id, + kind="domain", + ic_refs=list(d.ic_refs), + nets=list(d.power_nets), + )) + for ref in d.ic_refs: + ref_block[ref] = d.domain_id + for g in plan.groups: + bid = ref_block.get(g.ref) or f"group:{g.ref}" + if g.ref not in ref_block: + blocks.append(HierarchyBlock( + block_id=bid, kind="group", ic_refs=[g.ref], nets=[], + )) + ref_block[g.ref] = bid + else: + for net in graph.nets.values(): + if net.net_type.value != "power": + continue + ics = [ + p.component_ref for p in net.pins + if (graph.components.get(p.component_ref) + and graph.components[p.component_ref].component_type.value == "ic") + ] + if not ics: + continue + bid = f"rail:{net.name}" + blocks.append(HierarchyBlock( + block_id=bid, kind="rail", ic_refs=sorted(set(ics)), nets=[net.name], + )) + for ref in ics: + ref_block.setdefault(ref, bid) + + net_block: dict[str, str] = {} + for b in blocks: + for n in b.nets: + net_block[n] = b.block_id + + comps: list[HierarchyComponent] = [] + for ref, comp in sorted(graph.components.items()): + pins = [ + HierarchyPin(number=str(num), net=n or "") + for num, n in sorted(comp.pins.items(), key=lambda x: str(x[0])) + ] + nets = sorted({p.net for p in pins if p.net}) + bid = ref_block.get(ref) + if bid is None and nets: + for n in nets: + if n in net_block: + bid = net_block[n] + break + comps.append(HierarchyComponent( + ref=ref, + component_type=comp.component_type.value, + pins=pins, + nets=nets, + block_id=bid, + )) + + hnets: list[HierarchyNet] = [] + for name, net in sorted(graph.nets.items()): + refs = sorted({p.component_ref for p in net.pins}) + bid = net_block.get(name) + if bid is None: + for r in refs: + if r in ref_block: + bid = ref_block[r] + break + hnets.append(HierarchyNet( + name=name, + net_type=net.net_type.value, + components=refs, + block_id=bid, + )) + return HierarchyReport(components=comps, nets=hnets, blocks=blocks) + + +def check_hierarchy(graph: DesignGraph, plan: FunctionalGroupsReport | None = None) -> list[Finding]: + """FACT: IC pin with an empty net. Skip unnamed power-flags.""" + out: list[Finding] = [] + for ref, comp in sorted(graph.components.items()): + if comp.component_type.value != "ic": + continue + dangling = [str(n) for n, net in comp.pins.items() if not (net or "").strip()] + if not dangling: + continue + rec = f"Connect {ref} pin(s) {', '.join(dangling)} to a named net, or mark NC." + out.append(Finding( + designator=ref, + mpn=comp.mpn or "", + aspect="hierarchy", + finding=f"{ref} pin(s) {', '.join(dangling)} have no net.", + facts=f"{ref} pins {dangling} net=''.", + requirement="Every IC pin lands on a named net (or is explicitly NC).", + inference="Empty pin map is a graph FACT, not a layout millimetre claim.", + why="Component → pin → net hierarchy.", + status="INFO", + recommendation=rec, + action=rec, + source="hierarchy_check", + rule_id="PE-HIER-001", + finding_class="INFO", + provenance="TYPICAL", + evidence_status="SUFFICIENT", + pins=[f"{ref}.{p}" for p in dangling], + )) + _ = plan + return out diff --git a/backend/periscopex/pcb_checks.py b/backend/periscopex/pcb_checks.py index f820afe..ad914ae 100644 --- a/backend/periscopex/pcb_checks.py +++ b/backend/periscopex/pcb_checks.py @@ -5,7 +5,11 @@ from __future__ import annotations import logging from collections import Counter +from backend.periscopex.derating import build_derating_table +from backend.periscopex.esd_return_check import check_esd, check_return_path from backend.periscopex.finding_engine import complete_findings +from backend.periscopex.functional_groups import FunctionalGroupsReport +from backend.periscopex.hierarchy import check_hierarchy from backend.periscopex.models import DesignGraph, Finding, LayoutGraph from backend.periscopex.pcb_net_match import check_pcb_net_match from backend.periscopex.pcb_power_thermal import ( @@ -15,8 +19,10 @@ from backend.periscopex.pcb_power_thermal import ( check_pcb_thermal_copper, check_pcb_via_current, ) +from backend.periscopex.pi_check import check_power_integrity from backend.periscopex.placement_check import check_placement from backend.periscopex.si_check import check_si +from backend.periscopex.timing_check import check_timing log = logging.getLogger(__name__) @@ -40,32 +46,89 @@ def assign_pcb_finding_ids(findings: list[Finding]) -> None: def check_pcb_derating(graph: DesignGraph) -> list[Finding]: - """ERROR only when both Vop and Vrated exist and Vop exceeds Vrated.""" + """PASS / MARGIN / RISK (exceed) when both Vop and Vrated exist.""" out: list[Finding] = [] + n_pass = 0 for row in build_derating_table(graph): rated = row.get("rated_voltage_v") op = row.get("operating_voltage_v") + stress = row.get("stress") or "UNKNOWN" if rated is None or op is None: continue - if float(op) <= float(rated): - continue ref = str(row.get("designator") or "") - out.append(Finding( - designator=ref, - mpn=str(row.get("mpn") or ""), - aspect="derating", - finding=( - f"{ref} operates at {op:g} V with a {rated:g} V rating." - ), - why="Capacitor voltage rating must exceed the rail (no invented derating %).", - status="ERROR", - recommendation=( + mpn = str(row.get("mpn") or "") + net = row.get("net_plus") + ratio = float(op) / float(rated) + if stress == "RISK": + rec = ( f"Replace {ref} with a capacitor rated above {op:g} V, " "or lower the rail." - ), + ) + out.append(Finding( + designator=ref, + mpn=mpn, + aspect="derating", + finding=( + f"{ref} operates at {op:g} V with a {rated:g} V rating " + f"(Vop/Vrated={ratio:.2f})." + ), + facts=f"Vop={op:g} V, Vrated={rated:g} V, ratio={ratio:.3f}.", + requirement="Operating voltage must not exceed the capacitor rating.", + inference="Exceeding Vr is a measured RULE, not a dielectric %.", + why="Capacitor voltage rating must exceed the rail (no invented derating %).", + status="ERROR", + recommendation=rec, + action=rec, + source="pcb_derating", + rule_id="PE-DRT-001", + evidence_status="SUFFICIENT", + net=net, + pins=[], + )) + elif stress == "MARGIN": + rec = ( + f"Confirm {ref} voltage margin ({op:g}/{rated:g} V) or pick a " + "higher Vr. This is utilization, not a 50% ceramic rule." + ) + out.append(Finding( + designator=ref, + mpn=mpn, + aspect="derating", + finding=( + f"{ref} Vop/Vrated={ratio:.2f} ({op:g}/{rated:g} V) — MARGIN." + ), + facts=f"Vop={op:g} V, Vrated={rated:g} V, ratio={ratio:.3f}.", + requirement="Utilization > 0.8 of Vr is flagged as MARGIN (RISK).", + inference="Not an invented IPC/ceramic derating percentage.", + why="PASS/MARGIN/RISK from Vop and Vrated only.", + status="WARNING", + recommendation=rec, + action=rec, + source="pcb_derating", + rule_id="PE-DRT-002", + evidence_status="SUFFICIENT", + net=net, + pins=[], + )) + elif stress == "PASS": + n_pass += 1 + if n_pass: + rec = "No voltage-rating change required for PASS capacitors." + out.append(Finding( + designator="C", + mpn="", + aspect="derating", + finding=f"{n_pass} capacitor(s) PASS (Vop/Vrated ≤ 0.8).", + facts=f"PASS count={n_pass} (both Vop and Vrated present).", + requirement="Rated voltage exists and Vop is ≤ 80% of Vr.", + inference="Summary INFO — per-cap PASS lives on the derating table.", + why="Avoid one INFO card per capacitor.", + status="INFO", + recommendation=rec, + action=rec, source="pcb_derating", - rule_id="PE-DRT-001", - net=row.get("net_plus"), + rule_id="PE-DRT-003", + evidence_status="SUFFICIENT", pins=[], )) return out @@ -110,8 +173,9 @@ def run_pcb_checks( graph: DesignGraph, constraints_map: dict, layout: LayoutGraph | None, + plan: FunctionalGroupsReport | None = None, ) -> list[Finding]: - """Placement, SI, pad-net match, derating — skip individually on error.""" + """Placement, SI, pad-net match, derating, hierarchy, timing, PI, ESD.""" out: list[Finding] = [] for name, fn in ( ("pcb_net_match", lambda: check_pcb_net_match(graph, layout)), @@ -123,6 +187,11 @@ def run_pcb_checks( ("pcb_thermal_copper", lambda: check_pcb_thermal_copper(graph, constraints_map, layout)), ("pcb_kelvin", lambda: check_pcb_kelvin(graph, constraints_map)), ("pcb_gnd_stitch", lambda: check_pcb_gnd_stitch(graph, layout)), + ("hierarchy_check", lambda: check_hierarchy(graph, plan)), + ("timing_check", lambda: check_timing(graph, constraints_map)), + ("pi_check", lambda: check_power_integrity(graph, constraints_map, layout)), + ("esd_check", lambda: check_esd(graph, constraints_map)), + ("return_path", lambda: check_return_path(graph, layout)), ): try: out.extend(fn()) diff --git a/backend/periscopex/pi_check.py b/backend/periscopex/pi_check.py new file mode 100644 index 0000000..bfeafb8 --- /dev/null +++ b/backend/periscopex/pi_check.py @@ -0,0 +1,193 @@ +"""Power integrity: local vs bulk on IC supplies. Distance only with datasheet mm.""" + +from __future__ import annotations + +import math + +from backend.periscopex.functional_groups import _BULK_F +from backend.periscopex.models import ( + CapacitorSpecs, + ComponentConstraints, + ComponentType, + DesignGraph, + Finding, + LayoutGraph, +) +from backend.periscopex.passive_rail_check import _is_ic_supply_pin +from backend.periscopex.validate import _match_constraints + +_LOAD_KEYS = ("i_load_a", "i_load", "iout", "i_out") + + +def _cap_farads(comp) -> float | None: + if comp.component_type != ComponentType.CAPACITOR: + return None + if isinstance(comp.specs, CapacitorSpecs) and comp.specs.value_farads > 0: + return float(comp.specs.value_farads) + return None + + +def _i_load(comp) -> float | None: + specs = getattr(comp, "specs", None) + vals = getattr(specs, "values", None) if specs else None + if not isinstance(vals, dict): + return None + for k in _LOAD_KEYS: + v = vals.get(k) + try: + n = float(v) + except (TypeError, ValueError): + continue + if n > 0: + return n + return None + + +def _max_distance_mm(cons: ComponentConstraints | None) -> float | None: + if not cons: + return None + best = None + for rule in cons.layout_rules or []: + if rule.get("kind") != "decoupling_proximity": + continue + mm = rule.get("max_distance_mm") + if isinstance(mm, (int, float)) and mm > 0: + best = float(mm) if best is None else min(best, float(mm)) + return best + + +def _pad_xy(layout: LayoutGraph, ref: str) -> tuple[float, float] | None: + fp = layout.footprints.get(ref) + if not fp: + return None + return (fp.x, fp.y) + + +def check_power_integrity( + graph: DesignGraph, + constraints_map: dict[str, ComponentConstraints] | None = None, + layout: LayoutGraph | None = None, +) -> list[Finding]: + """Local cap presence (RISK). Local vs bulk (REVIEW). Distance only with mm.""" + cmap = constraints_map or {} + out: list[Finding] = [] + seen: set[str] = set() + for ref, comp in sorted(graph.components.items()): + if comp.component_type != ComponentType.IC: + continue + cons = _match_constraints(comp.mpn or comp.value, cmap) + i_load = _i_load(comp) + for pin_num, net in sorted(comp.pins.items(), key=lambda x: str(x[0])): + if not net or net in seen: + continue + if not _is_ic_supply_pin(graph, cons, pin_num, net): + continue + seen.add(net) + locals_: list[tuple[str, float]] = [] + bulks: list[tuple[str, float]] = [] + unvalued = 0 + for cref in graph.components_on_net(net): + ccomp = graph.components.get(cref) + if not ccomp: + continue + farads = _cap_farads(ccomp) + if farads is None: + if ccomp.component_type == ComponentType.CAPACITOR: + unvalued += 1 + continue + if farads >= _BULK_F: + bulks.append((cref, farads)) + else: + locals_.append((cref, farads)) + if not locals_ and unvalued == 0: + rec = f"Add a local decoupling capacitor on {net} at {ref}." + facts = f"{ref} supply {net}: 0 local caps (<1 µF); bulk={len(bulks)}." + if i_load is not None: + facts = f"{facts} I_load={i_load:g} A." + out.append(Finding( + designator=ref, + mpn=comp.mpn or "", + aspect="power_integrity", + finding=f"{ref} supply net {net} has no local decoupling capacitor.", + facts=facts, + requirement="IC supply pins need a local bypass (recommended).", + inference="Not a mandatory abs-max; RISK not RULE.", + why="Local vs bulk: local is C < 1 µF (same split as functional groups).", + status="WARNING", + recommendation=rec, + action=rec, + source="pi_check", + rule_id="PE-PI-001", + evidence_status="SUFFICIENT", + net=net, + pins=[f"{ref}.{pin_num}"], + )) + elif locals_ and not bulks and i_load is not None and i_load >= 0.5: + rec = ( + f"Confirm bulk capacitance on {net} for I_load={i_load:g} A, " + "or document why local-only is enough." + ) + out.append(Finding( + designator=ref, + mpn=comp.mpn or "", + aspect="power_integrity", + finding=( + f"{ref} {net} has local cap(s) {[c[0] for c in locals_]} " + f"and no bulk (≥1 µF) with I_load={i_load:g} A." + ), + facts=( + f"local={[(a, b) for a, b in locals_]}; bulk=[]; " + f"I_load={i_load:g} A." + ), + requirement="Bulk on a loaded rail is recommended, not abs-max.", + inference="REVIEW — no invented PSRR milliohms.", + why="Local vs bulk split uses valued capacitors only.", + status="INFO", + recommendation=rec, + action=rec, + source="pi_check", + rule_id="PE-PI-002", + evidence_status="SUFFICIENT", + net=net, + pins=[f"{ref}.{pin_num}"], + )) + limit = _max_distance_mm(cons) + if layout is None or limit is None or not locals_: + continue + ic_xy = _pad_xy(layout, ref) + if ic_xy is None: + continue + nearest = None + for cref, _f in locals_: + xy = _pad_xy(layout, cref) + if xy is None: + continue + dist = math.hypot(xy[0] - ic_xy[0], xy[1] - ic_xy[1]) + if nearest is None or dist < nearest[0]: + nearest = (dist, cref) + if nearest is None or nearest[0] <= limit: + continue + # Distance vs datasheet mm is PE-PLC-001's job; PI only records FACT if PLC didn't. + rec = f"Move {nearest[1]} within {limit:g} mm of {ref} (layout_rules)." + out.append(Finding( + designator=ref, + mpn=comp.mpn or "", + aspect="power_integrity", + finding=( + f"{nearest[1]} is {nearest[0]:.2f} mm from {ref} on {net} " + f"(max_distance_mm={limit:g})." + ), + facts=f"euclidean {nearest[0]:.2f} mm; limit {limit:g} mm from layout_rules.", + requirement=f"layout_rules decoupling_proximity max_distance_mm={limit:g}.", + inference="Same millimetres as PE-PLC-001; PI records the supply view.", + why="Distance judged only with datasheet millimetres.", + status="WARNING", + recommendation=rec, + action=rec, + source="pi_check", + rule_id="PE-PI-001", + evidence_status="SUFFICIENT", + net=net, + pins=[f"{ref}.{pin_num}"], + )) + return out diff --git a/backend/periscopex/timing_check.py b/backend/periscopex/timing_check.py new file mode 100644 index 0000000..4248e8d --- /dev/null +++ b/backend/periscopex/timing_check.py @@ -0,0 +1,185 @@ +"""Reset RC and strap dividers — only when R, C, and datasheet times/levels exist.""" + +from __future__ import annotations + +from backend.periscopex.models import ( + CapacitorSpecs, + ComponentConstraints, + ComponentType, + DesignGraph, + Finding, + ResistorSpecs, +) +from backend.periscopex.passive_rail_check import ( + _is_reset_pin, + _pin_name_tokens, + _resistor_to_ground, + _resistor_to_power, +) +from backend.periscopex.validate import _match_constraints + +_T_RESET_KEYS = ("t_reset_min_s", "t_reset_min_ms", "reset_delay_ms", "t_por_ms") +_VIH_KEYS = ("vih", "vih_min_v", "v_ih_min", "vih_min") +_VIL_KEYS = ("vil", "vil_max_v", "v_il_max", "vil_max") +_STRAP_RE = __import__("re").compile( + r"(?:PSEL|BOOT|STRAP|VSENSE|VSET|CFG)", + __import__("re").I, +) + + +def _specs_map(comp) -> dict: + specs = getattr(comp, "specs", None) + if specs is None: + return {} + vals = getattr(specs, "values", None) + return dict(vals) if isinstance(vals, dict) else {} + + +def _first_num(values: dict, keys: tuple[str, ...]) -> float | None: + for k in keys: + v = values.get(k) + if isinstance(v, bool) or v is None: + continue + try: + n = float(v) + except (TypeError, ValueError): + continue + if k.endswith("_ms"): + n = n / 1000.0 + return n + return None + + +def _r_on_net(graph: DesignGraph, net: str) -> list[float]: + out: list[float] = [] + for ref in graph.components_on_net(net): + comp = graph.components.get(ref) + if not comp or comp.component_type != ComponentType.RESISTOR: + continue + if isinstance(comp.specs, ResistorSpecs) and comp.specs.value_ohms > 0: + out.append(float(comp.specs.value_ohms)) + return out + + +def _c_on_net(graph: DesignGraph, net: str) -> list[float]: + out: list[float] = [] + for ref in graph.components_on_net(net): + comp = graph.components.get(ref) + if not comp or comp.component_type != ComponentType.CAPACITOR: + continue + if isinstance(comp.specs, CapacitorSpecs) and comp.specs.value_farads > 0: + out.append(float(comp.specs.value_farads)) + return out + + +def check_timing( + graph: DesignGraph, + constraints_map: dict[str, ComponentConstraints] | None = None, +) -> list[Finding]: + """PE-TIM-001 RC vs t_reset; PE-TIM-002 strap vs Vih/Vil. Skip without numbers.""" + cmap = constraints_map or {} + out: list[Finding] = [] + seen: set[str] = set() + for ref, comp in sorted(graph.components.items()): + if comp.component_type != ComponentType.IC: + continue + cons = _match_constraints(comp.mpn or comp.value, cmap) + values = _specs_map(comp) + t_min = _first_num(values, _T_RESET_KEYS) + vih = _first_num(values, _VIH_KEYS) + vil = _first_num(values, _VIL_KEYS) + for pin_num, net in sorted(comp.pins.items(), key=lambda x: str(x[0])): + if not net or net in seen: + continue + if _is_reset_pin(graph, cons, pin_num, net) and t_min is not None: + rs = _r_on_net(graph, net) + cs = _c_on_net(graph, net) + if not rs or not cs: + continue + tau = min(rs) * min(cs) + seen.add(net) + if tau + 1e-18 >= t_min: + continue + rec = ( + f"Increase R or C on {net} so τ=RC ≥ {t_min:g} s " + f"(measured τ={tau:g} s)." + ) + out.append(Finding( + designator=ref, + mpn=comp.mpn or "", + aspect="timing", + finding=( + f"{ref} reset net {net} has τ=RC={tau:g} s " + f"below t_reset={t_min:g} s." + ), + facts=f"R={min(rs):g} Ω, C={min(cs):g} F, τ={tau:g} s.", + requirement=f"Datasheet t_reset_min={t_min:g} s.", + inference="τ=R·C compared to t_reset; no 10 ms default.", + why="Reset delay only when R, C, and t_reset are numeric.", + status="ERROR", + recommendation=rec, + action=rec, + source="timing_check", + rule_id="PE-TIM-001", + calculation=f"τ=R·C={tau:g}; t_min={t_min:g}", + evidence_status="SUFFICIENT", + net=net, + pins=[f"{ref}.{pin_num}"], + )) + continue + tokens = _pin_name_tokens(cons, pin_num) or [net, str(pin_num)] + if not any(_STRAP_RE.search(t or "") for t in tokens): + continue + if vih is None and vil is None: + continue + if not (_resistor_to_power(graph, net) and _resistor_to_ground(graph, net)): + continue + rs = _r_on_net(graph, net) + if len(rs) < 2: + continue + rhi, rlo = max(rs), min(rs) + vrail = None + nobj = graph.nets.get(net) + # Strap mid-point: need rail voltage on the pull-up net. + for rref in graph.components_on_net(net): + rcomp = graph.components.get(rref) + if not rcomp or rcomp.component_type != ComponentType.RESISTOR: + continue + for pnet in rcomp.pins.values(): + other = graph.nets.get(pnet) + if other and other.voltage and other.voltage > 0: + vrail = float(other.voltage) + if vrail is None: + continue + vstrap = vrail * rlo / (rhi + rlo) + seen.add(net) + fail = (vih is not None and vstrap < vih) or (vil is not None and vstrap > vil and vih is None) + if not fail: + continue + rec = f"Adjust the strap divider on {net} so Vstrap meets Vih/Vil." + out.append(Finding( + designator=ref, + mpn=comp.mpn or "", + aspect="timing", + finding=( + f"{ref} strap {net} V={vstrap:g} V from {rhi:g}/{rlo:g} Ω " + f"on {vrail:g} V rail." + ), + facts=f"Vstrap={vstrap:g} V; Rhi={rhi:g}; Rlo={rlo:g}; Vrail={vrail:g}.", + requirement=( + f"Vih={vih if vih is not None else '—'} V, " + f"Vil={vil if vil is not None else '—'} V from specs." + ), + inference="Divider ratio vs Vih/Vil; no 50% default.", + why="Strap level only when divider ohms and Vih/Vil exist.", + status="ERROR", + recommendation=rec, + action=rec, + source="timing_check", + rule_id="PE-TIM-002", + calculation=f"V=Vrail·Rlo/(Rhi+Rlo)={vstrap:g}", + evidence_status="SUFFICIENT", + net=net, + pins=[f"{ref}.{pin_num}"], + )) + return out diff --git a/backend/services/pcb_pipeline.py b/backend/services/pcb_pipeline.py index b66a313..1d382a9 100644 --- a/backend/services/pcb_pipeline.py +++ b/backend/services/pcb_pipeline.py @@ -137,9 +137,16 @@ async def run_pcb_pipeline( fg_path = ws.local_path("functional_groups.json") fg_path.write_text(plan.model_dump_json(indent=2) + "\n") ws._upload_file("functional_groups.json") + from backend.periscopex.hierarchy import build_hierarchy + + hier = build_hierarchy(graph, plan) + hier_path = ws.local_path("hierarchy.json") + hier_path.write_text(hier.model_dump_json(indent=2) + "\n") + ws._upload_file("hierarchy.json") _step( project_id, "classify", "complete", - f"{len(plan.domains)} domains, {len(plan.groups)} groups", + f"{len(plan.domains)} domains, {len(plan.groups)} groups, " + f"{len(hier.blocks)} blocks", ) if _cancelled(storage, user_id, project_id): @@ -180,7 +187,7 @@ async def run_pcb_pipeline( return _step(project_id, "checks", "running") - findings = run_pcb_checks(graph, cmap, layout) + findings = run_pcb_checks(graph, cmap, layout, plan) _step( project_id, "checks", "complete", f"{len(findings)} deterministic findings", diff --git a/docs/motore-finding.md b/docs/motore-finding.md index f531a06..7c15a83 100644 --- a/docs/motore-finding.md +++ b/docs/motore-finding.md @@ -109,6 +109,8 @@ Geometria board: width/thickness, via, pour courtyard, placement mm, return/stit ## 9. Fasi (non fingere completezza) -**Fase A (questa slice):** oggetto finding + clamp provenance/class + decisions + UI schema/PCB + libreria condivisa + check geometria già gated. +**Fase A:** oggetto finding + clamp provenance/class (**ERROR solo se RULE e MANDATORY**) + decisions + UI schema/PCB + libreria condivisa + check geometria già gated. -**Fase B (pianificata):** gerarchia component→system; timing; energy/thermal solo con θJA/P/I_load; ratings; derating PASS/MARGIN/RISK; PI; SI oltre skew mm; return path oltre stitch INFO; ESD/EMI; thermal FEM; BOM↔PCB↔datasheet; SPOF come REVIEW. +**Fase B (questa slice):** gerarchia component→pin→net→block; derating PASS/MARGIN/RISK da Vop/Vrated; timing RC/strap solo con numeri; PI local vs bulk; ESD e return path come REVIEW. + +**Fase B ancora aperta:** thermal FEM; SI oltre skew mm; BOM↔PCB↔datasheet; SPOF; EMI oltre ESD. diff --git a/docs/piano-pcb-review.md b/docs/piano-pcb-review.md index 5adb155..c64d163 100644 --- a/docs/piano-pcb-review.md +++ b/docs/piano-pcb-review.md @@ -55,8 +55,14 @@ Mutex vs analisi e vs placement. SSE `pcb_*`. IDs `PCB-{ref}-{001}`. KiCad `/net | PE-LAY-001…003 | pad/ref/hierarchy nets | | PE-PLC-001…004 | `layout_rules` numerici | | PE-SI-001 | `length_match` mm | -| PE-DRT-001 | Vop e Vrated | | PE-PWR-001 | I_load + width; IPC solo con thickness e Tjmax | +| PE-DRT-001 | Vop e Vrated, Vop > Vr → RULE ERROR | +| PE-DRT-002 | 0.8 < Vop/Vr ≤ 1 → RISK MARGIN | +| PE-DRT-003 | Vop/Vr ≤ 0.8 → PASS summary INFO | +| PE-HIER-001 | pin IC senza net | +| PE-TIM-001/002 | RC / strap solo con numeri | +| PE-PI-001/002 | local vs bulk; mm solo da layout_rules | +| PE-ESD-001 / PE-RET-001 | REVIEW unless mandatory FACT | | PE-VIA-001 | via count + I_load (INFO, no tabella inventata) | | PE-THM-001 | P=I_load×drop, courtyard senza pour/via | | PE-KEL-001 | sense/Kelvin pintable + ≥2 altri sul net | @@ -70,10 +76,22 @@ AI PCB: skip IC senza pintable (`run schematic review first`). ## Fasi -**A — shipped (questa slice):** oggetto finding condiviso, clamp provenance/class, decisions, UI, libreria unica, geometria gated, net-slash. +**A — shipped:** oggetto finding condiviso, clamp provenance/class (**ERROR solo RULE+MANDATORY**), decisions, UI, libreria unica, geometria gated, net-slash, Action on card, geometria in contesto AI. -**B — pianificata (non finta):** gerarchia component→system; timing; energy/thermal solo con parametri; ratings; derating PASS/MARGIN/RISK; PI; SI oltre skew; return path oltre stitch INFO; ESD/EMI; thermal FEM; BOM↔PCB↔datasheet; SPOF come REVIEW. +**B — questa slice (shipped, gated):** -**C — fuori:** auto-place, write-back pcbnew, unire i job, sshd/keys. +| ID | Cosa | Gate | +| --- | --- | --- | +| Gerarchia | `hierarchy.json` component→pin→net→block | grafo (+ domains se `classify`) | +| PE-HIER-001 | pin IC senza net | FACT net='' | +| PE-DRT-001/002/003 | RISK / MARGIN / PASS | Vop **e** Vrated; niente % dielettrico inventato | +| PE-TIM-001/002 | RC reset / strap | R, C e t_reset o Vih/Vil numerici | +| PE-PI-001/002 | local vs bulk, I se noto | cap valorizzati; distanza solo con `max_distance_mm` | +| PE-ESD-001 | ESD connettore/USB | REVIEW se manca parte ESD | +| PE-RET-001 | return HS senza via GND | REVIEW, non ERROR | + +**B — ancora aperta:** energy/thermal FEM; SI oltre skew mm; BOM↔PCB↔datasheet completo; SPOF; EMI oltre ESD REVIEW. + +**C — fuori:** auto-place, write-back pcbnew, unire i job, sshd/keys, PinScope identity/fork. Deploy: `ssh periscope` + `/root/periscope/scripts/update-periscope.sh`. diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index b140a52..1f13516 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,17 @@ What's new in Periscope. +## 2.33.0 — 2026-09-20 — Fase B: hierarchy, derating bands, timing, PI, ESD/return + +Usable slices (not a platform): component→pin→net→block inventory, capacitor PASS/MARGIN/RISK from Vop/Vrated, RC/strap timing only with numbers, local vs bulk PI, ESD and HS return as REVIEW. + +- [Fixed] `complete_finding` clamps ERROR unless `finding_class=RULE` and `provenance=MANDATORY` (LLM REVIEW cannot stay ERROR). +- [New] `hierarchy.json` + `PE-HIER-001` dangling IC pins. +- [New] Derating table `stress` PASS/MARGIN/RISK; findings `PE-DRT-001/002/003`. +- [New] `PE-TIM-001/002` skip without R/C and t_reset or Vih/Vil. +- [New] `PE-PI-001/002` local vs bulk; distance only with `layout_rules` mm. +- [New] `PE-ESD-001`, `PE-RET-001` are REVIEW, never RULE ERROR. + ## 2.32.1 — 2026-09-20 — PCB Action on card + parsed geometry in AI context Finding cards always show an **Action.** sentence (`action`, else `recommendation`, else a default). PCB AI context now includes vias under the footprint, copper thickness, nearby trace widths, courtyard, and keepout polygons from `.kicad_pcb`. diff --git a/frontend/src/app/(app)/project/[id]/page.tsx b/frontend/src/app/(app)/project/[id]/page.tsx index 309f3df..ffbe476 100644 --- a/frontend/src/app/(app)/project/[id]/page.tsx +++ b/frontend/src/app/(app)/project/[id]/page.tsx @@ -943,6 +943,7 @@ function DeratingTable({ Value C_eff Type + Engine Net+ Net− Rated V @@ -1003,6 +1004,15 @@ function DeratingTable({ )} + + {row.stress && row.stress !== "UNKNOWN" ? ( + + {row.stress} + + ) : ( + + )} + {row.net_plus ?? } diff --git a/frontend/src/lib/layout-finding.ts b/frontend/src/lib/layout-finding.ts index 4e82a48..fdb7b3d 100644 --- a/frontend/src/lib/layout-finding.ts +++ b/frontend/src/lib/layout-finding.ts @@ -11,6 +11,10 @@ export function isLayoutFinding(f: { id.startsWith("PCB-") || f.source === "pcb_review" || f.source === "pcb_power_thermal" || + f.source === "hierarchy_check" || + f.source === "timing_check" || + f.source === "pi_check" || + f.source === "esd_return_check" || rid.startsWith("PE-PLC") || rid.startsWith("PE-LAY") || rid.startsWith("PE-SI") || @@ -19,7 +23,12 @@ export function isLayoutFinding(f: { rid.startsWith("PE-THM") || rid.startsWith("PE-VIA") || rid.startsWith("PE-KEL") || - rid.startsWith("PE-STCH") + rid.startsWith("PE-STCH") || + rid.startsWith("PE-HIER") || + rid.startsWith("PE-TIM") || + rid.startsWith("PE-PI") || + rid.startsWith("PE-ESD") || + rid.startsWith("PE-RET") ); } @@ -31,10 +40,19 @@ export function isPcbExamFinding(f: { return ( f.source === "pcb_review" || f.source === "pcb_power_thermal" || + f.source === "hierarchy_check" || + f.source === "timing_check" || + f.source === "pi_check" || + f.source === "esd_return_check" || rid.startsWith("PE-PWR") || rid.startsWith("PE-THM") || rid.startsWith("PE-VIA") || rid.startsWith("PE-KEL") || - rid.startsWith("PE-STCH") + rid.startsWith("PE-STCH") || + rid.startsWith("PE-HIER") || + rid.startsWith("PE-TIM") || + rid.startsWith("PE-PI") || + rid.startsWith("PE-ESD") || + rid.startsWith("PE-RET") ); } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 73bcc34..236c856 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -413,6 +413,7 @@ export interface DeratingRow { c_eff_f?: number | null; c_eff_formatted?: string | null; dc_bias_model?: "stima" | null; + stress?: "PASS" | "MARGIN" | "RISK" | "UNKNOWN"; } export interface DeratingSettings { diff --git a/tests/test_fase_b_checks.py b/tests/test_fase_b_checks.py new file mode 100644 index 0000000..e5fe17a --- /dev/null +++ b/tests/test_fase_b_checks.py @@ -0,0 +1,308 @@ +"""Fase B slices: hierarchy, derating stress, timing, PI, ESD/return.""" + +from __future__ import annotations + +from backend.periscopex.esd_return_check import check_esd, check_return_path +from backend.periscopex.finding_engine import complete_finding +from backend.periscopex.hierarchy import build_hierarchy, check_hierarchy +from backend.periscopex.models import ( + CapacitorSpecs, + Component, + ComponentConstraints, + ComponentType, + DesignGraph, + Finding, + InternalFeatures, + LayoutFootprint, + LayoutGraph, + LayoutSegment, + LayoutVia, + LayoutZone, + Net, + NetType, + Pin, + PinConnection, + ResistorSpecs, + SimpleComponentSpecs, +) +from backend.periscopex.pcb_checks import check_pcb_derating +from backend.periscopex.pi_check import check_power_integrity +from backend.periscopex.timing_check import check_timing + + +def test_hierarchy_component_pin_net_block(): + graph = DesignGraph( + components={ + "U1": Component( + reference="U1", value="LDO", footprint="", + component_type=ComponentType.IC, mpn="LDO", + pins={"1": "VIN", "2": "+3V3", "3": ""}, + ), + }, + nets={ + "VIN": Net( + name="VIN", net_type=NetType.POWER, voltage=5.0, + pins=[PinConnection(component_ref="U1", pin_number="1")], + ), + "+3V3": Net( + name="+3V3", net_type=NetType.POWER, voltage=3.3, + pins=[PinConnection(component_ref="U1", pin_number="2")], + ), + }, + ) + hier = build_hierarchy(graph) + assert hier.components[0].ref == "U1" + assert {p.number: p.net for p in hier.components[0].pins}["1"] == "VIN" + assert any(b.kind == "rail" for b in hier.blocks) + dangling = check_hierarchy(graph) + assert dangling and dangling[0].rule_id == "PE-HIER-001" + assert dangling[0].status == "INFO" + assert dangling[0].facts + + +def test_derating_pass_margin_risk(): + def cap(ref, rated, net="3V3"): + return Component( + reference=ref, value="", footprint="", + component_type=ComponentType.CAPACITOR, + component_subtype="passive.capacitor.ceramic", + mpn=ref, + pins={"1": net, "2": "GND"}, + specs=CapacitorSpecs( + value_farads=1e-6, value_formatted="1uF", + voltage_rating_v=f"{rated}V", dielectric="X7R", + ), + ) + + g = DesignGraph( + components={ + "C1": cap("C1", 16), + "C2": cap("C2", 4.0), + "C3": cap("C3", 3.0), + }, + nets={ + "3V3": Net( + name="3V3", net_type=NetType.POWER, voltage=3.3, + pins=[ + PinConnection(component_ref="C1", pin_number="1"), + PinConnection(component_ref="C2", pin_number="1"), + PinConnection(component_ref="C3", pin_number="1"), + ], + ), + "GND": Net(name="GND", net_type=NetType.GROUND, voltage=0.0, pins=[]), + }, + ) + from backend.periscopex.derating import build_derating_table + + by = {r["designator"]: r["stress"] for r in build_derating_table(g)} + assert by["C1"] == "PASS" + assert by["C2"] == "MARGIN" + assert by["C3"] == "RISK" + findings = check_pcb_derating(g) + ids = {f.rule_id for f in findings} + assert "PE-DRT-001" in ids + assert "PE-DRT-002" in ids + assert "PE-DRT-003" in ids + exceed = next(f for f in findings if f.rule_id == "PE-DRT-001") + complete_finding(exceed) + assert exceed.status == "ERROR" + assert exceed.finding_class == "RULE" + margin = next(f for f in findings if f.rule_id == "PE-DRT-002") + complete_finding(margin) + assert margin.status == "WARNING" + assert margin.finding_class == "RISK" + + +def test_timing_skips_without_numbers(): + graph = DesignGraph( + components={ + "U1": Component( + reference="U1", value="", footprint="", + component_type=ComponentType.IC, mpn="MCU", + pins={"1": "NRST"}, + ), + "R1": Component( + reference="R1", value="10k", footprint="", + component_type=ComponentType.RESISTOR, mpn="", + pins={"1": "NRST", "2": "+3V3"}, + ), + }, + nets={"NRST": Net(name="NRST", net_type=NetType.SIGNAL, pins=[])}, + ) + cons = ComponentConstraints( + mpn="MCU", + pintable=[Pin(number="1", name="NRST")], + absolute_maximum_ratings=[], + rules=[], + ) + assert check_timing(graph, {"MCU": cons}) == [] + + +def test_timing_reset_rc_vs_t_reset(): + graph = DesignGraph( + components={ + "U1": Component( + reference="U1", value="", footprint="", + component_type=ComponentType.IC, mpn="MCU", + pins={"1": "NRST"}, + specs=SimpleComponentSpecs( + specs_type="discrete", + values={"t_reset_min_s": 0.01}, + ), + ), + "R1": Component( + reference="R1", value="1k", footprint="", + component_type=ComponentType.RESISTOR, mpn="", + pins={"1": "NRST", "2": "+3V3"}, + specs=ResistorSpecs(value_ohms=1000, value_formatted="1k"), + ), + "C1": Component( + reference="C1", value="100n", footprint="", + component_type=ComponentType.CAPACITOR, mpn="", + pins={"1": "NRST", "2": "GND"}, + specs=CapacitorSpecs(value_farads=100e-9, value_formatted="100n"), + ), + }, + nets={ + "NRST": Net( + name="NRST", net_type=NetType.SIGNAL, + pins=[ + PinConnection(component_ref="U1", pin_number="1"), + PinConnection(component_ref="R1", pin_number="1"), + PinConnection(component_ref="C1", pin_number="1"), + ], + ), + "+3V3": Net(name="+3V3", net_type=NetType.POWER, voltage=3.3, pins=[]), + "GND": Net(name="GND", net_type=NetType.GROUND, pins=[]), + }, + ) + cons = ComponentConstraints( + mpn="MCU", + pintable=[Pin(number="1", name="NRST")], + absolute_maximum_ratings=[], + rules=[], + ) + findings = check_timing(graph, {"MCU": cons}) + assert findings and findings[0].rule_id == "PE-TIM-001" + complete_finding(findings[0]) + assert findings[0].status == "ERROR" + assert findings[0].finding_class == "RULE" + + +def test_pi_missing_local_is_risk_not_error(): + graph = DesignGraph( + components={ + "U1": Component( + reference="U1", value="LDO", footprint="", + component_type=ComponentType.IC, mpn="LDO", + pins={"1": "VIN"}, + ), + }, + nets={ + "VIN": Net( + name="VIN", net_type=NetType.POWER, voltage=5.0, + pins=[PinConnection(component_ref="U1", pin_number="1")], + ), + }, + ) + cons = ComponentConstraints( + mpn="LDO", + pintable=[Pin(number="1", name="VIN")], + absolute_maximum_ratings=[], + rules=[], + ) + findings = check_power_integrity(graph, {"LDO": cons}) + assert findings and findings[0].rule_id == "PE-PI-001" + complete_finding(findings[0]) + assert findings[0].status == "WARNING" + assert findings[0].finding_class == "RISK" + + +def test_esd_without_part_is_review_not_error(): + graph = DesignGraph( + components={ + "J1": Component( + reference="J1", value="USB", footprint="", + component_type=ComponentType.CONNECTOR, mpn="", + pins={"1": "USB_DP"}, + ), + "U2": Component( + reference="U2", value="UART", footprint="", + component_type=ComponentType.IC, mpn="CH", + pins={"1": "USB_DP"}, + ), + }, + nets={ + "USB_DP": Net( + name="USB_DP", net_type=NetType.SIGNAL, + pins=[ + PinConnection(component_ref="J1", pin_number="1"), + PinConnection(component_ref="U2", pin_number="1"), + ], + ), + }, + ) + cons = ComponentConstraints( + mpn="CH", + pintable=[Pin(number="1", name="UD+")], + absolute_maximum_ratings=[], + rules=[], + internal_features=InternalFeatures(esd_clamp_pins=["UD+"]), + ) + findings = check_esd(graph, {"CH": cons}) + assert findings and findings[0].rule_id == "PE-ESD-001" + complete_finding(findings[0]) + assert findings[0].finding_class == "REVIEW" + assert findings[0].status != "ERROR" + + +def test_return_path_hs_is_review(): + graph = DesignGraph( + components={ + "U1": Component( + reference="U1", value="", footprint="", + component_type=ComponentType.IC, mpn="X", + pins={"1": "USB_DP"}, + ), + }, + nets={ + "USB_DP": Net( + name="USB_DP", net_type=NetType.SIGNAL, + pins=[PinConnection(component_ref="U1", pin_number="1")], + ), + }, + ) + layout = LayoutGraph( + footprints={"U1": LayoutFootprint(reference="U1", x=0, y=0, layer="F.Cu")}, + segments=[ + LayoutSegment(start=(0, 0), end=(20, 8), width=0.2, layer="F.Cu", net="USB_DP"), + ], + zones=[ + LayoutZone(net="GND", layer="B.Cu", outlines=[[(0, -5), (20, -5), (20, 10), (0, 10)]]), + ], + vias=[], + ) + findings = check_return_path(graph, layout) + assert findings and findings[0].rule_id == "PE-RET-001" + complete_finding(findings[0]) + assert findings[0].finding_class == "REVIEW" + assert findings[0].status != "ERROR" + layout.vias = [LayoutVia(x=10, y=4, net="GND", drill=0.3)] + assert check_return_path(graph, layout) == [] + + +def test_llm_error_review_clamped_in_complete_finding(): + f = Finding( + designator="U1", + finding="no vias", + why="PowerPAD", + status="ERROR", + source="pcb_review", + facts="0 vias", + requirement="must have vias", + source_quote="Use thermal vias in the exposed pad.", + evidence_status="SUFFICIENT", + ) + complete_finding(f) + assert f.finding_class == "REVIEW" + assert f.status == "WARNING" diff --git a/tests/test_finding_engine.py b/tests/test_finding_engine.py index 2f7fc09..e310453 100644 --- a/tests/test_finding_engine.py +++ b/tests/test_finding_engine.py @@ -52,6 +52,56 @@ def test_llm_review_is_never_rule(): assert f.finding_class == "REVIEW" assert f.facts assert f.requirement + assert f.status == "WARNING" + + +def test_review_and_risk_cannot_stay_error(): + review = Finding( + designator="U1", + finding="PowerPAD vias", + why="layout note", + status="ERROR", + source="pcb_review", + facts="0 vias under U1", + requirement="datasheet PowerPAD", + source_quote="Connect the thermal pad with vias to GND.", + evidence_status="SUFFICIENT", + ) + complete_finding(review) + assert review.finding_class == "REVIEW" + assert review.status == "WARNING" + + risk = Finding( + designator="C1", + finding="high utilization", + why="Vop near Vr", + status="ERROR", + rule_id="PE-DRT-002", + source="pcb_derating", + evidence_status="SUFFICIENT", + facts="3.0 / 3.3 V", + requirement="utilization band", + ) + complete_finding(risk) + assert risk.finding_class == "RISK" + assert risk.status == "WARNING" + + +def test_mandatory_rule_stays_error(): + f = Finding( + designator="U1", + finding="NC tied", + why="NC must float", + status="ERROR", + rule_id="PE-NC-001", + source="nc_pin_check", + facts="NC on GND", + requirement="NC pins must not be connected.", + evidence_status="SUFFICIENT", + ) + complete_finding(f) + assert f.finding_class == "RULE" + assert f.provenance == "MANDATORY" assert f.status == "ERROR"