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:
2026-08-28 16:59:32 +02:00
co-authored by Cursor
parent d6b8b0086c
commit b1ee445da8
4 changed files with 434 additions and 1 deletions
+1 -1
View File
@@ -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):
+274
View File
@@ -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.210 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
+8
View File
@@ -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())
+151
View File
@@ -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"