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:
2026-09-20 09:35:04 +02:00
parent facbaa2305
commit 342792df2d
16 changed files with 1259 additions and 27 deletions
+13
View File
@@ -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"]))
+164
View File
@@ -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
+23
View File
@@ -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:
+160
View File
@@ -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
+84 -15
View File
@@ -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",
net=row.get("net_plus"),
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-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())
+193
View File
@@ -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
+185
View File
@@ -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
+9 -2
View File
@@ -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",
+4 -2
View File
@@ -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.
+22 -4
View File
@@ -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`.
+11
View File
@@ -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>
+20 -2
View File
@@ -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")
);
}
+1
View File
@@ -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 {
+308
View File
@@ -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"
+50
View File
@@ -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"