Size I2C pull-ups, flag NRST pull-downs, and distinguish LDO Cout from MCU decoupling.

Keep these as WARNING hints with wide bounds so a 4.7 kΩ or 100 nF VDD cap is not treated as a datasheet µF error.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-10 22:07:14 +02:00
co-authored by Cursor
parent e4fd6c3849
commit 4fca789517
4 changed files with 486 additions and 31 deletions
+234 -31
View File
@@ -10,13 +10,18 @@ from __future__ import annotations
import re
from backend.pinscopex.models import (
CapacitorSpecs,
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
Finding,
NetType,
ResistorSpecs,
)
from backend.pinscopex.validate import _match_constraints
from backend.pinscopex.led_current_check import _parse_resistance
from backend.pinscopex.resolve_passives import _parse_spice_value
_SUPPLY_PIN_RE = re.compile(
r"(?:^|[_/])(VDD|VCC|VDDA|VDDD|VDDIO|DVDD|AVDD|IOVDD|VDD33|VDD18|"
@@ -38,13 +43,27 @@ _NC_NET_RE = re.compile(
r"^(?:n/?c|n\.c\.|nc|unconnected|no[_-]?connect|not[_-]?connected)$",
re.IGNORECASE,
)
_OUT_PIN_RE = re.compile(
r"(?:^|[_/])(VOUT|V_OUT|VO|VREG|SWOUT)(?:$|[_/\d])",
re.IGNORECASE,
)
_ACTIVE_LOW_RESET_RE = re.compile(
r"(?:N/?RST|NRST|NRESET|RESET[_-]?N|RSTN)\b",
re.IGNORECASE,
)
# NXP UM10204-style Rp window, widened so 2.2k10k never false-positives.
_RP_MIN_OHM = 1_000.0
_RP_MAX_OHM = 22_000.0
_VDD_MIN_FARADS = 50e-9
_VOUT_MIN_FARADS = 0.47e-6
def check_supply_decoupling(
graph: DesignGraph,
constraints_map: dict[str, ComponentConstraints],
) -> list[Finding]:
"""WARNING when an IC supply net has no capacitor to ground."""
"""WARNING when an IC supply/VOUT net has no capacitor to ground, or
only farads well below a typical Cin/Cout when every cap is valued."""
findings: list[Finding] = []
seen_nets: set[str] = set()
for ref, comp in sorted(graph.components.items()):
@@ -56,38 +75,77 @@ def check_supply_decoupling(
continue
if _is_nc_net(net_name):
continue
if not _is_ic_supply_pin(graph, cons, pin_num, net_name):
role = None
if _is_ic_supply_pin(graph, cons, pin_num, net_name):
role = "supply"
elif _is_regulator_output_pin(cons, pin_num):
role = "output"
if role is None:
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",
net=net_name,
pins=[f"{ref}.{pin_num}"],
rule_id="PS-DEC-001",
))
if not _capacitor_to_ground(graph, 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."
if role == "supply"
else (
f"{ref} regulator output '{net_name}' ({pin_label}) "
f"has no Cout 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",
net=net_name,
pins=[f"{ref}.{pin_num}"],
rule_id="PS-DEC-001",
))
continue
min_f = _VOUT_MIN_FARADS if role == "output" else _VDD_MIN_FARADS
max_c = _max_known_cap_farads(graph, net_name)
if max_c is not None and max_c < min_f:
findings.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="decoupling",
source="supply_decoupling_check",
source_page=None,
status="WARNING",
finding=(
f"{ref} net '{net_name}' ({pin_label}) only has "
f"{max_c * 1e6:.3g} µF to ground; typical "
f"{'Cout' if role == 'output' else 'decoupling'} is larger."
),
why=(
"Cap values are known on this net and the largest is "
"below a wide typical minimum. This is not a datasheet "
"µF requirement — treat as a sizing hint."
),
recommendation=(
f"Add bulk capacitance on '{net_name}' (often ≥1 µF on "
f"LDO VOUT, ≥100 nF on MCU VDD) if the datasheet agrees."
),
reference="netlist topology",
net=net_name,
pins=[f"{ref}.{pin_num}"],
rule_id="PS-DEC-002",
))
return findings
@@ -114,6 +172,38 @@ def check_i2c_pullups(
if net and net.net_type in (NetType.POWER, NetType.GROUND):
continue
if _resistor_to_power(graph, net_name):
ohms = _parallel_pullup_ohms(graph, net_name)
if ohms is not None and (
ohms < _RP_MIN_OHM or ohms > _RP_MAX_OHM
):
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}) pull-up "
f"is {ohms:.3g} Ω (wide NXP-style band "
f"{_RP_MIN_OHM:.0f}{_RP_MAX_OHM:.0f} Ω)."
),
why=(
"UM10204 Rp depends on Vdd, Iol and bus capacitance. "
"This bound is wide on purpose; 2.210 kΩ at 3.3 V "
"is typical. Unknown resistor values are not sized."
),
recommendation=(
f"Use a pull-up on '{net_name}' inside "
f"{_RP_MIN_OHM:.0f}{_RP_MAX_OHM:.0f} Ω unless the "
f"bus capacitance/Iol calculation says otherwise."
),
reference="NXP UM10204 (wide bound)",
net=net_name,
pins=[f"{ref}.{pin_num}"],
rule_id="PS-I2C-002",
))
continue
pin_label = _pin_label(cons, pin_num, net_name)
findings.append(Finding(
@@ -167,9 +257,38 @@ def check_reset_pullups(
continue
if _other_ic_on_net(graph, net_name, ref):
continue
pin_label = _pin_label(cons, pin_num, net_name)
if _is_active_low_reset(cons, pin_num, net_name) and _resistor_to_ground(
graph, 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} active-low reset '{net_name}' ({pin_label}) "
f"has a pull-down to ground."
),
why=(
"An active-low NRST/RESET_N pin held down by a resistor "
"will sit in reset unless a stronger pull-up wins. "
"Datasheets that omit an internal pull-up expect a pull-up, "
"not a pull-down."
),
recommendation=(
f"Remove the pull-down on '{net_name}' or replace it "
f"with a pull-up to the I/O rail."
),
reference="netlist topology",
net=net_name,
pins=[f"{ref}.{pin_num}"],
rule_id="PS-RST-002",
))
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 "",
@@ -311,6 +430,90 @@ def _resistor_to_power(graph: DesignGraph, net_name: str) -> bool:
return False
def _resistor_to_ground(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_ground_net(graph, n) for n in others):
return True
return False
def _resistor_ohms(comp: Component) -> float | None:
specs = comp.specs
if isinstance(specs, ResistorSpecs) and specs.value_ohms > 0:
return float(specs.value_ohms)
return _parse_resistance(comp.value)
def _parallel_pullup_ohms(graph: DesignGraph, net_name: str) -> float | None:
acc = 0.0
known = 0
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 not any(_is_power_net(graph, n) for n in others):
continue
ohms = _resistor_ohms(comp)
if ohms is None or ohms <= 0:
return None
acc += 1.0 / ohms
known += 1
if not known or acc <= 0:
return None
return 1.0 / acc
def _cap_farads(comp: Component) -> float | None:
specs = comp.specs
if isinstance(specs, CapacitorSpecs) and specs.value_farads > 0:
return float(specs.value_farads)
raw = (comp.value or "").strip()
if not raw:
return None
try:
v = _parse_spice_value(raw)
except ValueError:
return None
return v if v > 0 else None
def _max_known_cap_farads(graph: DesignGraph, power_net: str) -> float | None:
known: list[float] = []
any_unknown = False
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 not any(_is_ground_net(graph, n) for n in others):
continue
farads = _cap_farads(cap)
if farads is None:
any_unknown = True
continue
known.append(farads)
if any_unknown or not known:
return None
return max(known)
def _is_regulator_output_pin(
cons: ComponentConstraints | None, pin_num: str,
) -> bool:
return any(_OUT_PIN_RE.search(t) for t in _pin_name_tokens(cons, pin_num))
def _is_active_low_reset(
cons: ComponentConstraints | None, pin_num: str, net_name: str,
) -> bool:
if _ACTIVE_LOW_RESET_RE.search(net_name or ""):
return True
return any(_ACTIVE_LOW_RESET_RE.search(t) for t in _pin_name_tokens(cons, pin_num))
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: