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"
|
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]:
|
def build_derating_table(graph: DesignGraph) -> list[dict]:
|
||||||
"""Build a capacitor voltage derating table from the design graph.
|
"""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_f": c_eff,
|
||||||
"c_eff_formatted": c_eff_fmt,
|
"c_eff_formatted": c_eff_fmt,
|
||||||
"dc_bias_model": "stima" if factor is not None else None,
|
"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"]))
|
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.")
|
requirement="Kelvin/sense pin should not share the load net.")
|
||||||
_add("PE-STCH-001", "TYPICAL", "INFO", domain="pcb",
|
_add("PE-STCH-001", "TYPICAL", "INFO", domain="pcb",
|
||||||
requirement="GND stitch via in the bbox of a signal over a GND pour.")
|
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()
|
_seed()
|
||||||
@@ -215,6 +233,11 @@ def complete_finding(f: Finding) -> Finding:
|
|||||||
if f.finding_class == "RULE":
|
if f.finding_class == "RULE":
|
||||||
f.finding_class = "RISK"
|
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:
|
if f.finding_class == "RULE" and f.confidence is None:
|
||||||
f.confidence = 0.9 if f.evidence_status == "SUFFICIENT" else 0.3
|
f.confidence = 0.9 if f.evidence_status == "SUFFICIENT" else 0.3
|
||||||
elif f.finding_class == "REVIEW" and f.confidence is None:
|
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
|
import logging
|
||||||
from collections import Counter
|
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.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.models import DesignGraph, Finding, LayoutGraph
|
||||||
from backend.periscopex.pcb_net_match import check_pcb_net_match
|
from backend.periscopex.pcb_net_match import check_pcb_net_match
|
||||||
from backend.periscopex.pcb_power_thermal import (
|
from backend.periscopex.pcb_power_thermal import (
|
||||||
@@ -15,8 +19,10 @@ from backend.periscopex.pcb_power_thermal import (
|
|||||||
check_pcb_thermal_copper,
|
check_pcb_thermal_copper,
|
||||||
check_pcb_via_current,
|
check_pcb_via_current,
|
||||||
)
|
)
|
||||||
|
from backend.periscopex.pi_check import check_power_integrity
|
||||||
from backend.periscopex.placement_check import check_placement
|
from backend.periscopex.placement_check import check_placement
|
||||||
from backend.periscopex.si_check import check_si
|
from backend.periscopex.si_check import check_si
|
||||||
|
from backend.periscopex.timing_check import check_timing
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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]:
|
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] = []
|
out: list[Finding] = []
|
||||||
|
n_pass = 0
|
||||||
for row in build_derating_table(graph):
|
for row in build_derating_table(graph):
|
||||||
rated = row.get("rated_voltage_v")
|
rated = row.get("rated_voltage_v")
|
||||||
op = row.get("operating_voltage_v")
|
op = row.get("operating_voltage_v")
|
||||||
|
stress = row.get("stress") or "UNKNOWN"
|
||||||
if rated is None or op is None:
|
if rated is None or op is None:
|
||||||
continue
|
continue
|
||||||
if float(op) <= float(rated):
|
|
||||||
continue
|
|
||||||
ref = str(row.get("designator") or "")
|
ref = str(row.get("designator") or "")
|
||||||
out.append(Finding(
|
mpn = str(row.get("mpn") or "")
|
||||||
designator=ref,
|
net = row.get("net_plus")
|
||||||
mpn=str(row.get("mpn") or ""),
|
ratio = float(op) / float(rated)
|
||||||
aspect="derating",
|
if stress == "RISK":
|
||||||
finding=(
|
rec = (
|
||||||
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=(
|
|
||||||
f"Replace {ref} with a capacitor rated above {op:g} V, "
|
f"Replace {ref} with a capacitor rated above {op:g} V, "
|
||||||
"or lower the rail."
|
"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",
|
source="pcb_derating",
|
||||||
rule_id="PE-DRT-001",
|
rule_id="PE-DRT-003",
|
||||||
net=row.get("net_plus"),
|
evidence_status="SUFFICIENT",
|
||||||
pins=[],
|
pins=[],
|
||||||
))
|
))
|
||||||
return out
|
return out
|
||||||
@@ -110,8 +173,9 @@ def run_pcb_checks(
|
|||||||
graph: DesignGraph,
|
graph: DesignGraph,
|
||||||
constraints_map: dict,
|
constraints_map: dict,
|
||||||
layout: LayoutGraph | None,
|
layout: LayoutGraph | None,
|
||||||
|
plan: FunctionalGroupsReport | None = None,
|
||||||
) -> list[Finding]:
|
) -> 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] = []
|
out: list[Finding] = []
|
||||||
for name, fn in (
|
for name, fn in (
|
||||||
("pcb_net_match", lambda: check_pcb_net_match(graph, layout)),
|
("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_thermal_copper", lambda: check_pcb_thermal_copper(graph, constraints_map, layout)),
|
||||||
("pcb_kelvin", lambda: check_pcb_kelvin(graph, constraints_map)),
|
("pcb_kelvin", lambda: check_pcb_kelvin(graph, constraints_map)),
|
||||||
("pcb_gnd_stitch", lambda: check_pcb_gnd_stitch(graph, layout)),
|
("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:
|
try:
|
||||||
out.extend(fn())
|
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 = ws.local_path("functional_groups.json")
|
||||||
fg_path.write_text(plan.model_dump_json(indent=2) + "\n")
|
fg_path.write_text(plan.model_dump_json(indent=2) + "\n")
|
||||||
ws._upload_file("functional_groups.json")
|
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(
|
_step(
|
||||||
project_id, "classify", "complete",
|
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):
|
if _cancelled(storage, user_id, project_id):
|
||||||
@@ -180,7 +187,7 @@ async def run_pcb_pipeline(
|
|||||||
return
|
return
|
||||||
|
|
||||||
_step(project_id, "checks", "running")
|
_step(project_id, "checks", "running")
|
||||||
findings = run_pcb_checks(graph, cmap, layout)
|
findings = run_pcb_checks(graph, cmap, layout, plan)
|
||||||
_step(
|
_step(
|
||||||
project_id, "checks", "complete",
|
project_id, "checks", "complete",
|
||||||
f"{len(findings)} deterministic findings",
|
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)
|
## 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-LAY-001…003 | pad/ref/hierarchy nets |
|
||||||
| PE-PLC-001…004 | `layout_rules` numerici |
|
| PE-PLC-001…004 | `layout_rules` numerici |
|
||||||
| PE-SI-001 | `length_match` mm |
|
| 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-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-VIA-001 | via count + I_load (INFO, no tabella inventata) |
|
||||||
| PE-THM-001 | P=I_load×drop, courtyard senza pour/via |
|
| PE-THM-001 | P=I_load×drop, courtyard senza pour/via |
|
||||||
| PE-KEL-001 | sense/Kelvin pintable + ≥2 altri sul net |
|
| 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
|
## 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`.
|
Deploy: `ssh periscope` + `/root/periscope/scripts/update-periscope.sh`.
|
||||||
|
|||||||
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
What's new in Periscope.
|
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
|
## 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`.
|
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">Value</th>
|
||||||
<th className="pb-2 pr-4 font-medium">C_eff</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">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">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>
|
<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>
|
<span className="text-muted-foreground">—</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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">
|
<td className="py-2 pr-4 font-mono text-xs">
|
||||||
{row.net_plus ?? <span className="text-muted-foreground">—</span>}
|
{row.net_plus ?? <span className="text-muted-foreground">—</span>}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export function isLayoutFinding(f: {
|
|||||||
id.startsWith("PCB-") ||
|
id.startsWith("PCB-") ||
|
||||||
f.source === "pcb_review" ||
|
f.source === "pcb_review" ||
|
||||||
f.source === "pcb_power_thermal" ||
|
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-PLC") ||
|
||||||
rid.startsWith("PE-LAY") ||
|
rid.startsWith("PE-LAY") ||
|
||||||
rid.startsWith("PE-SI") ||
|
rid.startsWith("PE-SI") ||
|
||||||
@@ -19,7 +23,12 @@ export function isLayoutFinding(f: {
|
|||||||
rid.startsWith("PE-THM") ||
|
rid.startsWith("PE-THM") ||
|
||||||
rid.startsWith("PE-VIA") ||
|
rid.startsWith("PE-VIA") ||
|
||||||
rid.startsWith("PE-KEL") ||
|
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 (
|
return (
|
||||||
f.source === "pcb_review" ||
|
f.source === "pcb_review" ||
|
||||||
f.source === "pcb_power_thermal" ||
|
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-PWR") ||
|
||||||
rid.startsWith("PE-THM") ||
|
rid.startsWith("PE-THM") ||
|
||||||
rid.startsWith("PE-VIA") ||
|
rid.startsWith("PE-VIA") ||
|
||||||
rid.startsWith("PE-KEL") ||
|
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_f?: number | null;
|
||||||
c_eff_formatted?: string | null;
|
c_eff_formatted?: string | null;
|
||||||
dc_bias_model?: "stima" | null;
|
dc_bias_model?: "stima" | null;
|
||||||
|
stress?: "PASS" | "MARGIN" | "RISK" | "UNKNOWN";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeratingSettings {
|
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.finding_class == "REVIEW"
|
||||||
assert f.facts
|
assert f.facts
|
||||||
assert f.requirement
|
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"
|
assert f.status == "ERROR"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user