Add lifecycle, errata, internal-features, and layout_rules checks.

Cache distributor EOL/NRND/RoHS without inventing replacements, flag catalogued errata pull-ups only, and store layout_rules with a closed kind enum so millimetres stay null unless the datasheet stated a number.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-10 22:48:27 +02:00
co-authored by Cursor
parent aa375349bd
commit 7aed64cae5
17 changed files with 867 additions and 5 deletions
+94
View File
@@ -0,0 +1,94 @@
"""Errata catalog — workaround on the graph, no scraping.
Favor: known MPN with a pull-up workaround missing on the net → PS-ERRATA-001.
Against: MPN not in catalog is silent (even TI-looking); workaround pull-up
present is silent; catalog entry without url is skipped.
"""
from __future__ import annotations
from backend.pinscopex.errata_check import check_errata
from backend.pinscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
Net,
NetType,
Pin,
PinConnection,
ResistorSpecs,
)
def _cons():
return {
"ERRX": ComponentConstraints(
mpn="ERRX",
pintable=[Pin(number=1, name="NRST"), Pin(number=2, name="GND")],
absolute_maximum_ratings=[], rules=[],
)
}
def _graph(with_pull: bool):
u = Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="ERRX",
pins={"1": "NRST", "2": "GND"},
)
comps = {"U1": u}
nets = {
"NRST": (NetType.SIGNAL, [("U1", "1")]),
"GND": (NetType.GROUND, [("U1", "2")]),
"3V3": (NetType.POWER, []),
}
if with_pull:
r = Component(
reference="R1", value="10k", footprint="",
component_type=ComponentType.RESISTOR, mpn="R1",
pins={"1": "NRST", "2": "3V3"},
specs=ResistorSpecs(value_ohms=10000, value_formatted="10k"),
)
comps["R1"] = r
nets["NRST"] = (NetType.SIGNAL, [("U1", "1"), ("R1", "1")])
nets["3V3"] = (NetType.POWER, [("R1", "2")])
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=comps, nets=net_objs)
CATALOG = {
"ERRX": {
"url": "https://www.ti.com/lit/er/fixture",
"workarounds": [
{"kind": "pullup", "pin_name": "NRST", "note": "10 kΩ to VDD per errata"},
],
}
}
def test_missing_errata_pullup_is_ps_errata_001():
findings = check_errata(_graph(False), _cons(), CATALOG)
assert len(findings) == 1
assert findings[0].rule_id == "PS-ERRATA-001"
assert findings[0].status == "WARNING"
assert findings[0].source == "errata_check"
assert "ti.com/lit/er" in findings[0].reference
def test_pullup_present_is_silent():
assert check_errata(_graph(True), _cons(), CATALOG) == []
def test_unknown_mpn_and_url_less_entry_are_silent():
g = _graph(False)
g.components["U1"].mpn = "UNKNOWNPART"
assert check_errata(g, _cons(), CATALOG) == []
no_url = {"ERRX": {"url": "", "workarounds": [{"kind": "pullup", "pin_name": "NRST"}]}}
assert check_errata(_graph(False), _cons(), no_url) == []
+83
View File
@@ -0,0 +1,83 @@
"""Internal features (block-diagram extraction) — open-drain pull-up.
Favor: pin listed in pullup_pins with no resistor to a rail → PS-INT-001.
Against: empty internal_features is silent; listed pin with a pull-up is
silent; a pin not in pullup_pins is not guessed as open-drain.
"""
from __future__ import annotations
from backend.pinscopex.internal_features_check import check_internal_features
from backend.pinscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
InternalFeatures,
Net,
NetType,
Pin,
PinConnection,
ResistorSpecs,
)
def _graph(with_pull: bool):
u = Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="UTEST",
pins={"1": "SDA", "2": "GND"},
)
comps = {"U1": u}
nets = {
"SDA": (NetType.SIGNAL, [("U1", "1")]),
"GND": (NetType.GROUND, [("U1", "2")]),
"3V3": (NetType.POWER, []),
}
if with_pull:
r = Component(
reference="R1", value="4k7", footprint="",
component_type=ComponentType.RESISTOR, mpn="R1",
pins={"1": "SDA", "2": "3V3"},
specs=ResistorSpecs(value_ohms=4700, value_formatted="4k7"),
)
comps["R1"] = r
nets["SDA"] = (NetType.SIGNAL, [("U1", "1"), ("R1", "1")])
nets["3V3"] = (NetType.POWER, [("R1", "2")])
net_objs = {
name: Net(
name=name, net_type=ntype,
pins=[PinConnection(component_ref=a, pin_number=str(b)) for a, b in conns],
)
for name, (ntype, conns) in nets.items()
}
return DesignGraph(components=comps, nets=net_objs)
def _cons(features: InternalFeatures | None):
return {
"UTEST": ComponentConstraints(
mpn="UTEST",
pintable=[Pin(number=1, name="SDA"), Pin(number=2, name="GND")],
absolute_maximum_ratings=[], rules=[],
internal_features=features,
)
}
def test_listed_open_drain_without_pull_is_warning():
feats = InternalFeatures(pullup_pins=["SDA"])
findings = check_internal_features(_graph(False), _cons(feats))
assert len(findings) == 1
assert findings[0].rule_id == "PS-INT-001"
assert findings[0].status == "WARNING"
def test_listed_pin_with_pullup_is_silent():
feats = InternalFeatures(pullup_pins=["SDA"])
assert check_internal_features(_graph(True), _cons(feats)) == []
def test_empty_features_does_not_guess_open_drain():
assert check_internal_features(_graph(False), _cons(None)) == []
assert check_internal_features(_graph(False), _cons(InternalFeatures())) == []
+54
View File
@@ -0,0 +1,54 @@
"""layout_rules from datasheet — closed kind enum, no invented millimetres.
Favor: decoupling_proximity with a numeric max_distance_mm and source_page;
empty list is valid (explicit skip).
Against: unknown kind is rejected; a non-numeric distance becomes null
(not a guessed JEDEC 3 mm); thermal_via without min_via_count is kept
but distance stays unset.
"""
from __future__ import annotations
from backend.pinscopex.layout_rules import validate_layout_rules
def test_valid_decoupling_proximity_keeps_distance():
ok, errors = validate_layout_rules([
{
"kind": "decoupling_proximity",
"pin": "VDD",
"cap_value_hint": "100nF",
"max_distance_mm": 2.0,
"same_layer": True,
"source_page": 14,
}
])
assert errors == []
assert len(ok) == 1
assert ok[0]["max_distance_mm"] == 2.0
assert ok[0]["kind"] == "decoupling_proximity"
def test_empty_list_is_explicit_skip():
ok, errors = validate_layout_rules([])
assert ok == []
assert errors == []
def test_unknown_kind_rejected_and_bad_distance_not_invented():
ok, errors = validate_layout_rules([
{"kind": "jedec_land_pattern", "max_distance_mm": 3.0},
{
"kind": "decoupling_proximity",
"pin": "VDD",
"max_distance_mm": "close",
"source_page": 2,
},
{"kind": "thermal_via", "pin": "EP", "min_via_count": 4},
])
assert any("kind" in e.lower() or "jedec" in e.lower() for e in errors)
dist_rows = [r for r in ok if r["kind"] == "decoupling_proximity"]
assert len(dist_rows) == 1
assert dist_rows[0]["max_distance_mm"] is None
via = [r for r in ok if r["kind"] == "thermal_via"]
assert len(via) == 1 and via[0]["min_via_count"] == 4
+98
View File
@@ -0,0 +1,98 @@
"""Lifecycle from distributor payload.
Favor: DigiKey Obsolete → PS-LF-001 WARNING and uses ProductSubstitutions;
NRND → PS-LF-002 INFO; explicit RoHS Non-Compliant → PS-LF-003.
Against: Active is silent; RoHS Not Applicable is not a fail; missing
catalog row is silent; no substitution key means no invented replacement.
"""
from __future__ import annotations
from backend.pinscopex.lifecycle import (
check_lifecycle,
parse_distributor_product,
)
from backend.pinscopex.models import Component, ComponentType, DesignGraph, Net, NetType, PinConnection
def _graph(mpn="PARTX"):
u = Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn=mpn,
pins={"1": "VDD"},
)
return DesignGraph(
components={"U1": u},
nets={"VDD": Net(name="VDD", net_type=NetType.POWER, pins=[
PinConnection(component_ref="U1", pin_number="1"),
])},
)
def test_obsolete_is_warning_and_uses_distributor_replacement():
rec = parse_distributor_product("ABC", {
"ManufacturerProductNumber": "ABC",
"ProductStatus": "Obsolete",
"RoHSStatus": "RoHS3 Compliant",
"QuantityAvailable": 12,
"ManufacturerLeadWeeks": "8",
"ProductSubstitutions": [{"ManufacturerProductNumber": "ABC-B"}],
})
assert rec.lifecycle == "eol"
assert rec.rohs_compliant is True
assert rec.stock == 12
assert rec.replacement == "ABC-B"
findings = check_lifecycle(_graph("ABC"), {"ABC": rec})
assert len(findings) == 1
assert findings[0].rule_id == "PS-LF-001"
assert findings[0].status == "WARNING"
assert findings[0].source == "lifecycle_check"
assert "ABC-B" in (findings[0].recommendation or "")
def test_nrnd_is_info():
rec = parse_distributor_product("N1", {"ProductStatus": "Not For New Designs"})
assert rec.lifecycle == "nrnd"
findings = check_lifecycle(_graph("N1"), {"N1": rec})
assert len(findings) == 1
assert findings[0].rule_id == "PS-LF-002"
assert findings[0].status == "INFO"
def test_explicit_rohs_non_compliant_is_warning():
rec = parse_distributor_product("R1", {
"ProductStatus": "Active",
"RoHSStatus": "Non-Compliant",
})
assert rec.rohs_compliant is False
findings = check_lifecycle(_graph("R1"), {"R1": rec})
assert len(findings) == 1
assert findings[0].rule_id == "PS-LF-003"
assert findings[0].status == "WARNING"
def test_active_is_silent():
rec = parse_distributor_product("A1", {"ProductStatus": "Active"})
assert rec.lifecycle == "active"
assert check_lifecycle(_graph("A1"), {"A1": rec}) == []
def test_rohs_not_applicable_is_not_a_fail():
rec = parse_distributor_product("R2", {
"ProductStatus": "Active",
"RoHSStatus": "Not Applicable",
})
assert rec.rohs_compliant is None
assert check_lifecycle(_graph("R2"), {"R2": rec}) == []
def test_missing_catalog_and_missing_substitute_are_not_guessed():
assert check_lifecycle(_graph("NOPE"), {}) == []
rec = parse_distributor_product("EOLX", {"ProductStatus": "Discontinued"})
assert rec.lifecycle == "eol"
assert rec.replacement is None
findings = check_lifecycle(_graph("EOLX"), {"EOLX": rec})
assert len(findings) == 1
assert findings[0].rule_id == "PS-LF-001"
assert "ABC-B" not in (findings[0].recommendation or "")
assert rec.replacement is None