diff --git a/backend/pinscopex/passive_rail_check.py b/backend/pinscopex/passive_rail_check.py index 73d9a9f..ac8fb60 100644 --- a/backend/pinscopex/passive_rail_check.py +++ b/backend/pinscopex/passive_rail_check.py @@ -10,13 +10,18 @@ from __future__ import annotations import re from backend.pinscopex.models import ( + CapacitorSpecs, + Component, ComponentConstraints, ComponentType, DesignGraph, Finding, NetType, + ResistorSpecs, ) from backend.pinscopex.validate import _match_constraints +from backend.pinscopex.led_current_check import _parse_resistance +from backend.pinscopex.resolve_passives import _parse_spice_value _SUPPLY_PIN_RE = re.compile( r"(?:^|[_/])(VDD|VCC|VDDA|VDDD|VDDIO|DVDD|AVDD|IOVDD|VDD33|VDD18|" @@ -38,13 +43,27 @@ _NC_NET_RE = re.compile( r"^(?:n/?c|n\.c\.|nc|unconnected|no[_-]?connect|not[_-]?connected)$", re.IGNORECASE, ) +_OUT_PIN_RE = re.compile( + r"(?:^|[_/])(VOUT|V_OUT|VO|VREG|SWOUT)(?:$|[_/\d])", + re.IGNORECASE, +) +_ACTIVE_LOW_RESET_RE = re.compile( + r"(?:N/?RST|NRST|NRESET|RESET[_-]?N|RSTN)\b", + re.IGNORECASE, +) +# NXP UM10204-style Rp window, widened so 2.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 net has no capacitor to ground.""" + """WARNING when an IC supply/VOUT net has no capacitor to ground, or + only farads well below a typical Cin/Cout when every cap is valued.""" findings: list[Finding] = [] seen_nets: set[str] = set() for ref, comp in sorted(graph.components.items()): @@ -56,38 +75,77 @@ def check_supply_decoupling( continue if _is_nc_net(net_name): continue - if not _is_ic_supply_pin(graph, cons, pin_num, net_name): + role = None + if _is_ic_supply_pin(graph, cons, pin_num, net_name): + role = "supply" + elif _is_regulator_output_pin(cons, pin_num): + role = "output" + if role is None: continue seen_nets.add(net_name) - if _capacitor_to_ground(graph, net_name): - continue pin_label = _pin_label(cons, pin_num, net_name) - findings.append(Finding( - designator=ref, - mpn=comp.mpn or "", - aspect="decoupling", - source="supply_decoupling_check", - source_page=None, - status="WARNING", - finding=( - f"{ref} supply net '{net_name}' ({pin_label}) has no " - f"capacitor to ground." - ), - why=( - f"Pin {pin_label} sits on '{net_name}' and that net has no " - f"capacitor whose other end is ground. Local decoupling " - f"may be missing (or only present on a different island " - f"behind a ferrite)." - ), - recommendation=( - f"Add a decoupling capacitor from '{net_name}' to ground " - f"near {ref}." - ), - reference="netlist topology", - net=net_name, - pins=[f"{ref}.{pin_num}"], - rule_id="PS-DEC-001", - )) + if not _capacitor_to_ground(graph, net_name): + findings.append(Finding( + designator=ref, + mpn=comp.mpn or "", + aspect="decoupling", + source="supply_decoupling_check", + source_page=None, + status="WARNING", + finding=( + f"{ref} supply net '{net_name}' ({pin_label}) has no " + f"capacitor to ground." + if role == "supply" + else ( + f"{ref} regulator output '{net_name}' ({pin_label}) " + f"has no Cout capacitor to ground." + ) + ), + why=( + f"Pin {pin_label} sits on '{net_name}' and that net has no " + f"capacitor whose other end is ground. Local decoupling " + f"may be missing (or only present on a different island " + f"behind a ferrite)." + ), + recommendation=( + f"Add a decoupling capacitor from '{net_name}' to ground " + f"near {ref}." + ), + reference="netlist topology", + net=net_name, + pins=[f"{ref}.{pin_num}"], + rule_id="PS-DEC-001", + )) + continue + min_f = _VOUT_MIN_FARADS if role == "output" else _VDD_MIN_FARADS + max_c = _max_known_cap_farads(graph, net_name) + if max_c is not None and max_c < min_f: + findings.append(Finding( + designator=ref, + mpn=comp.mpn or "", + aspect="decoupling", + source="supply_decoupling_check", + source_page=None, + status="WARNING", + finding=( + f"{ref} net '{net_name}' ({pin_label}) only has " + f"{max_c * 1e6:.3g} µF to ground; typical " + f"{'Cout' if role == 'output' else 'decoupling'} is larger." + ), + why=( + "Cap values are known on this net and the largest is " + "below a wide typical minimum. This is not a datasheet " + "µF requirement — treat as a sizing hint." + ), + recommendation=( + f"Add bulk capacitance on '{net_name}' (often ≥1 µF on " + f"LDO VOUT, ≥100 nF on MCU VDD) if the datasheet agrees." + ), + reference="netlist topology", + net=net_name, + pins=[f"{ref}.{pin_num}"], + rule_id="PS-DEC-002", + )) return findings @@ -114,6 +172,38 @@ def check_i2c_pullups( if net and net.net_type in (NetType.POWER, NetType.GROUND): continue if _resistor_to_power(graph, net_name): + ohms = _parallel_pullup_ohms(graph, net_name) + if ohms is not None and ( + ohms < _RP_MIN_OHM or ohms > _RP_MAX_OHM + ): + pin_label = _pin_label(cons, pin_num, net_name) + findings.append(Finding( + designator=ref, + mpn=comp.mpn or "", + aspect="i2c_pullup", + source="i2c_pullup_check", + source_page=None, + status="WARNING", + finding=( + f"I2C net '{net_name}' ({ref} {pin_label}) pull-up " + f"is {ohms:.3g} Ω (wide NXP-style band " + f"{_RP_MIN_OHM:.0f}–{_RP_MAX_OHM:.0f} Ω)." + ), + why=( + "UM10204 Rp depends on Vdd, Iol and bus capacitance. " + "This bound is wide on purpose; 2.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( @@ -167,9 +257,38 @@ def check_reset_pullups( continue if _other_ic_on_net(graph, net_name, ref): continue + pin_label = _pin_label(cons, pin_num, net_name) + if _is_active_low_reset(cons, pin_num, net_name) and _resistor_to_ground( + graph, net_name + ): + findings.append(Finding( + designator=ref, + mpn=comp.mpn or "", + aspect="reset_pullup", + source="reset_pullup_check", + source_page=None, + status="WARNING", + finding=( + f"{ref} active-low reset '{net_name}' ({pin_label}) " + f"has a pull-down to ground." + ), + why=( + "An active-low NRST/RESET_N pin held down by a resistor " + "will sit in reset unless a stronger pull-up wins. " + "Datasheets that omit an internal pull-up expect a pull-up, " + "not a pull-down." + ), + recommendation=( + f"Remove the pull-down on '{net_name}' or replace it " + f"with a pull-up to the I/O rail." + ), + reference="netlist topology", + net=net_name, + pins=[f"{ref}.{pin_num}"], + rule_id="PS-RST-002", + )) if _resistor_to_power(graph, net_name): continue - pin_label = _pin_label(cons, pin_num, net_name) findings.append(Finding( designator=ref, mpn=comp.mpn or "", @@ -311,6 +430,90 @@ def _resistor_to_power(graph: DesignGraph, net_name: str) -> bool: return False +def _resistor_to_ground(graph: DesignGraph, net_name: str) -> bool: + for ref in graph.components_on_net(net_name): + comp = graph.components[ref] + if comp.component_type != ComponentType.RESISTOR: + continue + others = {n for n in comp.pins.values() if n != net_name} + if any(_is_ground_net(graph, n) for n in others): + return True + return False + + +def _resistor_ohms(comp: Component) -> float | None: + specs = comp.specs + if isinstance(specs, ResistorSpecs) and specs.value_ohms > 0: + return float(specs.value_ohms) + return _parse_resistance(comp.value) + + +def _parallel_pullup_ohms(graph: DesignGraph, net_name: str) -> float | None: + acc = 0.0 + known = 0 + for ref in graph.components_on_net(net_name): + comp = graph.components[ref] + if comp.component_type != ComponentType.RESISTOR: + continue + others = {n for n in comp.pins.values() if n != net_name} + if not any(_is_power_net(graph, n) for n in others): + continue + ohms = _resistor_ohms(comp) + if ohms is None or ohms <= 0: + return None + acc += 1.0 / ohms + known += 1 + if not known or acc <= 0: + return None + return 1.0 / acc + + +def _cap_farads(comp: Component) -> float | None: + specs = comp.specs + if isinstance(specs, CapacitorSpecs) and specs.value_farads > 0: + return float(specs.value_farads) + raw = (comp.value or "").strip() + if not raw: + return None + try: + v = _parse_spice_value(raw) + except ValueError: + return None + return v if v > 0 else None + + +def _max_known_cap_farads(graph: DesignGraph, power_net: str) -> float | None: + known: list[float] = [] + any_unknown = False + for ref in graph.capacitors_on_net(power_net): + cap = graph.components[ref] + others = {n for n in cap.pins.values() if n != power_net} + if not any(_is_ground_net(graph, n) for n in others): + continue + farads = _cap_farads(cap) + if farads is None: + any_unknown = True + continue + known.append(farads) + if any_unknown or not known: + return None + return max(known) + + +def _is_regulator_output_pin( + cons: ComponentConstraints | None, pin_num: str, +) -> bool: + return any(_OUT_PIN_RE.search(t) for t in _pin_name_tokens(cons, pin_num)) + + +def _is_active_low_reset( + cons: ComponentConstraints | None, pin_num: str, net_name: str, +) -> bool: + if _ACTIVE_LOW_RESET_RE.search(net_name or ""): + return True + return any(_ACTIVE_LOW_RESET_RE.search(t) for t in _pin_name_tokens(cons, pin_num)) + + def _other_ic_on_net(graph: DesignGraph, net_name: str, self_ref: str) -> bool: for ref in graph.components_on_net(net_name): if ref == self_ref: diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index 791cfe3..f6e0e4d 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,15 @@ What's new in Pinscope. +## 2.12.0 — 2026-09-10 — Pull-up sizing and LDO Cout + +Deterministic schema checks now size I2C pull-ups, flag NRST pull-downs, and look at LDO VOUT capacitance — still WARNING, never a invented datasheet µF ERROR. + +- [New] I2C pull-up value vs a wide NXP UM10204 band (`PS-I2C-002`). 4.7 kΩ is in-band; missing values are not sized. +- [New] Active-low reset with a resistor to ground is `PS-RST-002`. +- [New] Regulator VOUT needs Cout (`PS-DEC-001`); 100 nF-only on VOUT is `PS-DEC-002`. MCU VDD 100 nF is not flagged. +- [Improved] Pin-mux UART0 on the `simple_project` MSPM0 nets is covered in tests (SPI PICO/POCI already was). + ## 2.11.0 — 2026-09-10 — DeepSeek V4.1 and re-analyze Pinscope now defaults to DeepSeek-V4.1-Flash (`deepseek-flash`) for every LLM stage, shows API cost in dollars, and lets you replace the BOM and netlist on an existing project without deleting it. diff --git a/tests/test_passive_rail_check.py b/tests/test_passive_rail_check.py index b57d303..e915c2b 100644 --- a/tests/test_passive_rail_check.py +++ b/tests/test_passive_rail_check.py @@ -256,3 +256,200 @@ def test_fb_and_rn_prefixes(): assert _classify_component("FB1", "") == ComponentType.INDUCTOR assert _classify_component("RN4", "") == ComponentType.RESISTOR assert _classify_component("F1", "") == ComponentType.FUSE + + +def _res(ref, pins, value="4.7k", ohms=None): + specs = None + if ohms is not None: + from backend.pinscopex.models import ResistorSpecs + specs = ResistorSpecs(value_ohms=ohms, value_formatted=f"{ohms}") + return Component( + reference=ref, value=value, footprint="", + component_type=ComponentType.RESISTOR, mpn=ref, pins=pins, specs=specs, + ) + + +def _cap(ref, pins, value="100n", farads=None): + specs = None + if farads is not None: + from backend.pinscopex.models import CapacitorSpecs + specs = CapacitorSpecs(value_farads=farads, value_formatted=value) + return Component( + reference=ref, value=value, footprint="", + component_type=ComponentType.CAPACITOR, mpn=ref, pins=pins, specs=specs, + ) + + +def _cmap_i2c(): + return { + "UTEST": ComponentConstraints( + mpn="UTEST", + pintable=[Pin(number=8, name="SDA")], + absolute_maximum_ratings=[], rules=[], + ) + } + + +def test_i2c_4k7_pullup_is_in_nxp_wide_band(): + r = _res("R1", {"1": "I2C_SDA", "2": "3V3"}, value="4.7k") + g = _graph( + {"U1": _ic("U1", {"8": "I2C_SDA"}), "R1": r}, + { + "I2C_SDA": (NetType.SIGNAL, [("U1", "8"), ("R1", "1")]), + "3V3": (NetType.POWER, [("R1", "2")]), + }, + ) + assert check_i2c_pullups(g, _cmap_i2c()) == [] + + +def test_i2c_100ohm_pullup_is_too_stiff(): + r = _res("R1", {"1": "I2C_SDA", "2": "3V3"}, ohms=100) + g = _graph( + {"U1": _ic("U1", {"8": "I2C_SDA"}), "R1": r}, + { + "I2C_SDA": (NetType.SIGNAL, [("U1", "8"), ("R1", "1")]), + "3V3": (NetType.POWER, [("R1", "2")]), + }, + ) + findings = check_i2c_pullups(g, _cmap_i2c()) + assert len(findings) == 1 + assert findings[0].rule_id == "PS-I2C-002" + assert findings[0].status == "WARNING" + + +def test_i2c_100k_pullup_is_too_weak(): + r = _res("R1", {"1": "I2C_SDA", "2": "3V3"}, ohms=100_000) + g = _graph( + {"U1": _ic("U1", {"8": "I2C_SDA"}), "R1": r}, + { + "I2C_SDA": (NetType.SIGNAL, [("U1", "8"), ("R1", "1")]), + "3V3": (NetType.POWER, [("R1", "2")]), + }, + ) + findings = check_i2c_pullups(g, _cmap_i2c()) + assert [f.rule_id for f in findings] == ["PS-I2C-002"] + + +def test_i2c_pullup_without_value_is_not_sized(): + r = _res("R1", {"1": "I2C_SDA", "2": "3V3"}, value="") + g = _graph( + {"U1": _ic("U1", {"8": "I2C_SDA"}), "R1": r}, + { + "I2C_SDA": (NetType.SIGNAL, [("U1", "8"), ("R1", "1")]), + "3V3": (NetType.POWER, [("R1", "2")]), + }, + ) + assert check_i2c_pullups(g, _cmap_i2c()) == [] + + +def test_nrst_pulldown_is_warning(): + cons = { + "UTEST": ComponentConstraints( + mpn="UTEST", + pintable=[Pin(number=4, name="NRST")], + absolute_maximum_ratings=[], rules=[], + ) + } + r = _res("R1", {"1": "/NRST", "2": "GND"}, value="10k") + g = _graph( + {"U1": _ic("U1", {"4": "/NRST"}), "R1": r}, + { + "/NRST": (NetType.SIGNAL, [("U1", "4"), ("R1", "1")]), + "GND": (NetType.GROUND, [("R1", "2")]), + }, + ) + findings = check_reset_pullups(g, cons) + assert any(f.rule_id == "PS-RST-002" for f in findings) + + +def test_nrst_pullup_is_not_pulldown(): + cons = { + "UTEST": ComponentConstraints( + mpn="UTEST", + pintable=[Pin(number=4, name="NRST")], + absolute_maximum_ratings=[], rules=[], + ) + } + r = _res("R8", {"1": "+3V3", "2": "/NRST"}, value="5k1") + g = _graph( + {"U1": _ic("U1", {"4": "/NRST"}), "R8": r}, + { + "/NRST": (NetType.SIGNAL, [("U1", "4"), ("R8", "2")]), + "+3V3": (NetType.POWER, [("R8", "1")]), + }, + ) + assert check_reset_pullups(g, cons) == [] + + +def test_ldo_vout_needs_cout(): + cons = { + "LDOX": ComponentConstraints( + mpn="LDOX", + pintable=[ + Pin(number=1, name="VIN"), + Pin(number=2, name="VOUT"), + Pin(number=3, name="GND"), + ], + absolute_maximum_ratings=[], rules=[], + ) + } + cin = _cap("C1", {"1": "VIN", "2": "GND"}, value="1u") + g = _graph( + { + "U1": Component( + reference="U1", value="", footprint="", + component_type=ComponentType.IC, mpn="LDOX", + pins={"1": "VIN", "2": "VOUT", "3": "GND"}, + ), + "C1": cin, + }, + { + "VIN": (NetType.POWER, [("U1", "1"), ("C1", "1")]), + "VOUT": (NetType.POWER, [("U1", "2")]), + "GND": (NetType.GROUND, [("U1", "3"), ("C1", "2")]), + }, + ) + findings = check_supply_decoupling(g, cons) + assert any(f.net == "VOUT" and f.rule_id == "PS-DEC-001" for f in findings) + assert not any(f.net == "VIN" for f in findings) + + +def test_ldo_vout_100n_only_is_value_warning(): + cons = { + "LDOX": ComponentConstraints( + mpn="LDOX", + pintable=[Pin(number=2, name="VOUT")], + absolute_maximum_ratings=[], rules=[], + ) + } + cout = _cap("C2", {"1": "VOUT", "2": "GND"}, farads=100e-9) + g = _graph( + { + "U1": Component( + reference="U1", value="", footprint="", + component_type=ComponentType.IC, mpn="LDOX", + pins={"2": "VOUT"}, + ), + "C2": cout, + }, + { + "VOUT": (NetType.POWER, [("U1", "2"), ("C2", "1")]), + "GND": (NetType.GROUND, [("C2", "2")]), + }, + ) + findings = check_supply_decoupling(g, cons) + assert len(findings) == 1 + assert findings[0].rule_id == "PS-DEC-002" + assert findings[0].status == "WARNING" + + +def test_vdd_100n_is_not_a_value_warning(): + cap = _cap("C1", {"1": "3V3", "2": "GND"}, farads=100e-9) + g = _graph( + {"U1": _ic("U1", {"1": "3V3", "2": "GND"}), "C1": cap}, + { + "3V3": (NetType.POWER, [("U1", "1"), ("C1", "1")]), + "GND": (NetType.GROUND, [("U1", "2"), ("C1", "2")]), + }, + ) + assert check_supply_decoupling(g, _cmap_vdd()) == [] diff --git a/tests/test_pin_mux_check.py b/tests/test_pin_mux_check.py index 7089c99..0230d73 100644 --- a/tests/test_pin_mux_check.py +++ b/tests/test_pin_mux_check.py @@ -178,6 +178,52 @@ def test_spi_controller_peripheral_names_are_synonyms(): assert normalize_functions(["SPI0_STE0"]) == {("SPI0", "NSS")} +def test_simple_project_uart0_nets_are_feasible_on_mspm0_pins(): + from pathlib import Path + + from backend.pinscopex.models import DesignGraph + + graph = DesignGraph.model_validate_json( + (Path(__file__).resolve().parents[1] / "simple_project" / "design_graph.json").read_text() + ) + cmap = { + "MSPM0G3507SPTR": _constraints( + "MSPM0G3507SPTR", + [ + Pin(number=1, name="PA11", functions=["UART0_TX", "SPI1_CS1"]), + Pin(number=2, name="PA12", functions=["UART0_RX", "SPI1_CS0"]), + ], + ) + } + findings = check_pin_mux_feasibility(graph, cmap) + uart = [f for f in findings if f.net and "UART0" in f.net] + assert uart == [] + + +def test_simple_project_uart0_swapped_on_mspm0_is_error(): + from pathlib import Path + + from backend.pinscopex.models import DesignGraph + + graph = DesignGraph.model_validate_json( + (Path(__file__).resolve().parents[1] / "simple_project" / "design_graph.json").read_text() + ) + cmap = { + "MSPM0G3507SPTR": _constraints( + "MSPM0G3507SPTR", + [ + Pin(number=1, name="PA11", functions=["UART0_RX"]), + Pin(number=2, name="PA12", functions=["UART0_TX"]), + ], + ) + } + findings = check_pin_mux_feasibility(graph, cmap) + nets = {f.net for f in findings} + assert "/UART0.TX" in nets + assert "/UART0.RX" in nets + assert all(f.status == "ERROR" for f in findings if f.net and "UART0" in f.net) + + def test_spi_genuine_infeasibility_still_fires_with_modern_names(): # Net asserts SPI0_MOSI on a pin that exposes SPI0 only as POCI (==MISO) — # genuinely infeasible even after synonym collapse -> ERROR.