Ship Fase B slices: hierarchy, derating bands, timing, PI, ESD/return.
ERROR stays only for RULE+MANDATORY. Hierarchy walks component→pin→net→block. Derating is PASS/MARGIN/RISK from Vop/Vrated. Timing and PI skip without numbers. ESD and HS return path are REVIEW, not RULE ERROR.
This commit is contained in:
@@ -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"]))
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user