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:
@@ -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))
|
||||
@@ -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.sequencing_check import check_power_sequencing
|
||||
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):
|
||||
@@ -93,6 +96,9 @@ def run_deterministic_on_graph(graph: DesignGraph) -> list[Finding]:
|
||||
out.extend(check_power_margin(graph, cmap))
|
||||
out.extend(check_power_sequencing(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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -44,6 +44,13 @@ def _check_subtype(v: object) -> str | None:
|
||||
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):
|
||||
mpn: str
|
||||
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]
|
||||
absolute_maximum_ratings: list[AbsMaxRating]
|
||||
rules: list[Rule]
|
||||
internal_features: InternalFeatures | None = None
|
||||
layout_rules: list[dict] = []
|
||||
|
||||
_validate_subtype = field_validator("component_subtype", mode="before")(
|
||||
staticmethod(_check_subtype)
|
||||
|
||||
Reference in New Issue
Block a user