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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -53,6 +53,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
|
||||
|
||||
TRACE_VERSION = 1
|
||||
|
||||
@@ -80,6 +83,9 @@ def _run_deterministic_checks(
|
||||
("hf_coverage_check", lambda: check_hf_decoupling_coverage(graph, constraints_map)),
|
||||
("filter_check", lambda: check_filters(graph, constraints_map)),
|
||||
("thermal_check", lambda: check_thermal(graph, constraints_map)),
|
||||
("power_margin_check", lambda: check_power_margin(graph, constraints_map)),
|
||||
("sequencing_check", lambda: check_power_sequencing(graph, constraints_map)),
|
||||
("dnp_check", lambda: check_dnp_enables(graph, constraints_map)),
|
||||
):
|
||||
try:
|
||||
out.extend(fn())
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
What's new in Pinscope.
|
||||
|
||||
## 2.15.0 — 2026-09-10 — Power margin, sequencing, DNP enable
|
||||
|
||||
Schema checks now compare regulator load to Iout_max, look at PG→EN when a sequence is declared, and treat DNP as a fitted-variant graph.
|
||||
|
||||
- [New] `PS-PWR-001` when specified IQ+I_load exceeds Iout_max, or an explicit series R/ferrite DCR drops >5% of the rail. Missing IQ and PCB traces are not guessed.
|
||||
- [New] `PS-SEQ-001` WARNING if `power_sequence` is in IC specs and upstream PG does not net to downstream EN.
|
||||
- [New] BOM `DNP`/`Fitted`/`Variant` on `bom_fields`. Fitted enable with only a DNP pull is `PS-DNP-001` ERROR; no DNP column skips the check.
|
||||
|
||||
## 2.14.0 — 2026-09-10 — Filtri e termico schema
|
||||
|
||||
Deterministic checks now match RC/LC/π/T filters and estimate LDO/resistor dissipation without inventing missing numbers.
|
||||
|
||||
@@ -94,7 +94,7 @@ export interface Net {
|
||||
export interface DesignGraph {
|
||||
components: Record<string, Component>;
|
||||
nets: Record<string, Net>;
|
||||
bom_fields?: Record<string, { mpn?: string | null; value?: string }>;
|
||||
bom_fields?: Record<string, { mpn?: string | null; value?: string; dnp?: boolean; variant?: string | null }>;
|
||||
schematic_fields?: Record<string, { mpn?: string | null; value?: string }>;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""DNP variant: fitted enable without pull/driver is ERROR."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from backend.pinscopex.dnp_check import check_dnp_enables
|
||||
from backend.pinscopex.models import (
|
||||
Component,
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
Net,
|
||||
NetType,
|
||||
Pin,
|
||||
PinConnection,
|
||||
ResistorSpecs,
|
||||
)
|
||||
from backend.pinscopex.parsers import parse_bom
|
||||
|
||||
|
||||
def _graph(components, nets, bom_fields=None):
|
||||
net_objs = {
|
||||
name: Net(
|
||||
name=name, net_type=ntype,
|
||||
pins=[PinConnection(component_ref=r, pin_number=str(p)) for r, p in conns],
|
||||
)
|
||||
for name, (ntype, conns) in nets.items()
|
||||
}
|
||||
return DesignGraph(components=components, nets=net_objs, bom_fields=bom_fields or {})
|
||||
|
||||
|
||||
def _cons():
|
||||
return {
|
||||
"LDOX": ComponentConstraints(
|
||||
mpn="LDOX",
|
||||
pintable=[
|
||||
Pin(number=1, name="VIN"),
|
||||
Pin(number=2, name="VOUT"),
|
||||
Pin(number=3, name="EN"),
|
||||
Pin(number=4, name="GND"),
|
||||
],
|
||||
absolute_maximum_ratings=[], rules=[],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _ldo():
|
||||
return Component(
|
||||
reference="U1", value="", footprint="",
|
||||
component_type=ComponentType.IC, mpn="LDOX",
|
||||
pins={"1": "VIN", "2": "VOUT", "3": "EN_NET", "4": "GND"},
|
||||
)
|
||||
|
||||
|
||||
def _r(dnp_net="EN_NET"):
|
||||
return Component(
|
||||
reference="R1", value="10k", footprint="",
|
||||
component_type=ComponentType.RESISTOR, mpn="R1",
|
||||
pins={"1": dnp_net, "2": "VIN"},
|
||||
specs=ResistorSpecs(value_ohms=10000, value_formatted="10k"),
|
||||
)
|
||||
|
||||
|
||||
def test_dnp_pull_leaves_enable_floating():
|
||||
g = _graph(
|
||||
{"U1": _ldo(), "R1": _r()},
|
||||
{
|
||||
"VIN": (NetType.POWER, [("U1", "1"), ("R1", "2")]),
|
||||
"VOUT": (NetType.POWER, [("U1", "2")]),
|
||||
"EN_NET": (NetType.SIGNAL, [("U1", "3"), ("R1", "1")]),
|
||||
"GND": (NetType.GROUND, [("U1", "4")]),
|
||||
},
|
||||
bom_fields={
|
||||
"U1": {"mpn": "LDOX", "value": "", "dnp": False},
|
||||
"R1": {"mpn": "R1", "value": "10k", "dnp": True},
|
||||
},
|
||||
)
|
||||
findings = check_dnp_enables(g, _cons())
|
||||
assert len(findings) == 1
|
||||
assert findings[0].rule_id == "PS-DNP-001"
|
||||
assert findings[0].status == "ERROR"
|
||||
|
||||
|
||||
def test_fitted_pull_is_silent():
|
||||
g = _graph(
|
||||
{"U1": _ldo(), "R1": _r()},
|
||||
{
|
||||
"VIN": (NetType.POWER, [("U1", "1"), ("R1", "2")]),
|
||||
"VOUT": (NetType.POWER, [("U1", "2")]),
|
||||
"EN_NET": (NetType.SIGNAL, [("U1", "3"), ("R1", "1")]),
|
||||
"GND": (NetType.GROUND, [("U1", "4")]),
|
||||
},
|
||||
bom_fields={
|
||||
"U1": {"mpn": "LDOX", "value": "", "dnp": False},
|
||||
"R1": {"mpn": "R1", "value": "10k", "dnp": False},
|
||||
},
|
||||
)
|
||||
assert check_dnp_enables(g, _cons()) == []
|
||||
|
||||
|
||||
def test_no_dnp_column_skips_floating_enable():
|
||||
g = _graph(
|
||||
{"U1": _ldo()},
|
||||
{
|
||||
"VIN": (NetType.POWER, [("U1", "1")]),
|
||||
"VOUT": (NetType.POWER, [("U1", "2")]),
|
||||
"EN_NET": (NetType.SIGNAL, [("U1", "3")]),
|
||||
"GND": (NetType.GROUND, [("U1", "4")]),
|
||||
},
|
||||
bom_fields={"U1": {"mpn": "LDOX", "value": ""}},
|
||||
)
|
||||
assert check_dnp_enables(g, _cons()) == []
|
||||
|
||||
|
||||
def test_parse_bom_reads_dnp_and_skips_when_column_absent(tmp_path: Path):
|
||||
with_dnp = tmp_path / "dnp.csv"
|
||||
with_dnp.write_text(
|
||||
"Reference,Value,DNP,Manufacturer Part Number\n"
|
||||
"U1,LDO,,LDOX\n"
|
||||
"R1,10k,1,Rpull\n"
|
||||
)
|
||||
bom = parse_bom(with_dnp)
|
||||
assert bom["U1"]["dnp"] is False
|
||||
assert bom["R1"]["dnp"] is True
|
||||
|
||||
no_col = tmp_path / "plain.csv"
|
||||
no_col.write_text("Reference,Value,Manufacturer Part Number\nU1,LDO,LDOX\n")
|
||||
bom2 = parse_bom(no_col)
|
||||
assert "dnp" not in bom2["U1"]
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Regulator Iout margin and series-R IR drop — no invented IQ or traces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.pinscopex.models import (
|
||||
Component,
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
Net,
|
||||
NetType,
|
||||
Pin,
|
||||
PinConnection,
|
||||
ResistorSpecs,
|
||||
SimpleComponentSpecs,
|
||||
)
|
||||
from backend.pinscopex.power_margin_check import check_power_margin
|
||||
|
||||
|
||||
def _graph(components, nets):
|
||||
net_objs = {}
|
||||
for name, (ntype, volt, conns) in nets.items():
|
||||
net_objs[name] = Net(
|
||||
name=name, net_type=ntype, voltage=volt,
|
||||
pins=[PinConnection(component_ref=r, pin_number=str(p)) for r, p in conns],
|
||||
)
|
||||
return DesignGraph(components=components, nets=net_objs)
|
||||
|
||||
|
||||
def _cons():
|
||||
return {
|
||||
"LDOX": ComponentConstraints(
|
||||
mpn="LDOX", component_subtype="ic.power.ldo",
|
||||
pintable=[
|
||||
Pin(number=1, name="VIN"),
|
||||
Pin(number=2, name="VOUT"),
|
||||
Pin(number=3, name="GND"),
|
||||
],
|
||||
absolute_maximum_ratings=[], rules=[],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _ldo(values):
|
||||
return Component(
|
||||
reference="U1", value="", footprint="",
|
||||
component_type=ComponentType.IC, component_subtype="ic.power.ldo",
|
||||
mpn="LDOX",
|
||||
pins={"1": "VIN", "2": "VOUT", "3": "GND"},
|
||||
specs=SimpleComponentSpecs(specs_type="ic", component_subtype="ic.power.ldo", values=values),
|
||||
)
|
||||
|
||||
|
||||
def _mcu(iq=None):
|
||||
values = {} if iq is None else {"iq_a": iq}
|
||||
return Component(
|
||||
reference="U2", value="", footprint="",
|
||||
component_type=ComponentType.IC, mpn="MCU",
|
||||
pins={"1": "VOUT", "2": "GND"},
|
||||
specs=SimpleComponentSpecs(specs_type="ic", values=values) if values else None,
|
||||
)
|
||||
|
||||
|
||||
def test_load_over_iout_max_is_ps_pwr_001():
|
||||
g = _graph(
|
||||
{
|
||||
"U1": _ldo({"i_load_a": 0.4, "iout_max_a": 0.5}),
|
||||
"U2": _mcu(0.2),
|
||||
},
|
||||
{
|
||||
"VIN": (NetType.POWER, 5.0, [("U1", "1")]),
|
||||
"VOUT": (NetType.POWER, 3.3, [("U1", "2"), ("U2", "1")]),
|
||||
"GND": (NetType.GROUND, 0.0, [("U1", "3"), ("U2", "2")]),
|
||||
},
|
||||
)
|
||||
findings = check_power_margin(g, _cons())
|
||||
assert any(f.rule_id == "PS-PWR-001" and f.designator == "U1" for f in findings)
|
||||
|
||||
|
||||
def test_missing_iq_is_not_guessed_into_margin_fail():
|
||||
g = _graph(
|
||||
{
|
||||
"U1": _ldo({"iout_max_a": 0.1}),
|
||||
"U2": _mcu(None),
|
||||
},
|
||||
{
|
||||
"VIN": (NetType.POWER, 5.0, [("U1", "1")]),
|
||||
"VOUT": (NetType.POWER, 3.3, [("U1", "2"), ("U2", "1")]),
|
||||
"GND": (NetType.GROUND, 0.0, [("U1", "3"), ("U2", "2")]),
|
||||
},
|
||||
)
|
||||
assert check_power_margin(g, _cons()) == []
|
||||
|
||||
|
||||
def test_series_r_ir_drop_uses_i_load_not_trace():
|
||||
r = Component(
|
||||
reference="R1", value="1", footprint="",
|
||||
component_type=ComponentType.RESISTOR, mpn="R1",
|
||||
pins={"1": "USB", "2": "VIN"},
|
||||
specs=ResistorSpecs(value_ohms=1.0, value_formatted="1"),
|
||||
)
|
||||
g = _graph(
|
||||
{"U1": _ldo({"i_load_a": 0.5, "iout_max_a": 1.0}), "R1": r},
|
||||
{
|
||||
"USB": (NetType.POWER, 5.0, [("R1", "1")]),
|
||||
"VIN": (NetType.POWER, 5.0, [("R1", "2"), ("U1", "1")]),
|
||||
"VOUT": (NetType.POWER, 3.3, [("U1", "2")]),
|
||||
"GND": (NetType.GROUND, 0.0, [("U1", "3")]),
|
||||
},
|
||||
)
|
||||
findings = check_power_margin(g, _cons())
|
||||
assert any(f.designator == "R1" and f.rule_id == "PS-PWR-001" for f in findings)
|
||||
|
||||
|
||||
def test_no_series_r_does_not_invent_trace_drop():
|
||||
g = _graph(
|
||||
{"U1": _ldo({"i_load_a": 0.5, "iout_max_a": 1.0})},
|
||||
{
|
||||
"VIN": (NetType.POWER, 5.0, [("U1", "1")]),
|
||||
"VOUT": (NetType.POWER, 3.3, [("U1", "2")]),
|
||||
"GND": (NetType.GROUND, 0.0, [("U1", "3")]),
|
||||
},
|
||||
)
|
||||
assert check_power_margin(g, _cons()) == []
|
||||
@@ -0,0 +1,91 @@
|
||||
"""PG→EN sequencing only when power_sequence is in IC specs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.pinscopex.models import (
|
||||
Component,
|
||||
ComponentConstraints,
|
||||
ComponentType,
|
||||
DesignGraph,
|
||||
Net,
|
||||
NetType,
|
||||
Pin,
|
||||
PinConnection,
|
||||
SimpleComponentSpecs,
|
||||
)
|
||||
from backend.pinscopex.sequencing_check import check_power_sequencing
|
||||
|
||||
|
||||
def _graph(components, nets):
|
||||
net_objs = {
|
||||
name: Net(
|
||||
name=name, net_type=ntype,
|
||||
pins=[PinConnection(component_ref=r, pin_number=str(p)) for r, p in conns],
|
||||
)
|
||||
for name, (ntype, conns) in nets.items()
|
||||
}
|
||||
return DesignGraph(components=components, nets=net_objs)
|
||||
|
||||
|
||||
def _cons():
|
||||
return {
|
||||
"L1": ComponentConstraints(
|
||||
mpn="L1", component_subtype="ic.power.ldo",
|
||||
pintable=[
|
||||
Pin(number=1, name="VIN"), Pin(number=2, name="VOUT"),
|
||||
Pin(number=3, name="PG"), Pin(number=4, name="GND"),
|
||||
],
|
||||
absolute_maximum_ratings=[], rules=[],
|
||||
),
|
||||
"L2": ComponentConstraints(
|
||||
mpn="L2", component_subtype="ic.power.ldo",
|
||||
pintable=[
|
||||
Pin(number=1, name="VIN"), Pin(number=2, name="VOUT"),
|
||||
Pin(number=3, name="EN"), Pin(number=4, name="GND"),
|
||||
],
|
||||
absolute_maximum_ratings=[], rules=[],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _ldo(ref, mpn, pins, values=None):
|
||||
return Component(
|
||||
reference=ref, value="", footprint="",
|
||||
component_type=ComponentType.IC, component_subtype="ic.power.ldo",
|
||||
mpn=mpn, pins=pins,
|
||||
specs=SimpleComponentSpecs(
|
||||
specs_type="ic", component_subtype="ic.power.ldo", values=values or {},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _dual(pg_net, en_net, sequence=True):
|
||||
u1 = _ldo("U1", "L1", {"1": "5V", "2": "3V3", "3": pg_net, "4": "GND"})
|
||||
vals = {"power_sequence": "pg_before_en"} if sequence else {}
|
||||
u2 = _ldo("U2", "L2", {"1": "3V3", "2": "1V8", "3": en_net, "4": "GND"}, vals)
|
||||
return _graph(
|
||||
{"U1": u1, "U2": u2},
|
||||
{
|
||||
"5V": (NetType.POWER, [("U1", "1")]),
|
||||
"3V3": (NetType.POWER, [("U1", "2"), ("U2", "1")]),
|
||||
"1V8": (NetType.POWER, [("U2", "2")]),
|
||||
pg_net: (NetType.SIGNAL, [("U1", "3")] + ([("U2", "3")] if pg_net == en_net else [])),
|
||||
**({en_net: (NetType.SIGNAL, [("U2", "3")])} if pg_net != en_net else {}),
|
||||
"GND": (NetType.GROUND, [("U1", "4"), ("U2", "4")]),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_pg_not_tied_to_en_is_warning():
|
||||
findings = check_power_sequencing(_dual("PGOOD", "EN_1V8"), _cons())
|
||||
assert len(findings) == 1
|
||||
assert findings[0].rule_id == "PS-SEQ-001"
|
||||
assert findings[0].status == "WARNING"
|
||||
|
||||
|
||||
def test_pg_tied_to_en_is_silent():
|
||||
assert check_power_sequencing(_dual("SEQ", "SEQ"), _cons()) == []
|
||||
|
||||
|
||||
def test_no_power_sequence_spec_skips_even_if_pg_open():
|
||||
assert check_power_sequencing(_dual("PGOOD", "EN_1V8", sequence=False), _cons()) == []
|
||||
Reference in New Issue
Block a user