diff --git a/backend/pinscopex/models.py b/backend/pinscopex/models.py index f5b596c..88ac180 100644 --- a/backend/pinscopex/models.py +++ b/backend/pinscopex/models.py @@ -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): diff --git a/backend/pinscopex/passive_rail_check.py b/backend/pinscopex/passive_rail_check.py new file mode 100644 index 0000000..9a1a2b2 --- /dev/null +++ b/backend/pinscopex/passive_rail_check.py @@ -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 diff --git a/backend/services/validation.py b/backend/services/validation.py index 7149649..d2efcd3 100644 --- a/backend/services/validation.py +++ b/backend/services/validation.py @@ -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()) diff --git a/tests/test_passive_rail_check.py b/tests/test_passive_rail_check.py new file mode 100644 index 0000000..0450256 --- /dev/null +++ b/tests/test_passive_rail_check.py @@ -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"