From 78013bd404f0230bfcc504e9f4ca9b9351dd4253 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Thu, 10 Sep 2026 22:34:06 +0200 Subject: [PATCH] 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 --- backend/pinscopex/dnp_check.py | 112 +++++++++++++++ backend/pinscopex/eval_report.py | 6 + backend/pinscopex/graph.py | 12 +- backend/pinscopex/parsers.py | 17 ++- backend/pinscopex/power_margin_check.py | 183 ++++++++++++++++++++++++ backend/pinscopex/sequencing_check.py | 109 ++++++++++++++ backend/pinscopex/thermal_check.py | 8 +- backend/services/validation.py | 6 + frontend/content/changelog.md | 8 ++ frontend/src/lib/types.ts | 2 +- tests/test_dnp_check.py | 130 +++++++++++++++++ tests/test_power_margin_check.py | 124 ++++++++++++++++ tests/test_sequencing_check.py | 91 ++++++++++++ 13 files changed, 800 insertions(+), 8 deletions(-) create mode 100644 backend/pinscopex/dnp_check.py create mode 100644 backend/pinscopex/power_margin_check.py create mode 100644 backend/pinscopex/sequencing_check.py create mode 100644 tests/test_dnp_check.py create mode 100644 tests/test_power_margin_check.py create mode 100644 tests/test_sequencing_check.py diff --git a/backend/pinscopex/dnp_check.py b/backend/pinscopex/dnp_check.py new file mode 100644 index 0000000..225df1e --- /dev/null +++ b/backend/pinscopex/dnp_check.py @@ -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 diff --git a/backend/pinscopex/eval_report.py b/backend/pinscopex/eval_report.py index 1001d3e..45909b7 100644 --- a/backend/pinscopex/eval_report.py +++ b/backend/pinscopex/eval_report.py @@ -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 diff --git a/backend/pinscopex/graph.py b/backend/pinscopex/graph.py index c29dc99..44956c1 100644 --- a/backend/pinscopex/graph.py +++ b/backend/pinscopex/graph.py @@ -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, diff --git a/backend/pinscopex/parsers.py b/backend/pinscopex/parsers.py index eb8c586..951a365 100644 --- a/backend/pinscopex/parsers.py +++ b/backend/pinscopex/parsers.py @@ -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 diff --git a/backend/pinscopex/power_margin_check.py b/backend/pinscopex/power_margin_check.py new file mode 100644 index 0000000..acd9bd7 --- /dev/null +++ b/backend/pinscopex/power_margin_check.py @@ -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 diff --git a/backend/pinscopex/sequencing_check.py b/backend/pinscopex/sequencing_check.py new file mode 100644 index 0000000..0fc48cc --- /dev/null +++ b/backend/pinscopex/sequencing_check.py @@ -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 diff --git a/backend/pinscopex/thermal_check.py b/backend/pinscopex/thermal_check.py index 339dfa2..ccf46b8 100644 --- a/backend/pinscopex/thermal_check.py +++ b/backend/pinscopex/thermal_check.py @@ -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 diff --git a/backend/services/validation.py b/backend/services/validation.py index b5a384e..3df3b74 100644 --- a/backend/services/validation.py +++ b/backend/services/validation.py @@ -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()) diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index dbf71c5..1ecebba 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -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. diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 1806591..afb5248 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -94,7 +94,7 @@ export interface Net { export interface DesignGraph { components: Record; nets: Record; - bom_fields?: Record; + bom_fields?: Record; schematic_fields?: Record; } diff --git a/tests/test_dnp_check.py b/tests/test_dnp_check.py new file mode 100644 index 0000000..9081116 --- /dev/null +++ b/tests/test_dnp_check.py @@ -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"] diff --git a/tests/test_power_margin_check.py b/tests/test_power_margin_check.py new file mode 100644 index 0000000..a5071ef --- /dev/null +++ b/tests/test_power_margin_check.py @@ -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()) == [] diff --git a/tests/test_sequencing_check.py b/tests/test_sequencing_check.py new file mode 100644 index 0000000..6db29d7 --- /dev/null +++ b/tests/test_sequencing_check.py @@ -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()) == []