Add power-margin, PG-to-EN sequencing, and DNP enable checks.

Compare only specified IQ plus I_load to Iout_max and series-R drop, flag sequencing only when power_sequence is in specs, and treat DNP as a fitted-variant graph so a missing enable pull is ERROR only when the BOM actually marks DNP.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-10 22:34:06 +02:00
co-authored by Cursor
parent a45a06e761
commit 78013bd404
13 changed files with 800 additions and 8 deletions
+112
View File
@@ -0,0 +1,112 @@
"""Enable pins on the fitted variant: no pull and no driver is ERROR.
Runs only when the BOM actually marks DNP/fitted. Enable tied to a rail
is a driver. DNP resistors are removed from the variant graph.
"""
from __future__ import annotations
import re
from backend.pinscopex.models import (
ComponentConstraints,
ComponentType,
DesignGraph,
Finding,
)
from backend.pinscopex.passive_rail_check import (
_is_ground_net,
_is_power_net,
_pin_name_tokens,
)
from backend.pinscopex.validate import _match_constraints
_EN_RE = re.compile(
r"(?:^|[_/])(EN|ENA|ENABLE|n?SHDN|nEN|EN_N|CHIP_EN)(?:$|[_/\d])",
re.I,
)
def _dnp_map(graph: DesignGraph) -> dict[str, bool] | None:
"""Return {ref: is_dnp} if any BOM row carries DNP/fitted, else None."""
fields = graph.bom_fields or {}
if not fields:
return None
if not any("dnp" in (v or {}) or "fitted" in (v or {}) for v in fields.values()):
return None
out: dict[str, bool] = {}
for ref in graph.components:
row = fields.get(ref) or {}
if "dnp" in row:
out[ref] = bool(row.get("dnp"))
elif "fitted" in row:
out[ref] = not bool(row.get("fitted"))
else:
out[ref] = False
return out
def _is_fitted(dnp: dict[str, bool], ref: str) -> bool:
return not dnp.get(ref, False)
def check_dnp_enables(
graph: DesignGraph,
constraints_map: dict[str, ComponentConstraints] | None = None,
) -> list[Finding]:
dnp = _dnp_map(graph)
if dnp is None:
return []
cmap = constraints_map or {}
findings: list[Finding] = []
for ref, comp in sorted(graph.components.items()):
if not _is_fitted(dnp, ref):
continue
if comp.component_type != ComponentType.IC:
continue
cons = _match_constraints(comp.mpn or comp.value, cmap)
for pin_num, net in sorted(comp.pins.items(), key=lambda x: str(x[0])):
tokens = _pin_name_tokens(cons, pin_num)
names = tokens or [net or "", pin_num]
if not any(_EN_RE.search(t) for t in names):
continue
if _is_power_net(graph, net) or _is_ground_net(graph, net):
continue
has_pull = False
has_driver = False
for r in graph.components_on_net(net):
if r == ref or not _is_fitted(dnp, r):
continue
other = graph.components.get(r)
if not other:
continue
if other.component_type == ComponentType.IC:
has_driver = True
continue
if other.component_type != ComponentType.RESISTOR:
continue
others = {n for n in other.pins.values() if n != net}
if any(_is_power_net(graph, n) or _is_ground_net(graph, n) for n in others):
has_pull = True
if has_pull or has_driver:
continue
variant = (graph.bom_fields.get(ref) or {}).get("variant")
findings.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="dnp",
source="dnp_check",
status="ERROR",
finding=(
f"{ref} enable '{net}' has no fitted pull or driver "
f"(DNP parts ignored)."
),
why="On the fitted variant the enable net is floating.",
recommendation="Fit a pull, tie EN to a rail, or drive it from a PG/GPIO.",
reference="BOM DNP/fitted",
net=net,
pins=[f"{ref}.{pin_num}"],
rule_id="PS-DNP-001",
variant=str(variant) if variant else None,
))
return findings
+6
View File
@@ -24,6 +24,9 @@ from backend.pinscopex.bom_match_check import check_bom_schematic_match
from backend.pinscopex.hf_coverage_check import check_hf_decoupling_coverage
from backend.pinscopex.filter_check import check_filters
from backend.pinscopex.thermal_check import check_thermal
from backend.pinscopex.power_margin_check import check_power_margin
from backend.pinscopex.sequencing_check import check_power_sequencing
from backend.pinscopex.dnp_check import check_dnp_enables
class EvalScores(BaseModel):
@@ -87,6 +90,9 @@ def run_deterministic_on_graph(graph: DesignGraph) -> list[Finding]:
out.extend(check_hf_decoupling_coverage(graph, cmap))
out.extend(check_filters(graph, cmap))
out.extend(check_thermal(graph, cmap))
out.extend(check_power_margin(graph, cmap))
out.extend(check_power_sequencing(graph, cmap))
out.extend(check_dnp_enables(graph, cmap))
return out
+8 -4
View File
@@ -269,10 +269,14 @@ def build_graph(
# only tokenise correctly with the BOM's ref list as a lookup. EDIF
# netlists ignore known_refs (designators are unambiguous tokens).
bom = parse_bom(bom_path, reference_col=reference_col, mpn_col=mpn_col)
bom_fields = {
ref: {"mpn": entry.get("mpn"), "value": entry.get("value", "")}
for ref, entry in bom.items()
}
bom_fields = {}
for ref, entry in bom.items():
row = {"mpn": entry.get("mpn"), "value": entry.get("value", "")}
if "dnp" in entry:
row["dnp"] = entry.get("dnp")
if entry.get("variant") is not None:
row["variant"] = entry.get("variant")
bom_fields[ref] = row
schematic_fields: dict[str, dict] = {}
parts, raw_nets, fmt = parse_netlist_any(
netlist_path,
+16 -1
View File
@@ -267,6 +267,9 @@ def parse_bom(
result: dict[str, dict] = {}
text = Path(path).read_text()
reader = csv.DictReader(text.splitlines())
colnames = {n.lower() for n in (reader.fieldnames or []) if n}
has_dnp_col = bool(colnames & {"dnp", "dni", "fitted", "populate"})
has_variant_col = bool(colnames & {"variant"})
for row in reader:
refs_raw = row.get(reference_col, "")
@@ -286,13 +289,25 @@ def parse_bom(
if not mpn and any(re.match(r"^U\d", r, re.I) for r in refs):
mpn = (value or "").strip() or None
dnp_raw = (row.get("DNP") or row.get("DNI") or "").strip().lower()
fitted_raw = (row.get("Fitted") or row.get("Populate") or "").strip().lower()
variant = (row.get("Variant") or row.get("variant") or "").strip() or None
is_dnp = dnp_raw in {"1", "y", "yes", "true", "dnp", "dni", "x"}
if not is_dnp and fitted_raw in {"0", "n", "no", "false"}:
is_dnp = True
for ref in refs:
result[ref] = {
entry = {
"value": value,
"footprint": footprint,
"mpn": mpn,
"lcsc": lcsc,
"datasheet_url": datasheet_url,
}
if has_dnp_col:
entry["dnp"] = is_dnp
if has_variant_col:
entry["variant"] = variant
result[ref] = entry
return result
+183
View File
@@ -0,0 +1,183 @@
"""Regulator current margin and explicit series-R IR drop.
Iout_max is the rating, never the load. IQ/load are summed only when every
IC on the rail has a spec. Trace resistance is never estimated.
"""
from __future__ import annotations
from backend.pinscopex.led_current_check import _net_voltage, _parse_resistance
from backend.pinscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
Finding,
InductorSpecs,
ResistorSpecs,
)
from backend.pinscopex.passive_rail_check import _is_ground_net
from backend.pinscopex.thermal_check import (
_IOUT_MAX_KEYS,
_LOAD_KEYS,
_VIN_PIN,
_VOUT_PIN,
_first,
_is_ldo,
_pin_net_by_role,
_specs_values,
)
from backend.pinscopex.validate import _match_constraints
_IQ_KEYS = (
"iq_a", "quiescent_current_a", "supply_current_a", "idd_a", "icc_a",
)
_IR_FRAC = 0.05 # 5% of the rail — wide, not a datasheet number
def _two_nets(comp: Component) -> tuple[str, str] | None:
nets = list(dict.fromkeys(comp.pins.values()))
if len(nets) != 2:
return None
return nets[0], nets[1]
def _series_ohms(comp: Component) -> float | None:
if comp.component_type == ComponentType.RESISTOR:
if isinstance(comp.specs, ResistorSpecs) and comp.specs.value_ohms >= 0:
return float(comp.specs.value_ohms)
return _parse_resistance(comp.value)
if comp.component_type == ComponentType.INDUCTOR:
if isinstance(comp.specs, InductorSpecs) and comp.specs.dcr_ohms is not None:
return float(comp.specs.dcr_ohms)
return None
def _expand_rail(graph: DesignGraph, start: str) -> set[str]:
"""Follow series R/L between nets; do not walk through ICs (VIN/VOUT)."""
seen = {start}
stack = [start]
while stack:
n = stack.pop()
for ref in graph.components_on_net(n):
c = graph.components.get(ref)
if not c or c.component_type not in (
ComponentType.RESISTOR, ComponentType.INDUCTOR,
):
continue
pair = _two_nets(c)
if not pair:
continue
other = pair[1] if pair[0] == n else pair[0]
if other in seen or _is_ground_net(graph, other):
continue
seen.add(other)
stack.append(other)
return seen
def _regulators(graph: DesignGraph, cmap: dict[str, ComponentConstraints]):
for ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.IC:
continue
cons = _match_constraints(comp.mpn or comp.value, cmap)
vin = _pin_net_by_role(graph, comp, cons, _VIN_PIN)
vout = _pin_net_by_role(graph, comp, cons, _VOUT_PIN)
if not (vin and vout) and not _is_ldo(comp, cons):
continue
if not (vin and vout):
continue
yield ref, comp, cons, vin, vout
def check_power_margin(
graph: DesignGraph,
constraints_map: dict[str, ComponentConstraints] | None = None,
) -> list[Finding]:
cmap = constraints_map or {}
findings: list[Finding] = []
for ref, comp, cons, vin, vout in _regulators(graph, cmap):
values = _specs_values(comp)
iout_max = _first(values, _IOUT_MAX_KEYS)
i_load = _first(values, _LOAD_KEYS)
ics: list[Component] = []
missing_iq = False
iq_sum = 0.0
for net in _expand_rail(graph, vout):
for r in graph.components_on_net(net):
c = graph.components.get(r)
if not c or c.component_type != ComponentType.IC or r == ref:
continue
if c in ics:
continue
ics.append(c)
iq = _first(_specs_values(c), _IQ_KEYS)
if iq is None:
missing_iq = True
else:
iq_sum += iq
i_total = None
if i_load is not None and not missing_iq:
i_total = i_load + iq_sum
elif i_load is not None and not ics:
i_total = i_load
elif not missing_iq and ics and i_load is None:
i_total = iq_sum
if iout_max is not None and i_total is not None and i_total > iout_max:
findings.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="power",
source="power_margin_check",
status="WARNING",
finding=(
f"{ref} load ≈ {i_total:.3g} A exceeds Iout_max {iout_max:.3g} A "
f"on '{vout}'."
),
why="Sum of specified IQ on the rail plus I_load. Missing IQ was not guessed.",
recommendation="Raise the regulator rating or cut the load.",
reference="regulator Iout_max",
net=vout,
pins=[ref],
rule_id="PS-PWR-001",
))
# IR drop only through an explicit series R/ferrite on VIN or VOUT.
if i_load is None:
continue
for r in graph.components_on_net(vin):
c = graph.components.get(r)
if not c or c.component_type not in (
ComponentType.RESISTOR, ComponentType.INDUCTOR,
):
continue
pair = _two_nets(c)
if not pair:
continue
ohms = _series_ohms(c)
if ohms is None or ohms <= 0:
continue
drop = i_load * ohms
vrail = _net_voltage(graph, vin) or _net_voltage(graph, vout)
if vrail is None or vrail <= 0:
continue
if drop <= _IR_FRAC * vrail:
continue
findings.append(Finding(
designator=r,
mpn=c.mpn or "",
aspect="power",
source="power_margin_check",
status="WARNING",
finding=(
f"{r} series drop ≈ {drop:.3g} V at I_load={i_load:.3g} A "
f"into {ref} VIN '{vin}'."
),
why="IR from an explicit series R/ferrite DCR. Trace resistance was not estimated.",
recommendation="Lower DCR or the load, or accept the drop if it is intended.",
reference="netlist series R",
net=vin,
pins=[r],
rule_id="PS-PWR-001",
))
return findings
+109
View File
@@ -0,0 +1,109 @@
"""Power-good → enable sequencing when the IC specs declare a sequence.
No RC time constants are invented. Missing power_sequence means skip.
"""
from __future__ import annotations
import re
from backend.pinscopex.models import (
ComponentConstraints,
ComponentType,
DesignGraph,
Finding,
)
from backend.pinscopex.thermal_check import (
_VIN_PIN,
_VOUT_PIN,
_is_ldo,
_pin_net_by_role,
_specs_values,
)
from backend.pinscopex.validate import _match_constraints
_PG_RE = re.compile(r"(?:^|[_/])(PG|PGOOD|PWRGD|POWER_GOOD|POK)(?:$|[_/\d])", re.I)
_EN_RE = re.compile(
r"(?:^|[_/])(EN|ENA|ENABLE|n?SHDN|nEN|EN_N)(?:$|[_/\d])",
re.I,
)
def _has_sequence(comp) -> bool:
values = _specs_values(comp)
raw = values.get("power_sequence")
if raw is None or raw == "" or raw is False:
return False
if isinstance(raw, (int, float)) and raw == 0:
return False
return True
def check_power_sequencing(
graph: DesignGraph,
constraints_map: dict[str, ComponentConstraints] | None = None,
) -> list[Finding]:
cmap = constraints_map or {}
regs: list[tuple] = []
for ref, comp in sorted(graph.components.items()):
if comp.component_type != ComponentType.IC:
continue
cons = _match_constraints(comp.mpn or comp.value, cmap)
vin = _pin_net_by_role(graph, comp, cons, _VIN_PIN)
vout = _pin_net_by_role(graph, comp, cons, _VOUT_PIN)
if not (vin and vout) and not _is_ldo(comp, cons):
continue
pg = _pin_net_by_role(graph, comp, cons, _PG_RE, exclude_re=None)
en = _pin_net_by_role(graph, comp, cons, _EN_RE, exclude_re=None)
regs.append((ref, comp, cons, vin, vout, pg, en))
findings: list[Finding] = []
for dref, dcomp, dcons, dvin, _dvout, _dpg, den in regs:
if not _has_sequence(dcomp):
continue
if not den or not dvin:
continue
upstream = [
row for row in regs
if row[0] != dref and row[4] and row[4] == dvin
]
if not upstream:
continue
uref, ucomp, _ucons, _uvin, _uvout, upg, _uen = upstream[0]
if not upg:
findings.append(Finding(
designator=dref,
mpn=dcomp.mpn or "",
aspect="sequencing",
source="sequencing_check",
status="WARNING",
finding=(
f"{dref} specs declare power_sequence but upstream {uref} "
f"has no PG pin feeding {dref} EN '{den}'."
),
why="Sequence was listed in IC specs; delay milliseconds were not estimated.",
recommendation="Tie the upstream power-good to this enable, or remove the sequence spec if unused.",
reference="power_sequence",
net=den,
pins=[dref, uref],
rule_id="PS-SEQ-001",
))
continue
if upg != den:
findings.append(Finding(
designator=dref,
mpn=dcomp.mpn or "",
aspect="sequencing",
source="sequencing_check",
status="WARNING",
finding=(
f"{uref} PG '{upg}' does not connect to {dref} EN '{den}'."
),
why="Declared power_sequence expects PG to enable the next rail.",
recommendation="Net the upstream PG to the downstream EN.",
reference="power_sequence",
net=den,
pins=[f"{uref}", f"{dref}"],
rule_id="PS-SEQ-001",
))
return findings
+6 -2
View File
@@ -103,12 +103,16 @@ def _pin_net_by_role(
comp: Component,
cons: ComponentConstraints | None,
role_re: re.Pattern,
exclude_re: re.Pattern | None = _NOT_OUT,
) -> str | None:
for pin_num, net in comp.pins.items():
tokens = _pin_name_tokens(cons, pin_num) or [pin_num]
if any(role_re.search(t) and not _NOT_OUT.search(t) for t in tokens):
if any(
role_re.search(t) and not (exclude_re and exclude_re.search(t))
for t in tokens
):
return net
if role_re.search(net or ""):
if role_re.search(net or "") and not (exclude_re and exclude_re.search(net or "")):
return net
return None