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>
525 lines
19 KiB
Python
525 lines
19 KiB
Python
"""Deterministic supply decoupling and I2C/reset pull-up checks.
|
||
|
||
These only fire when the graph already shows a pintable supply pin, an I2C
|
||
net/pin name, or a reset pin — they do not guess capacitor values, mux
|
||
alt-functions, or datasheet µF minima.
|
||
"""
|
||
|
||
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|"
|
||
r"VIN|VBAT|VBUS|VCORE)(?:$|[_/\d])",
|
||
re.IGNORECASE,
|
||
)
|
||
_RAIL_PIN_RE = re.compile(r"^(?:\+?\d+V\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"(?:^|[^A-Za-z0-9])(SDA|SCL)(\d+)?(?:$|[^A-Za-z0-9])", re.IGNORECASE)
|
||
_SPI_NAME_RE = re.compile(r"(?i)\b(MISO|MOSI|SCLK|SCK)\b")
|
||
_RESET_RE = re.compile(
|
||
r"\b(N?RST(?:N|B)?|NRST|RESET(?:_?N|_?B)?|NRESET|CHIP_PU)\b",
|
||
re.IGNORECASE,
|
||
)
|
||
_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.2k–10k 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/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()):
|
||
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 _is_nc_net(net_name):
|
||
continue
|
||
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)
|
||
pin_label = _pin_label(cons, pin_num, net_name)
|
||
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
|
||
|
||
|
||
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 _is_nc_net(net_name):
|
||
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):
|
||
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.2–10 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(
|
||
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",
|
||
net=net_name,
|
||
pins=[f"{ref}.{pin_num}"],
|
||
rule_id="PS-I2C-001",
|
||
))
|
||
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 _is_nc_net(net_name):
|
||
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
|
||
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
|
||
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",
|
||
net=net_name,
|
||
pins=[f"{ref}.{pin_num}"],
|
||
rule_id="PS-RST-001",
|
||
))
|
||
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 _is_nc_net(name: str) -> bool:
|
||
return bool(_NC_NET_RE.match((name or "").strip()))
|
||
|
||
|
||
def _pin_name_tokens(cons: ComponentConstraints | None, pin_num: str) -> list[str]:
|
||
"""Slash-separated pin *name* tokens only — not the mux alt-function table."""
|
||
if not cons:
|
||
return []
|
||
pin = cons.pin_by_number(pin_num)
|
||
if not pin or not pin.name:
|
||
return []
|
||
return [t.strip() for t in re.split(r"[/,]", pin.name) if t.strip()]
|
||
|
||
|
||
def _looks_like_supply(text: str) -> bool:
|
||
t = (text or "").strip()
|
||
if not t:
|
||
return False
|
||
if _NOT_SUPPLY_RE.search(t) and not _SUPPLY_PIN_RE.search(t):
|
||
return False
|
||
return bool(_SUPPLY_PIN_RE.search(t) or _RAIL_PIN_RE.match(t))
|
||
|
||
|
||
def _is_ic_supply_pin(
|
||
graph: DesignGraph,
|
||
cons: ComponentConstraints | None,
|
||
pin_num: str,
|
||
net_name: str,
|
||
) -> bool:
|
||
tokens = _pin_name_tokens(cons, pin_num)
|
||
if tokens:
|
||
return any(_looks_like_supply(t) for t in tokens)
|
||
# No pintable row: fall back to net name / POWER type.
|
||
if _looks_like_supply(net_name or ""):
|
||
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:
|
||
net = net_name or ""
|
||
if re.match(r"(?i)SPI([_-]|$)", net) or re.search(
|
||
r"(?i)\bSPI[_-]?(CLK|SCK|MOSI|MISO|CS|SS)\b", net,
|
||
):
|
||
return False
|
||
tokens = _pin_name_tokens(cons, pin_num)
|
||
if any(_SPI_NAME_RE.search(t) for t in tokens):
|
||
return False
|
||
if _I2C_RE.search(net):
|
||
return True
|
||
return any(_I2C_RE.search(t) for t in tokens)
|
||
|
||
|
||
def _is_reset_pin(
|
||
graph: DesignGraph,
|
||
cons: ComponentConstraints | None,
|
||
pin_num: str,
|
||
net_name: str,
|
||
) -> bool:
|
||
if _RESET_RE.search(net_name or ""):
|
||
return True
|
||
return any(_RESET_RE.search(t) for t in _pin_name_tokens(cons, pin_num))
|
||
|
||
|
||
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)
|
||
if net and net.net_type == NetType.POWER:
|
||
return True
|
||
return bool(re.match(r"^\d+V\d*", (name or "").upper()))
|
||
|
||
|
||
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 _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:
|
||
continue
|
||
other = graph.components.get(ref)
|
||
if other and other.component_type == ComponentType.IC:
|
||
return True
|
||
return False
|