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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -943,6 +943,7 @@ function DeratingTable({
|
||||
<th className="pb-2 pr-4 font-medium">Value</th>
|
||||
<th className="pb-2 pr-4 font-medium">C_eff</th>
|
||||
<th className="pb-2 pr-4 font-medium">Type</th>
|
||||
<th className="pb-2 pr-4 font-medium">Engine</th>
|
||||
<th className="pb-2 pr-4 font-medium">Net+</th>
|
||||
<th className="pb-2 pr-4 font-medium">Net−</th>
|
||||
<th className="pb-2 pr-4 font-medium text-right">Rated V</th>
|
||||
@@ -1003,6 +1004,15 @@ function DeratingTable({
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-xs">
|
||||
{row.stress && row.stress !== "UNKNOWN" ? (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{row.stress}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">
|
||||
{row.net_plus ?? <span className="text-muted-foreground">—</span>}
|
||||
</td>
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user