Add deterministic decoupling, I2C pull-up, and reset-float checks.
Flag supply nets with no cap to ground and open-drain I2C/reset nets with no pull-up, without guessing datasheet capacitor values. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -318,7 +318,7 @@ class Finding(BaseModel):
|
||||
status: Literal["ERROR", "WARNING", "INFO"]
|
||||
recommendation: str = ""
|
||||
reference: str = ""
|
||||
source: str | None = None # None/"review" = LLM datasheet review; "pin_mux_check"/"led_current_check" = deterministic
|
||||
source: str | None = None # None/"review" = LLM; "pin_mux_check"/"led_current_check"/"supply_decoupling_check"/… = deterministic
|
||||
|
||||
|
||||
class ValidationReport(BaseModel):
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Deterministic supply decoupling and I2C/reset pull-up checks.
|
||||
|
||||
These only fire when the graph already shows a power pin, an I2C net, or a
|
||||
reset pin — they do not guess capacitor values or datasheet µF minima.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from backend.pinscopex.models import (
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
Finding,
|
||||
NetType,
|
||||
)
|
||||
from backend.pinscopex.validate import _match_constraints
|
||||
|
||||
_SUPPLY_PIN_RE = re.compile(
|
||||
r"(?:^|[_/])(VDD|VCC|VDDA|VDDD|VDDIO|DVDD|AVDD|IOVDD|VDD33|VDD18|"
|
||||
r"VIN|VBAT|VBUS|VCORE)(?:$|[_/\d])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_NOT_SUPPLY_RE = re.compile(
|
||||
r"\b(VSS|GND|VEE|VOUT|VREF|SW|LX|FB|BOOT|NC|VPP)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_I2C_RE = re.compile(r"\b(SDA|SCL)(\d+)?\b", re.IGNORECASE)
|
||||
_RESET_RE = re.compile(
|
||||
r"\b(N?RST(?:N|B)?|NRST|RESET(?:_?N|_?B)?|NRESET|CHIP_PU)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def check_supply_decoupling(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict[str, ComponentConstraints],
|
||||
) -> list[Finding]:
|
||||
"""WARNING when an IC supply net has no capacitor to ground."""
|
||||
findings: list[Finding] = []
|
||||
seen_nets: 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, constraints_map)
|
||||
for pin_num, net_name in sorted(comp.pins.items(), key=lambda x: str(x[0])):
|
||||
if net_name in seen_nets:
|
||||
continue
|
||||
if not _is_ic_supply_pin(graph, cons, pin_num, net_name):
|
||||
continue
|
||||
seen_nets.add(net_name)
|
||||
if _capacitor_to_ground(graph, net_name):
|
||||
continue
|
||||
pin_label = _pin_label(cons, pin_num, net_name)
|
||||
findings.append(Finding(
|
||||
designator=ref,
|
||||
mpn=comp.mpn or "",
|
||||
aspect="decoupling",
|
||||
source="supply_decoupling_check",
|
||||
source_page=None,
|
||||
status="WARNING",
|
||||
finding=(
|
||||
f"{ref} supply net '{net_name}' ({pin_label}) has no "
|
||||
f"capacitor to ground."
|
||||
),
|
||||
why=(
|
||||
f"Pin {pin_label} sits on '{net_name}' and that net has no "
|
||||
f"capacitor whose other end is ground. Local decoupling "
|
||||
f"may be missing (or only present on a different island "
|
||||
f"behind a ferrite)."
|
||||
),
|
||||
recommendation=(
|
||||
f"Add a decoupling capacitor from '{net_name}' to ground "
|
||||
f"near {ref}."
|
||||
),
|
||||
reference="netlist topology",
|
||||
))
|
||||
return findings
|
||||
|
||||
|
||||
def check_i2c_pullups(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict[str, ComponentConstraints],
|
||||
) -> list[Finding]:
|
||||
"""WARNING when an SDA/SCL net has no resistor to a power rail."""
|
||||
findings: list[Finding] = []
|
||||
seen_nets: 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, constraints_map)
|
||||
for pin_num, net_name in sorted(comp.pins.items(), key=lambda x: str(x[0])):
|
||||
if net_name in seen_nets:
|
||||
continue
|
||||
if not _is_i2c_pin(graph, cons, pin_num, net_name):
|
||||
continue
|
||||
seen_nets.add(net_name)
|
||||
net = graph.nets.get(net_name)
|
||||
if net and net.net_type in (NetType.POWER, NetType.GROUND):
|
||||
continue
|
||||
if _resistor_to_power(graph, net_name):
|
||||
continue
|
||||
pin_label = _pin_label(cons, pin_num, net_name)
|
||||
findings.append(Finding(
|
||||
designator=ref,
|
||||
mpn=comp.mpn or "",
|
||||
aspect="i2c_pullup",
|
||||
source="i2c_pullup_check",
|
||||
source_page=None,
|
||||
status="WARNING",
|
||||
finding=(
|
||||
f"I2C net '{net_name}' ({ref} {pin_label}) has no pull-up "
|
||||
f"resistor to a power rail."
|
||||
),
|
||||
why=(
|
||||
f"SDA/SCL is open-drain. Without a resistor from "
|
||||
f"'{net_name}' to a supply, the bus cannot idle high."
|
||||
),
|
||||
recommendation=(
|
||||
f"Add a pull-up (typically 2.2–10 kΩ) from '{net_name}' "
|
||||
f"to the I2C I/O rail."
|
||||
),
|
||||
reference="netlist topology",
|
||||
))
|
||||
return findings
|
||||
|
||||
|
||||
def check_reset_pullups(
|
||||
graph: DesignGraph,
|
||||
constraints_map: dict[str, ComponentConstraints],
|
||||
) -> list[Finding]:
|
||||
"""WARNING when a reset pin's net is only this IC and has no pull-up."""
|
||||
findings: list[Finding] = []
|
||||
seen_nets: 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, constraints_map)
|
||||
for pin_num, net_name in sorted(comp.pins.items(), key=lambda x: str(x[0])):
|
||||
if net_name in seen_nets:
|
||||
continue
|
||||
if not _is_reset_pin(graph, cons, pin_num, net_name):
|
||||
continue
|
||||
seen_nets.add(net_name)
|
||||
net = graph.nets.get(net_name)
|
||||
if net and net.net_type in (NetType.POWER, NetType.GROUND):
|
||||
continue
|
||||
if _other_ic_on_net(graph, net_name, ref):
|
||||
continue
|
||||
if _resistor_to_power(graph, net_name):
|
||||
continue
|
||||
pin_label = _pin_label(cons, pin_num, net_name)
|
||||
findings.append(Finding(
|
||||
designator=ref,
|
||||
mpn=comp.mpn or "",
|
||||
aspect="reset_pullup",
|
||||
source="reset_pullup_check",
|
||||
source_page=None,
|
||||
status="WARNING",
|
||||
finding=(
|
||||
f"{ref} reset pin {pin_label} on '{net_name}' has no "
|
||||
f"pull-up and no other IC driving the net."
|
||||
),
|
||||
why=(
|
||||
f"The net only lands on {ref} (plus passives). Without a "
|
||||
f"resistor to a supply, an active-low reset input can float."
|
||||
),
|
||||
recommendation=(
|
||||
f"Add a pull-up to the I/O rail, or drive '{net_name}' "
|
||||
f"from a reset supervisor / GPIO."
|
||||
),
|
||||
reference="netlist topology",
|
||||
))
|
||||
return findings
|
||||
|
||||
|
||||
def _pin_label(cons: ComponentConstraints | None, pin_num: str, net_name: str) -> str:
|
||||
if cons:
|
||||
pin = cons.pin_by_number(pin_num)
|
||||
if pin and pin.name:
|
||||
return f"{pin_num} ({pin.name})"
|
||||
return str(pin_num)
|
||||
|
||||
|
||||
def _pin_blob(
|
||||
cons: ComponentConstraints | None, pin_num: str, net_name: str,
|
||||
) -> str:
|
||||
parts = [net_name or ""]
|
||||
if cons:
|
||||
pin = cons.pin_by_number(pin_num)
|
||||
if pin:
|
||||
parts.append(pin.name or "")
|
||||
if pin.functions:
|
||||
parts.extend(pin.functions)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _is_ic_supply_pin(
|
||||
graph: DesignGraph,
|
||||
cons: ComponentConstraints | None,
|
||||
pin_num: str,
|
||||
net_name: str,
|
||||
) -> bool:
|
||||
blob = _pin_blob(cons, pin_num, net_name)
|
||||
if _NOT_SUPPLY_RE.search(blob) and not _SUPPLY_PIN_RE.search(blob):
|
||||
return False
|
||||
if _SUPPLY_PIN_RE.search(blob):
|
||||
return True
|
||||
net = graph.nets.get(net_name)
|
||||
return bool(net and net.net_type == NetType.POWER)
|
||||
|
||||
|
||||
def _is_i2c_pin(
|
||||
graph: DesignGraph,
|
||||
cons: ComponentConstraints | None,
|
||||
pin_num: str,
|
||||
net_name: str,
|
||||
) -> bool:
|
||||
return bool(_I2C_RE.search(_pin_blob(cons, pin_num, net_name)))
|
||||
|
||||
|
||||
def _is_reset_pin(
|
||||
graph: DesignGraph,
|
||||
cons: ComponentConstraints | None,
|
||||
pin_num: str,
|
||||
net_name: str,
|
||||
) -> bool:
|
||||
return bool(_RESET_RE.search(_pin_blob(cons, pin_num, net_name)))
|
||||
|
||||
|
||||
def _is_ground_net(graph: DesignGraph, name: str) -> bool:
|
||||
net = graph.nets.get(name)
|
||||
if net and net.net_type == NetType.GROUND:
|
||||
return True
|
||||
u = name.upper().replace("-", "_")
|
||||
return u in ("GND", "VSS", "AGND", "DGND", "PGND", "GNDA", "GNDD") or (
|
||||
u.startswith("GND") or u.endswith("_GND") or u.endswith("_VSS")
|
||||
)
|
||||
|
||||
|
||||
def _is_power_net(graph: DesignGraph, name: str) -> bool:
|
||||
net = graph.nets.get(name)
|
||||
return bool(net and net.net_type == NetType.POWER)
|
||||
|
||||
|
||||
def _capacitor_to_ground(graph: DesignGraph, power_net: str) -> bool:
|
||||
for ref in graph.capacitors_on_net(power_net):
|
||||
cap = graph.components[ref]
|
||||
others = {n for n in cap.pins.values() if n != power_net}
|
||||
if any(_is_ground_net(graph, n) for n in others):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _resistor_to_power(graph: DesignGraph, net_name: str) -> bool:
|
||||
for ref in graph.components_on_net(net_name):
|
||||
comp = graph.components[ref]
|
||||
if comp.component_type != ComponentType.RESISTOR:
|
||||
continue
|
||||
others = {n for n in comp.pins.values() if n != net_name}
|
||||
if any(_is_power_net(graph, n) for n in others):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _other_ic_on_net(graph: DesignGraph, net_name: str, self_ref: str) -> bool:
|
||||
for ref in graph.components_on_net(net_name):
|
||||
if ref == self_ref:
|
||||
continue
|
||||
other = graph.components.get(ref)
|
||||
if other and other.component_type == ComponentType.IC:
|
||||
return True
|
||||
return False
|
||||
@@ -44,6 +44,11 @@ from backend.pinscopex.quote_verify import verify_finding_citations
|
||||
from backend.pinscopex.utils import safe_mpn
|
||||
from backend.pinscopex.pin_mux_check import check_pin_mux_feasibility
|
||||
from backend.pinscopex.led_current_check import check_led_current
|
||||
from backend.pinscopex.passive_rail_check import (
|
||||
check_i2c_pullups,
|
||||
check_reset_pullups,
|
||||
check_supply_decoupling,
|
||||
)
|
||||
|
||||
TRACE_VERSION = 1
|
||||
|
||||
@@ -62,6 +67,9 @@ def _run_deterministic_checks(
|
||||
for name, fn in (
|
||||
("pin_mux_check", lambda: check_pin_mux_feasibility(graph, constraints_map)),
|
||||
("led_current_check", lambda: check_led_current(graph)),
|
||||
("supply_decoupling_check", lambda: check_supply_decoupling(graph, constraints_map)),
|
||||
("i2c_pullup_check", lambda: check_i2c_pullups(graph, constraints_map)),
|
||||
("reset_pullup_check", lambda: check_reset_pullups(graph, constraints_map)),
|
||||
):
|
||||
try:
|
||||
out.extend(fn())
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Supply decoupling and I2C/reset pull-up checks — graph topology only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.pinscopex.models import (
|
||||
Component,
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
Net,
|
||||
NetType,
|
||||
Pin,
|
||||
PinConnection,
|
||||
)
|
||||
from backend.pinscopex.passive_rail_check import (
|
||||
check_i2c_pullups,
|
||||
check_reset_pullups,
|
||||
check_supply_decoupling,
|
||||
)
|
||||
|
||||
|
||||
def _graph(components, nets):
|
||||
net_objs = {}
|
||||
for name, (ntype, conns) in nets.items():
|
||||
net_objs[name] = Net(
|
||||
name=name, net_type=ntype,
|
||||
pins=[PinConnection(component_ref=r, pin_number=str(p)) for r, p in conns],
|
||||
)
|
||||
return DesignGraph(components=components, nets=net_objs)
|
||||
|
||||
|
||||
def _ic(ref, pins, mpn="UTEST"):
|
||||
return Component(
|
||||
reference=ref, value="", footprint="",
|
||||
component_type=ComponentType.IC, mpn=mpn, pins=pins,
|
||||
)
|
||||
|
||||
|
||||
def _cmap_vdd():
|
||||
return {
|
||||
"UTEST": ComponentConstraints(
|
||||
mpn="UTEST",
|
||||
pintable=[Pin(number=1, name="VDD"), Pin(number=2, name="GND")],
|
||||
absolute_maximum_ratings=[], rules=[],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def test_missing_decoupling_is_warning():
|
||||
g = _graph(
|
||||
{"U1": _ic("U1", {"1": "3V3", "2": "GND"})},
|
||||
{
|
||||
"3V3": (NetType.POWER, [("U1", "1")]),
|
||||
"GND": (NetType.GROUND, [("U1", "2")]),
|
||||
},
|
||||
)
|
||||
findings = check_supply_decoupling(g, _cmap_vdd())
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "WARNING"
|
||||
assert findings[0].source == "supply_decoupling_check"
|
||||
assert "3V3" in findings[0].finding
|
||||
|
||||
|
||||
def test_cap_to_gnd_clears_decoupling():
|
||||
cap = Component(
|
||||
reference="C1", value="100n", footprint="",
|
||||
component_type=ComponentType.CAPACITOR, mpn="C1",
|
||||
pins={"1": "3V3", "2": "GND"},
|
||||
)
|
||||
g = _graph(
|
||||
{"U1": _ic("U1", {"1": "3V3", "2": "GND"}), "C1": cap},
|
||||
{
|
||||
"3V3": (NetType.POWER, [("U1", "1"), ("C1", "1")]),
|
||||
"GND": (NetType.GROUND, [("U1", "2"), ("C1", "2")]),
|
||||
},
|
||||
)
|
||||
assert check_supply_decoupling(g, _cmap_vdd()) == []
|
||||
|
||||
|
||||
def test_i2c_missing_pullup():
|
||||
cons = {
|
||||
"UTEST": ComponentConstraints(
|
||||
mpn="UTEST",
|
||||
pintable=[Pin(number=8, name="SDA")],
|
||||
absolute_maximum_ratings=[], rules=[],
|
||||
)
|
||||
}
|
||||
g = _graph(
|
||||
{"U1": _ic("U1", {"8": "I2C_SDA"})},
|
||||
{"I2C_SDA": (NetType.SIGNAL, [("U1", "8")])},
|
||||
)
|
||||
findings = check_i2c_pullups(g, cons)
|
||||
assert len(findings) == 1
|
||||
assert findings[0].source == "i2c_pullup_check"
|
||||
|
||||
|
||||
def test_i2c_pullup_present():
|
||||
cons = {
|
||||
"UTEST": ComponentConstraints(
|
||||
mpn="UTEST",
|
||||
pintable=[Pin(number=8, name="SDA")],
|
||||
absolute_maximum_ratings=[], rules=[],
|
||||
)
|
||||
}
|
||||
r = Component(
|
||||
reference="R1", value="4.7k", footprint="",
|
||||
component_type=ComponentType.RESISTOR, mpn="R1",
|
||||
pins={"1": "I2C_SDA", "2": "3V3"},
|
||||
)
|
||||
g = _graph(
|
||||
{"U1": _ic("U1", {"8": "I2C_SDA"}), "R1": r},
|
||||
{
|
||||
"I2C_SDA": (NetType.SIGNAL, [("U1", "8"), ("R1", "1")]),
|
||||
"3V3": (NetType.POWER, [("R1", "2")]),
|
||||
},
|
||||
)
|
||||
assert check_i2c_pullups(g, cons) == []
|
||||
|
||||
|
||||
def test_reset_no_finding_when_gpio_drives():
|
||||
cons = {
|
||||
"UTEST": ComponentConstraints(
|
||||
mpn="UTEST",
|
||||
pintable=[Pin(number=3, name="nRESET")],
|
||||
absolute_maximum_ratings=[], rules=[],
|
||||
)
|
||||
}
|
||||
u2 = _ic("U2", {"1": "MCU_RST"}, mpn="MCU2")
|
||||
g = _graph(
|
||||
{"U1": _ic("U1", {"3": "MCU_RST"}), "U2": u2},
|
||||
{"MCU_RST": (NetType.SIGNAL, [("U1", "3"), ("U2", "1")])},
|
||||
)
|
||||
assert check_reset_pullups(g, cons) == []
|
||||
|
||||
|
||||
def test_reset_floating_is_warning():
|
||||
cons = {
|
||||
"UTEST": ComponentConstraints(
|
||||
mpn="UTEST",
|
||||
pintable=[Pin(number=3, name="nRESET")],
|
||||
absolute_maximum_ratings=[], rules=[],
|
||||
)
|
||||
}
|
||||
g = _graph(
|
||||
{"U1": _ic("U1", {"3": "NRST_NET"})},
|
||||
{"NRST_NET": (NetType.SIGNAL, [("U1", "3")])},
|
||||
)
|
||||
findings = check_reset_pullups(g, cons)
|
||||
assert len(findings) == 1
|
||||
assert findings[0].source == "reset_pullup_check"
|
||||
assert findings[0].status == "WARNING"
|
||||
Reference in New Issue
Block a user