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
+90
View File
@@ -0,0 +1,90 @@
"""Errata workarounds from a known-URL catalog. No HTML scrape."""
from __future__ import annotations
import logging
import re
from backend.pinscopex.models import ComponentConstraints, DesignGraph, Finding
from backend.pinscopex.passive_rail_check import (
_pin_name_tokens,
_resistor_to_power,
)
from backend.pinscopex.validate import _match_constraints
log = logging.getLogger(__name__)
# Exact MPN → URL + structured workarounds. Empty by default so eval is quiet.
DEFAULT_ERRATA_CATALOG: dict[str, dict] = {}
def check_errata(
graph: DesignGraph,
constraints_map: dict[str, ComponentConstraints] | None,
catalog: dict[str, dict] | None = None,
) -> list[Finding]:
cmap = constraints_map or {}
cat = DEFAULT_ERRATA_CATALOG if catalog is None else catalog
findings: list[Finding] = []
for ref, comp in sorted(graph.components.items()):
mpn = (comp.mpn or "").strip()
if not mpn:
continue
entry = cat.get(mpn)
if entry is None:
continue
url = (entry.get("url") or "").strip()
if not url:
log.info("errata skip %s: catalog row has no url", mpn)
continue
cons = _match_constraints(mpn, cmap)
for wa in entry.get("workarounds") or []:
kind = (wa.get("kind") or "").lower()
pin_name = (wa.get("pin_name") or "").strip()
if kind != "pullup" or not pin_name:
continue
net = _net_for_pin_name(graph, ref, cons, pin_name)
if not net:
continue
if _resistor_to_power(graph, net):
continue
findings.append(Finding(
designator=ref,
mpn=mpn,
aspect="errata",
source="errata_check",
status="WARNING",
finding=(
f"{ref} {pin_name} is missing the errata pull-up on '{net}'."
),
why=wa.get("note") or "Vendor errata workaround is not on the schematic.",
recommendation="Add the pull-up described in the errata, or confirm the die revision.",
reference=url,
net=net,
pins=[f"{ref}.{pin_name}"],
rule_id="PS-ERRATA-001",
))
return findings
def _net_for_pin_name(
graph: DesignGraph,
ref: str,
cons: ComponentConstraints | None,
pin_name: str,
) -> str | None:
comp = graph.components.get(ref)
if not comp:
return None
want = pin_name.upper()
for pin_num, net in comp.pins.items():
if (net or "").upper() == want:
return net
tokens = _pin_name_tokens(cons, pin_num)
if any(t.upper() == want or _token_match(t, pin_name) for t in tokens):
return net
return None
def _token_match(token: str, pin_name: str) -> bool:
return bool(re.search(rf"(?:^|[_/]){re.escape(pin_name)}(?:$|[_/\d])", token, re.I))
+6
View File
@@ -27,6 +27,9 @@ from backend.pinscopex.thermal_check import check_thermal
from backend.pinscopex.power_margin_check import check_power_margin from backend.pinscopex.power_margin_check import check_power_margin
from backend.pinscopex.sequencing_check import check_power_sequencing from backend.pinscopex.sequencing_check import check_power_sequencing
from backend.pinscopex.dnp_check import check_dnp_enables from backend.pinscopex.dnp_check import check_dnp_enables
from backend.pinscopex.lifecycle import check_lifecycle
from backend.pinscopex.errata_check import check_errata
from backend.pinscopex.internal_features_check import check_internal_features
class EvalScores(BaseModel): class EvalScores(BaseModel):
@@ -93,6 +96,9 @@ def run_deterministic_on_graph(graph: DesignGraph) -> list[Finding]:
out.extend(check_power_margin(graph, cmap)) out.extend(check_power_margin(graph, cmap))
out.extend(check_power_sequencing(graph, cmap)) out.extend(check_power_sequencing(graph, cmap))
out.extend(check_dnp_enables(graph, cmap)) out.extend(check_dnp_enables(graph, cmap))
out.extend(check_lifecycle(graph, {}))
out.extend(check_errata(graph, cmap))
out.extend(check_internal_features(graph, cmap))
return out return out
@@ -0,0 +1,56 @@
"""Open-drain / on-die pull-up pins from extracted internal_features."""
from __future__ import annotations
from backend.pinscopex.models import ComponentConstraints, DesignGraph, Finding
from backend.pinscopex.passive_rail_check import (
_pin_name_tokens,
_resistor_to_power,
)
from backend.pinscopex.validate import _match_constraints
def check_internal_features(
graph: DesignGraph,
constraints_map: dict[str, ComponentConstraints] | None = None,
) -> list[Finding]:
cmap = constraints_map or {}
findings: list[Finding] = []
for ref, comp in sorted(graph.components.items()):
cons = _match_constraints(comp.mpn or comp.value, cmap)
feats = cons.internal_features if cons else None
if not feats or not feats.pullup_pins:
continue
for pin_name in feats.pullup_pins:
net = None
for pin_num, n in comp.pins.items():
tokens = _pin_name_tokens(cons, pin_num)
names = tokens or [n or "", str(pin_num)]
if any(
t.upper() == pin_name.upper() or (n or "").upper() == pin_name.upper()
for t in names
):
net = n
break
if not net:
continue
if _resistor_to_power(graph, net):
continue
findings.append(Finding(
designator=ref,
mpn=comp.mpn or "",
aspect="internal_features",
source="internal_features_check",
status="WARNING",
finding=(
f"{ref} {pin_name} is listed as needing an external pull-up "
f"and net '{net}' has none."
),
why="internal_features.pullup_pins from the datasheet block diagram.",
recommendation="Add a pull-up to the I/O rail, or confirm an on-die pull is enabled.",
reference="internal_features",
net=net,
pins=[f"{ref}.{pin_name}"],
rule_id="PS-INT-001",
))
return findings
+60
View File
@@ -0,0 +1,60 @@
"""Validate datasheet layout_rules. Distances stay null unless numeric."""
from __future__ import annotations
from typing import Any
KNOWN_KINDS = frozenset({"decoupling_proximity", "thermal_via", "keepout"})
def _num(v: Any) -> float | None:
if v is None or v is False:
return None
if isinstance(v, bool):
return None
if isinstance(v, (int, float)):
return float(v)
try:
return float(str(v).strip())
except (TypeError, ValueError):
return None
def validate_layout_rules(raw: list | None) -> tuple[list[dict], list[str]]:
"""Return (normalized rows, errors). Empty list is a valid skip."""
if not raw:
return [], []
if not isinstance(raw, list):
return [], ["layout_rules must be an array"]
ok: list[dict] = []
errors: list[str] = []
for i, row in enumerate(raw):
if not isinstance(row, dict):
errors.append(f"layout_rules[{i}] must be an object")
continue
kind = str(row.get("kind") or "").strip()
if kind not in KNOWN_KINDS:
errors.append(f"layout_rules[{i}] unknown kind {kind!r}")
continue
dist = _num(row.get("max_distance_mm"))
via = row.get("min_via_count")
via_i = None
if isinstance(via, int) and not isinstance(via, bool):
via_i = via
elif via is not None:
n = _num(via)
via_i = int(n) if n is not None else None
page = row.get("source_page")
page_i = int(page) if isinstance(page, int) else None
ok.append({
"kind": kind,
"pin": row.get("pin"),
"cap_value_hint": row.get("cap_value_hint"),
"max_distance_mm": dist,
"same_layer": row.get("same_layer") if isinstance(row.get("same_layer"), bool) else None,
"min_via_count": via_i,
"net_class": row.get("net_class"),
"note": row.get("note"),
"source_page": page_i,
})
return ok, errors
+221
View File
@@ -0,0 +1,221 @@
"""Distributor lifecycle / RoHS — cached records only, never a guessed equivalent."""
from __future__ import annotations
import json
import re
from pathlib import Path
from pydantic import BaseModel
from backend.pinscopex.models import ComponentType, DesignGraph, Finding
from backend.pinscopex.utils import safe_mpn
_EOL = re.compile(
r"\b(obsolete|eol|end\s*of\s*life|discontinued|last\s*time\s*buy|ltb)\b",
re.I,
)
_NRND = re.compile(
r"\b(nrnd|not\s+for\s+new\s+designs|not\s+recommended)\b",
re.I,
)
_ACTIVE = re.compile(r"\b(active|production|recommended)\b", re.I)
_ROHS_NO = re.compile(r"\b(non[-\s]?compliant|not\s+compliant|no)\b", re.I)
_ROHS_YES = re.compile(r"\b(rohs\s*\d*\s*compliant|compliant|yes|true)\b", re.I)
_ROHS_NA = re.compile(r"\b(not\s+applicable|n/?a|exempt)\b", re.I)
class LifecycleRecord(BaseModel):
mpn: str
source: str = ""
lifecycle: str | None = None # active | nrnd | eol | unknown
rohs_compliant: bool | None = None
stock: int | None = None
lead_time: str | None = None
replacement: str | None = None
product_status_raw: str = ""
def _status_lifecycle(raw: str) -> str | None:
s = (raw or "").strip()
if not s:
return None
if _EOL.search(s):
return "eol"
if _NRND.search(s):
return "nrnd"
if _ACTIVE.search(s):
return "active"
return "unknown"
def _rohs(raw: str) -> bool | None:
s = (raw or "").strip()
if not s:
return None
if _ROHS_NA.search(s):
return None
if _ROHS_NO.search(s):
return False
if _ROHS_YES.search(s):
return True
return None
def _replacement(product: dict) -> str | None:
for key in ("ProductSubstitutions", "Substitutes", "replacement", "Replacement"):
val = product.get(key)
if not val:
continue
if isinstance(val, str) and val.strip():
return val.strip()
if isinstance(val, list) and val:
first = val[0]
if isinstance(first, str) and first.strip():
return first.strip()
if isinstance(first, dict):
for k in ("ManufacturerProductNumber", "ManufacturerPartNumber", "mpn"):
if first.get(k):
return str(first[k]).strip()
return None
def parse_distributor_product(mpn: str, product: dict, *, source: str = "digikey") -> LifecycleRecord:
"""Map a DigiKey/Mouser/LCSC product dict. Unknown fields stay None."""
status = (
product.get("ProductStatus")
or product.get("productStatus")
or product.get("partLifeCycle")
or product.get("LifecycleStatus")
or ""
)
rohs_raw = (
product.get("RoHSStatus")
or product.get("rohsStatus")
or product.get("rohs")
or ""
)
if isinstance(rohs_raw, bool):
rohs = rohs_raw
rohs_raw = "true" if rohs_raw else "false"
else:
rohs = _rohs(str(rohs_raw))
stock = product.get("QuantityAvailable")
if stock is None:
stock = product.get("stock")
try:
stock_i = int(stock) if stock is not None else None
except (TypeError, ValueError):
stock_i = None
lead = product.get("ManufacturerLeadWeeks") or product.get("lead_time") or product.get("LeadTime")
return LifecycleRecord(
mpn=mpn,
source=source,
lifecycle=_status_lifecycle(str(status)),
rohs_compliant=rohs,
stock=stock_i,
lead_time=str(lead) if lead not in (None, "") else None,
replacement=_replacement(product),
product_status_raw=str(status),
)
def load_lifecycle_dir(directory: str | Path) -> dict[str, LifecycleRecord]:
out: dict[str, LifecycleRecord] = {}
path = Path(directory)
if not path.is_dir():
return out
for f in path.glob("*.json"):
raw = json.loads(f.read_text())
rec = LifecycleRecord.model_validate(raw)
out[rec.mpn] = rec
return out
def write_lifecycle_record(directory: str | Path, rec: LifecycleRecord) -> Path:
path = Path(directory)
path.mkdir(parents=True, exist_ok=True)
dest = path / f"{safe_mpn(rec.mpn)}.json"
dest.write_text(rec.model_dump_json(indent=2) + "\n")
return dest
def _match_record(mpn: str | None, records: dict[str, LifecycleRecord]) -> LifecycleRecord | None:
if not mpn:
return None
if mpn in records:
return records[mpn]
norm = re.sub(r"[/_\-\s]", "", mpn).upper()
for key, rec in records.items():
if re.sub(r"[/_\-\s]", "", key).upper() == norm:
return rec
return None
def check_lifecycle(
graph: DesignGraph,
records: dict[str, LifecycleRecord] | None,
) -> list[Finding]:
recs = records or {}
findings: list[Finding] = []
seen: set[str] = set()
for ref, comp in sorted(graph.components.items()):
if comp.component_type in (
ComponentType.MECHANICAL, ComponentType.FIDUCIAL, ComponentType.TEST_POINT,
):
continue
mpn = (comp.mpn or "").strip()
rec = _match_record(mpn, recs)
if rec is None:
continue
if mpn in seen:
continue
seen.add(mpn)
if rec.lifecycle == "eol":
rec_txt = (
f"Distributor replacement: {rec.replacement}."
if rec.replacement else
"No distributor replacement was listed."
)
findings.append(Finding(
designator=ref,
mpn=mpn,
aspect="lifecycle",
source="lifecycle_check",
status="WARNING",
finding=f"{mpn} is EOL/obsolete ({rec.product_status_raw or 'eol'}).",
why="Distributor ProductStatus, not an LLM equivalent search.",
recommendation=rec_txt,
reference=rec.source or "distributor",
pins=[ref],
rule_id="PS-LF-001",
))
elif rec.lifecycle == "nrnd":
findings.append(Finding(
designator=ref,
mpn=mpn,
aspect="lifecycle",
source="lifecycle_check",
status="INFO",
finding=f"{mpn} is NRND ({rec.product_status_raw or 'nrnd'}).",
why="Distributor ProductStatus.",
recommendation="Prefer an Active orderable if the design is new.",
reference=rec.source or "distributor",
pins=[ref],
rule_id="PS-LF-002",
))
if rec.rohs_compliant is False:
findings.append(Finding(
designator=ref,
mpn=mpn,
aspect="lifecycle",
source="lifecycle_check",
status="WARNING",
finding=f"{mpn} is marked RoHS non-compliant.",
why="RoHS fail only when the distributor flag is explicit.",
recommendation="Choose a RoHS-compliant orderable of the same MPN family.",
reference=rec.source or "distributor",
pins=[ref],
rule_id="PS-LF-003",
))
return findings
+9
View File
@@ -44,6 +44,13 @@ def _check_subtype(v: object) -> str | None:
return validate_subtype(str(v)) return validate_subtype(str(v))
class InternalFeatures(BaseModel):
"""Block-diagram extras: ESD clamps, on-die pull-ups, analog switches."""
esd_clamp_pins: list[str] = []
pullup_pins: list[str] = []
analog_switch: list[str] = []
class ComponentConstraints(BaseModel): class ComponentConstraints(BaseModel):
mpn: str mpn: str
model_version: str = "1.0.0" # semver; bumped on prune (patch) or skill update (minor) model_version: str = "1.0.0" # semver; bumped on prune (patch) or skill update (minor)
@@ -52,6 +59,8 @@ class ComponentConstraints(BaseModel):
pintable: list[Pin] pintable: list[Pin]
absolute_maximum_ratings: list[AbsMaxRating] absolute_maximum_ratings: list[AbsMaxRating]
rules: list[Rule] rules: list[Rule]
internal_features: InternalFeatures | None = None
layout_rules: list[dict] = []
_validate_subtype = field_validator("component_subtype", mode="before")( _validate_subtype = field_validator("component_subtype", mode="before")(
staticmethod(_check_subtype) staticmethod(_check_subtype)
+35
View File
@@ -125,6 +125,20 @@ PINTABLE_TOOL = {
"required": ["parameter", "unit", "source_page"], "required": ["parameter", "unit", "source_page"],
}, },
}, },
"internal_features": {
"type": "object",
"description": "Optional block-diagram extras. Omit or empty if not shown.",
"properties": {
"esd_clamp_pins": {"type": "array", "items": {"type": "string"}},
"pullup_pins": {"type": "array", "items": {"type": "string"}},
"analog_switch": {"type": "array", "items": {"type": "string"}},
},
},
"layout_rules": {
"type": "array",
"description": "Optional PCB layout constraints from typical-application pages. kind must be decoupling_proximity, thermal_via, or keepout. max_distance_mm only if the PDF states a number.",
"items": {"type": "object"},
},
}, },
"required": ["component_subtype", "component_subtype_description", "package_info", "pintable"], "required": ["component_subtype", "component_subtype_description", "package_info", "pintable"],
}, },
@@ -351,6 +365,25 @@ def _coerce_abs_max(raw: object) -> list[dict]:
return out return out
def _coerce_layout_rules(raw: object) -> list[dict]:
from backend.pinscopex.layout_rules import validate_layout_rules
rows, _errors = validate_layout_rules(raw if isinstance(raw, list) else [])
return rows
def _coerce_internal_features(raw: object):
from backend.pinscopex.models import InternalFeatures
if not isinstance(raw, dict):
return None
try:
feat = InternalFeatures.model_validate(raw)
except Exception:
return None
if not feat.esd_clamp_pins and not feat.pullup_pins and not feat.analog_switch:
return None
return feat
_GENERATE_SPECS_TOOL = { _GENERATE_SPECS_TOOL = {
"name": "save_specs_schema", "name": "save_specs_schema",
"description": "Save the standardized parameter schema for a component type.", "description": "Save the standardized parameter schema for a component type.",
@@ -623,6 +656,8 @@ async def extract_pintable(
result.get("absolute_maximum_ratings") or [], result.get("absolute_maximum_ratings") or [],
), ),
rules=[], rules=[],
internal_features=_coerce_internal_features(result.get("internal_features")),
layout_rules=_coerce_layout_rules(result.get("layout_rules")),
) )
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
+2
View File
@@ -35,6 +35,8 @@ _PAGE_KEYWORDS = re.compile(
r"|power\s+supply|thermal\s+(resistance|shutdown|pad)|ESD\s+(rating|tolerance)" r"|power\s+supply|thermal\s+(resistance|shutdown|pad)|ESD\s+(rating|tolerance)"
r"|decoupling|bypass\s+capacitor|typical\s+application" r"|decoupling|bypass\s+capacitor|typical\s+application"
r"|application\s+(circuit|schematic|information|note)|reference\s+design" r"|application\s+(circuit|schematic|information|note)|reference\s+design"
r"|block\s+diagram|functional\s+block|internal\s+block"
r"|pcb\s+layout|layout\s+consideration|thermal\s+via"
r"|ordering\s+information|device\s+information", r"|ordering\s+information|device\s+information",
re.IGNORECASE, re.IGNORECASE,
) )
+19 -5
View File
@@ -57,6 +57,9 @@ from backend.pinscopex.thermal_check import check_thermal
from backend.pinscopex.power_margin_check import check_power_margin from backend.pinscopex.power_margin_check import check_power_margin
from backend.pinscopex.sequencing_check import check_power_sequencing from backend.pinscopex.sequencing_check import check_power_sequencing
from backend.pinscopex.dnp_check import check_dnp_enables from backend.pinscopex.dnp_check import check_dnp_enables
from backend.pinscopex.lifecycle import check_lifecycle, load_lifecycle_dir
from backend.pinscopex.errata_check import check_errata
from backend.pinscopex.internal_features_check import check_internal_features
TRACE_VERSION = 1 TRACE_VERSION = 1
@@ -67,7 +70,8 @@ def _is_deterministic(f: Finding) -> bool:
def _run_deterministic_checks( def _run_deterministic_checks(
graph: DesignGraph, constraints_map: dict graph: DesignGraph, constraints_map: dict,
lifecycle_map: dict | None = None,
) -> list[Finding]: ) -> list[Finding]:
"""Run the deterministic graph checks, fail-soft per check — a check bug """Run the deterministic graph checks, fail-soft per check — a check bug
can never break the review or the report.""" can never break the review or the report."""
@@ -87,6 +91,9 @@ def _run_deterministic_checks(
("power_margin_check", lambda: check_power_margin(graph, constraints_map)), ("power_margin_check", lambda: check_power_margin(graph, constraints_map)),
("sequencing_check", lambda: check_power_sequencing(graph, constraints_map)), ("sequencing_check", lambda: check_power_sequencing(graph, constraints_map)),
("dnp_check", lambda: check_dnp_enables(graph, constraints_map)), ("dnp_check", lambda: check_dnp_enables(graph, constraints_map)),
("lifecycle_check", lambda: check_lifecycle(graph, lifecycle_map)),
("errata_check", lambda: check_errata(graph, constraints_map)),
("internal_features_check", lambda: check_internal_features(graph, constraints_map)),
): ):
try: try:
out.extend(fn()) out.extend(fn())
@@ -693,10 +700,17 @@ async def validate_design_async(
graph = DesignGraph.model_validate(raw) graph = DesignGraph.model_validate(raw)
datasheets = _load_datasheets(datasheets_dir) datasheets = _load_datasheets(datasheets_dir)
constraints_map = _build_constraints_map(datasheets) constraints_map = _build_constraints_map(datasheets)
lifecycle_map = {}
# Deterministic graph checks (pin-mux feasibility, LED current). Pure for cand in (
# functions of the graph; fail-soft. Seeded into all_findings below. Path(datasheets_dir).parent / "lifecycle",
deterministic_findings = _run_deterministic_checks(graph, constraints_map) Path(datasheets_dir) / "lifecycle",
):
loaded = load_lifecycle_dir(cand)
if loaded:
lifecycle_map.update(loaded)
deterministic_findings = _run_deterministic_checks(
graph, constraints_map, lifecycle_map,
)
pdf_dir_path = Path(pdf_dir) pdf_dir_path = Path(pdf_dir)
+9
View File
@@ -2,6 +2,15 @@
What's new in Pinscope. What's new in Pinscope.
## 2.17.0 — 2026-09-10 — Lifecycle and datasheet extras
Distributor lifecycle is a cached check, not a review scrape. Errata and layout_rules stay structured and skip when the catalog or the PDF has no number.
- [New] `PS-LF-001` EOL, `PS-LF-002` NRND, `PS-LF-003` explicit RoHS fail. Replacement only if the distributor lists it. Active / RoHS N/A / missing cache row are silent.
- [New] `PS-ERRATA-001` when a catalogued workaround pull-up is missing. No URL → skip.
- [New] `PS-INT-001` when `internal_features.pullup_pins` has no rail resistor.
- [New] `layout_rules` closed kinds; non-numeric `max_distance_mm` is stored as null.
## 2.16.0 — 2026-09-10 — KiCad cad-bridge ## 2.16.0 — 2026-09-10 — KiCad cad-bridge
Pinscope writes `pinscope-findings.json` next to the report so a KiCad 9/10 action plugin can pan to the symbol uuid on the right sheet. Pinscope writes `pinscope-findings.json` next to the report so a KiCad 9/10 action plugin can pan to the symbol uuid on the right sheet.
+6
View File
@@ -28,6 +28,12 @@ Rules for pin extraction:
- Include ALL pins — power, ground, NC, and signal pins - Include ALL pins — power, ground, NC, and signal pins
- Use pin names verbatim from the datasheet — do not rename or normalize - Use pin names verbatim from the datasheet — do not rename or normalize
- For multiplexed pins, put the primary name in `name` and alternates in `functions` - For multiplexed pins, put the primary name in `name` and alternates in `functions`
Optional extras (omit if the PDF does not show them):
- `internal_features.pullup_pins` / `esd_clamp_pins` / `analog_switch` from the **block diagram** only.
- `layout_rules` from **PCB layout / typical application** pages. `kind` is only `decoupling_proximity`, `thermal_via`, or `keepout`. Set `max_distance_mm` only when the document states a number — do not invent JEDEC millimetres.
Rules for pin extraction:`
- If the datasheet has separate tables for different packages, extract for the package matching the MPN - If the datasheet has separate tables for different packages, extract for the package matching the MPN
- Pay careful attention to pin numbering — off-by-one errors here break everything downstream - Pay careful attention to pin numbering — off-by-one errors here break everything downstream
- **Modules vs bare die (critical).** MPNs containing `WROOM`, `WROVER`, `MODULE`, `MOD-`, or `SIP` are *modules*. Extract the **module landing-pad table** (connector pins the schematic uses). Do **not** extract the SoC/QFN ball map from a nested chip chapter or a sibling chip-only PDF. - **Modules vs bare die (critical).** MPNs containing `WROOM`, `WROVER`, `MODULE`, `MOD-`, or `SIP` are *modules*. Extract the **module landing-pad table** (connector pins the schematic uses). Do **not** extract the SoC/QFN ball map from a nested chip chapter or a sibling chip-only PDF.
+12
View File
@@ -45,6 +45,18 @@
}, },
"required": ["parameter", "unit", "source_page"] "required": ["parameter", "unit", "source_page"]
} }
},
"internal_features": {
"type": "object",
"properties": {
"esd_clamp_pins": {"type": "array", "items": {"type": "string"}},
"pullup_pins": {"type": "array", "items": {"type": "string"}},
"analog_switch": {"type": "array", "items": {"type": "string"}}
}
},
"layout_rules": {
"type": "array",
"items": {"type": "object"}
} }
}, },
"required": ["component_subtype", "package_info", "pintable"] "required": ["component_subtype", "package_info", "pintable"]
+13
View File
@@ -81,6 +81,19 @@ def validate(data: dict) -> list[str]:
f"absolute_maximum_ratings[{i}] missing required field: {f}" f"absolute_maximum_ratings[{i}] missing required field: {f}"
) )
if "layout_rules" in data and data["layout_rules"] is not None:
if not isinstance(data["layout_rules"], list):
errors.append("layout_rules must be an array")
else:
kinds = {"decoupling_proximity", "thermal_via", "keepout"}
for i, row in enumerate(data["layout_rules"]):
if not isinstance(row, dict):
errors.append(f"layout_rules[{i}] must be an object")
continue
kind = row.get("kind")
if kind not in kinds:
errors.append(f"layout_rules[{i}] unknown kind: {kind!r}")
return errors return errors
+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